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
|
@@ -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
|