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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0f0262e29ecc34754628eac6e0e6b2474697686c0f971d4f1d1ca633783b0759
4
- data.tar.gz: 433ff5ae3813cd6dd0569806c773910e46c321978b0e590bfa3768e67b51e97d
3
+ metadata.gz: f6787d1edc51a6a692859fe62834270cfa2339ade8b00e596e8861632eb59913
4
+ data.tar.gz: 7407abf9f7a64c80fc307843ffeadd9d130cfb45a12673a85f6cbedf2710a3e4
5
5
  SHA512:
6
- metadata.gz: e5c369c73ee00cc45d0e6fd81fa5ece2f29b81e920dbcd98f7e0e99dd7f4f8eb003b6c7375b8f1c371273049c4ce64832b17919bb640fdf47ea00ecef6e3803f
7
- data.tar.gz: 240f51e2f1b7dde1acda418cbfd8bf2dc7789ee1634729bd055e8b48b93189c954aec844bda2a3fe96ac559b1d676af0eba1c7fa42458f274ea9ee853f5f5b78
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.
@@ -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
- @line = cx.text_system ? cx.text_system.layout_line(value, font: @font, size: @font_size) : approximate_line(value)
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
- measurement = @measure || if @wrap == :none && !@ellipsis
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.finite? ? [width, 0].max : Float::INFINITY
70
- @paragraph = paragraph(value, limit, cx.text_system)
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 = Point.new(position.x - @text_bounds.x, position.y - @text_bounds.y)
139
- @paragraph ? @paragraph.hit_test(point) : @line ? @line.index_for_x(point.x) : 0
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.each do |line, start, finish, x, y, height|
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
- cx.scene.quad(bounds.x + x + left, bounds.y + y, [right - left, 1].max, height,
223
- color: cx.theme.colors.selection)
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
- attr_reader :text, :lines, :width, :height
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.ranges(@limit) { |value| layout(value).width }
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
- source = ellipsize(source) if @ellipsis && wrap == :none
25
- line = spaced(layout(source))
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
- Line.new(line, range.begin, range.end, x, index * @line_height, @line_height)
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 = @lines.length * @line_height
48
+ @height = y
37
49
  end
38
50
 
39
51
  def hit_test(point)
40
52
  return 0 if @lines.empty?
41
- line = @lines[(point.y / @line_height).floor.clamp(0, @lines.length - 1)]
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
- line = @lines.find { |item| offset <= item.finish } || @lines.last
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
- LineLayout.new(line.text, glyphs.freeze, carets.last.last, line.ascent, line.descent, line.size, carets.freeze)
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
- return value if @limit.infinite? || layout(value).width <= @limit
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? && layout(clusters.join + "…").width > @limit
95
- clusters.join + "…"
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Zaniah
4
- VERSION = "0.3.0"
4
+ VERSION = "0.4.0"
5
5
  end
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
- 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?) -> void
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.3.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
- - ydah
7
+ - Yudai Takada
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-09-14 00:00:00.000000000 Z
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.1.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.1.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