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.
@@ -0,0 +1,492 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zaniah
4
+ module UI
5
+ # A sparse-data friendly two-axis view. Only cells intersecting the viewport
6
+ # are built; row and column offsets reuse the list Fenwick index.
7
+ class Grid < Component
8
+ Area = Data.define(:rows, :columns)
9
+
10
+ attr_reader :selection, :scroll_state, :visible_rows, :visible_columns
11
+
12
+ def initialize(rows:, columns:, row_height: 24, column_width: 96,
13
+ frozen_rows: 0, frozen_columns: 0, estimated_row_height: 24,
14
+ estimated_column_width: 96, overscan: 1, &render_cell)
15
+ super()
16
+ raise ArgumentError, "rows and columns must be nonnegative integers" unless [rows, columns].all? { |count| count.is_a?(Integer) && count >= 0 }
17
+ validate_frozen_counts(frozen_rows, frozen_columns, rows, columns)
18
+ raise ArgumentError, "overscan must be a nonnegative integer" unless overscan.is_a?(Integer) && overscan >= 0
19
+ raise ArgumentError, "a cell renderer is required" unless render_cell
20
+
21
+ @rows, @columns = rows, columns
22
+ @frozen_rows, @frozen_columns, @overscan = frozen_rows, frozen_columns, overscan
23
+ @row_size, @column_size = size_provider(row_height), size_provider(column_width)
24
+ @row_overrides, @column_overrides = {}, {}
25
+ @hidden_rows, @hidden_columns = {}, {}
26
+ @row_index = List::HeightIndex.new(rows, estimate(estimated_row_height, row_height))
27
+ @column_index = List::HeightIndex.new(columns, estimate(estimated_column_width, column_width))
28
+ @render_cell, @selection, @selection_anchor = render_cell, [], nil
29
+ @scroll_state = ScrollState.new(axis: :both)
30
+ @active_cell = [0, 0]
31
+ @cell_mouse_down = ->(event, context) do
32
+ row, column = cell_at(event.position)
33
+ begin_cell(row, column, event, context) if row && column
34
+ end
35
+ @cell_drag = ->(event, context) { drag_cell(event, context) }
36
+ @cell_mouse_up = ->(*) { @resize = @fill_drag = nil }
37
+ end
38
+
39
+ def on_select(&block) = (@on_select = block; self)
40
+ def on_edit(&block) = (@on_edit = block; self)
41
+ def on_fill(&block) = (@on_fill = block; self)
42
+ def on_resize(&block) = (@on_resize = block; self)
43
+
44
+ def selection=(areas)
45
+ unless areas.is_a?(Array) && areas.all? { |area| area.is_a?(Area) }
46
+ raise ArgumentError, "selection must be an array of Grid::Area"
47
+ end
48
+ areas.each { |area| validate_area(area) }
49
+ @selection = areas.dup.freeze
50
+ @cx&.window&.request_frame
51
+ @selection
52
+ end
53
+
54
+ def set_row_height(index, height)
55
+ validate_axis_index(index, @rows, "row")
56
+ height = validate_size(height)
57
+ @row_overrides[index] = height
58
+ @row_index.update(index, @hidden_rows.key?(index) ? 0 : height)
59
+ @cx&.window&.request_frame
60
+ self
61
+ end
62
+
63
+ def set_column_width(index, width)
64
+ validate_axis_index(index, @columns, "column")
65
+ width = validate_size(width)
66
+ @column_overrides[index] = width
67
+ @column_index.update(index, @hidden_columns.key?(index) ? 0 : width)
68
+ @cx&.window&.request_frame
69
+ self
70
+ end
71
+
72
+ def hide_row(index)
73
+ hide_rows([index], hidden: true)
74
+ end
75
+
76
+ def unhide_row(index)
77
+ hide_rows([index], hidden: false)
78
+ end
79
+
80
+ def hide_column(index)
81
+ hide_columns([index], hidden: true)
82
+ end
83
+
84
+ def unhide_column(index)
85
+ hide_columns([index], hidden: false)
86
+ end
87
+
88
+ def hide_rows(indices, hidden:)
89
+ update_hidden_indices(indices, hidden, @rows, @hidden_rows, @row_index, method(:row_content_size), "row")
90
+ end
91
+
92
+ def hide_columns(indices, hidden:)
93
+ update_hidden_indices(indices, hidden, @columns, @hidden_columns, @column_index, method(:column_content_size), "column")
94
+ end
95
+
96
+ def row_hidden?(index)
97
+ validate_axis_index(index, @rows, "row")
98
+ @hidden_rows.key?(index)
99
+ end
100
+
101
+ def column_hidden?(index)
102
+ validate_axis_index(index, @columns, "column")
103
+ @hidden_columns.key?(index)
104
+ end
105
+
106
+ def freeze_panes(rows:, columns:)
107
+ validate_frozen_counts(rows, columns, @rows, @columns)
108
+ @frozen_rows, @frozen_columns = rows, columns
109
+ @cx&.window&.request_frame
110
+ self
111
+ end
112
+
113
+ def scroll_to(row:, column:, align: :nearest)
114
+ validate_cell(row, column)
115
+ frozen_width, frozen_height = frozen_size
116
+ x = [@column_index.prefix(column) - frozen_width, 0].max
117
+ y = [@row_index.prefix(row) - frozen_height, 0].max
118
+ @scroll_state.scroll_rect(Bounds.new(x, y, @column_index[column], @row_index[row]), align: align)
119
+ @cx&.window&.request_frame
120
+ self
121
+ end
122
+
123
+ def build(cx)
124
+ @cx = cx
125
+ width = dimension(:width, cx.window.content_size.width)
126
+ height = dimension(:height, cx.window.content_size.height)
127
+ refresh_frozen_sizes
128
+ sync_scroll(width, height)
129
+ refresh_visible_sizes(width, height)
130
+ sync_scroll(width, height)
131
+ calculate_visible(width, height)
132
+
133
+ root = Div.new.flex_col.overflow_hidden.style(width: width, height: height)
134
+ frozen_height = [@row_index.prefix(@frozen_rows), height].min
135
+ frozen_width = [@column_index.prefix(@frozen_columns), width].min
136
+ if frozen_height.positive?
137
+ root.child(pane_row(width, frozen_height, frozen: true, frozen_width: frozen_width))
138
+ end
139
+ body_height = [height - frozen_height, 0].max
140
+ root.child(pane_row(width, body_height, frozen: false, frozen_width: frozen_width)) if body_height.positive?
141
+ root
142
+ .focusable(context: {in_grid: true}) { |action| grid_action(action) }
143
+ .on_scroll_wheel { |event, context| scroll(event, context) }
144
+ end
145
+
146
+ def tui_cells(*)
147
+ "#{@rows}×#{@columns} grid (#{@selection.length} selected ranges)"
148
+ end
149
+
150
+ def accessibility_node(_cx)
151
+ node(:table, label: "Grid", states: {rows: @rows, columns: @columns,
152
+ selected: @selection.map { |area| [area.rows, area.columns] }})
153
+ end
154
+
155
+ private
156
+
157
+ def size_provider(value)
158
+ return value if value.respond_to?(:call)
159
+ ->(_index) { value }
160
+ end
161
+
162
+ def validate_frozen_counts(rows, columns, row_count, column_count)
163
+ unless rows.is_a?(Integer) && columns.is_a?(Integer) && rows.between?(0, row_count) && columns.between?(0, column_count)
164
+ raise ArgumentError, "frozen counts must fit the grid"
165
+ end
166
+ end
167
+
168
+ def estimate(value, source)
169
+ value = source if !source.respond_to?(:call) && source.is_a?(Numeric)
170
+ validate_size(value)
171
+ end
172
+
173
+ def validate_size(value)
174
+ raise ArgumentError, "cell size must be finite and positive" unless value.is_a?(Numeric) && value.finite? && value.positive?
175
+ value.to_f
176
+ end
177
+
178
+ def validate_axis_index(index, count, axis)
179
+ raise IndexError, "#{axis} outside grid" unless index.is_a?(Integer) && index.between?(0, count - 1)
180
+ end
181
+
182
+ def update_hidden_indices(indices, hidden, count, state, index, unhidden_size, axis)
183
+ raise ArgumentError, "hidden must be true or false" unless hidden == true || hidden == false
184
+ raise ArgumentError, "#{axis} indexes must be an array" unless indices.is_a?(Array)
185
+ indices.each { |item| validate_axis_index(item, count, axis) }
186
+
187
+ items = indices.uniq
188
+ restored_sizes = {}
189
+ unless hidden
190
+ items.each { |item| restored_sizes[item] = unhidden_size.call(item) if state.key?(item) }
191
+ end
192
+ changed = false
193
+ items.each do |item|
194
+ next if state.key?(item) == hidden
195
+ if hidden
196
+ state[item] = true
197
+ index.update(item, 0)
198
+ else
199
+ state.delete(item)
200
+ index.update(item, restored_sizes.fetch(item))
201
+ end
202
+ changed = true
203
+ end
204
+ @cx&.window&.request_frame if changed
205
+ self
206
+ end
207
+
208
+ def row_size(index)
209
+ return 0 if @hidden_rows.key?(index)
210
+ row_content_size(index)
211
+ end
212
+
213
+ def column_size(index)
214
+ return 0 if @hidden_columns.key?(index)
215
+ column_content_size(index)
216
+ end
217
+
218
+ def row_content_size(index) = @row_overrides.fetch(index) { validate_size(@row_size.call(index)) }
219
+ def column_content_size(index) = @column_overrides.fetch(index) { validate_size(@column_size.call(index)) }
220
+
221
+ def dimension(property, available)
222
+ value = @component_style[property]
223
+ value = value.resolve(available) if value.is_a?(Length)
224
+ value.is_a?(Numeric) ? [value, 0].max : available
225
+ end
226
+
227
+ def refresh_frozen_sizes
228
+ @frozen_rows.times { |index| @row_index.update(index, row_size(index)) }
229
+ @frozen_columns.times { |index| @column_index.update(index, column_size(index)) }
230
+ end
231
+
232
+ def sync_scroll(width, height)
233
+ frozen_width, frozen_height = frozen_size
234
+ @scroll_state.update(
235
+ content_size: Size.new([@column_index.total - frozen_width, 0].max, [@row_index.total - frozen_height, 0].max),
236
+ viewport_size: Size.new([width - frozen_width, 0].max, [height - frozen_height, 0].max))
237
+ end
238
+
239
+ def frozen_size = [@column_index.prefix(@frozen_columns), @row_index.prefix(@frozen_rows)]
240
+
241
+ def refresh_visible_sizes(width, height)
242
+ 2.times do
243
+ frozen_width, frozen_height = frozen_size
244
+ first_row = @row_index.index_at(frozen_height + @scroll_state.offset.y)
245
+ last_row = @row_index.index_at(frozen_height + @scroll_state.offset.y + [height - frozen_height, 0].max) + 1
246
+ first_column = @column_index.index_at(frozen_width + @scroll_state.offset.x)
247
+ last_column = @column_index.index_at(frozen_width + @scroll_state.offset.x + [width - frozen_width, 0].max) + 1
248
+ row_range = ((first_row - @overscan).clamp(@frozen_rows, @rows))...([last_row + @overscan, @rows].min)
249
+ column_range = ((first_column - @overscan).clamp(@frozen_columns, @columns))...([last_column + @overscan, @columns].min)
250
+ row_range.each { |index| @row_index.update(index, row_size(index)) }
251
+ column_range.each { |index| @column_index.update(index, column_size(index)) }
252
+ sync_scroll(width, height)
253
+ end
254
+ end
255
+
256
+ def calculate_visible(width, height)
257
+ frozen_width, frozen_height = frozen_size
258
+ row_start = (@row_index.index_at(frozen_height + @scroll_state.offset.y) - @overscan).clamp(@frozen_rows, @rows)
259
+ row_finish = [@row_index.index_at(frozen_height + @scroll_state.offset.y + [height - frozen_height, 0].max) + 1 + @overscan, @rows].min
260
+ column_start = (@column_index.index_at(frozen_width + @scroll_state.offset.x) - @overscan).clamp(@frozen_columns, @columns)
261
+ column_finish = [@column_index.index_at(frozen_width + @scroll_state.offset.x + [width - frozen_width, 0].max) + 1 + @overscan, @columns].min
262
+ @visible_rows, @visible_columns = row_start...row_finish, column_start...column_finish
263
+ end
264
+
265
+ def pane_row(width, height, frozen:, frozen_width:)
266
+ cells = []
267
+ left_width = frozen_width
268
+ right_width = [width - left_width, 0].max
269
+ if frozen
270
+ if left_width.positive?
271
+ cells << pane(left_width, height, rows: 0...@frozen_rows, columns: 0...@frozen_columns,
272
+ x_offset: 0, y_offset: 0, origin_y: 0, frozen: true)
273
+ end
274
+ if right_width.positive?
275
+ cells << pane(right_width, height, rows: 0...@frozen_rows, columns: @visible_columns,
276
+ x_offset: left_width + @scroll_state.offset.x, y_offset: 0, origin_y: 0, frozen: false, left: left_width)
277
+ end
278
+ else
279
+ if left_width.positive?
280
+ cells << pane(left_width, height, rows: @visible_rows, columns: 0...@frozen_columns,
281
+ x_offset: 0, y_offset: frozen_rows_height + @scroll_state.offset.y,
282
+ origin_y: frozen_rows_height, frozen: false)
283
+ end
284
+ if right_width.positive?
285
+ cells << pane(right_width, height, rows: @visible_rows, columns: @visible_columns,
286
+ x_offset: frozen_columns_width + @scroll_state.offset.x,
287
+ y_offset: frozen_rows_height + @scroll_state.offset.y,
288
+ origin_y: frozen_rows_height, frozen: false, left: left_width)
289
+ end
290
+ end
291
+ Div.new.flex_row.h(height).w(width).overflow_hidden.children(cells)
292
+ end
293
+
294
+ def pane(width, height, rows:, columns:, x_offset:, y_offset:, origin_y:, frozen:, left: 0)
295
+ children = []
296
+ row_geometry = rows.map { |row| [row, @row_index.prefix(row), @row_index[row]] }
297
+ column_geometry = columns.map { |column| [column, @column_index.prefix(column), @column_index[column]] }
298
+ row_geometry.each do |row, row_origin, row_height|
299
+ row_top = frozen ? row_origin : row_origin - y_offset
300
+ next unless row_height.positive?
301
+ column_geometry.each do |column, column_origin, column_width|
302
+ column_left = frozen ? column_origin : column_origin - x_offset
303
+ next unless column_width.positive?
304
+ next if column_left + column_width <= (frozen ? left : 0) || row_top + row_height <= 0
305
+ next if column_left >= left + width || row_top >= height
306
+ children << cell(row, column, left: column_left, top: row_top + origin_y,
307
+ width: column_width, height: row_height, left_clip: left, frozen: frozen)
308
+ end
309
+ end
310
+ Div.new.style(position: :relative, width: width, height: height, overflow: :hidden).children(children)
311
+ end
312
+
313
+ def cell(row, column, left:, top:, width:, height:, left_clip:, frozen:)
314
+ bounds = Bounds.new(left + left_clip, top, width, height)
315
+ content = @render_cell.call(row, column, bounds, @cx)
316
+ plain_text = content.is_a?(String) || content.is_a?(Numeric)
317
+ if plain_text
318
+ text = content.to_s
319
+ text = text.encode(Encoding::UTF_8) unless text.encoding == Encoding::UTF_8 && text.valid_encoding?
320
+ content = Text.new(text,
321
+ size: @cx.theme.typography.size_sm, color: @cx.theme.colors.text)
322
+ end
323
+ if content && !renderable?(content)
324
+ raise TypeError, "grid cell renderer must return a renderable element, String, Numeric, or nil"
325
+ end
326
+ selected = @selection.any? { |area| area.rows.cover?(row) && area.columns.cover?(column) }
327
+ direct_text = (plain_text || content.is_a?(Text)) && !fill_corner?(row, column)
328
+ wrapper = (direct_text ? content : Div.new).style(position: :absolute, left: left, top: top, width: width, height: height,
329
+ overflow: :hidden, background: selected ? @cx.theme.colors.selection : @cx.theme.colors.surface,
330
+ border: 1, border_color: @cx.theme.colors.border, cursor: :pointer)
331
+ wrapper.child(content) if content && !direct_text
332
+ wrapper.on_mouse_down(&@cell_mouse_down)
333
+ wrapper.on_drag(&@cell_drag)
334
+ wrapper.on_mouse_up(&@cell_mouse_up)
335
+ if fill_corner?(row, column)
336
+ wrapper.child(Div.new.w(7).h(7).style(position: :absolute, right: 0, bottom: 0,
337
+ background: @cx.theme.colors.accent, cursor: :crosshair)
338
+ .on_mouse_down { |event, _context| @fill_drag = @selection.last; :capture }
339
+ .on_drag { |event, context| update_fill(event, context) }
340
+ .on_mouse_up { @fill_drag = nil })
341
+ end
342
+ wrapper
343
+ end
344
+
345
+ def renderable?(value)
346
+ %i[request_layout prepaint paint].all? { |method| value.respond_to?(method) }
347
+ end
348
+
349
+ def fill_corner?(row, column)
350
+ area = @selection.last
351
+ area && row == area.rows.end - 1 && column == area.columns.end - 1
352
+ end
353
+
354
+ def begin_cell(row, column, event, context)
355
+ bounds = cell_bounds(row, column)
356
+ @cx.dispatcher.focus(focus_handle, origin: :pointer)
357
+ @on_edit&.call(row, column, event, context) if event.click_count >= 2
358
+ if event.position.x >= bounds.right - 4
359
+ @resize = [:column, column, event.position.x, @column_index[column]]
360
+ return :capture
361
+ elsif event.position.y >= bounds.bottom - 4
362
+ @resize = [:row, row, event.position.y, @row_index[row]]
363
+ return :capture
364
+ end
365
+ @active_cell = [row, column]
366
+ modifiers = event.modifiers.map(&:to_s)
367
+ if modifiers.include?("shift") && @selection_anchor
368
+ set_active_area(@selection_anchor, [row, column], additive: false, context: context)
369
+ elsif (modifiers & %w[cmd ctrl]).any?
370
+ area = Area.new(rows: row...(row + 1), columns: column...(column + 1))
371
+ @selection = (@selection.include?(area) ? @selection - [area] : @selection + [area]).freeze
372
+ @selection_anchor = [row, column]
373
+ notify_select(event, context)
374
+ else
375
+ @selection_anchor = [row, column]
376
+ set_active_area([row, column], [row, column], additive: false, context: context)
377
+ end
378
+ :capture
379
+ end
380
+
381
+ def drag_cell(event, context)
382
+ if @resize
383
+ axis, index, origin, size = @resize
384
+ value = validate_size(size + (axis == :row ? event.position.y : event.position.x) - origin)
385
+ axis == :row ? set_row_height(index, value) : set_column_width(index, value)
386
+ @on_resize&.call(axis, index, value, context)
387
+ elsif @selection_anchor
388
+ target_row, target_column = cell_at(event.position)
389
+ set_active_area(@selection_anchor, [target_row || @active_cell[0], target_column || @active_cell[1]], additive: false, context: context)
390
+ end
391
+ end
392
+
393
+ def cell_bounds(row, column)
394
+ x = @column_index.prefix(column)
395
+ y = @row_index.prefix(row)
396
+ x -= @scroll_state.offset.x if column >= @frozen_columns
397
+ y -= @scroll_state.offset.y if row >= @frozen_rows
398
+ Bounds.new(@bounds.x + x, @bounds.y + y, @column_index[column], @row_index[row])
399
+ end
400
+
401
+ def set_active_area(first, last, additive:, context: nil)
402
+ area = Area.new(rows: [first[0], last[0]].min..([first[0], last[0]].max),
403
+ columns: [first[1], last[1]].min..([first[1], last[1]].max))
404
+ area = Area.new(rows: area.rows.begin...(area.rows.end + 1), columns: area.columns.begin...(area.columns.end + 1))
405
+ @selection = (additive ? @selection + [area] : [area]).freeze
406
+ notify_select(nil, context)
407
+ end
408
+
409
+ def notify_select(event, context)
410
+ @on_select&.call(@selection, event, context)
411
+ context&.window&.request_frame
412
+ @cx&.window&.request_frame
413
+ end
414
+
415
+ def update_fill(event, context)
416
+ return unless @fill_drag
417
+ row, column = cell_at(event.position)
418
+ return unless row && column
419
+ area = Area.new(rows: [@fill_drag.rows.begin, row].min...([@fill_drag.rows.end - 1, row].max + 1),
420
+ columns: [@fill_drag.columns.begin, column].min...([@fill_drag.columns.end - 1, column].max + 1))
421
+ @on_fill&.call(@fill_drag, area, context)
422
+ context.window.request_frame
423
+ end
424
+
425
+ def cell_at(point)
426
+ return [nil, nil] unless @bounds
427
+ x = point.x - @bounds.x
428
+ y = point.y - @bounds.y
429
+ frozen_width, frozen_height = frozen_size
430
+ column = x < frozen_width ? @column_index.index_at(x) : @column_index.index_at(frozen_width + @scroll_state.offset.x + x - frozen_width)
431
+ row = y < frozen_height ? @row_index.index_at(y) : @row_index.index_at(frozen_height + @scroll_state.offset.y + y - frozen_height)
432
+ [row < @rows ? row : nil, column < @columns ? column : nil]
433
+ end
434
+
435
+ def scroll(event, context)
436
+ @scroll_state.glide_by(event.delta, animator: context.animator, key: [:grid_scroll, object_id],
437
+ duration: context.theme.motion.reduced? ? 0 : context.theme.motion.duration_slow)
438
+ context.window.request_frame
439
+ end
440
+
441
+ def grid_action(action)
442
+ row, column = @active_cell
443
+ next_row, next_column = case action
444
+ when :previous_option then [[row - 1, 0].max, column]
445
+ when :next_option then [[row + 1, @rows - 1].min, column]
446
+ when :previous_column then [row, [column - 1, 0].max]
447
+ when :next_column then [row, [column + 1, @columns - 1].min]
448
+ when :extend_previous then [[row - 1, 0].max, column]
449
+ when :extend_next then [[row + 1, @rows - 1].min, column]
450
+ when :extend_column_previous then [row, [column - 1, 0].max]
451
+ when :extend_column_next then [row, [column + 1, @columns - 1].min]
452
+ when :first then [0, column]
453
+ when :last then [@rows - 1, column]
454
+ when :select_all
455
+ return false if @rows.zero? || @columns.zero?
456
+ @selection = [Area.new(rows: 0...@rows, columns: 0...@columns)].freeze
457
+ notify_select(nil, @cx)
458
+ return true
459
+ when :activate
460
+ return false if @rows.zero? || @columns.zero?
461
+ @on_edit&.call(row, column, nil, @cx)
462
+ return true
463
+ else return false
464
+ end
465
+ return false if @rows.zero? || @columns.zero?
466
+ @active_cell = [next_row, next_column]
467
+ if action.to_s.start_with?("extend_")
468
+ @selection_anchor ||= [row, column]
469
+ set_active_area(@selection_anchor, @active_cell, additive: false, context: @cx)
470
+ else
471
+ @selection_anchor = @active_cell
472
+ set_active_area(@active_cell, @active_cell, additive: false, context: @cx)
473
+ end
474
+ scroll_to(row: next_row, column: next_column)
475
+ true
476
+ end
477
+
478
+ def validate_area(area)
479
+ ranges = [area.rows, area.columns]
480
+ raise ArgumentError, "selection areas need nonempty half-open integer ranges" unless ranges.all? { |range| range.is_a?(Range) && range.exclude_end? && range.begin.is_a?(Integer) && range.end.is_a?(Integer) && range.begin >= 0 && range.begin < range.end }
481
+ raise ArgumentError, "selection is outside the grid" unless area.rows.end <= @rows && area.columns.end <= @columns
482
+ end
483
+
484
+ def validate_cell(row, column)
485
+ raise IndexError, "cell outside grid" unless row.is_a?(Integer) && column.is_a?(Integer) && row.between?(0, @rows - 1) && column.between?(0, @columns - 1)
486
+ end
487
+
488
+ def frozen_rows_height = @row_index.prefix(@frozen_rows)
489
+ def frozen_columns_width = @column_index.prefix(@frozen_columns)
490
+ end
491
+ end
492
+ end