zaniah 0.3.0 → 0.4.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 +4 -4
- data/CHANGELOG.md +4 -0
- data/README.md +1 -1
- data/docs/adr/010-text-overlay-layout.md +24 -0
- data/docs/text.md +19 -0
- data/lib/zaniah/layout/engine.rb +10 -5
- data/lib/zaniah/text.rb +252 -15
- data/lib/zaniah/text_system/line_breaker.rb +5 -1
- data/lib/zaniah/text_system/paragraph.rb +161 -14
- data/lib/zaniah/version.rb +1 -1
- data/sig/elements.rbs +20 -0
- data/sig/text.rbs +54 -3
- metadata +6 -5
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f6787d1edc51a6a692859fe62834270cfa2339ade8b00e596e8861632eb59913
|
|
4
|
+
data.tar.gz: 7407abf9f7a64c80fc307843ffeadd9d130cfb45a12673a85f6cbedf2710a3e4
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 35cdfc811f06f704d21202e60029c2869eb5b01698c4e52cde3ce2b7c0e45eaa0c6c5a60ec46e9e9f4e15fd2e1b7f88a75b71b9ec829423540259238739cf468
|
|
7
|
+
data.tar.gz: beaae799ef7227cf30d354819b4d896ee1957666c1617d4a43a15fd8d0112180b0dcf74dadf930c6df8376eaa0045663c83400cca2f6bf019142ab9cb2af0592
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.4.0 — 2026-09-15
|
|
6
|
+
|
|
7
|
+
- Add inline and block text overlays with wrapping-aware layout, hit testing, and row-local relayout.
|
|
8
|
+
|
|
5
9
|
## 0.3.0 — 2026-09-14
|
|
6
10
|
|
|
7
11
|
- Improve large flex layouts, GPU scene submission, and software-rendered gradients for smoother frames.
|
data/README.md
CHANGED
|
@@ -37,7 +37,7 @@ the drawing surface beneath the editor.
|
|
|
37
37
|
- Gradients, transforms, paths, SVG, shadows, clipping, and GPU glyph atlases
|
|
38
38
|
- Retained element state, subscriptions, and non-blocking background tasks
|
|
39
39
|
- Scroll views, inertial input, and uniform or variable-height virtual lists
|
|
40
|
-
- OpenType shaping, font fallback, Japanese wrapping, selection, editing, and IME
|
|
40
|
+
- OpenType shaping, font fallback, Japanese wrapping, text overlays, selection, editing, and IME
|
|
41
41
|
- Themes, state styles, keyed animation, springs, and reduced-motion support
|
|
42
42
|
- Opt-in controls, overlays, tables, trees, charts, forms, and terminal fallbacks
|
|
43
43
|
- Spatial keyboard focus and a diffed cross-platform accessibility tree
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# ADR 010: Model text overlays as layout gaps
|
|
2
|
+
|
|
3
|
+
- Status: Accepted
|
|
4
|
+
- Date: 2026-09-15
|
|
5
|
+
- Decision deadline: before text overlay implementation
|
|
6
|
+
|
|
7
|
+
## Context
|
|
8
|
+
|
|
9
|
+
Inline hints and block annotations must affect wrapping and pointer mapping without
|
|
10
|
+
becoming editable text. Absolutely painting them over an ordinary paragraph would
|
|
11
|
+
leave wrapping, carets, and selection unaware of their size.
|
|
12
|
+
|
|
13
|
+
## Decision
|
|
14
|
+
|
|
15
|
+
Represent inline overlays as measured gaps at UTF-8 grapheme boundaries and block
|
|
16
|
+
overlays as measured rows around logical source lines. Cache the wrapped paragraph
|
|
17
|
+
for each explicit source line so changing one inline overlay only reshapes that row.
|
|
18
|
+
Keep overlay elements as ordinary positioned children for painting and input.
|
|
19
|
+
|
|
20
|
+
## Consequences
|
|
21
|
+
|
|
22
|
+
Text offsets remain unchanged, overlay children stay clickable, and coordinate
|
|
23
|
+
conversion accounts for every gap. Layout changes after an overlay row still shift
|
|
24
|
+
later vertical positions, but those rows reuse their shaped and wrapped paragraphs.
|
data/docs/text.md
CHANGED
|
@@ -123,3 +123,22 @@ Offsets are UTF-8 byte offsets and edits must land on extended grapheme-cluster
|
|
|
123
123
|
boundaries. The string-backed buffer is intended for ordinary fields and
|
|
124
124
|
documents up to tens of thousands of characters; a piece table is deliberately
|
|
125
125
|
outside the current scope.
|
|
126
|
+
|
|
127
|
+
## Inline and block overlays
|
|
128
|
+
|
|
129
|
+
Attach non-editable UI elements to text without inserting bytes into the source:
|
|
130
|
+
|
|
131
|
+
```ruby
|
|
132
|
+
hint = Zaniah::Div.new.w(72).h(20).bg("#334155").on_click { show_type_help }
|
|
133
|
+
code_lens = Zaniah::Div.new.h(20).child(Zaniah::Text.new("3 references"))
|
|
134
|
+
|
|
135
|
+
text = Zaniah::Text.new(source, wrap: :word)
|
|
136
|
+
.inline_overlay(offset: 42, element: hint, align: :after)
|
|
137
|
+
.block_overlay(line: 10, element: code_lens, position: :above, height: 20)
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
Inline overlay width participates in wrapping. `align: :after` keeps the logical
|
|
141
|
+
caret before the overlay; `:before` keeps it after the overlay. Block line numbers
|
|
142
|
+
are zero-based. Overlay elements receive pointer events but are never part of text
|
|
143
|
+
selection. Use `hit_test(point)` and `offset_to_point(offset)` for local display ↔
|
|
144
|
+
UTF-8 byte-offset conversion, and `remove_overlay(element)` to detach an overlay.
|
data/lib/zaniah/layout/engine.rb
CHANGED
|
@@ -125,6 +125,16 @@ module Zaniah
|
|
|
125
125
|
end
|
|
126
126
|
|
|
127
127
|
def layout(node, x, y, width, height, available_w, available_h, clamp = true)
|
|
128
|
+
if node.children.empty?
|
|
129
|
+
if node.style[:display] == :none
|
|
130
|
+
node.bounds = Bounds.new(x, y, 0, 0)
|
|
131
|
+
else
|
|
132
|
+
width = clamp_size(node, :width, width, available_w) if clamp
|
|
133
|
+
height = clamp_size(node, :height, height, available_h) if clamp
|
|
134
|
+
node.bounds = Bounds.new(x, y, width, height)
|
|
135
|
+
end
|
|
136
|
+
return
|
|
137
|
+
end
|
|
128
138
|
key = [node.generation, x, y, width, height, available_w, available_h]
|
|
129
139
|
return if node.cache[key]
|
|
130
140
|
if node.style[:display] == :none
|
|
@@ -137,11 +147,6 @@ module Zaniah
|
|
|
137
147
|
height = clamp_size(node, :height, height, available_h)
|
|
138
148
|
end
|
|
139
149
|
node.bounds = Bounds.new(x, y, width, height)
|
|
140
|
-
if node.children.empty?
|
|
141
|
-
node.cache.shift if node.cache.length >= 5
|
|
142
|
-
node.cache[key] = true
|
|
143
|
-
return
|
|
144
|
-
end
|
|
145
150
|
content = content_bounds(node)
|
|
146
151
|
flow, absolute = node.children.partition { |child| child.style[:position] != :absolute }
|
|
147
152
|
s = node.style
|
data/lib/zaniah/text.rb
CHANGED
|
@@ -2,6 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
module Zaniah
|
|
4
4
|
class Text < Element
|
|
5
|
+
InlineOverlay = Data.define(:element, :offset, :align)
|
|
6
|
+
BlockOverlay = Data.define(:element, :line, :position, :height)
|
|
7
|
+
|
|
5
8
|
attr_reader :text, :font_size, :selection, :buffer
|
|
6
9
|
|
|
7
10
|
def initialize(text, size: 14, color: "#ddd", font: nil, wrap: :none,
|
|
@@ -10,6 +13,7 @@ module Zaniah
|
|
|
10
13
|
@text, @font_size, @color, @font = text, size, color, font
|
|
11
14
|
@wrap, @line_height, @letter_spacing = wrap, line_height, letter_spacing
|
|
12
15
|
@text_align, @ellipsis, @kinsoku = align, ellipsis, kinsoku
|
|
16
|
+
@inline_overlays, @block_overlays, @row_layout_cache = [], [], []
|
|
13
17
|
end
|
|
14
18
|
|
|
15
19
|
def measured(&block) = (@measure = block; self)
|
|
@@ -17,6 +21,55 @@ module Zaniah
|
|
|
17
21
|
def placeholder(text, color: nil) = (@placeholder = text.to_s; @placeholder_color = color; self)
|
|
18
22
|
def secure(value = true) = (@secure = !!value; self)
|
|
19
23
|
def text_color = @color
|
|
24
|
+
|
|
25
|
+
def inline_overlay(offset:, element:, align: :after)
|
|
26
|
+
offset = Integer(offset)
|
|
27
|
+
raise ArgumentError, "offset is outside the text" unless offset.between?(0, @text.bytesize)
|
|
28
|
+
raise ArgumentError, "offset splits a grapheme cluster" unless Unicode.grapheme_boundary?(@text, offset)
|
|
29
|
+
raise ArgumentError, "align must be before or after" unless %i[before after].include?(align)
|
|
30
|
+
add_overlay(element)
|
|
31
|
+
@inline_overlays << InlineOverlay.new(element, offset, align)
|
|
32
|
+
@row_layout_cache[line_at(@text, offset)] = nil
|
|
33
|
+
self
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def block_overlay(line:, element:, position: :above, height:)
|
|
37
|
+
line = Integer(line)
|
|
38
|
+
height = Float(height)
|
|
39
|
+
raise ArgumentError, "line is outside the text" unless line.between?(0, @text.count("\n"))
|
|
40
|
+
raise ArgumentError, "position must be above or below" unless %i[above below].include?(position)
|
|
41
|
+
raise ArgumentError, "height must be finite and positive" unless height.finite? && height.positive?
|
|
42
|
+
add_overlay(element)
|
|
43
|
+
@block_overlays << BlockOverlay.new(element, line, position, height)
|
|
44
|
+
self
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def remove_overlay(element)
|
|
48
|
+
removed = @inline_overlays.select { |overlay| overlay.element.equal?(element) }
|
|
49
|
+
@inline_overlays.reject! { |overlay| overlay.element.equal?(element) }
|
|
50
|
+
@block_overlays.reject! { |overlay| overlay.element.equal?(element) }
|
|
51
|
+
removed.each { |overlay| @row_layout_cache[line_at(@text, overlay.offset)] = nil }
|
|
52
|
+
if @children.delete(element)
|
|
53
|
+
element.send(:parent=, nil) if element.respond_to?(:parent=, true)
|
|
54
|
+
end
|
|
55
|
+
self
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def hit_test(point)
|
|
59
|
+
raise Error, "text must be laid out before coordinate conversion" unless @paragraph || @line
|
|
60
|
+
offset = @paragraph ? @paragraph.hit_test(point) : @line.index_for_x(point.x)
|
|
61
|
+
display_to_logical_offset(offset)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def offset_to_point(offset)
|
|
65
|
+
raise Error, "text must be laid out before coordinate conversion" unless @paragraph || @line
|
|
66
|
+
offset = Integer(offset)
|
|
67
|
+
raise RangeError, "offset is outside the text" unless offset.between?(0, @text.bytesize)
|
|
68
|
+
raise ArgumentError, "offset splits a grapheme cluster" unless Unicode.grapheme_boundary?(@text, offset)
|
|
69
|
+
display_offset = logical_to_display_offset(offset)
|
|
70
|
+
@paragraph ? @paragraph.offset_to_point(display_offset) : Point.new(@line.x_for_index(display_offset), 0)
|
|
71
|
+
end
|
|
72
|
+
|
|
20
73
|
def selection=(value)
|
|
21
74
|
raise ArgumentError, "expected a TextSelection" unless value.is_a?(TextSelection)
|
|
22
75
|
raise ArgumentError, "selection is outside the text" unless value.anchor <= @text.bytesize && value.head <= @text.bytesize
|
|
@@ -53,25 +106,51 @@ module Zaniah
|
|
|
53
106
|
def request_layout(cx)
|
|
54
107
|
@text = @buffer.to_s if @buffer
|
|
55
108
|
value = display_text
|
|
56
|
-
@paragraph = nil
|
|
57
|
-
|
|
109
|
+
@paragraph, @line = nil, nil
|
|
110
|
+
overlays = !@inline_overlays.empty? || !@block_overlays.empty?
|
|
111
|
+
unless overlays
|
|
112
|
+
@line = cx.text_system ? cx.text_system.layout_line(value, font: @font, size: @font_size) : approximate_line(value)
|
|
113
|
+
end
|
|
58
114
|
line_height = @line ? @line.ascent + @line.descent : 0
|
|
59
|
-
unless @wrap == :none && !@ellipsis
|
|
115
|
+
unless @wrap == :none && !@ellipsis && !overlays
|
|
60
116
|
available = @style[:width]
|
|
61
117
|
available = available.resolve(cx.window.content_size.width) if available.is_a?(Length)
|
|
62
118
|
available = cx.window.content_size.width unless available.is_a?(Numeric)
|
|
63
|
-
@paragraph = paragraph(value, available, cx.text_system)
|
|
119
|
+
@paragraph = overlays ? nil : paragraph(value, available, cx.text_system)
|
|
120
|
+
end
|
|
121
|
+
overlay_nodes = overlay_elements.to_h do |element|
|
|
122
|
+
node = element.request_layout(cx)
|
|
123
|
+
raise Error, "overlay element must return a layout node" unless node.is_a?(Layout::Node)
|
|
124
|
+
[element.object_id, node]
|
|
64
125
|
end
|
|
65
|
-
|
|
126
|
+
overlay_styles = overlay_nodes.transform_values(&:style)
|
|
127
|
+
if overlays
|
|
128
|
+
@paragraph = overlay_paragraph(value, available, cx.text_system, overlay_nodes, overlay_styles)
|
|
129
|
+
position_overlay_nodes(overlay_nodes, overlay_styles)
|
|
130
|
+
end
|
|
131
|
+
measurement = if @measure
|
|
132
|
+
lambda do |width, height|
|
|
133
|
+
if overlays
|
|
134
|
+
@paragraph = overlay_paragraph(value, effective_width(width), cx.text_system, overlay_nodes, overlay_styles)
|
|
135
|
+
position_overlay_nodes(overlay_nodes, overlay_styles)
|
|
136
|
+
end
|
|
137
|
+
@measure.call(width, height)
|
|
138
|
+
end
|
|
139
|
+
elsif @wrap == :none && !@ellipsis && !overlays
|
|
66
140
|
->(_width, _height) { [@line ? @line.width : value.length * @font_size * 0.6, [@font_size * 1.4, line_height].max, @font_size] }
|
|
67
141
|
else
|
|
68
142
|
lambda do |width, _height|
|
|
69
|
-
limit = width
|
|
70
|
-
@paragraph =
|
|
143
|
+
limit = effective_width(width)
|
|
144
|
+
@paragraph = if overlays
|
|
145
|
+
overlay_paragraph(value, limit, cx.text_system, overlay_nodes, overlay_styles)
|
|
146
|
+
else
|
|
147
|
+
paragraph(value, limit, cx.text_system)
|
|
148
|
+
end
|
|
149
|
+
position_overlay_nodes(overlay_nodes, overlay_styles) if overlays
|
|
71
150
|
[@paragraph.width, @paragraph.height, @paragraph.lines.first&.layout&.ascent || @font_size]
|
|
72
151
|
end
|
|
73
152
|
end
|
|
74
|
-
@layout_node = Layout::Node.new(style: @style, measure: measurement)
|
|
153
|
+
@layout_node = Layout::Node.new(style: @style, children: overlay_nodes.values, measure: measurement)
|
|
75
154
|
end
|
|
76
155
|
|
|
77
156
|
def prepaint(bounds, state, cx)
|
|
@@ -118,10 +197,11 @@ module Zaniah
|
|
|
118
197
|
end
|
|
119
198
|
|
|
120
199
|
def begin_selection(event)
|
|
200
|
+
return if overlay_at(local_point(event.position))
|
|
121
201
|
offset = offset_at(event.position)
|
|
122
202
|
@selection = if event.click_count >= 3 && @paragraph
|
|
123
|
-
range = @paragraph.line_range_at(offset)
|
|
124
|
-
TextSelection.new(range.begin, range.end)
|
|
203
|
+
range = @paragraph.line_range_at(logical_to_display_offset(offset))
|
|
204
|
+
TextSelection.new(display_to_logical_offset(range.begin), display_to_logical_offset(range.end))
|
|
125
205
|
elsif event.click_count == 2
|
|
126
206
|
range = Unicode.word_range_at(@text, [offset, @text.bytesize].min)
|
|
127
207
|
TextSelection.new(range.begin, range.end)
|
|
@@ -135,10 +215,12 @@ module Zaniah
|
|
|
135
215
|
end
|
|
136
216
|
|
|
137
217
|
def offset_at(position)
|
|
138
|
-
point =
|
|
139
|
-
|
|
218
|
+
point = local_point(position)
|
|
219
|
+
hit_test(point)
|
|
140
220
|
end
|
|
141
221
|
|
|
222
|
+
def local_point(position) = Point.new(position.x - @text_bounds.x, position.y - @text_bounds.y)
|
|
223
|
+
|
|
142
224
|
def text_action(action)
|
|
143
225
|
return false unless @selection
|
|
144
226
|
head = @selection.head
|
|
@@ -213,14 +295,32 @@ module Zaniah
|
|
|
213
295
|
|
|
214
296
|
def paint_selection(bounds, cx)
|
|
215
297
|
range = @selection.range
|
|
216
|
-
selection_lines.
|
|
298
|
+
selection_lines.each_with_index do |(line, start, finish, x, y, height), line_index|
|
|
217
299
|
first, last = [range.begin, start].max, [range.end, finish].min
|
|
218
300
|
next if last <= first
|
|
219
301
|
left = line.x_for_index(first - start)
|
|
220
302
|
right = line.x_for_index(last - start)
|
|
303
|
+
gaps = if @paragraph.respond_to?(:inline_placements)
|
|
304
|
+
@paragraph.inline_placements.select { |placement| placement.line == line_index }
|
|
305
|
+
.map { |placement| [placement.x - x, placement.x - x + placement.width] }
|
|
306
|
+
else
|
|
307
|
+
[]
|
|
308
|
+
end
|
|
309
|
+
segments = gaps.sort.each_with_object([[left, right]]) do |(gap_start, gap_finish), values|
|
|
310
|
+
segment_start, segment_finish = values.pop
|
|
311
|
+
if gap_finish <= segment_start || gap_start >= segment_finish
|
|
312
|
+
values << [segment_start, segment_finish]
|
|
313
|
+
else
|
|
314
|
+
values << [segment_start, gap_start] if gap_start > segment_start
|
|
315
|
+
values << [gap_finish, segment_finish] if gap_finish < segment_finish
|
|
316
|
+
end
|
|
317
|
+
end
|
|
221
318
|
cx.scene.layer(Scene::LAYER_SELECTION) do
|
|
222
|
-
|
|
223
|
-
|
|
319
|
+
segments.each do |segment_start, segment_finish|
|
|
320
|
+
next unless segment_finish > segment_start
|
|
321
|
+
cx.scene.quad(bounds.x + x + segment_start, bounds.y + y,
|
|
322
|
+
segment_finish - segment_start, height, color: cx.theme.colors.selection)
|
|
323
|
+
end
|
|
224
324
|
end
|
|
225
325
|
end
|
|
226
326
|
end
|
|
@@ -279,5 +379,142 @@ module Zaniah
|
|
|
279
379
|
wrap: @wrap, line_height: @line_height, letter_spacing: @letter_spacing,
|
|
280
380
|
align: @text_align, ellipsis: @ellipsis, kinsoku: @kinsoku, typesetter: typesetter)
|
|
281
381
|
end
|
|
382
|
+
|
|
383
|
+
def add_overlay(element)
|
|
384
|
+
unless element.respond_to?(:request_layout) && element.respond_to?(:prepaint) && element.respond_to?(:paint)
|
|
385
|
+
raise ArgumentError, "overlay element must be renderable"
|
|
386
|
+
end
|
|
387
|
+
raise ArgumentError, "element is already an overlay" if overlay_elements.any? { |candidate| candidate.equal?(element) }
|
|
388
|
+
child(element)
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
def overlay_elements = (@inline_overlays + @block_overlays).map(&:element)
|
|
392
|
+
|
|
393
|
+
def line_at(value, offset) = value.byteslice(0...offset).count("\n")
|
|
394
|
+
|
|
395
|
+
def effective_width(width)
|
|
396
|
+
configured = @style[:width]
|
|
397
|
+
width = configured.resolve(width) if configured.is_a?(Length)
|
|
398
|
+
width = configured if configured.is_a?(Numeric)
|
|
399
|
+
width.finite? ? [width, 0].max : Float::INFINITY
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
def source_rows(value)
|
|
403
|
+
start = 0
|
|
404
|
+
value.split("\n", -1).map do |source|
|
|
405
|
+
[start, source].tap { start += source.bytesize + 1 }
|
|
406
|
+
end
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
def overlay_paragraph(value, width, typesetter, nodes, styles)
|
|
410
|
+
engine = Layout::Engine.new
|
|
411
|
+
rows = source_rows(value)
|
|
412
|
+
logical_rows = source_rows(@text)
|
|
413
|
+
logical_boundaries = Unicode.grapheme_boundaries(@text)
|
|
414
|
+
inline = @inline_overlays.filter_map do |overlay|
|
|
415
|
+
next if logical_boundaries.bsearch { |offset| offset >= overlay.offset } != overlay.offset
|
|
416
|
+
display_offset = logical_to_display_offset(overlay.offset, after_insertion: overlay.align == :after)
|
|
417
|
+
node = nodes.fetch(overlay.element.object_id)
|
|
418
|
+
node.style = styles.fetch(overlay.element.object_id) unless node.style.equal?(styles.fetch(overlay.element.object_id))
|
|
419
|
+
measured = engine.measure(node, width: width, height: Float::INFINITY)
|
|
420
|
+
unless measured.all? { |size| size.is_a?(Numeric) && size.finite? && !size.negative? }
|
|
421
|
+
raise Error, "inline overlay must have a finite nonnegative size"
|
|
422
|
+
end
|
|
423
|
+
[row_at(rows, display_offset), overlay, display_offset, measured]
|
|
424
|
+
end
|
|
425
|
+
inline_by_row = inline.group_by(&:first)
|
|
426
|
+
blocks = @block_overlays.filter_map do |overlay|
|
|
427
|
+
next unless (logical_row = logical_rows[overlay.line])
|
|
428
|
+
start, source = logical_row
|
|
429
|
+
logical_offset = overlay.position == :above ? start : start + source.bytesize
|
|
430
|
+
offset = logical_to_display_offset(logical_offset, after_insertion: overlay.position == :below)
|
|
431
|
+
line = row_at(rows, offset)
|
|
432
|
+
node = nodes.fetch(overlay.element.object_id)
|
|
433
|
+
node.style = styles.fetch(overlay.element.object_id) unless node.style.equal?(styles.fetch(overlay.element.object_id))
|
|
434
|
+
block_width = width.finite? ? width : engine.measure(node, width: width, height: overlay.height).first
|
|
435
|
+
raise Error, "block overlay must have a finite nonnegative width" unless block_width.is_a?(Numeric) && block_width.finite? && !block_width.negative?
|
|
436
|
+
TextSystem::OverlayParagraph::Block.new(overlay.element.object_id, line,
|
|
437
|
+
offset, block_width, overlay.height, overlay.position)
|
|
438
|
+
end
|
|
439
|
+
layout_key = [value.dup.freeze, width, @font_size, @font&.object_id, @wrap, @line_height,
|
|
440
|
+
@letter_spacing, @text_align, @ellipsis, @kinsoku, typesetter&.object_id,
|
|
441
|
+
inline.map { |row, overlay, offset, size| [row, overlay.element.object_id, offset, overlay.align, *size] },
|
|
442
|
+
blocks.map { |block| [block.key, block.line, block.width, block.height, block.position] }]
|
|
443
|
+
return @overlay_layout_cache.last if @overlay_layout_cache&.first == layout_key
|
|
444
|
+
|
|
445
|
+
composed = rows.each_with_index.map do |(start, source), row|
|
|
446
|
+
row_overlays = (inline_by_row[row] || []).map do |_target, overlay, offset, (overlay_width, overlay_height)|
|
|
447
|
+
TextSystem::Paragraph::InlineOverlay.new(overlay.element.object_id,
|
|
448
|
+
offset - start, overlay_width, overlay_height, overlay.align)
|
|
449
|
+
end
|
|
450
|
+
key = [source, width, @font_size, @font&.object_id, @wrap, @line_height,
|
|
451
|
+
@letter_spacing, @text_align, @ellipsis, @kinsoku, typesetter&.object_id,
|
|
452
|
+
row_overlays.map { |overlay| [overlay.key, overlay.offset, overlay.width, overlay.height, overlay.align] }]
|
|
453
|
+
cached = @row_layout_cache[row]
|
|
454
|
+
unless cached && cached.first == key
|
|
455
|
+
cached = [key, TextSystem::Paragraph.new(source, width: width, size: @font_size,
|
|
456
|
+
font: @font, wrap: @wrap, line_height: @line_height, letter_spacing: @letter_spacing,
|
|
457
|
+
align: @text_align, ellipsis: @ellipsis, kinsoku: @kinsoku, typesetter: typesetter,
|
|
458
|
+
inline_overlays: row_overlays)]
|
|
459
|
+
@row_layout_cache[row] = cached
|
|
460
|
+
end
|
|
461
|
+
TextSystem::OverlayParagraph::Row.new(cached.last, start)
|
|
462
|
+
end
|
|
463
|
+
@row_layout_cache.slice!(rows.length..-1) if @row_layout_cache.length > rows.length
|
|
464
|
+
result = TextSystem::OverlayParagraph.new(value, rows: composed, blocks: blocks, width: width)
|
|
465
|
+
@overlay_layout_cache = [layout_key, result]
|
|
466
|
+
result
|
|
467
|
+
end
|
|
468
|
+
|
|
469
|
+
def row_at(rows, offset)
|
|
470
|
+
following = rows.bsearch_index { |start, _source| start > offset }
|
|
471
|
+
following ? following - 1 : rows.length - 1
|
|
472
|
+
end
|
|
473
|
+
|
|
474
|
+
def position_overlay_nodes(nodes, styles)
|
|
475
|
+
placements = (@paragraph.inline_placements + @paragraph.block_placements).to_h { |placement| [placement.key, placement] }
|
|
476
|
+
nodes.each do |key, node|
|
|
477
|
+
unless (placement = placements[key])
|
|
478
|
+
node.style = styles.fetch(key).merge(position: :absolute, display: :none)
|
|
479
|
+
next
|
|
480
|
+
end
|
|
481
|
+
node = nodes.fetch(placement.key)
|
|
482
|
+
node.style = styles.fetch(key).merge(position: :absolute, left: placement.x, top: placement.y,
|
|
483
|
+
width: placement.width, height: placement.height)
|
|
484
|
+
end
|
|
485
|
+
end
|
|
486
|
+
|
|
487
|
+
def logical_to_display_offset(offset, after_insertion: false)
|
|
488
|
+
return 0 if @placeholder && @text.empty? && !@buffer&.composition
|
|
489
|
+
composition = @buffer&.composition
|
|
490
|
+
return offset unless composition
|
|
491
|
+
insertion = @selection.head
|
|
492
|
+
offset > insertion || (after_insertion && offset == insertion) ? offset + composition.text.bytesize : offset
|
|
493
|
+
end
|
|
494
|
+
|
|
495
|
+
def display_to_logical_offset(offset)
|
|
496
|
+
return 0 if @placeholder && @text.empty? && !@buffer&.composition
|
|
497
|
+
if (composition = @buffer&.composition)
|
|
498
|
+
insertion = @selection.head
|
|
499
|
+
offset = if offset <= insertion
|
|
500
|
+
offset
|
|
501
|
+
elsif offset <= insertion + composition.text.bytesize
|
|
502
|
+
insertion
|
|
503
|
+
else
|
|
504
|
+
offset - composition.text.bytesize
|
|
505
|
+
end
|
|
506
|
+
end
|
|
507
|
+
offset = offset.clamp(0, @text.bytesize)
|
|
508
|
+
return offset if Unicode.grapheme_boundary?(@text, offset)
|
|
509
|
+
before, after = Unicode.previous_boundary(@text, offset), Unicode.next_boundary(@text, offset)
|
|
510
|
+
offset - before <= after - offset ? before : after
|
|
511
|
+
end
|
|
512
|
+
|
|
513
|
+
def overlay_at(point)
|
|
514
|
+
return unless @paragraph.respond_to?(:block_placements)
|
|
515
|
+
(@paragraph.inline_placements + @paragraph.block_placements).find do |placement|
|
|
516
|
+
Bounds.new(placement.x, placement.y, placement.width, placement.height).contains?(point)
|
|
517
|
+
end
|
|
518
|
+
end
|
|
282
519
|
end
|
|
283
520
|
end
|
|
@@ -10,6 +10,10 @@ module Zaniah
|
|
|
10
10
|
end
|
|
11
11
|
|
|
12
12
|
def ranges(width, &measure)
|
|
13
|
+
ranges_with_offsets(width) { |value, _first, _finish| measure.call(value) }
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def ranges_with_offsets(width, &measure)
|
|
13
17
|
width = Float(width)
|
|
14
18
|
raise ArgumentError, "width must be nonnegative" if width.nan? || width.negative?
|
|
15
19
|
result, offset = [], 0
|
|
@@ -34,7 +38,7 @@ module Zaniah
|
|
|
34
38
|
fit, last_word, index = first, nil, first
|
|
35
39
|
while index < clusters.length
|
|
36
40
|
candidate = segment.byteslice(bytes[first]...bytes[index + 1])
|
|
37
|
-
break if index > first && measure.call(candidate) > width
|
|
41
|
+
break if index > first && measure.call(candidate, base + bytes[first], base + bytes[index + 1]) > width
|
|
38
42
|
fit = index + 1
|
|
39
43
|
last_word = fit if break_after?(clusters[index])
|
|
40
44
|
index += 1
|
|
@@ -4,10 +4,13 @@ module Zaniah
|
|
|
4
4
|
module TextSystem
|
|
5
5
|
class Paragraph
|
|
6
6
|
Line = Data.define(:layout, :start, :finish, :x, :y, :height)
|
|
7
|
-
|
|
7
|
+
InlineOverlay = Data.define(:key, :offset, :width, :height, :align)
|
|
8
|
+
Placement = Data.define(:key, :offset, :x, :y, :width, :height, :line, :align)
|
|
9
|
+
attr_reader :text, :lines, :width, :height, :inline_placements
|
|
8
10
|
|
|
9
11
|
def initialize(text, width:, size: 14, font: nil, wrap: :word, line_height: nil,
|
|
10
|
-
letter_spacing: 0, align: :start, ellipsis: false, kinsoku: :push, typesetter: nil
|
|
12
|
+
letter_spacing: 0, align: :start, ellipsis: false, kinsoku: :push, typesetter: nil,
|
|
13
|
+
inline_overlays: [])
|
|
11
14
|
raise ArgumentError, "text must be valid UTF-8" unless text.is_a?(String) && text.encoding == Encoding::UTF_8 && text.valid_encoding?
|
|
12
15
|
raise ArgumentError, "align must be start, center, end, or justify" unless %i[start center end justify].include?(align)
|
|
13
16
|
raise ArgumentError, "letter spacing must be finite" unless letter_spacing.is_a?(Numeric) && letter_spacing.finite?
|
|
@@ -17,34 +20,47 @@ module Zaniah
|
|
|
17
20
|
raise ArgumentError, "line height must be numeric" if line_height && !line_height.is_a?(Numeric)
|
|
18
21
|
@line_height = line_height ? (line_height <= 4 ? size * line_height : line_height) : size * 1.4
|
|
19
22
|
raise ArgumentError, "line height must be finite and positive" unless @line_height.is_a?(Numeric) && @line_height.finite? && @line_height.positive?
|
|
23
|
+
@inline_overlays = validate_overlays(inline_overlays)
|
|
20
24
|
breaker = LineBreaker.new(@text, wrap: wrap, kinsoku: kinsoku)
|
|
21
|
-
ranges = breaker.
|
|
25
|
+
ranges = breaker.ranges_with_offsets(@limit) do |value, first, finish|
|
|
26
|
+
layout_with_overlays(value, first, overlays_for(first, finish)).width
|
|
27
|
+
end
|
|
28
|
+
y, @inline_placements = 0.0, []
|
|
22
29
|
@lines = ranges.each_with_index.map do |range, index|
|
|
23
30
|
source = @text.byteslice(range) || ""
|
|
24
|
-
|
|
25
|
-
|
|
31
|
+
visible = source.bytesize
|
|
32
|
+
source, visible = ellipsize(source, range.begin) if @ellipsis && wrap == :none
|
|
33
|
+
overlays = overlays_for(range.begin, range.end).select { |overlay| overlay.offset - range.begin <= visible }
|
|
34
|
+
line = spaced(layout_with_overlays(source, range.begin, overlays))
|
|
26
35
|
line = justified(line) if @align == :justify && index < ranges.length - 1
|
|
36
|
+
height = [@line_height, overlays.map(&:height).max || 0].max
|
|
27
37
|
x = case @align
|
|
28
38
|
when :center then [(@limit - line.width) / 2.0, 0].max
|
|
29
39
|
when :end then [@limit - line.width, 0].max
|
|
30
40
|
else 0.0
|
|
31
41
|
end
|
|
32
42
|
x = 0.0 if @limit.infinite?
|
|
33
|
-
|
|
43
|
+
append_placements(overlays, line, range.begin, x, y, height, index)
|
|
44
|
+
Line.new(line, range.begin, range.end, x, y, height).tap { y += height }
|
|
34
45
|
end.freeze
|
|
46
|
+
@inline_placements.freeze
|
|
35
47
|
@width = [@lines.map { |line| line.x + line.layout.width }.max || 0, @limit].min
|
|
36
|
-
@height =
|
|
48
|
+
@height = y
|
|
37
49
|
end
|
|
38
50
|
|
|
39
51
|
def hit_test(point)
|
|
40
52
|
return 0 if @lines.empty?
|
|
41
|
-
|
|
53
|
+
if (overlay = @inline_placements.find { |item| Bounds.new(item.x, item.y, item.width, item.height).contains?(point) })
|
|
54
|
+
return overlay.offset
|
|
55
|
+
end
|
|
56
|
+
line = @lines.find { |item| point.y < item.y + item.height } || @lines.last
|
|
42
57
|
[line.start + line.layout.index_for_x(point.x - line.x), line.finish].min
|
|
43
58
|
end
|
|
44
59
|
|
|
45
60
|
def offset_to_point(offset)
|
|
46
61
|
offset = Integer(offset).clamp(0, @text.bytesize)
|
|
47
|
-
|
|
62
|
+
placement = @inline_placements.find { |item| item.offset == offset && item.align == :before }
|
|
63
|
+
line = placement ? @lines.fetch(placement.line) : @lines.find { |item| offset <= item.finish } || @lines.last
|
|
48
64
|
Point.new(line.x + line.layout.x_for_index((offset - line.start).clamp(0, line.layout.text.bytesize)), line.y)
|
|
49
65
|
end
|
|
50
66
|
|
|
@@ -55,6 +71,67 @@ module Zaniah
|
|
|
55
71
|
|
|
56
72
|
private
|
|
57
73
|
|
|
74
|
+
def validate_overlays(overlays)
|
|
75
|
+
raise ArgumentError, "inline overlays must be an array" unless overlays.is_a?(Array)
|
|
76
|
+
overlays.map do |overlay|
|
|
77
|
+
unless overlay.is_a?(InlineOverlay) && overlay.offset.is_a?(Integer) && overlay.offset.between?(0, @text.bytesize) &&
|
|
78
|
+
Unicode.grapheme_boundary?(@text, overlay.offset) && %i[before after].include?(overlay.align) &&
|
|
79
|
+
[overlay.width, overlay.height].all? { |value| value.is_a?(Numeric) && value.finite? && !value.negative? }
|
|
80
|
+
raise ArgumentError, "invalid inline overlay"
|
|
81
|
+
end
|
|
82
|
+
overlay
|
|
83
|
+
end.sort_by { |overlay| [overlay.offset, overlay.align == :before ? 0 : 1] }.freeze
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def overlays_for(first, finish)
|
|
87
|
+
@inline_overlays.select do |overlay|
|
|
88
|
+
(overlay.offset > first && overlay.offset < finish) ||
|
|
89
|
+
(overlay.offset == first && (first.zero? || @text.getbyte(first - 1) == 10 || overlay.align == :before)) ||
|
|
90
|
+
(overlay.offset == finish && (finish == @text.bytesize || @text.getbyte(finish) == 10 || overlay.align == :after))
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def layout_with_overlays(value, start, overlays)
|
|
95
|
+
return layout(value) if overlays.empty?
|
|
96
|
+
boundaries = overlays.map { |overlay| overlay.offset - start }.uniq.sort
|
|
97
|
+
glyphs, carets, cursor, first = [], [[0, 0.0]], 0.0, 0
|
|
98
|
+
boundaries.each do |boundary|
|
|
99
|
+
part = layout(value.byteslice(first...boundary) || "")
|
|
100
|
+
append_layout(glyphs, carets, part, first, cursor)
|
|
101
|
+
cursor += part.width
|
|
102
|
+
at = overlays.select { |overlay| overlay.offset - start == boundary }
|
|
103
|
+
before = at.sum { |overlay| overlay.align == :before ? overlay.width : 0 }
|
|
104
|
+
carets[-1] = [boundary, cursor + before]
|
|
105
|
+
cursor += at.sum(&:width)
|
|
106
|
+
first = boundary
|
|
107
|
+
end
|
|
108
|
+
part = layout(value.byteslice(first..-1) || "")
|
|
109
|
+
append_layout(glyphs, carets, part, first, cursor)
|
|
110
|
+
cursor += part.width
|
|
111
|
+
LineLayout.new(value.dup.freeze, glyphs.freeze, cursor, part.ascent, part.descent, part.size, carets.freeze)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def append_layout(glyphs, carets, line, byte, x)
|
|
115
|
+
line.glyphs.each do |glyph|
|
|
116
|
+
glyphs << Glyph.new(glyph.font, glyph.id, glyph.start + byte, glyph.finish + byte, glyph.x + x, glyph.advance)
|
|
117
|
+
end
|
|
118
|
+
line.carets.drop(1).each { |offset, position| carets << [offset + byte, position + x] }
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def append_placements(overlays, line, start, x, y, height, line_index)
|
|
122
|
+
overlays.group_by(&:offset).each do |offset, group|
|
|
123
|
+
caret = line.x_for_index(offset - start)
|
|
124
|
+
before = group.select { |overlay| overlay.align == :before }
|
|
125
|
+
after = group.select { |overlay| overlay.align == :after }
|
|
126
|
+
cursor = x + caret - before.sum(&:width)
|
|
127
|
+
(before + after).each do |overlay|
|
|
128
|
+
@inline_placements << Placement.new(overlay.key, offset, cursor,
|
|
129
|
+
y + (height - overlay.height) / 2.0, overlay.width, overlay.height, line_index, overlay.align)
|
|
130
|
+
cursor += overlay.width
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
58
135
|
def layout(value)
|
|
59
136
|
return @typesetter.layout_line(value, font: @font, size: @size) if @typesetter
|
|
60
137
|
byte, x, carets = 0, 0.0, [[0, 0.0]]
|
|
@@ -74,7 +151,8 @@ module Zaniah
|
|
|
74
151
|
index = line.carets.bsearch_index { |byte, _| byte >= glyph.start } || line.carets.length - 1
|
|
75
152
|
Glyph.new(glyph.font, glyph.id, glyph.start, glyph.finish, glyph.x + index * @letter_spacing, glyph.advance)
|
|
76
153
|
end
|
|
77
|
-
|
|
154
|
+
trailing = line.width - line.carets.last.last
|
|
155
|
+
LineLayout.new(line.text, glyphs.freeze, carets.last.last + trailing, line.ascent, line.descent, line.size, carets.freeze)
|
|
78
156
|
end
|
|
79
157
|
|
|
80
158
|
def justified(line)
|
|
@@ -88,11 +166,80 @@ module Zaniah
|
|
|
88
166
|
LineLayout.new(line.text, glyphs.freeze, @limit, line.ascent, line.descent, line.size, carets.freeze)
|
|
89
167
|
end
|
|
90
168
|
|
|
91
|
-
def ellipsize(value)
|
|
92
|
-
|
|
169
|
+
def ellipsize(value, start)
|
|
170
|
+
overlays = overlays_for(start, start + value.bytesize)
|
|
171
|
+
return [value, value.bytesize] if @limit.infinite? || layout_with_overlays(value, start, overlays).width <= @limit
|
|
93
172
|
clusters = value.grapheme_clusters
|
|
94
|
-
clusters.pop while !clusters.empty? &&
|
|
95
|
-
|
|
173
|
+
clusters.pop while !clusters.empty? && layout_with_overlays(clusters.join + "…", start,
|
|
174
|
+
overlays.select { |overlay| overlay.offset - start <= clusters.join.bytesize }).width > @limit
|
|
175
|
+
visible = clusters.join
|
|
176
|
+
[visible + "…", visible.bytesize]
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
class OverlayParagraph
|
|
181
|
+
Row = Data.define(:paragraph, :start)
|
|
182
|
+
Block = Data.define(:key, :line, :offset, :width, :height, :position)
|
|
183
|
+
|
|
184
|
+
attr_reader :text, :lines, :width, :height, :inline_placements, :block_placements
|
|
185
|
+
|
|
186
|
+
def initialize(text, rows:, blocks:, width:)
|
|
187
|
+
@text, @lines, @inline_placements, @block_placements = text.dup.freeze, [], [], []
|
|
188
|
+
y = 0.0
|
|
189
|
+
rows.each_with_index do |row, row_index|
|
|
190
|
+
y = append_blocks(blocks, row_index, :above, y)
|
|
191
|
+
first_line = @lines.length
|
|
192
|
+
row.paragraph.lines.each do |line|
|
|
193
|
+
@lines << Paragraph::Line.new(line.layout, line.start + row.start,
|
|
194
|
+
line.finish + row.start, line.x, line.y + y, line.height)
|
|
195
|
+
end
|
|
196
|
+
row.paragraph.inline_placements.each do |placement|
|
|
197
|
+
@inline_placements << placement.with(offset: placement.offset + row.start,
|
|
198
|
+
y: placement.y + y, line: placement.line + first_line)
|
|
199
|
+
end
|
|
200
|
+
y += row.paragraph.height
|
|
201
|
+
y = append_blocks(blocks, row_index, :below, y)
|
|
202
|
+
end
|
|
203
|
+
@lines.freeze
|
|
204
|
+
@inline_placements.freeze
|
|
205
|
+
@block_placements.freeze
|
|
206
|
+
@width = [[@lines.map { |line| line.x + line.layout.width }.max || 0,
|
|
207
|
+
@block_placements.map { |placement| placement.x + placement.width }.max || 0].max, width].min
|
|
208
|
+
@height = y
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def hit_test(point)
|
|
212
|
+
placements = @inline_placements + @block_placements
|
|
213
|
+
if (overlay = placements.find { |item| Bounds.new(item.x, item.y, item.width, item.height).contains?(point) })
|
|
214
|
+
return overlay.offset
|
|
215
|
+
end
|
|
216
|
+
return 0 if @lines.empty?
|
|
217
|
+
line = @lines.find { |item| point.y < item.y + item.height } || @lines.last
|
|
218
|
+
[line.start + line.layout.index_for_x(point.x - line.x), line.finish].min
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def offset_to_point(offset)
|
|
222
|
+
offset = Integer(offset).clamp(0, @text.bytesize)
|
|
223
|
+
placement = @inline_placements.find { |item| item.offset == offset && item.align == :before }
|
|
224
|
+
line = placement ? @lines.fetch(placement.line) : @lines.find { |item| offset <= item.finish } || @lines.last
|
|
225
|
+
Point.new(line.x + line.layout.x_for_index((offset - line.start).clamp(0, line.layout.text.bytesize)), line.y)
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def line_range_at(offset)
|
|
229
|
+
line = @lines.find { |item| offset <= item.finish } || @lines.last
|
|
230
|
+
line.start...line.finish
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
private
|
|
234
|
+
|
|
235
|
+
def append_blocks(blocks, line, position, y)
|
|
236
|
+
blocks.each do |block|
|
|
237
|
+
next unless block.line == line && block.position == position
|
|
238
|
+
@block_placements << Paragraph::Placement.new(block.key, block.offset, 0, y,
|
|
239
|
+
block.width, block.height, line, position)
|
|
240
|
+
y += block.height
|
|
241
|
+
end
|
|
242
|
+
y
|
|
96
243
|
end
|
|
97
244
|
end
|
|
98
245
|
end
|
data/lib/zaniah/version.rb
CHANGED
data/sig/elements.rbs
CHANGED
|
@@ -88,6 +88,21 @@ module Zaniah
|
|
|
88
88
|
class Div < Element
|
|
89
89
|
end
|
|
90
90
|
class Text < Element
|
|
91
|
+
class InlineOverlay < Data
|
|
92
|
+
attr_reader element: _Renderable
|
|
93
|
+
attr_reader offset: Integer
|
|
94
|
+
attr_reader align: :before | :after
|
|
95
|
+
def self.new: (_Renderable element, Integer offset, :before | :after align) -> InlineOverlay
|
|
96
|
+
| (element: _Renderable, offset: Integer, align: :before | :after) -> InlineOverlay
|
|
97
|
+
end
|
|
98
|
+
class BlockOverlay < Data
|
|
99
|
+
attr_reader element: _Renderable
|
|
100
|
+
attr_reader line: Integer
|
|
101
|
+
attr_reader position: :above | :below
|
|
102
|
+
attr_reader height: Float
|
|
103
|
+
def self.new: (_Renderable element, Integer line, :above | :below position, Float height) -> BlockOverlay
|
|
104
|
+
| (element: _Renderable, line: Integer, position: :above | :below, height: Float) -> BlockOverlay
|
|
105
|
+
end
|
|
91
106
|
attr_reader text: String
|
|
92
107
|
attr_reader font_size: Numeric
|
|
93
108
|
attr_reader text_color: color
|
|
@@ -99,6 +114,11 @@ module Zaniah
|
|
|
99
114
|
def on_change: () { (String value) -> void } -> self
|
|
100
115
|
def placeholder: (String text, ?color: color?) -> self
|
|
101
116
|
def secure: (?bool value) -> self
|
|
117
|
+
def inline_overlay: (offset: Integer, element: _Renderable, ?align: :before | :after) -> self
|
|
118
|
+
def block_overlay: (line: Integer, element: _Renderable, ?position: :above | :below, height: Numeric) -> self
|
|
119
|
+
def remove_overlay: (_Renderable element) -> self
|
|
120
|
+
def hit_test: (Point point) -> Integer
|
|
121
|
+
def offset_to_point: (Integer offset) -> Point
|
|
102
122
|
def wrap: (?:none | :word | :anywhere mode, ?line_height: Numeric?, ?letter_spacing: Numeric, ?align: :start | :center | :end | :justify, ?ellipsis: bool, ?kinsoku: :hanging | :push | :none) -> self
|
|
103
123
|
def selectable: (?bool value) -> self
|
|
104
124
|
def editable: (?TextBuffer? buffer) -> self
|
data/sig/text.rbs
CHANGED
|
@@ -50,8 +50,30 @@ module Zaniah
|
|
|
50
50
|
class LineBreaker
|
|
51
51
|
def initialize: (String text, ?wrap: :none | :word | :anywhere, ?kinsoku: :hanging | :push | :none) -> void
|
|
52
52
|
def ranges: (Numeric width) { (String) -> Numeric } -> Array[Range[Integer]]
|
|
53
|
+
def ranges_with_offsets: (Numeric width) { (String, Integer, Integer) -> Numeric } -> Array[Range[Integer]]
|
|
53
54
|
end
|
|
54
55
|
class Paragraph
|
|
56
|
+
class InlineOverlay < Data
|
|
57
|
+
attr_reader key: Object
|
|
58
|
+
attr_reader offset: Integer
|
|
59
|
+
attr_reader width: Numeric
|
|
60
|
+
attr_reader height: Numeric
|
|
61
|
+
attr_reader align: :before | :after
|
|
62
|
+
def self.new: (Object key, Integer offset, Numeric width, Numeric height, :before | :after align) -> InlineOverlay
|
|
63
|
+
| (key: Object, offset: Integer, width: Numeric, height: Numeric, align: :before | :after) -> InlineOverlay
|
|
64
|
+
end
|
|
65
|
+
class Placement < Data
|
|
66
|
+
attr_reader key: Object
|
|
67
|
+
attr_reader offset: Integer
|
|
68
|
+
attr_reader x: Numeric
|
|
69
|
+
attr_reader y: Numeric
|
|
70
|
+
attr_reader width: Numeric
|
|
71
|
+
attr_reader height: Numeric
|
|
72
|
+
attr_reader line: Integer
|
|
73
|
+
attr_reader align: Symbol
|
|
74
|
+
def self.new: (Object key, Integer offset, Numeric x, Numeric y, Numeric width, Numeric height, Integer line, Symbol align) -> Placement
|
|
75
|
+
| (key: Object, offset: Integer, x: Numeric, y: Numeric, width: Numeric, height: Numeric, line: Integer, align: Symbol) -> Placement
|
|
76
|
+
end
|
|
55
77
|
class Line < Data
|
|
56
78
|
attr_reader layout: LineLayout
|
|
57
79
|
attr_reader start: Integer
|
|
@@ -66,7 +88,36 @@ module Zaniah
|
|
|
66
88
|
attr_reader lines: Array[Line]
|
|
67
89
|
attr_reader width: Numeric
|
|
68
90
|
attr_reader height: Numeric
|
|
69
|
-
|
|
91
|
+
attr_reader inline_placements: Array[Placement]
|
|
92
|
+
def initialize: (String text, width: Numeric, ?size: Numeric, ?font: _Font?, ?wrap: :none | :word | :anywhere, ?line_height: Numeric?, ?letter_spacing: Numeric, ?align: :start | :center | :end | :justify, ?ellipsis: bool, ?kinsoku: :hanging | :push | :none, ?typesetter: _LineTypesetter?, ?inline_overlays: Array[InlineOverlay]) -> void
|
|
93
|
+
def hit_test: (Point point) -> Integer
|
|
94
|
+
def offset_to_point: (Integer offset) -> Point
|
|
95
|
+
def line_range_at: (Integer offset) -> Range[Integer]
|
|
96
|
+
end
|
|
97
|
+
class OverlayParagraph
|
|
98
|
+
class Row < Data
|
|
99
|
+
attr_reader paragraph: Paragraph
|
|
100
|
+
attr_reader start: Integer
|
|
101
|
+
def self.new: (Paragraph paragraph, Integer start) -> Row
|
|
102
|
+
| (paragraph: Paragraph, start: Integer) -> Row
|
|
103
|
+
end
|
|
104
|
+
class Block < Data
|
|
105
|
+
attr_reader key: Object
|
|
106
|
+
attr_reader line: Integer
|
|
107
|
+
attr_reader offset: Integer
|
|
108
|
+
attr_reader width: Numeric
|
|
109
|
+
attr_reader height: Numeric
|
|
110
|
+
attr_reader position: :above | :below
|
|
111
|
+
def self.new: (Object key, Integer line, Integer offset, Numeric width, Numeric height, :above | :below position) -> Block
|
|
112
|
+
| (key: Object, line: Integer, offset: Integer, width: Numeric, height: Numeric, position: :above | :below) -> Block
|
|
113
|
+
end
|
|
114
|
+
attr_reader text: String
|
|
115
|
+
attr_reader lines: Array[Paragraph::Line]
|
|
116
|
+
attr_reader width: Numeric
|
|
117
|
+
attr_reader height: Numeric
|
|
118
|
+
attr_reader inline_placements: Array[Paragraph::Placement]
|
|
119
|
+
attr_reader block_placements: Array[Paragraph::Placement]
|
|
120
|
+
def initialize: (String text, rows: Array[Row], blocks: Array[Block], width: Numeric) -> void
|
|
70
121
|
def hit_test: (Point point) -> Integer
|
|
71
122
|
def offset_to_point: (Integer offset) -> Point
|
|
72
123
|
def line_range_at: (Integer offset) -> Range[Integer]
|
|
@@ -146,7 +197,7 @@ module Zaniah
|
|
|
146
197
|
attr_reader segmenter: _Segmenter
|
|
147
198
|
def initialize: (?font: _Font?, ?font_db: :native | _FontDB, ?capacity: Integer, ?shaper: :native | _Shaper, ?segmenter: :native | _Segmenter) -> void
|
|
148
199
|
def layout_line: (String text, ?font: _Font?, ?size: Numeric) -> LineLayout
|
|
149
|
-
def layout_paragraph: (String text, width: Numeric, ?size: Numeric, ?font: _Font?, ?wrap: :none | :word | :anywhere, ?line_height: Numeric?, ?letter_spacing: Numeric, ?align: :start | :center | :end | :justify, ?ellipsis: bool, ?kinsoku: :hanging | :push | :none) -> Paragraph
|
|
200
|
+
def layout_paragraph: (String text, width: Numeric, ?size: Numeric, ?font: _Font?, ?wrap: :none | :word | :anywhere, ?line_height: Numeric?, ?letter_spacing: Numeric, ?align: :start | :center | :end | :justify, ?ellipsis: bool, ?kinsoku: :hanging | :push | :none, ?inline_overlays: Array[Paragraph::InlineOverlay]) -> Paragraph
|
|
150
201
|
def fork: (?capacity: Integer) -> Typesetter
|
|
151
202
|
def close: () -> void
|
|
152
203
|
end
|
|
@@ -163,7 +214,7 @@ module Zaniah
|
|
|
163
214
|
def initialize: (?font: _Font?, ?font_db: :native | _FontDB, ?capacity: Integer, ?font_raster: :native | :alhena | :core_text | :coretext | :freetype | _Rasterizer, ?shaper: :native | _Shaper, ?segmenter: :native | _Segmenter, ?cache_dir: String?) -> void
|
|
164
215
|
def layout_line: (String text, ?font: _Font?, ?size: Numeric) -> LineLayout
|
|
165
216
|
def paint_line: (Scene scene, LineLayout line, x: Numeric, y: Numeric, ?color: color, ?spans: spans?) -> void
|
|
166
|
-
def paint_paragraph: (Scene scene, Paragraph paragraph, x: Numeric, y: Numeric, ?color: color) -> Scene
|
|
217
|
+
def paint_paragraph: (Scene scene, Paragraph | OverlayParagraph paragraph, x: Numeric, y: Numeric, ?color: color) -> Scene
|
|
167
218
|
def end_frame: () -> void
|
|
168
219
|
def prewarm: (?String text, ?size: Numeric) -> self
|
|
169
220
|
def close: () -> void
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: zaniah
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.4.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
|
-
-
|
|
7
|
+
- Yudai Takada
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-09-
|
|
11
|
+
date: 2026-09-15 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: alhena
|
|
@@ -16,14 +16,14 @@ dependencies:
|
|
|
16
16
|
requirements:
|
|
17
17
|
- - "~>"
|
|
18
18
|
- !ruby/object:Gem::Version
|
|
19
|
-
version: 0.
|
|
19
|
+
version: 0.2.0
|
|
20
20
|
type: :runtime
|
|
21
21
|
prerelease: false
|
|
22
22
|
version_requirements: !ruby/object:Gem::Requirement
|
|
23
23
|
requirements:
|
|
24
24
|
- - "~>"
|
|
25
25
|
- !ruby/object:Gem::Version
|
|
26
|
-
version: 0.
|
|
26
|
+
version: 0.2.0
|
|
27
27
|
- !ruby/object:Gem::Dependency
|
|
28
28
|
name: rexml
|
|
29
29
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -82,6 +82,7 @@ files:
|
|
|
82
82
|
- docs/adr/007-text-buffer.md
|
|
83
83
|
- docs/adr/008-accessibility-bridge.md
|
|
84
84
|
- docs/adr/009-grid-scope.md
|
|
85
|
+
- docs/adr/010-text-overlay-layout.md
|
|
85
86
|
- docs/adr/README.md
|
|
86
87
|
- docs/animation.md
|
|
87
88
|
- docs/components.md
|