hadar 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.
@@ -0,0 +1,377 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hadar
4
+ class RichTextWriteback
5
+ def initialize(projection, value)
6
+ @projection, @document, @text, @segments = projection, projection.document, projection.text, projection.segments
7
+ @value = value
8
+ end
9
+
10
+ def replace
11
+ value = @value
12
+ change = diff(value.text)
13
+ level_changes = paragraph_level_changes(value, change)
14
+ if change[:first] == change[:last] && change[:inserted].empty?
15
+ raise Error, "edit one list item level at a time" if level_changes.length > 1
16
+ return apply_list_level_change(level_changes.first) unless level_changes.empty?
17
+
18
+ style = style_change(value)
19
+ return @document unless style
20
+ return apply_style_change(style)
21
+ end
22
+ raise Error, "list levels cannot be changed together with text" unless level_changes.empty?
23
+
24
+ segment = editable_segment(change[:first], change[:last])
25
+ validate_styles!(value, change, segment)
26
+ local_first = change[:first] - segment.start
27
+ local_last = change[:last] - segment.start
28
+ replacement = segment.text.byteslice(0...local_first).to_s + change[:inserted] +
29
+ segment.text.byteslice(local_last..).to_s
30
+
31
+ case segment.node.type
32
+ when :text
33
+ source_text = @document.source.byteslice(segment.node.range)
34
+ raise Error, "escaped Markdown text cannot be edited through rich text yet" unless source_text == segment.text
35
+
36
+ first = segment.node.range.begin + local_first
37
+ last = segment.node.range.begin + local_last
38
+ Beid::Editing.edit_range(@document, segment.node, first...last, change[:inserted])
39
+ when :code_span
40
+ Beid::Editing.replace_text(@document, segment.node, replacement)
41
+ when :image
42
+ range = segment.node.attributes.fetch(:label_range)
43
+ source_text = @document.source.byteslice(range)
44
+ raise Error, "escaped image labels cannot be edited through rich text yet" unless source_text == segment.text
45
+
46
+ first = range.begin + local_first
47
+ last = range.begin + local_last
48
+ Beid::Editing.edit_range(@document, segment.node, first...last, change[:inserted])
49
+ else
50
+ raise Error, "rich text edit crosses Markdown structure"
51
+ end
52
+ end
53
+
54
+ private
55
+
56
+ def paragraph_level_changes(value, change)
57
+ if change[:first] != change[:last] || !change[:inserted].empty?
58
+ first_line = @text.byteslice(0...change[:first]).to_s.count("\n")
59
+ last_line = @text.byteslice(0...change[:last]).to_s.count("\n")
60
+ inherited = @projection.paragraph_styles.fetch(first_line, {})
61
+ expected = @projection.paragraph_styles.take(first_line) +
62
+ Array.new(change[:inserted].count("\n") + 1) { inherited } +
63
+ @projection.paragraph_styles.drop(last_line + 1)
64
+ raise Error, "list levels cannot be changed together with text" unless value.paragraph_styles == expected
65
+
66
+ return []
67
+ end
68
+
69
+ line_starts = [0]
70
+ @text.b.each_byte.with_index { |byte, index| line_starts << index + 1 if byte == 10 }
71
+ changes = {}
72
+ value.paragraph_styles.each_with_index do |style, index|
73
+ raise Error, "only list levels can be written as paragraph styles" unless (style.keys - [:level]).empty?
74
+
75
+ offset = line_starts.fetch(index, @text.bytesize)
76
+ segment = @segments.find do |candidate|
77
+ candidate.list_item && candidate.start <= offset && offset < candidate.finish
78
+ end
79
+ if segment
80
+ current = style[:level]
81
+ next if current == segment.list_level
82
+ raise Error, "list level must be a nonnegative integer" unless current.is_a?(Integer) && current >= 0
83
+
84
+ key = segment.list_item.object_id
85
+ change = {item: segment.list_item, level: current}
86
+ if changes.key?(key) && changes[key][:level] != current
87
+ raise Error, "one list item cannot have multiple levels"
88
+ end
89
+ changes[key] = change
90
+ elsif style.key?(:level)
91
+ raise Error, "list level can only be changed on an existing list item"
92
+ end
93
+ end
94
+ changes.values
95
+ end
96
+
97
+ def apply_list_level_change(change)
98
+ item = change.fetch(:item)
99
+ context = @projection.list_context.fetch(item.object_id)
100
+ current_level = context.fetch(:level)
101
+ target_level = change.fetch(:level)
102
+ unless (target_level - current_level).abs == 1
103
+ raise Error, "change list level one step at a time"
104
+ end
105
+
106
+ source = @document.source.b
107
+ line_start = physical_line_start(source, item.range.begin)
108
+ item_range = line_start...item.range.end
109
+ prefix = source.byteslice(line_start...item.range.begin).to_s
110
+ raise Error, "list item indentation is not whitespace" unless prefix.match?(/\A[ \t]*\z/)
111
+
112
+ target_indent = if target_level > current_level
113
+ previous = context[:previous] || raise(Error, "a list item needs a previous sibling before it can be indented")
114
+ list_item_content_indent(source, previous)
115
+ else
116
+ parent = context[:parent] || raise(Error, "a top-level list item cannot be outdented")
117
+ parent_start = physical_line_start(source, parent.range.begin)
118
+ indentation_columns(source.byteslice(parent_start...parent.range.begin).to_s)
119
+ end
120
+ target_parent_line = if target_level > current_level
121
+ source_line(context.fetch(:previous).range.begin, source)
122
+ else
123
+ parent_context = @projection.list_context.fetch(context.fetch(:parent).object_id)
124
+ parent_context[:parent] && source_line(parent_context[:parent].range.begin, source)
125
+ end
126
+ delta = target_indent - indentation_columns(prefix)
127
+ raise Error, "list level change has no source indentation change" if delta.zero?
128
+
129
+ replacement = shift_list_item(source.byteslice(item_range).to_s, delta)
130
+ updated = Beid::Editing.edit_range(@document, context.fetch(:list), item_range, replacement)
131
+ new_prefix = replacement[/\A[ \t]*/].to_s
132
+ updated_item, actual_level = find_list_item(updated.root, line_start + new_prefix.bytesize)
133
+ unless updated_item && actual_level == target_level && updated_item.marker == item.marker
134
+ raise Error, "list level change could not be represented without changing Markdown structure"
135
+ end
136
+ unless list_item_structure(updated.root, updated.source) ==
137
+ expected_list_item_structure(item, line_start, item_range.end, target_level - current_level, target_parent_line)
138
+ raise Error, "list level change would alter a neighboring list item"
139
+ end
140
+
141
+ updated
142
+ end
143
+
144
+ def physical_line_start(source, offset)
145
+ newline = source.rindex("\n".b, offset - 1)
146
+ newline ? newline + 1 : 0
147
+ end
148
+
149
+ def source_line(offset, source)
150
+ source.byteslice(0...offset).count("\n")
151
+ end
152
+
153
+ def list_item_content_indent(source, item)
154
+ line_start = physical_line_start(source, item.range.begin)
155
+ line_end = source.index("\n".b, item.range.begin) || source.bytesize
156
+ line = source.byteslice(line_start...line_end).to_s
157
+ marker = /\A[ \t]*(?:[-+*]|\d{1,9}[.)])[ \t]+/.match(line)
158
+ raise Error, "list item marker cannot be indented safely" unless marker
159
+
160
+ indentation_columns(marker[0])
161
+ end
162
+
163
+ def indentation_columns(prefix)
164
+ prefix.each_char.reduce(0) { |column, character| character == "\t" ? (column / 4 + 1) * 4 : column + 1 }
165
+ end
166
+
167
+ def shift_list_item(text, delta)
168
+ text.each_line.map do |line|
169
+ body = line.sub(/(?:\r\n|\r|\n)\z/, "")
170
+ next line if body.match?(/\A[ \t]*\z/)
171
+ next (" " * delta) + line if delta.positive?
172
+
173
+ remove_indent(line, -delta)
174
+ end.join
175
+ end
176
+
177
+ def remove_indent(line, columns)
178
+ prefix = line[/\A[ \t]*/].to_s
179
+ removed_columns = 0
180
+ removed_bytes = 0
181
+ prefix.each_char do |character|
182
+ width = character == "\t" ? (removed_columns / 4 + 1) * 4 - removed_columns : 1
183
+ raise Error, "list item indentation cannot be removed evenly" if removed_columns + width > columns
184
+
185
+ removed_columns += width
186
+ removed_bytes += character.bytesize
187
+ break if removed_columns == columns
188
+ end
189
+ raise Error, "list item has a continuation line with insufficient indentation" unless removed_columns == columns
190
+
191
+ line.byteslice(removed_bytes..).to_s
192
+ end
193
+
194
+ def find_list_item(node, offset, level = 0)
195
+ if %i[list ordered_list].include?(node.type)
196
+ node.children.each do |item|
197
+ return [item, level] if item.type == :list_item && item.range.begin == offset
198
+
199
+ item.children.each do |child|
200
+ found = find_list_item(child, offset, level + 1)
201
+ return found if found
202
+ end
203
+ end
204
+ else
205
+ node.children.each do |child|
206
+ found = find_list_item(child, offset, level)
207
+ return found if found
208
+ end
209
+ end
210
+ nil
211
+ end
212
+
213
+ def list_item_structure(root, source)
214
+ items = {}
215
+ collect_list_item_structure(root, source, 0, nil, items)
216
+ items.transform_values { |entry| {level: entry[:level], parent: entry[:parent]} }
217
+ end
218
+
219
+ def expected_list_item_structure(item, line_start, item_end, level_delta, target_parent_line)
220
+ source = @document.source.b
221
+ original = {}
222
+ collect_list_item_structure(@document.root, source, 0, nil, original)
223
+ selected_line = source_line(item.range.begin, source)
224
+ original.to_h do |line, entry|
225
+ inside_item = entry[:offset] >= line_start && entry[:offset] < item_end
226
+ expected = {level: entry[:level] + (inside_item ? level_delta : 0), parent: entry[:parent]}
227
+ expected[:parent] = target_parent_line if line == selected_line
228
+ [line, expected]
229
+ end.tap do |depths|
230
+ raise Error, "list item is not part of its projected source" unless depths.key?(selected_line)
231
+ end
232
+ end
233
+
234
+ def collect_list_item_structure(node, source, level, parent_line, items)
235
+ if %i[list ordered_list].include?(node.type)
236
+ node.children.each do |item|
237
+ line = source_line(item.range.begin, source)
238
+ items[line] = {level: level, parent: parent_line, offset: item.range.begin}
239
+ item.children.each { |child| collect_list_item_structure(child, source, level + 1, line, items) }
240
+ end
241
+ else
242
+ node.children.each { |child| collect_list_item_structure(child, source, level, parent_line, items) }
243
+ end
244
+ end
245
+
246
+ def diff(value)
247
+ raise ArgumentError, "rich text must be valid UTF-8" unless value.is_a?(String) && value.valid_encoding?
248
+
249
+ old_clusters, new_clusters = @text.scan(/\X/), value.scan(/\X/)
250
+ prefix = 0
251
+ prefix += 1 while prefix < old_clusters.length && prefix < new_clusters.length &&
252
+ old_clusters[prefix] == new_clusters[prefix]
253
+ suffix = 0
254
+ suffix += 1 while suffix < old_clusters.length - prefix && suffix < new_clusters.length - prefix &&
255
+ old_clusters[-suffix - 1] == new_clusters[-suffix - 1]
256
+ first = old_clusters.take(prefix).sum(&:bytesize)
257
+ last = old_clusters.take(old_clusters.length - suffix).sum(&:bytesize)
258
+ inserted_finish = new_clusters.take(new_clusters.length - suffix).sum(&:bytesize)
259
+ {first: first, last: last, inserted: value.byteslice(first...inserted_finish).to_s,
260
+ old_clusters: old_clusters, new_clusters: new_clusters, prefix: prefix, suffix: suffix}
261
+ end
262
+
263
+ def editable_segment(first, last)
264
+ candidates = @segments.select do |segment|
265
+ segment.node && if first == last
266
+ segment.start < first && first <= segment.finish
267
+ else
268
+ segment.start <= first && last <= segment.finish
269
+ end
270
+ end
271
+ candidates = @segments.select { |segment| segment.node && segment.start <= first && first < segment.finish } if candidates.empty? && first == last
272
+ segment = candidates.first
273
+ raise Error, "rich text edit must stay within one Markdown text run" unless segment
274
+ unless %i[text code_span image].include?(segment.node.type)
275
+ raise Error, "rich text edit crosses Markdown structure"
276
+ end
277
+
278
+ segment
279
+ end
280
+
281
+ def style_change(value)
282
+ clusters = @text.scan(/\X/)
283
+ changes = []
284
+ clusters.each_with_index do |cluster, index|
285
+ offset = cluster_offset(clusters, index)
286
+ original = old_style_at(offset)
287
+ current = rich_style_at(value, offset)
288
+ delta = (original.keys | current.keys).each_with_object({}) do |key, result|
289
+ result[key] = [original[key], current[key]] unless original[key] == current[key]
290
+ end
291
+ next if delta.empty?
292
+
293
+ finish = offset + cluster.bytesize
294
+ if changes.last && changes.last[:finish] == offset && changes.last[:delta] == delta
295
+ changes.last[:finish] = finish
296
+ else
297
+ changes << {first: offset, finish: finish, delta: delta}
298
+ end
299
+ end
300
+ return if changes.empty?
301
+ raise Error, "rich text style changes must target one source run" unless changes.one?
302
+
303
+ changes.first
304
+ end
305
+
306
+ def apply_style_change(change)
307
+ segment = editable_segment(change[:first], change[:finish])
308
+ raise Error, "only Markdown text runs can be restyled" unless segment.node.type == :text
309
+ source_text = @document.source.byteslice(segment.node.range)
310
+ raise Error, "escaped Markdown text cannot be restyled through rich text yet" unless source_text == segment.text
311
+
312
+ formats = change[:delta]
313
+ raise Error, "only bold and italic styles can be written to Markdown" unless (formats.keys - %i[bold italic]).empty?
314
+ additions = formats.select { |_key, (old, new)| new == true && old != true }.keys
315
+ removals = formats.select { |_key, (old, new)| old == true && new.nil? }.keys
316
+ raise Error, "rich text style changes cannot be represented by Markdown" unless additions.length + removals.length == formats.length
317
+ raise Error, "combine only one Markdown style change at a time" if additions.any? && removals.any?
318
+
319
+ if additions.any?
320
+ first = segment.node.range.begin + change[:first] - segment.start
321
+ last = segment.node.range.begin + change[:finish] - segment.start
322
+ selected = source_text.byteslice((first - segment.node.range.begin)...(last - segment.node.range.begin))
323
+ opening = (additions.include?(:bold) ? "**" : "") + (additions.include?(:italic) ? "_" : "")
324
+ closing = (additions.include?(:italic) ? "_" : "") + (additions.include?(:bold) ? "**" : "")
325
+ return Beid::Editing.edit_range(@document, segment.node, first...last, opening + selected + closing)
326
+ end
327
+
328
+ raise Error, "remove one Markdown style at a time" unless removals.one?
329
+ key = removals.first
330
+ wrapper = segment.wrappers.reverse.find do |style, node|
331
+ style == key && node.children == [segment.node] &&
332
+ segment.start == change[:first] && segment.finish == change[:finish]
333
+ end&.last
334
+ raise Error, "removing a style requires selecting its complete Markdown run" unless wrapper
335
+
336
+ marker_size = wrapper.marker.bytesize
337
+ inner = @document.source.byteslice((wrapper.range.begin + marker_size)...(wrapper.range.end - marker_size))
338
+ Beid::Editing.edit_range(@document, wrapper, wrapper.range, inner)
339
+ end
340
+
341
+ def rich_style_at(value, offset)
342
+ value.spans.find { |span| offset >= span.start && offset < span.finish }&.style || {}
343
+ end
344
+
345
+ def validate_styles!(value, change, segment)
346
+ old_clusters = change[:old_clusters]
347
+ new_clusters = change[:new_clusters]
348
+ old_end = old_clusters.length - change[:suffix]
349
+ new_end = new_clusters.length - change[:suffix]
350
+ change[:prefix].times do |index|
351
+ check_style!(value, new_clusters, index, old_style_at(cluster_offset(old_clusters, index)))
352
+ end
353
+ change[:suffix].times do |index|
354
+ old_index = old_end + index
355
+ new_index = new_end + index
356
+ check_style!(value, new_clusters, new_index, old_style_at(cluster_offset(old_clusters, old_index)))
357
+ end
358
+ (change[:prefix]...new_end).each do |index|
359
+ check_style!(value, new_clusters, index, segment.style)
360
+ end
361
+ end
362
+
363
+ def check_style!(value, clusters, index, expected)
364
+ offset = cluster_offset(clusters, index)
365
+ actual = rich_style_at(value, offset)
366
+ raise Error, "rich-text style changes cannot yet be written back to Markdown" unless actual == expected
367
+ end
368
+
369
+ def old_style_at(offset)
370
+ @segments.find { |segment| offset >= segment.start && offset < segment.finish }&.style || {}
371
+ end
372
+
373
+ def cluster_offset(clusters, index)
374
+ clusters.take(index).sum(&:bytesize)
375
+ end
376
+ end
377
+ end
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hadar
4
+ class Slide
5
+ attr_reader :index, :document, :nodes, :layout, :slots, :theme, :notes
6
+
7
+ def initialize(index:, document:, nodes:, layout: nil, theme:, deck:)
8
+ @index, @document, @nodes, @theme, @deck = index, document, nodes.freeze, theme, deck
9
+ @notes, @notes_ranges = extract_notes
10
+ @layout = Layout.select(content_nodes, requested: layout)
11
+ @slots = build_slots.freeze
12
+ freeze
13
+ end
14
+
15
+ def slot(name)
16
+ slots.fetch(name.to_sym) do
17
+ Slot.new(name: name, nodes: [], document: document, deck: @deck, slide_index: index)
18
+ end
19
+ end
20
+
21
+ def title = slot(:title).text
22
+
23
+ private
24
+
25
+ def content_nodes
26
+ nodes.reject { |node| layout_directive?(node) || speaker_note_node?(node) }
27
+ end
28
+
29
+ def extract_notes
30
+ entries = nodes.filter_map do |node|
31
+ if note_directive?(node)
32
+ [node.range, normalize_notes(node.attributes.dig(:values, "notes"))]
33
+ elsif node.type == :html_block
34
+ multiline_notes(node)
35
+ end
36
+ end
37
+ [entries.empty? ? nil : entries.map(&:last).join("\n\n").freeze,
38
+ entries.flat_map { |range, _| note_ranges_for(range) }.freeze]
39
+ end
40
+
41
+ def multiline_notes(node)
42
+ source = document.source
43
+ return unless source.byteslice(node.range).match?(/\A[ \t]{0,3}<!--[ \t]*notes[ \t]*:/)
44
+
45
+ terminator = source.b.index("-->".b, node.range.begin)
46
+ return unless terminator && terminator + 3 <= nodes.last.range.end
47
+
48
+ range = node.range.begin...(terminator + 3)
49
+ values = Beid::Directive.parse(source.byteslice(range))
50
+ return unless values&.key?("notes")
51
+
52
+ [range, normalize_notes(values.fetch("notes"))]
53
+ end
54
+
55
+ def note_ranges_for(range)
56
+ nodes.filter_map do |node|
57
+ node.range if node.range.begin < range.end && range.begin < node.range.end
58
+ end
59
+ end
60
+
61
+ def normalize_notes(value)
62
+ value.to_s.sub(/\A(?:\r\n|\r|\n)/, "").sub(/(?:\r\n|\r|\n)\z/, "")
63
+ end
64
+
65
+ def note_directive?(node)
66
+ node.type == :directive && node.attributes[:kind] == :html_comment &&
67
+ node.attributes.dig(:values, "notes")
68
+ end
69
+
70
+ def speaker_note_node?(node)
71
+ note_directive?(node) || @notes_ranges.any? do |range|
72
+ node.range.begin < range.end && range.begin < node.range.end
73
+ end
74
+ end
75
+
76
+ def build_slots
77
+ content = content_nodes
78
+ definition = Layout.fetch(layout)
79
+ mapping = definition.slots.to_h { |name| [name, []] }
80
+ case layout
81
+ when :title
82
+ headings = content.select { |node| node.type == :heading }
83
+ mapping[:title] = headings.take(1)
84
+ mapping[:subtitle] = headings.drop(1)
85
+ when :title_body
86
+ heading = content.find { |node| node.type == :heading }
87
+ mapping[:title] = [heading].compact
88
+ mapping[:body] = content.reject { |node| node.equal?(heading) }
89
+ when :two_column
90
+ mapping[:title] = content.find { |node| node.type == :heading }&.then { |node| [node] } || []
91
+ content.each do |node|
92
+ next unless node.type == :directive && node.attributes[:kind] == :div
93
+
94
+ target = node.attributes[:name].to_s.to_sym
95
+ mapping[target] = node.children if %i[left right].include?(target)
96
+ end
97
+ when :image_text
98
+ image = content.flat_map { |node| descendants(node, :image) }.first
99
+ mapping[:image] = [image].compact
100
+ mapping[:title] = content.find { |node| node.type == :heading }&.then { |node| [node] } || []
101
+ mapping[:text] = content.reject { |node| node.equal?(mapping[:title].first) || contains?(node, image) }
102
+ when :full_bleed_image
103
+ mapping[:image] = content.flat_map { |node| descendants(node, :image) }
104
+ when :quote
105
+ mapping[:quote] = content.select { |node| node.type == :block_quote }
106
+ mapping[:attribution] = content.reject { |node| node.type == :block_quote }
107
+ when :code
108
+ mapping[:title] = content.find { |node| node.type == :heading }&.then { |node| [node] } || []
109
+ mapping[:code] = content.select { |node| node.type == :code_block }
110
+ end
111
+ mapping.to_h do |name, entries|
112
+ [name, Slot.new(name: name, nodes: entries, document: document,
113
+ deck: @deck, slide_index: index)]
114
+ end
115
+ end
116
+
117
+ def layout_directive?(node)
118
+ node.type == :directive && node.attributes[:kind] == :html_comment &&
119
+ node.attributes[:values]&.key?("layout")
120
+ end
121
+
122
+ def descendants(node, type)
123
+ [node, *node.children.flat_map { |child| descendants(child, type) }].select { |entry| entry.type == type }
124
+ end
125
+
126
+ def contains?(node, target)
127
+ target && (node.equal?(target) || node.children.any? { |child| contains?(child, target) })
128
+ end
129
+ end
130
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hadar
4
+ class SlideList
5
+ DEFAULT_ROW_HEIGHT = 180
6
+
7
+ attr_reader :deck, :element, :selected_index
8
+
9
+ def initialize(deck, renderer: Renderer.new, selected: nil,
10
+ row_height: DEFAULT_ROW_HEIGHT, on_select: nil)
11
+ raise TypeError, "deck must be a Hadar::Deck" unless deck.is_a?(Deck)
12
+ raise TypeError, "renderer must respond to build" unless renderer.respond_to?(:build)
13
+ raise TypeError, "on_select must be callable" if on_select && !on_select.respond_to?(:call)
14
+
15
+ @deck, @renderer, @selected_index, @on_select = deck, renderer, selected, on_select
16
+ @row_height = Float(row_height)
17
+ raise ArgumentError, "row_height must be finite and positive" unless @row_height.finite? && @row_height.positive?
18
+ validate_selection!(selected) unless selected.nil?
19
+
20
+ @element = Zaniah::UniformList.new(count: deck.length, row_height: @row_height) do |index|
21
+ render_slide(index)
22
+ end
23
+ end
24
+
25
+ def build(width:, height:)
26
+ [width, height].each do |dimension|
27
+ unless dimension.is_a?(Numeric) && dimension.finite? && dimension.positive?
28
+ raise ArgumentError, "list width and height must be finite and positive"
29
+ end
30
+ end
31
+ element.style(width: width, height: height)
32
+ end
33
+
34
+ def select(index)
35
+ validate_selection!(index)
36
+ @selected_index = index
37
+ slide = deck.slide(index)
38
+ @on_select&.call(slide, index)
39
+ slide
40
+ end
41
+
42
+ private
43
+
44
+ def render_slide(index)
45
+ slide = deck.slide(index)
46
+ selected = index == selected_index
47
+ Zaniah::Div.new.key(index).w_full.h_full.overflow_hidden
48
+ .border(selected ? 3 : 1)
49
+ .border_color(slide.theme.colors.fetch(selected ? "accent" : "muted"))
50
+ .on_click do |_event, context|
51
+ select(index)
52
+ context.window.request_frame
53
+ end
54
+ .child(thumbnail_for(slide))
55
+ end
56
+
57
+ def thumbnail_for(slide)
58
+ preview = @renderer.build(slide)
59
+ compact_text(preview)
60
+ end
61
+
62
+ def compact_text(element)
63
+ element.wrap(:none) if element.is_a?(Zaniah::Text)
64
+ element.children.each { |child| compact_text(child) }
65
+ element
66
+ end
67
+
68
+ def validate_selection!(index)
69
+ unless index.is_a?(Integer) && (0...deck.length).cover?(index)
70
+ raise IndexError, "slide index is outside the deck"
71
+ end
72
+ end
73
+ end
74
+ end