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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +9 -0
- data/README.md +9 -0
- data/docs/adr/012-productivity-components.md +25 -0
- data/docs/components.md +16 -1
- data/docs/images.md +18 -0
- data/docs/layout.md +46 -0
- data/docs/native.md +9 -0
- data/docs/text.md +22 -0
- data/lib/zaniah/image.rb +22 -5
- data/lib/zaniah/input/keymap.rb +12 -0
- data/lib/zaniah/jpeg.rb +431 -0
- data/lib/zaniah/list/height_index.rb +3 -2
- data/lib/zaniah/platform/headless/window.rb +8 -0
- data/lib/zaniah/platform/linux/wayland_window.rb +14 -0
- data/lib/zaniah/platform/linux/window.rb +12 -0
- data/lib/zaniah/platform/mac/window.rb +15 -0
- data/lib/zaniah/platform/windows/window.rb +15 -0
- data/lib/zaniah/ui/charts.rb +299 -0
- data/lib/zaniah/ui/editors.rb +303 -13
- data/lib/zaniah/ui/grid.rb +492 -0
- data/lib/zaniah/ui/rich_text_surface.rb +302 -0
- data/lib/zaniah/ui/terminal_view.rb +22 -0
- data/lib/zaniah/ui.rb +2 -0
- data/lib/zaniah/version.rb +1 -1
- data/lib/zaniah.rb +1 -0
- data/sig/elements.rbs +2 -0
- data/sig/native.rbs +7 -0
- data/sig/platform.rbs +2 -0
- data/sig/ui.rbs +67 -2
- data/sig/zaniah.rbs +14 -0
- metadata +15 -4
data/lib/zaniah/ui/charts.rb
CHANGED
|
@@ -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
|
data/lib/zaniah/ui/editors.rb
CHANGED
|
@@ -34,34 +34,324 @@ module Zaniah
|
|
|
34
34
|
end
|
|
35
35
|
|
|
36
36
|
class RichText < Component
|
|
37
|
-
|
|
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
|
-
|
|
41
|
+
attr_reader :buffer, :selection, :paragraph_styles
|
|
42
|
+
|
|
43
|
+
def initialize(runs, selectable: true, editable: false)
|
|
40
44
|
super()
|
|
41
|
-
|
|
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
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
|
54
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|