zaniah 0.6.2 → 0.6.3

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.
@@ -161,5 +161,304 @@ module Zaniah
161
161
  end
162
162
  end
163
163
  end
164
+
165
+ class PieChart < Sparkline
166
+ attr_reader :slices
167
+
168
+ def initialize(data, width: 320, height: 240, colors: nil, label: "Pie chart")
169
+ super([1], width: width, height: height, label: label)
170
+ entries = data.is_a?(Hash) ? data.to_a : data.to_a.each_with_index.map { |value, index| ["#{index + 1}", value] }
171
+ raise ArgumentError, "pie chart needs at least one slice" if entries.empty?
172
+
173
+ @slices = entries.map do |name, value|
174
+ value = Float(value)
175
+ raise ArgumentError, "pie values must be finite and nonnegative" unless value.finite? && value >= 0
176
+ [name.to_s.freeze, value].freeze
177
+ end.freeze
178
+ raise ArgumentError, "pie slice labels must be unique" unless @slices.map(&:first).uniq.length == @slices.length
179
+ @values = @slices.map(&:last).freeze
180
+ @total = @values.sum
181
+ raise ArgumentError, "pie chart needs a positive total" unless @total.positive?
182
+ @series = {label.to_s => @values}.freeze
183
+ @width, @height, @colors, @label = Float(width), Float(height), colors, label.to_s
184
+ raise ArgumentError, "chart dimensions must be positive" unless @width.positive? && @height.positive?
185
+ end
186
+
187
+ def build(cx)
188
+ palette = Array(@colors || [cx.theme.colors.accent, cx.theme.colors.info, cx.theme.colors.success, cx.theme.colors.warning])
189
+ raise ArgumentError, "chart colors must not be empty" if palette.empty?
190
+
191
+ @canvas = Canvas.new do |bounds, context|
192
+ @plot_bounds = bounds
193
+ paint_slices(context.scene, bounds, palette)
194
+ end.w(@width).h(@height).on_hover { |event, context| show_slice_tooltip(event, context) }
195
+ .focusable(context: {in_chart: true}) { |action| chart_action(action, cx) }
196
+ legend = Div.new.flex_row.gap(10).children(@slices.each_with_index.map do |(name, value), index|
197
+ percent = value / @total * 100
198
+ Div.new.flex_row.items_center.gap(4)
199
+ .child(Div.new.w(8).h(8).bg(palette[index % palette.length]))
200
+ .child(Label.new("#{name}: #{format("%.1f", percent)}%", size: :xs))
201
+ end)
202
+ Div.new.gap(4).child(@canvas).child(legend)
203
+ end
204
+
205
+ def tui_cells(*) = spark(@values)
206
+
207
+ def accessibility_node(_cx)
208
+ categories = @slices.map { |name, value| {label: name, value: value}.freeze }.freeze
209
+ node(:image, label: @label, value: {total: @total, categories: categories, selected: selected_summary}.freeze)
210
+ end
211
+
212
+ protected
213
+
214
+ def tooltip_at(index)
215
+ name, value = @slices.fetch(index)
216
+ "#{name}: #{value}"
217
+ end
218
+
219
+ private
220
+
221
+ def paint_slices(scene, bounds, palette)
222
+ cx, cy = bounds.x + bounds.width / 2, bounds.y + bounds.height / 2
223
+ radius = [bounds.width, bounds.height].min / 2
224
+ angle = -Math::PI / 2
225
+ @slices.each_with_index do |(_name, value), index|
226
+ next if value.zero?
227
+
228
+ finish = angle + Math::PI * 2 * value / @total
229
+ scene.path(slice_path(cx, cy, radius, angle, finish), fill: palette[index % palette.length])
230
+ angle = finish
231
+ end
232
+ end
233
+
234
+ def slice_path(cx, cy, radius, start, finish)
235
+ if finish - start >= Math::PI * 2 - 1e-10
236
+ "M#{cx},#{cy - radius} A#{radius},#{radius} 0 1 1 #{cx},#{cy + radius} A#{radius},#{radius} 0 1 1 #{cx},#{cy - radius} Z"
237
+ else
238
+ sx, sy = polar(cx, cy, radius, start)
239
+ ex, ey = polar(cx, cy, radius, finish)
240
+ large = finish - start > Math::PI ? 1 : 0
241
+ "M#{cx},#{cy} L#{sx},#{sy} A#{radius},#{radius} 0 #{large} 1 #{ex},#{ey} Z"
242
+ end
243
+ end
244
+
245
+ def polar(cx, cy, radius, angle) = [cx + Math.cos(angle) * radius, cy + Math.sin(angle) * radius]
246
+
247
+ def show_slice_tooltip(event, cx)
248
+ return unless @plot_bounds
249
+
250
+ center_x = @plot_bounds.x + @plot_bounds.width / 2
251
+ center_y = @plot_bounds.y + @plot_bounds.height / 2
252
+ angle = (Math.atan2(event.position.y - center_y, event.position.x - center_x) + Math::PI / 2) % (Math::PI * 2)
253
+ target = angle / (Math::PI * 2) * @total
254
+ index = @values.each_index.find do |candidate|
255
+ target -= @values[candidate]
256
+ target < 0
257
+ end || @values.rindex(&:positive?)
258
+ @selected_index = index
259
+ cx.window.offer_tooltip(tooltip_at(index), position: event.position, delay: 0)
260
+ end
261
+ end
262
+
263
+ class DonutChart < PieChart
264
+ def initialize(data, **options)
265
+ super
266
+ @inner_radius = 0.56
267
+ end
268
+
269
+ private
270
+
271
+ def slice_path(cx, cy, radius, start, finish)
272
+ inner = radius * @inner_radius
273
+ if finish - start >= Math::PI * 2 - 1e-10
274
+ "M#{cx},#{cy - radius} A#{radius},#{radius} 0 1 1 #{cx},#{cy + radius} A#{radius},#{radius} 0 1 1 #{cx},#{cy - radius} " \
275
+ "L#{cx},#{cy - inner} A#{inner},#{inner} 0 1 0 #{cx},#{cy + inner} A#{inner},#{inner} 0 1 0 #{cx},#{cy - inner} Z"
276
+ else
277
+ sx, sy = polar(cx, cy, radius, start)
278
+ ex, ey = polar(cx, cy, radius, finish)
279
+ isx, isy = polar(cx, cy, inner, start)
280
+ iex, iey = polar(cx, cy, inner, finish)
281
+ large = finish - start > Math::PI ? 1 : 0
282
+ "M#{sx},#{sy} A#{radius},#{radius} 0 #{large} 1 #{ex},#{ey} L#{iex},#{iey} " \
283
+ "A#{inner},#{inner} 0 #{large} 0 #{isx},#{isy} Z"
284
+ end
285
+ end
286
+ end
287
+
288
+ class ScatterChart < Sparkline
289
+ attr_reader :points
290
+
291
+ def initialize(series, width: 480, height: 240, colors: nil, label: "Scatter chart")
292
+ input = series.is_a?(Hash) ? series : {label => series}
293
+ raise ArgumentError, "scatter chart needs at least one series" if input.empty?
294
+ @points = input.to_h do |name, values|
295
+ pairs = values.to_a.map do |pair|
296
+ raise ArgumentError, "scatter points must contain x and y" unless pair.respond_to?(:length) && pair.length == 2
297
+ x, y = pair.map { |value| Float(value) }
298
+ raise ArgumentError, "scatter values must be finite" unless x.finite? && y.finite?
299
+ [x, y].freeze
300
+ end
301
+ raise ArgumentError, "scatter series must not be empty" if pairs.empty?
302
+ [name.to_s.freeze, pairs.freeze]
303
+ end.freeze
304
+ super(@points.values.first.map(&:last), width: width, height: height, label: label)
305
+ @series = @points.transform_values { |pairs| pairs.map(&:last).freeze }.freeze
306
+ @width, @height, @colors, @label = Float(width), Float(height), colors, label.to_s
307
+ raise ArgumentError, "chart dimensions must be positive" unless @width.positive? && @height.positive?
308
+ end
309
+
310
+ def build(cx)
311
+ palette = Array(@colors || [cx.theme.colors.accent, cx.theme.colors.info, cx.theme.colors.success, cx.theme.colors.warning])
312
+ raise ArgumentError, "chart colors must not be empty" if palette.empty?
313
+
314
+ canvas = @canvas = Canvas.new do |bounds, context|
315
+ @plot_bounds = bounds.inset(Edges.new(12, 8, 20, 28))
316
+ paint_axes(context.scene, @plot_bounds, cx.theme.colors.border)
317
+ paint_points(context.scene, @plot_bounds, palette)
318
+ end.w(@width).h(@height).on_hover { |event, context| show_scatter_tooltip(event, context) }
319
+ .focusable(context: {in_chart: true}) { |action| chart_action(action, cx) }
320
+ legend = Div.new.flex_row.gap(10).children(@points.keys.each_with_index.map do |name, index|
321
+ Div.new.flex_row.items_center.gap(4).child(Div.new.w(8).h(8).bg(palette[index % palette.length]))
322
+ .child(Label.new(name, size: :xs))
323
+ end)
324
+ Div.new.gap(4).child(canvas).child(legend)
325
+ end
326
+
327
+ def tui_cells(*) = spark(@series.values.first)
328
+
329
+ def accessibility_node(_cx)
330
+ node(:image, label: @label, value: {series: @points, selected: selected_summary}.freeze)
331
+ end
332
+
333
+ protected
334
+
335
+ def tooltip_at(index)
336
+ @points.map do |name, values|
337
+ x, y = values[[index, values.length - 1].min]
338
+ "#{name}: (#{x}, #{y})"
339
+ end.join(" · ")
340
+ end
341
+
342
+ private
343
+
344
+ def paint_axes(scene, bounds, color)
345
+ scene.path("M#{bounds.x},#{bounds.y}V#{bounds.bottom}H#{bounds.right}", stroke: color, width: 1)
346
+ end
347
+
348
+ def paint_points(scene, bounds, palette)
349
+ all = @points.values.flatten(1)
350
+ min_x, max_x = all.map(&:first).minmax
351
+ min_y, max_y = all.map(&:last).minmax
352
+ @screen_points = []
353
+ @points.each_with_index do |(name, values), index|
354
+ paths = values.map do |x_value, y_value|
355
+ x = bounds.x + (max_x == min_x ? 0.5 : (x_value - min_x) / (max_x - min_x)) * bounds.width
356
+ y = bounds.bottom - (max_y == min_y ? 0.5 : (y_value - min_y) / (max_y - min_y)) * bounds.height
357
+ @screen_points << [name, x_value, y_value, x, y]
358
+ "M#{x - 3},#{y} A3,3 0 1 0 #{x + 3},#{y} A3,3 0 1 0 #{x - 3},#{y} Z"
359
+ end.join
360
+ scene.path(paths, fill: palette[index % palette.length]) unless paths.empty?
361
+ end
362
+ end
363
+
364
+ def show_scatter_tooltip(event, cx)
365
+ nearest = @screen_points.min_by do |(_name, _x_value, _y_value, x, y)|
366
+ (x - event.position.x)**2 + (y - event.position.y)**2
367
+ end
368
+ return unless nearest
369
+
370
+ name, x_value, y_value = nearest
371
+ cx.window.offer_tooltip("#{name}: (#{x_value}, #{y_value})", position: event.position, delay: 0)
372
+ end
373
+ end
374
+
375
+ class AreaChart < LineChart
376
+ def initialize(series, width: 480, height: 240, colors: nil, stacked: false, label: "Area chart")
377
+ super(series, width: width, height: height, colors: colors, label: label)
378
+ raise ArgumentError, "stacked area series must have equal lengths" if stacked && @series.values.map(&:length).uniq.length > 1
379
+ raise ArgumentError, "stacked area values must be nonnegative" if stacked && @series.values.flatten.any?(&:negative?)
380
+ @stacked = stacked
381
+ end
382
+
383
+ def build(cx)
384
+ palette = Array(@colors || [cx.theme.colors.accent, cx.theme.colors.info, cx.theme.colors.success, cx.theme.colors.warning])
385
+ raise ArgumentError, "chart colors must not be empty" if palette.empty?
386
+
387
+ canvas = @canvas = Canvas.new do |bounds, context|
388
+ @plot_bounds = bounds.inset(Edges.new(12, 8, 20, 28))
389
+ axes(context.scene, @plot_bounds, cx.theme.colors.border)
390
+ paint_areas(context.scene, @plot_bounds, palette)
391
+ end.w(@width).h(@height).on_hover { |event, context| show_line_tooltip(event, context) }
392
+ .focusable(context: {in_chart: true}) { |action| chart_action(action, cx) }
393
+ legend = Div.new.flex_row.gap(10).children(@series.keys.each_with_index.map do |name, index|
394
+ Div.new.flex_row.items_center.gap(4).child(Div.new.w(8).h(8).bg(palette[index % palette.length]))
395
+ .child(Label.new(name, size: :xs))
396
+ end)
397
+ Div.new.gap(4).child(canvas).child(legend)
398
+ end
399
+
400
+ private
401
+
402
+ def paint_areas(scene, bounds, palette)
403
+ count = @series.values.map(&:length).max
404
+ totals = Array.new(count, 0.0)
405
+ if @stacked
406
+ @series.each_value { |values| values.each_with_index { |value, index| totals[index] += value } }
407
+ minimum, maximum = 0.0, totals.max
408
+ else
409
+ minimum, maximum = [0.0, *@series.values.flatten].minmax
410
+ end
411
+ span = maximum == minimum ? 1.0 : maximum - minimum
412
+ y = ->(value) { bounds.bottom - (value - minimum) / span * bounds.height }
413
+ x = ->(index, length) { bounds.x + (length == 1 ? 0.5 : index.to_f / (length - 1)) * bounds.width }
414
+ cumulative = Array.new(count, 0.0)
415
+
416
+ @series.each_value.with_index do |values, series_index|
417
+ base = @stacked ? cumulative.dup : Array.new(values.length, 0.0)
418
+ top = values.each_with_index.map { |value, index| @stacked ? (cumulative[index] += value) : value }
419
+ top_x = top.each_index.map { |index| x.call(index, top.length) }
420
+ base_x = base.each_index.map { |index| x.call(index, base.length) }
421
+ path = "M#{base_x.first},#{y.call(base.first)} " \
422
+ "L#{top_x.zip(top).map { |px, value| "#{px},#{y.call(value)}" }.join(" L")} " \
423
+ "L#{base_x.zip(base).reverse.map { |px, value| "#{px},#{y.call(value)}" }.join(" L")} Z"
424
+ scene.path(path, fill: palette[series_index % palette.length])
425
+ end
426
+ end
427
+ end
428
+
429
+ class StackedBarChart < BarChart
430
+ private
431
+
432
+ def paint_bars(scene, bounds, palette)
433
+ count = @series.values.map(&:length).max
434
+ positive_totals = Array.new(count, 0.0)
435
+ negative_totals = Array.new(count, 0.0)
436
+ @series.each_value do |values|
437
+ values.each_with_index do |value, index|
438
+ value.negative? ? negative_totals[index] += value : positive_totals[index] += value
439
+ end
440
+ end
441
+ minimum, maximum = [negative_totals.min, 0].min, [positive_totals.max, 0].max
442
+ span = maximum == minimum ? 1.0 : maximum - minimum
443
+ y = ->(value) { bounds.bottom - (value - minimum) / span * bounds.height }
444
+ zero = y.call(0)
445
+ group_width = bounds.width / [count, 1].max
446
+ bar_width = group_width * 0.75
447
+ positive = Array.new(count, 0.0)
448
+ negative = Array.new(count, 0.0)
449
+
450
+ @series.values.each_with_index do |values, series_index|
451
+ values.each_with_index do |value, index|
452
+ base = value.negative? ? negative[index] - value : positive[index] - value
453
+ finish = base + value
454
+ first, last = [y.call(base), y.call(finish)].minmax
455
+ x = bounds.x + index * group_width + (group_width - bar_width) / 2
456
+ scene.path("M#{x},#{first}H#{x + bar_width}V#{[last, first + 1].max}H#{x}Z", fill: palette[series_index % palette.length])
457
+ value.negative? ? negative[index] = finish : positive[index] = finish
458
+ end
459
+ end
460
+ scene.path("M#{bounds.x},#{zero}H#{bounds.right}", stroke: "#777", width: 1)
461
+ end
462
+ end
164
463
  end
165
464
  end
@@ -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