zaniah 0.6.2 → 0.6.4

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.
@@ -34,34 +34,324 @@ module Zaniah
34
34
  end
35
35
 
36
36
  class RichText < Component
37
- attr_reader :runs
37
+ Span = Data.define(:start, :finish, :style)
38
+ STYLE_KEYS = %i[bold italic size color font link].freeze
39
+ LIST_STYLES = %i[none bullet ordered].freeze
38
40
 
39
- def initialize(runs, selectable: true)
41
+ attr_reader :buffer, :selection, :paragraph_styles
42
+
43
+ def initialize(runs, selectable: true, editable: false)
40
44
  super()
41
- @runs, @selectable = normalize_runs(runs), !!selectable
45
+ text, @spans = normalize_runs(runs)
46
+ @buffer = TextBuffer.new(text)
47
+ @paragraph_styles = Array.new(text.count("\n") + 1) { {} }
48
+ @selection, @selectable, @editable = TextSelection.new(0), !!selectable, !!editable
42
49
  end
43
50
 
44
- def build(cx)
45
- Div.new.flex_row.style(flex_wrap: :wrap).children(@runs.map do |run|
46
- text = Text.new(run[:text], size: run[:size] || cx.theme.typography.size_md,
47
- color: run[:color] || cx.theme.colors.text)
48
- text.selectable if @selectable
49
- text
51
+ def text = @buffer.to_s
52
+
53
+ def runs
54
+ slices = []
55
+ offset = 0
56
+ @spans.each do |span|
57
+ if offset < span.start
58
+ slices << {text: text.byteslice(offset...span.start)}
59
+ end
60
+ slices << {text: text.byteslice(span.start...span.finish), **span.style}
61
+ offset = span.finish
62
+ end
63
+ slices << {text: text.byteslice(offset..)} if offset < text.bytesize
64
+ slices.freeze
65
+ end
66
+
67
+ def spans = @spans
68
+ def editable? = @editable
69
+ def on_change(&block) = (@on_change = block; self)
70
+ def on_select(&block) = (@on_select = block; self)
71
+
72
+ def selection=(value)
73
+ raise ArgumentError, "expected a TextSelection" unless value.is_a?(TextSelection)
74
+ checked_offset(value.anchor)
75
+ checked_offset(value.head)
76
+ @selection = value
77
+ @on_select&.call(value, self)
78
+ @cx&.window&.request_frame
79
+ value
80
+ end
81
+
82
+ def apply(range, style)
83
+ first, finish = checked_range(range)
84
+ changes = normalize_style(style)
85
+ return self if first == finish || changes.empty?
86
+ boundaries = [0, text.bytesize, first, finish, *@spans.flat_map { |span| [span.start, span.finish] }].uniq.sort
87
+ @spans = merge_spans(boundaries.each_cons(2).filter_map do |from, to|
88
+ next if from == to
89
+ current = style_for_range(from)
90
+ updated = current.dup
91
+ changes.each { |key, value| value.nil? ? updated.delete(key) : updated[key] = value } if from >= first && to <= finish
92
+ Span.new(from, to, updated.freeze) unless updated.empty?
93
+ end)
94
+ changed
95
+ end
96
+
97
+ def insert(offset, value, style: nil)
98
+ offset = checked_offset(offset)
99
+ validate_text(value)
100
+ return self if value.empty?
101
+ inherited = style ? normalize_style(style) : style_at(offset)
102
+ size, newlines = value.bytesize, value.count("\n")
103
+ @buffer.insert(offset, value)
104
+ @spans = merge_spans(@spans.flat_map do |span|
105
+ if span.finish <= offset
106
+ [span]
107
+ elsif span.start >= offset
108
+ [Span.new(span.start + size, span.finish + size, span.style)]
109
+ else
110
+ [Span.new(span.start, offset, span.style), Span.new(offset + size, span.finish + size, span.style)]
111
+ end
112
+ end + (inherited.empty? ? [] : [Span.new(offset, offset + size, inherited.freeze)]))
113
+ if newlines.positive?
114
+ line = @buffer.line_at(offset)
115
+ @paragraph_styles.insert(line + 1, *Array.new(newlines) { @paragraph_styles[line].dup.freeze })
116
+ end
117
+ sync_paragraph_styles
118
+ @selection = TextSelection.new(offset + size)
119
+ changed
120
+ end
121
+
122
+ def delete(range)
123
+ first, finish = checked_range(range)
124
+ return self if first == finish
125
+ old = text
126
+ first_line = @buffer.line_at(first)
127
+ removed_lines = old.byteslice(first...finish).count("\n")
128
+ @buffer.delete(first...finish)
129
+ delta = finish - first
130
+ @spans = merge_spans(@spans.filter_map do |span|
131
+ from = map_offset(span.start, first, finish, delta)
132
+ to = map_offset(span.finish, first, finish, delta)
133
+ Span.new(from, to, span.style) if to > from
50
134
  end)
135
+ @paragraph_styles.slice!(first_line + 1, removed_lines) if removed_lines.positive?
136
+ sync_paragraph_styles
137
+ @selection = TextSelection.new(first)
138
+ changed
139
+ end
140
+
141
+ def replace(range, value, style: nil)
142
+ first, finish = checked_range(range)
143
+ validate_text(value)
144
+ return delete(first...finish) if value.empty?
145
+ inherited = style ? normalize_style(style) : style_at(first)
146
+ first_line, last_line = @buffer.line_at(first), @buffer.line_at(finish)
147
+ before_styles = @paragraph_styles.take(first_line)
148
+ paragraph_style = @paragraph_styles.fetch(first_line, {}).dup.freeze
149
+ after_styles = @paragraph_styles.drop(last_line + 1)
150
+ @buffer.replace(first...finish, value)
151
+ delta = finish - first
152
+ size = value.bytesize
153
+ replacement_spans = @spans.flat_map do |span|
154
+ if finish == first
155
+ if span.finish <= first
156
+ [span]
157
+ elsif span.start >= first
158
+ [Span.new(span.start + size, span.finish + size, span.style)]
159
+ else
160
+ [Span.new(span.start, first, span.style), Span.new(first + size, span.finish + size, span.style)]
161
+ end
162
+ elsif span.finish <= first
163
+ [span]
164
+ elsif span.start >= finish
165
+ [Span.new(span.start - delta + size, span.finish - delta + size, span.style)]
166
+ else
167
+ pieces = []
168
+ pieces << Span.new(span.start, first, span.style) if span.start < first
169
+ pieces << Span.new(first + size, span.finish - delta + size, span.style) if span.finish > finish
170
+ pieces
171
+ end
172
+ end
173
+ replacement_spans << Span.new(first, first + size, inherited.freeze) unless inherited.empty?
174
+ @spans = merge_spans(replacement_spans)
175
+ replacement_paragraphs = Array.new(value.count("\n") + 1) { paragraph_style }
176
+ @paragraph_styles = before_styles + replacement_paragraphs + after_styles
177
+ sync_paragraph_styles
178
+ @selection = TextSelection.new(first + size)
179
+ changed
180
+ end
181
+
182
+ def paragraph_style(range, align: nil, list: nil, level: nil)
183
+ first, finish = checked_range(range)
184
+ raise ArgumentError, "align must be start, center, end, or justify" if align && !%i[start center end justify].include?(align)
185
+ raise ArgumentError, "list must be none, bullet, or ordered" if list && !LIST_STYLES.include?(list)
186
+ raise ArgumentError, "level must be a nonnegative integer" if level && (!level.is_a?(Integer) || level.negative?)
187
+ first_line, last_line = @buffer.line_at(first), @buffer.line_at(finish)
188
+ last_line = [last_line, @paragraph_styles.length - 1].min
189
+ values = {align: align, list: list, level: level}.compact
190
+ (first_line..last_line).each { |index| @paragraph_styles[index] = @paragraph_styles[index].merge(values).freeze }
191
+ @on_change&.call(text, self)
192
+ @cx&.window&.request_frame
193
+ self
194
+ end
195
+
196
+ def build(cx) = (@cx = cx; RichTextSurface.new(self))
197
+ def tui_cells(*) = text
198
+ def accessibility_node(_cx) = node(@editable ? :textbox : :text, label: "Rich text", value: text,
199
+ states: {multiline: true, readonly: !@editable}, actions: @editable ? %i[focus set_value] : [])
200
+
201
+ def display_text
202
+ return text unless @buffer.composition
203
+ range = @selection.range
204
+ text.byteslice(0...range.begin) + @buffer.composition.text + text.byteslice(range.end..)
205
+ end
206
+
207
+ def text_action(action)
208
+ head = @selection.head
209
+ case action
210
+ when :select_all then self.selection = TextSelection.new(0, text.bytesize)
211
+ when :move_left then self.selection = TextSelection.new(@selection.collapsed? ? Unicode.previous_boundary(text, head) : @selection.range.begin)
212
+ when :move_right then self.selection = TextSelection.new(@selection.collapsed? ? Unicode.next_boundary(text, head) : @selection.range.end)
213
+ when :select_left then self.selection = TextSelection.new(@selection.anchor, Unicode.previous_boundary(text, head))
214
+ when :select_right then self.selection = TextSelection.new(@selection.anchor, Unicode.next_boundary(text, head))
215
+ when :line_start, :select_line_start then move_line_edge(action, :start)
216
+ when :line_end, :select_line_end then move_line_edge(action, :end)
217
+ when :delete_backward then delete_backward
218
+ when :delete_forward then delete_forward
219
+ when :insert_newline then replace_selection("\n") if @editable
220
+ else return false
221
+ end
222
+ true
223
+ end
224
+
225
+ def input(event)
226
+ case event
227
+ when Input::TextInput
228
+ return false unless @editable
229
+ replace_selection(event.text)
230
+ when Input::Composition
231
+ return false unless @editable
232
+ @buffer.set_composition(event.text, selection: event.selection)
233
+ else return false
234
+ end
235
+ true
236
+ end
237
+
238
+ def changed
239
+ @on_change&.call(text, self)
240
+ @cx&.window&.request_frame
241
+ self
242
+ end
243
+
244
+ def style_at(offset)
245
+ span = @spans.find { |item| offset >= item.start && offset < item.finish } ||
246
+ @spans.reverse.find { |item| item.finish == offset }
247
+ span ? span.style.dup : {}
248
+ end
249
+
250
+ def style_for_range(offset)
251
+ @spans.find { |span| offset >= span.start && offset < span.finish }&.style&.dup || {}
51
252
  end
52
253
 
53
- def tui_cells(*) = @runs.map { |run| run[:text] }.join
54
- def accessibility_node(_cx) = node(:text, label: tui_cells)
254
+ def paragraph_style_at(offset)
255
+ @paragraph_styles.fetch(@buffer.line_at([offset, text.bytesize].min), {})
256
+ end
55
257
 
56
258
  private
57
259
 
58
260
  def normalize_runs(value)
59
- Array(value).map do |run|
261
+ text, spans = +"", []
262
+ Array(value).each do |run|
60
263
  run = {text: run} unless run.is_a?(Hash)
61
264
  raise ArgumentError, "rich text runs require text" unless run.key?(:text)
62
- {text: run[:text].to_s.encode(Encoding::UTF_8), color: run[:color], size: run[:size]}.freeze
265
+ part = run[:text].to_s.encode(Encoding::UTF_8)
266
+ style = normalize_style(run.reject { |key, _| key.to_sym == :text })
267
+ first = text.bytesize
268
+ text << part
269
+ spans << Span.new(first, text.bytesize, style.freeze) unless part.empty? || style.empty?
270
+ end
271
+ [text, merge_spans(spans)]
272
+ end
273
+
274
+ def normalize_style(value)
275
+ raise ArgumentError, "rich text style must be a Hash" unless value.is_a?(Hash)
276
+ style = value.to_h { |key, item| [key.to_sym, item] }
277
+ unknown = style.keys - STYLE_KEYS
278
+ raise ArgumentError, "unsupported rich text style: #{unknown.join(', ')}" unless unknown.empty?
279
+ if style[:size] && (!style[:size].is_a?(Numeric) || !style[:size].finite? || !style[:size].positive?)
280
+ raise ArgumentError, "font size must be finite and positive"
281
+ end
282
+ style
283
+ end
284
+
285
+ def merge_spans(spans)
286
+ spans.sort_by(&:start).each_with_object([]) do |span, result|
287
+ next if span.start == span.finish
288
+ previous = result.last
289
+ if previous && previous.finish == span.start && previous.style == span.style
290
+ result[-1] = Span.new(previous.start, span.finish, span.style)
291
+ else
292
+ result << span
293
+ end
63
294
  end.freeze
64
295
  end
296
+
297
+ def checked_range(range)
298
+ raise ArgumentError, "expected a Range" unless range.is_a?(Range)
299
+ first, finish = Integer(range.begin), Integer(range.end)
300
+ finish += 1 unless range.exclude_end?
301
+ checked_offset(first)
302
+ checked_offset(finish)
303
+ raise ArgumentError, "range is reversed" if finish < first
304
+ [first, finish]
305
+ end
306
+
307
+ def checked_offset(offset)
308
+ raise ArgumentError, "offset is outside text" unless offset.is_a?(Integer) && offset.between?(0, text.bytesize)
309
+ raise ArgumentError, "offset splits a grapheme cluster" unless Unicode.grapheme_boundary?(text, offset)
310
+ offset
311
+ end
312
+
313
+ def validate_text(value)
314
+ raise ArgumentError, "text must be valid UTF-8" unless value.is_a?(String) && value.encoding == Encoding::UTF_8 && value.valid_encoding?
315
+ end
316
+
317
+ def map_offset(offset, first, finish, delta)
318
+ return offset if offset <= first
319
+ return offset - delta if offset >= finish
320
+ first
321
+ end
322
+
323
+ def sync_paragraph_styles
324
+ length = text.count("\n") + 1
325
+ @paragraph_styles = @paragraph_styles.take(length)
326
+ @paragraph_styles << {}.freeze while @paragraph_styles.length < length
327
+ end
328
+
329
+ def replace_selection(value)
330
+ range = @selection.range
331
+ if range.begin == range.end
332
+ insert(range.begin, value)
333
+ else
334
+ replace(range, value)
335
+ end
336
+ end
337
+
338
+ def delete_backward
339
+ range = @selection.collapsed? ? Unicode.previous_boundary(text, @selection.head)...@selection.head : @selection.range
340
+ delete(range)
341
+ end
342
+
343
+ def delete_forward
344
+ range = @selection.collapsed? ? @selection.head...Unicode.next_boundary(text, @selection.head) : @selection.range
345
+ delete(range)
346
+ end
347
+
348
+ def move_line_edge(action, edge)
349
+ line = @buffer.line_at(@selection.head)
350
+ start = @buffer.offset_at(line, 0)
351
+ finish = text.index("\n", start) || text.bytesize
352
+ target = edge == :start ? start : finish
353
+ self.selection = action.to_s.start_with?("select") ? TextSelection.new(@selection.anchor, target) : TextSelection.new(target)
354
+ end
65
355
  end
66
356
  end
67
357
  end