rukbat 0.1.0

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,847 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rukbat
4
+ class GridView < Zaniah::UI::Component
5
+ UI = Zaniah::UI
6
+ MAX_FILL_CELLS = 100_000
7
+
8
+ attr_reader :grid, :formula_field, :active_cell, :status, :completion_session,
9
+ :completion_candidate
10
+
11
+ def initialize(workbook, on_save: nil)
12
+ super()
13
+ @workbook, @on_save = workbook, on_save
14
+ @active_cell, @status = [1, 1], "Ready"
15
+ @rendered_cells = {}
16
+ @editing_cell = @inline_buffer = @inline_editor = nil
17
+ @inline_focus_pending = false
18
+ @formula_field = UI::TextField.new("")
19
+ @formula_field.on_change do |text, cx|
20
+ update_completion(text)
21
+ @inline_buffer.replace(0...@inline_buffer.bytesize, text.encode(Encoding::UTF_8)) if @inline_buffer
22
+ cx.window.request_frame
23
+ end
24
+ @function_index = Spica::Index.new(Furud::Functions.standard.names)
25
+ @completion_session = @function_index.session
26
+ @completion_button = UI::Button.new("Use completion", size: :sm, variant: :secondary)
27
+ .on_click { apply_completion }
28
+ @apply_button = UI::Button.new("Apply", size: :sm).on_click { apply_edit }
29
+ @save_button = UI::Button.new("Save", size: :sm, variant: :secondary).on_click { save }
30
+ @undo_button = UI::Button.new("Undo", size: :sm, variant: :ghost).on_click { undo }
31
+ @redo_button = UI::Button.new("Redo", size: :sm, variant: :ghost).on_click { redo }
32
+ @add_sheet_button = UI::Button.new("+ Sheet", size: :sm, variant: :secondary).on_click { add_sheet }
33
+ @number_button = UI::Button.new("#,##0.00", size: :sm, variant: :ghost).on_click { apply_format(number_format: "#,##0.00") }
34
+ @percent_button = UI::Button.new("%", size: :sm, variant: :ghost).on_click { apply_format(number_format: "0.00%") }
35
+ @bold_button = UI::Button.new("Bold", size: :sm, variant: :ghost).on_click { toggle_bold }
36
+ @fill_button = UI::Button.new("Fill", size: :sm, variant: :ghost).on_click { apply_format(background: "#FFF2CC") }
37
+ @freeze_button = UI::Button.new("Freeze", size: :sm, variant: :ghost).on_click { freeze_panes }
38
+ @unfreeze_button = UI::Button.new("Unfreeze", size: :sm, variant: :ghost).on_click { unfreeze_panes }
39
+ @clear_conditional_button = UI::Button.new("Clear highlights", size: :sm, variant: :ghost).on_click { clear_highlights }
40
+ @line_chart_button = UI::Button.new("Line", size: :sm, variant: :ghost).on_click { show_chart(:line) }
41
+ @bar_chart_button = UI::Button.new("Bar", size: :sm, variant: :ghost).on_click { show_chart(:bar) }
42
+ @pie_chart_button = UI::Button.new("Pie", size: :sm, variant: :ghost).on_click { show_chart(:pie) }
43
+ @font_down_button = UI::Button.new("A−", size: :sm, variant: :ghost).on_click { change_font_size(-1) }
44
+ @font_up_button = UI::Button.new("A+", size: :sm, variant: :ghost).on_click { change_font_size(1) }
45
+ @align_button = UI::Button.new("Align", size: :sm, variant: :ghost).on_click { cycle_alignment }
46
+ @text_color_button = UI::Button.new("Text color", size: :sm, variant: :ghost).on_click { apply_format(color: "#C00000") }
47
+ @border_button = UI::Button.new("Border", size: :sm, variant: :ghost).on_click do
48
+ apply_format(border_color: "#7F8793", border_width: 1)
49
+ end
50
+ @sort_button = UI::Button.new("Sort ↑", size: :sm, variant: :ghost).on_click { sort_selection }
51
+ @filter_field = UI::TextField.new("")
52
+ @filter_button = UI::Button.new("Filter", size: :sm, variant: :ghost).on_click { apply_filter }
53
+ @clear_filter_button = UI::Button.new("Show all", size: :sm, variant: :ghost).on_click { clear_filter }
54
+ @remove_duplicates_button = UI::Button.new("Unique", size: :sm, variant: :ghost).on_click { remove_duplicates }
55
+ @hide_rows_button = UI::Button.new("Hide rows", size: :sm, variant: :ghost).on_click { hide_selected_rows }
56
+ @hide_columns_button = UI::Button.new("Hide cols", size: :sm, variant: :ghost).on_click { hide_selected_columns }
57
+ @show_hidden_button = UI::Button.new("Show hidden", size: :sm, variant: :ghost).on_click { show_hidden }
58
+ @insert_row_button = UI::Button.new("Insert row", size: :sm, variant: :ghost).on_click { edit_structure(:insert_rows) }
59
+ @delete_rows_button = UI::Button.new("Delete rows", size: :sm, variant: :ghost).on_click { edit_structure(:delete_rows) }
60
+ @insert_column_button = UI::Button.new("Insert col", size: :sm, variant: :ghost).on_click { edit_structure(:insert_columns) }
61
+ @delete_columns_button = UI::Button.new("Delete cols", size: :sm, variant: :ghost).on_click { edit_structure(:delete_columns) }
62
+ @set_print_area_button = UI::Button.new("Set print area", size: :sm, variant: :ghost).on_click { set_print_area }
63
+ @clear_print_area_button = UI::Button.new("Clear print area", size: :sm, variant: :ghost).on_click { clear_print_area }
64
+ @export_pdf_button = UI::Button.new("Export PDF", size: :sm, variant: :ghost).on_click { export_pdf }
65
+ @find_field = UI::TextField.new("")
66
+ @replace_field = UI::TextField.new("")
67
+ @find_button = UI::Button.new("Find", size: :sm, variant: :ghost).on_click { find_selection }
68
+ @replace_button = UI::Button.new("Replace", size: :sm, variant: :ghost).on_click { replace_selection }
69
+ @comment_field = UI::TextField.new("")
70
+ @comment_button = UI::Button.new("Comment", size: :sm, variant: :ghost).on_click { save_comment }
71
+ @name_field = UI::TextField.new("")
72
+ @name_button = UI::Button.new("Name range", size: :sm, variant: :ghost).on_click { name_selection }
73
+ @highlight_button = UI::Button.new("> 0", size: :sm, variant: :ghost).on_click { highlight_positive }
74
+ @sort_ascending = true
75
+ @filter_hidden_rows = []
76
+ @applied_hidden_rows = []
77
+ @applied_hidden_columns = []
78
+ @change_observer = @workbook.on_cells_changed do |sheet, cells|
79
+ invalidate_rendered_cells(sheet, cells)
80
+ request_frame if sheet.nil? || sheet == @workbook.active_sheet
81
+ end
82
+ frozen_rows, frozen_columns = @workbook.frozen_panes
83
+ @grid = UI::Grid.new(rows: Workbook::MAX_ROWS + 1, columns: Workbook::MAX_COLUMNS + 1,
84
+ row_height: 24, column_width: ->(index) { index.zero? ? 52 : 112 },
85
+ frozen_rows: frozen_rows, frozen_columns: frozen_columns) do |row, column, bounds, _cx|
86
+ render_cell(row, column, bounds)
87
+ end
88
+ @grid.on_select { |areas, _event, cx| selection_changed(areas, cx) }
89
+ @grid.on_edit { |row, column, _event, cx| begin_edit(row, column, cx) }
90
+ @grid.on_fill { |source, target, _cx| fill(source, target) }
91
+ sync_grid_visibility
92
+ sync_formula_field
93
+ end
94
+
95
+ def request_layout(cx)
96
+ @rendered_cells_used = {}
97
+ layout = super
98
+ if @inline_focus_pending && @inline_editor&.focus_handle
99
+ cx.dispatcher.focus(@inline_editor.focus_handle)
100
+ @inline_focus_pending = false
101
+ end
102
+ layout
103
+ ensure
104
+ if @rendered_cells_used
105
+ @rendered_cells.delete_if { |key, _cell| !@rendered_cells_used.key?(key) }
106
+ @rendered_cells_used = nil
107
+ end
108
+ end
109
+
110
+ def build(cx)
111
+ @cx = cx
112
+ render_context = [cx.theme, cx.text_system]
113
+ if @render_context != render_context
114
+ @rendered_cells.clear
115
+ @render_context = render_context
116
+ end
117
+ toolbar = Zaniah::Div.new.flex_row.items_center.gap(cx.theme.spacing[1])
118
+ .child(@undo_button).child(@redo_button).child(@save_button).child(@add_sheet_button)
119
+ .child(@number_button).child(@percent_button).child(@bold_button).child(@fill_button)
120
+ .child(@freeze_button).child(@unfreeze_button).child(@clear_conditional_button)
121
+ .child(@line_chart_button).child(@bar_chart_button).child(@pie_chart_button)
122
+ format_toolbar = Zaniah::Div.new.flex_row.items_center.gap(cx.theme.spacing[1])
123
+ .child(@font_down_button).child(@font_up_button).child(@align_button)
124
+ .child(@text_color_button).child(@border_button)
125
+ data_toolbar = Zaniah::Div.new.flex_row.items_center.gap(cx.theme.spacing[1])
126
+ .child(@sort_button).child(@remove_duplicates_button)
127
+ .child(@filter_field.style(width: 120)).child(@filter_button).child(@clear_filter_button)
128
+ .child(@hide_rows_button).child(@hide_columns_button).child(@show_hidden_button)
129
+ structure_toolbar = Zaniah::Div.new.flex_row.items_center.gap(cx.theme.spacing[1])
130
+ .child(@insert_row_button).child(@delete_rows_button)
131
+ .child(@insert_column_button).child(@delete_columns_button)
132
+ .child(@set_print_area_button).child(@clear_print_area_button).child(@export_pdf_button)
133
+ annotation_toolbar = Zaniah::Div.new.flex_row.items_center.gap(cx.theme.spacing[1])
134
+ .child(@highlight_button).child(@comment_field.style(width: 150)).child(@comment_button)
135
+ .child(@name_field.style(width: 120)).child(@name_button)
136
+ find_toolbar = Zaniah::Div.new.flex_row.items_center.gap(cx.theme.spacing[1])
137
+ .child(@find_field.style(width: 140)).child(@find_button)
138
+ .child(@replace_field.style(width: 140)).child(@replace_button)
139
+ sheets = Zaniah::Div.new.flex_row.items_center.gap(cx.theme.spacing[1])
140
+ @workbook.sheet_names.each do |name|
141
+ variant = name == @workbook.active_sheet ? :secondary : :ghost
142
+ sheets.child(UI::Button.new(name, size: :sm, variant: variant).on_click do
143
+ clear_filter
144
+ @workbook.activate(name)
145
+ sync_grid_visibility
146
+ sync_frozen_panes
147
+ sync_formula_field
148
+ update_status
149
+ end)
150
+ end
151
+ formula_row = Zaniah::Div.new.flex_row.items_center.gap(cx.theme.spacing[1])
152
+ .child(UI::Label.new(cell_address(*@active_cell), size: :sm))
153
+ .child(@formula_field.style(flex_grow: 1, flex_basis: 0))
154
+ .child(UI::Label.new(@completion_candidate || "", tone: :muted, size: :sm))
155
+ .child(@completion_button.disabled(@completion_candidate.nil?))
156
+ .child(@apply_button)
157
+ status_row = Zaniah::Div.new.flex_row.items_center.style(justify_content: :space_between)
158
+ .child(UI::Label.new(@status, tone: :muted, size: :sm)).child(sheets)
159
+ content = Zaniah::Div.new.flex_col.gap(cx.theme.spacing[1]).p(cx.theme.spacing[2])
160
+ .style(width: percent(100), height: percent(100))
161
+ .child(toolbar).child(format_toolbar).child(data_toolbar).child(structure_toolbar).child(annotation_toolbar)
162
+ .child(find_toolbar).child(formula_row).child(@grid.flex_1)
163
+ content.child(@chart_component) if @chart_component
164
+ content.child(status_row)
165
+ content
166
+ end
167
+
168
+ def undo
169
+ @status = @workbook.undo ? "Undone" : "Nothing to undo"
170
+ sync_grid_visibility
171
+ sync_frozen_panes
172
+ sync_formula_field
173
+ request_frame
174
+ end
175
+
176
+ def redo
177
+ @status = @workbook.redo ? "Redone" : "Nothing to redo"
178
+ sync_grid_visibility
179
+ sync_frozen_panes
180
+ sync_formula_field
181
+ request_frame
182
+ end
183
+
184
+ def handle_shortcut(event, window)
185
+ return false unless event.is_a?(Zaniah::Input::KeyDown)
186
+
187
+ if @editing_cell
188
+ case event.keystroke.to_s
189
+ when "enter" then return commit_inline_edit
190
+ when "esc", "escape" then return cancel_inline_edit
191
+ end
192
+ end
193
+
194
+ case event.keystroke.to_s
195
+ when "ctrl-s", "cmd-s" then save
196
+ when "ctrl-z", "cmd-z"
197
+ return false if focused_text_field?(window)
198
+ undo
199
+ when "ctrl-shift-z", "cmd-shift-z"
200
+ return false if focused_text_field?(window)
201
+ self.redo
202
+ else return false
203
+ end
204
+ true
205
+ end
206
+
207
+ def apply_edit
208
+ if @editing_cell
209
+ @inline_buffer.replace(0...@inline_buffer.bytesize, @formula_field.value.encode(Encoding::UTF_8))
210
+ return commit_inline_edit
211
+ end
212
+
213
+ value = CSVFile.parse_cell(@formula_field.value)
214
+ value.nil? ? @workbook.clear(*@active_cell) : @workbook.set(*@active_cell, value)
215
+ @status = "Applied #{cell_address(*@active_cell)}"
216
+ sync_formula_field
217
+ request_frame
218
+ true
219
+ rescue Rukbat::Error, ArgumentError, TypeError => error
220
+ @status = error.message
221
+ request_frame
222
+ false
223
+ end
224
+
225
+ private
226
+
227
+ def sort_selection
228
+ clear_filter
229
+ top, left, bottom, right = selected_coordinates
230
+ by = left
231
+ @workbook.sort(top, left, bottom, right, by: by, ascending: @sort_ascending)
232
+ @sort_ascending = !@sort_ascending
233
+ @sort_button.text = @sort_ascending ? "Sort ↑" : "Sort ↓" if @sort_button.respond_to?(:text=)
234
+ @status = "Sorted #{cell_address(top, left)}:#{cell_address(bottom, right)}"
235
+ sync_formula_field
236
+ request_frame
237
+ true
238
+ rescue Rukbat::Error => error
239
+ @status = error.message
240
+ request_frame
241
+ false
242
+ end
243
+
244
+ def apply_filter
245
+ top, left, bottom, = selected_coordinates
246
+ clear_filter
247
+ return true if top > bottom
248
+
249
+ rows = @workbook.filter_rows(top, bottom, by: left, query: @filter_field.value)
250
+ matching = rows.to_h { |row| [row, true] }
251
+ @filter_hidden_rows = (top..bottom).reject { |row| matching.key?(row) }
252
+ @grid.hide_rows(@filter_hidden_rows, hidden: true) unless @filter_hidden_rows.empty?
253
+ @status = "Showing #{rows.length} matching rows"
254
+ request_frame
255
+ true
256
+ rescue Rukbat::Error, ArgumentError => error
257
+ @status = error.message
258
+ request_frame
259
+ false
260
+ end
261
+
262
+ def clear_filter
263
+ @grid.hide_rows(@filter_hidden_rows, hidden: false) unless @filter_hidden_rows.empty?
264
+ @filter_hidden_rows = []
265
+ sync_grid_visibility
266
+ @status = "Filter cleared"
267
+ request_frame
268
+ true
269
+ end
270
+
271
+ def remove_duplicates
272
+ clear_filter
273
+ top, left, bottom, right = selected_coordinates
274
+ removed = @workbook.remove_duplicates(top, left, bottom, right)
275
+ sync_grid_visibility
276
+ sync_formula_field
277
+ @status = "Removed #{removed} duplicate rows"
278
+ request_frame
279
+ true
280
+ rescue Rukbat::Error => error
281
+ @status = error.message
282
+ request_frame
283
+ false
284
+ end
285
+
286
+ def find_selection
287
+ top, left, bottom, right = selected_coordinates
288
+ matches = @workbook.find(@find_field.value, top: top, left: left, bottom: bottom, right: right)
289
+ @grid.selection = matches.map do |reference|
290
+ UI::Grid::Area.new(rows: reference.row...(reference.row + 1),
291
+ columns: reference.column...(reference.column + 1))
292
+ end
293
+ @active_cell = [matches.first.row, matches.first.column] unless matches.empty?
294
+ sync_formula_field unless matches.empty?
295
+ @status = "Found #{matches.length} cells"
296
+ request_frame
297
+ true
298
+ rescue Rukbat::Error => error
299
+ @status = error.message
300
+ request_frame
301
+ false
302
+ end
303
+
304
+ def replace_selection
305
+ top, left, bottom, right = selected_coordinates
306
+ count = @workbook.replace_all(@find_field.value, @replace_field.value,
307
+ top: top, left: left, bottom: bottom, right: right)
308
+ sync_formula_field
309
+ @status = "Replaced #{count} cells"
310
+ request_frame
311
+ true
312
+ rescue Rukbat::Error => error
313
+ @status = error.message
314
+ request_frame
315
+ false
316
+ end
317
+
318
+ def save_comment
319
+ @workbook.set_comment(*@active_cell, @comment_field.value)
320
+ @status = "Comment saved for #{cell_address(*@active_cell)}"
321
+ request_frame
322
+ true
323
+ rescue Rukbat::Error => error
324
+ @status = error.message
325
+ request_frame
326
+ false
327
+ end
328
+
329
+ def name_selection
330
+ top, left, bottom, right = selected_coordinates
331
+ @workbook.define_name(@name_field.value, top, left, bottom, right)
332
+ @status = "Named range #{@name_field.value}"
333
+ request_frame
334
+ true
335
+ rescue Rukbat::Error => error
336
+ @status = error.message
337
+ request_frame
338
+ false
339
+ end
340
+
341
+ def change_font_size(delta)
342
+ current = @workbook.format_at(*@active_cell).fetch(:font_size, 12)
343
+ apply_format(font_size: (current + delta).clamp(6, 72))
344
+ end
345
+
346
+ def cycle_alignment
347
+ current = @workbook.format_at(*@active_cell).fetch(:horizontal_alignment, :left)
348
+ alignment = {left: :center, center: :right, right: :left}.fetch(current, :left)
349
+ apply_format(horizontal_alignment: alignment)
350
+ end
351
+
352
+ def hide_selected_rows
353
+ top, _left, bottom, = selected_coordinates
354
+ @workbook.hide_rows(top, bottom)
355
+ sync_grid_visibility
356
+ @status = "Hidden rows #{top}-#{bottom}"
357
+ request_frame
358
+ true
359
+ rescue Rukbat::Error => error
360
+ @status = error.message
361
+ request_frame
362
+ false
363
+ end
364
+
365
+ def hide_selected_columns
366
+ _top, left, _bottom, right = selected_coordinates
367
+ @workbook.hide_columns(left, right)
368
+ sync_grid_visibility
369
+ @status = "Hidden columns #{left}-#{right}"
370
+ request_frame
371
+ true
372
+ rescue Rukbat::Error => error
373
+ @status = error.message
374
+ request_frame
375
+ false
376
+ end
377
+
378
+ def show_hidden
379
+ clear_filter
380
+ @workbook.clear_hidden(:rows)
381
+ @workbook.clear_hidden(:columns)
382
+ sync_grid_visibility
383
+ @status = "All rows and columns are visible"
384
+ request_frame
385
+ true
386
+ rescue Rukbat::Error => error
387
+ @status = error.message
388
+ request_frame
389
+ false
390
+ end
391
+
392
+ def edit_structure(operation)
393
+ top, left, bottom, right = selected_coordinates
394
+ if operation == :insert_rows || operation == :delete_rows
395
+ start = top
396
+ count = operation == :insert_rows ? 1 : bottom - top + 1
397
+ else
398
+ start = left
399
+ count = operation == :insert_columns ? 1 : right - left + 1
400
+ end
401
+ @workbook.public_send(operation, start, count)
402
+ sync_grid_visibility
403
+ sync_formula_field
404
+ @status = "#{operation.to_s.tr('_', ' ').capitalize} at #{start} (#{count})"
405
+ request_frame
406
+ true
407
+ rescue Rukbat::Error => error
408
+ @status = error.message
409
+ request_frame
410
+ false
411
+ end
412
+
413
+ def set_print_area
414
+ top, left, bottom, right = selected_coordinates
415
+ @workbook.set_print_area(top, left, bottom, right)
416
+ @status = "Print area set to #{cell_address(top, left)}:#{cell_address(bottom, right)}"
417
+ request_frame
418
+ true
419
+ rescue Rukbat::Error => error
420
+ @status = error.message
421
+ request_frame
422
+ false
423
+ end
424
+
425
+ def clear_print_area
426
+ @workbook.clear_print_area
427
+ @status = "Print area cleared"
428
+ request_frame
429
+ true
430
+ rescue Rukbat::Error => error
431
+ @status = error.message
432
+ request_frame
433
+ false
434
+ end
435
+
436
+ def export_pdf
437
+ window = @cx&.window
438
+ raise Rukbat::Error, "PDF export requires an interactive window" unless window&.respond_to?(:prompt_for_paths)
439
+
440
+ font = window.prompt_for_paths.first
441
+ return false unless font
442
+ path = window.prompt_for_paths(save: true).first
443
+ return false unless path
444
+ path = "#{path}.pdf" if File.extname(path).empty?
445
+
446
+ PDFFile.write(@workbook, path, font: font)
447
+ @status = "Exported #{File.basename(path)}"
448
+ request_frame
449
+ true
450
+ rescue Rukbat::Error, ArgumentError => error
451
+ @status = error.message
452
+ request_frame
453
+ false
454
+ end
455
+
456
+ def sync_grid_visibility
457
+ @grid.hide_rows(@applied_hidden_rows, hidden: false) unless @applied_hidden_rows.empty?
458
+ @grid.hide_columns(@applied_hidden_columns, hidden: false) unless @applied_hidden_columns.empty?
459
+ @applied_hidden_rows = @workbook.hidden_rows
460
+ @applied_hidden_columns = @workbook.hidden_columns
461
+ @grid.hide_rows(@applied_hidden_rows, hidden: true) unless @applied_hidden_rows.empty?
462
+ @grid.hide_columns(@applied_hidden_columns, hidden: true) unless @applied_hidden_columns.empty?
463
+ end
464
+
465
+ def highlight_positive
466
+ top, left, bottom, right = selected_coordinates
467
+ @workbook.add_conditional_format(top, left, bottom, right, operator: :greater_than,
468
+ value: 0, style: {color: "#008000"})
469
+ @status = "Added positive-value highlight"
470
+ request_frame
471
+ true
472
+ rescue Rukbat::Error => error
473
+ @status = error.message
474
+ request_frame
475
+ false
476
+ end
477
+
478
+ def clear_highlights
479
+ @workbook.clear_conditional_formats
480
+ @status = "Conditional formatting cleared"
481
+ request_frame
482
+ true
483
+ rescue Rukbat::Error => error
484
+ @status = error.message
485
+ request_frame
486
+ false
487
+ end
488
+
489
+ private
490
+
491
+ def render_cell(row, column, bounds)
492
+ key = [@workbook.active_sheet, row, column, bounds.height]
493
+ @rendered_cells_used[key] = true if @rendered_cells_used
494
+ return @rendered_cells[key] if @rendered_cells.key?(key)
495
+
496
+ content = if row.zero?
497
+ UI::Label.new(column.zero? ? "" : column_name(column), size: :sm, wrap: :none)
498
+ elsif column.zero?
499
+ UI::Label.new(row.to_s, size: :sm, wrap: :none)
500
+ else
501
+ render_data_cell(row, column, bounds)
502
+ end
503
+ @rendered_cells[key] = content
504
+ rescue Rukbat::Error
505
+ @rendered_cells[key] = UI::Label.new("", size: :sm)
506
+ end
507
+
508
+ def render_data_cell(row, column, bounds)
509
+ return @inline_editor if @editing_cell == [row, column]
510
+
511
+ text, style = @workbook.presentation_at(row, column)
512
+ comment = @workbook.comment_at(row, column)
513
+ if style.empty? && comment.nil?
514
+ line_height = 12 * 1.4
515
+ return Zaniah::Text.new(text, size: 12, color: @cx.theme.colors.text, wrap: :none)
516
+ .style(margin_top: [(bounds.height - line_height) / 2, 0].max)
517
+ end
518
+
519
+ font = if style[:font_family] || style[:bold] || style[:italic]
520
+ @cx.text_system&.font_db&.find(family: style[:font_family],
521
+ weight: style[:bold] ? 700 : 400, style: style[:italic] ? :italic : :normal)
522
+ end
523
+ alignment = {left: :start, center: :center, right: :end}.fetch(style[:horizontal_alignment], :start)
524
+ vertical = {top: :start, middle: :center, bottom: :end}.fetch(style[:vertical_alignment], :center)
525
+ content = Zaniah::Text.new(text, size: style[:font_size] || 12, color: style[:color] || @cx.theme.colors.text,
526
+ font: font, wrap: :none, align: alignment)
527
+ cell = Zaniah::Div.new.flex_row.w_full.h_full.p([1, 4])
528
+ .style(align_items: vertical, background: style[:background] || "#0000",
529
+ border: style[:border_width] || 0, border_color: style[:border_color])
530
+ .child(content)
531
+ cell.tooltip(comment) if comment
532
+ cell
533
+ end
534
+
535
+ def invalidate_rendered_cells(sheet, cells)
536
+ if cells.nil?
537
+ @rendered_cells.delete_if { |key, _cell| sheet.nil? || key[0] == sheet }
538
+ return
539
+ end
540
+
541
+ if cells.is_a?(Workbook::CellRange)
542
+ @rendered_cells.delete_if do |key, _cell|
543
+ key[0] == sheet && cells.include?(key[1], key[2])
544
+ end
545
+ return
546
+ end
547
+
548
+ changed = cells.to_h { |row, column| [[sheet, row, column], true] }
549
+ @rendered_cells.delete_if { |key, _cell| changed.key?(key.first(3)) }
550
+ end
551
+
552
+ def selection_changed(areas, cx)
553
+ area = areas.last
554
+ return unless area
555
+ row, column = [area.rows.begin, 1].max, [area.columns.begin, 1].max
556
+ return if row > Workbook::MAX_ROWS || column > Workbook::MAX_COLUMNS
557
+
558
+ if @editing_cell && @editing_cell != [row, column] && !commit_inline_edit
559
+ edited_row, edited_column = @editing_cell
560
+ @grid.selection = [UI::Grid::Area.new(rows: edited_row...(edited_row + 1),
561
+ columns: edited_column...(edited_column + 1))]
562
+ cx.window.request_frame if cx.respond_to?(:window)
563
+ return
564
+ end
565
+ @active_cell = [row, column]
566
+ sync_formula_field unless @editing_cell == @active_cell
567
+ update_status(area)
568
+ cx.window.request_frame if cx.respond_to?(:window)
569
+ end
570
+
571
+ def begin_edit(row, column, cx)
572
+ return if row.zero? || column.zero?
573
+ return if @editing_cell == [row, column]
574
+ return unless commit_inline_edit if @editing_cell && @editing_cell != [row, column]
575
+
576
+ @active_cell = [row, column]
577
+ sync_formula_field
578
+ @editing_cell = [row, column]
579
+ @inline_buffer = Zaniah::TextBuffer.new(@workbook.input_at(row, column).to_s.encode(Encoding::UTF_8))
580
+ @inline_editor = Zaniah::Text.new(@inline_buffer.to_s, size: 12, color: cx.theme.colors.text, wrap: :none)
581
+ .editable(@inline_buffer)
582
+ .style(width: percent(100), height: percent(100), padding: [0, 4])
583
+ .on_change do |text|
584
+ @formula_field.buffer.replace(0...@formula_field.buffer.bytesize, text.encode(Encoding::UTF_8))
585
+ update_completion(text)
586
+ cx.window.request_frame
587
+ end
588
+ @inline_editor.selection = Zaniah::TextSelection.new(@inline_buffer.bytesize)
589
+ @inline_focus_pending = true
590
+ @rendered_cells.delete_if { |key, _cell| key[0] == @workbook.active_sheet && key[1] == row && key[2] == column }
591
+ cx.dispatcher.focus(@formula_field.focus_handle) if @formula_field.focus_handle
592
+ request_frame
593
+ end
594
+
595
+ def commit_inline_edit
596
+ return false unless @editing_cell
597
+
598
+ row, column = @editing_cell
599
+ value = CSVFile.parse_cell(@inline_buffer.to_s)
600
+ value.nil? ? @workbook.clear(row, column) : @workbook.set(row, column, value)
601
+ @editing_cell = @inline_buffer = @inline_editor = nil
602
+ @inline_focus_pending = false
603
+ @status = "Applied #{cell_address(row, column)}"
604
+ @rendered_cells.delete_if { |key, _cell| key[0] == @workbook.active_sheet && key[1] == row && key[2] == column }
605
+ sync_formula_field
606
+ @cx&.dispatcher&.focus(@grid.focus_handle)
607
+ request_frame
608
+ true
609
+ rescue Rukbat::Error, ArgumentError, TypeError => error
610
+ @status = error.message
611
+ request_frame
612
+ false
613
+ end
614
+
615
+ def cancel_inline_edit
616
+ return false unless @editing_cell
617
+
618
+ row, column = @editing_cell
619
+ @editing_cell = @inline_buffer = @inline_editor = nil
620
+ @inline_focus_pending = false
621
+ @rendered_cells.delete_if { |key, _cell| key[0] == @workbook.active_sheet && key[1] == row && key[2] == column }
622
+ sync_formula_field
623
+ @cx&.dispatcher&.focus(@grid.focus_handle)
624
+ request_frame
625
+ true
626
+ end
627
+
628
+ def fill(source, target)
629
+ source_top, source_left = [source.rows.begin, 1].max, [source.columns.begin, 1].max
630
+ source_bottom, source_right = source.rows.end, source.columns.end
631
+ return if source_top >= source_bottom || source_left >= source_right
632
+
633
+ target_top, target_left = [target.rows.begin, 1].max, [target.columns.begin, 1].max
634
+ fill_cells = (target.rows.end - target_top) * (target.columns.end - target_left)
635
+ if fill_cells > MAX_FILL_CELLS
636
+ @status = "Fill area exceeds #{MAX_FILL_CELLS} cells"
637
+ request_frame
638
+ return false
639
+ end
640
+
641
+ source_height, source_width = source_bottom - source_top, source_right - source_left
642
+ changes = []
643
+ (target_top...target.rows.end).each do |row|
644
+ (target_left...target.columns.end).each do |column|
645
+ next if row >= source_top && row < source_bottom && column >= source_left && column < source_right
646
+
647
+ from_row = source_top + ((row - source_top) % source_height)
648
+ from_column = source_left + ((column - source_left) % source_width)
649
+ value = @workbook.input_at(from_row, from_column)
650
+ value = translated_formula(value, from_row, from_column, row, column) if value.is_a?(String) && value.start_with?("=")
651
+ changes << [row, column, value]
652
+ end
653
+ end
654
+ @workbook.set_many(changes) unless changes.empty?
655
+ update_status(target)
656
+ request_frame
657
+ end
658
+
659
+ def translated_formula(value, from_row, from_column, to_row, to_column)
660
+ from = Furud::Reference.new(sheet: @workbook.active_sheet, row: from_row, column: from_column)
661
+ to = Furud::Reference.new(sheet: @workbook.active_sheet, row: to_row, column: to_column)
662
+ @workbook.translate_formula(value, from: from, to: to)
663
+ end
664
+
665
+ def update_status(area = @grid.selection.last)
666
+ return @status = "Ready" unless area
667
+
668
+ top, left = [area.rows.begin, 1].max, [area.columns.begin, 1].max
669
+ bottom, right = [area.rows.end - 1, top].max, [area.columns.end - 1, left].max
670
+ summary = @workbook.summary(top, left, bottom, right)
671
+ @status = "#{summary.count} cells · Sum #{summary.sum} · Average #{summary.average || "—"} · Numeric #{summary.numeric_count}"
672
+ rescue Rukbat::Error => error
673
+ @status = error.message
674
+ end
675
+
676
+ def sync_formula_field
677
+ value = @workbook.input_at(*@active_cell).to_s
678
+ @formula_field.buffer.replace(0...@formula_field.buffer.bytesize, value.encode(Encoding::UTF_8))
679
+ comment = @workbook.comment_at(*@active_cell).to_s
680
+ @comment_field.buffer.replace(0...@comment_field.buffer.bytesize, comment.encode(Encoding::UTF_8))
681
+ update_completion(value)
682
+ end
683
+
684
+ def update_completion(text)
685
+ token = text[/([A-Za-z][A-Za-z0-9_.]*)\z/, 1]
686
+ @completion_candidate = nil
687
+ return unless token && (text.start_with?("=") || text.match?(/[+\-*\/(,]\s*#{Regexp.escape(token)}\z/i))
688
+ return if token.length < 2
689
+
690
+ @completion_session.query = token
691
+ match = @completion_session.matches(1).first
692
+ @completion_candidate = match.candidate if match && match.candidate != token.upcase
693
+ end
694
+
695
+ def apply_completion
696
+ return unless @completion_candidate
697
+ token = @formula_field.value[/([A-Za-z][A-Za-z0-9_.]*)\z/, 1]
698
+ return unless token
699
+
700
+ text = @formula_field.value
701
+ value = text.byteslice(0, text.bytesize - token.bytesize) + @completion_candidate
702
+ @formula_field.buffer.replace(0...@formula_field.buffer.bytesize, value.encode(Encoding::UTF_8))
703
+ update_completion(value)
704
+ request_frame
705
+ end
706
+
707
+ def apply_format(**properties)
708
+ coordinates = selected_coordinates
709
+ @workbook.format_range(*coordinates, sheet: @workbook.active_sheet, **properties)
710
+ @status = "Formatted #{cell_address(coordinates[0], coordinates[1])}"
711
+ request_frame
712
+ true
713
+ rescue Rukbat::Error => error
714
+ @status = error.message
715
+ request_frame
716
+ false
717
+ end
718
+
719
+ def toggle_bold
720
+ value = !@workbook.format_at(*@active_cell).fetch(:bold, false)
721
+ apply_format(bold: value)
722
+ end
723
+
724
+ def freeze_panes
725
+ rows, columns = @active_cell[0] + 1, @active_cell[1] + 1
726
+ @workbook.set_frozen_panes(rows: rows, columns: columns)
727
+ @grid.freeze_panes(rows: rows, columns: columns)
728
+ @status = "Frozen through #{cell_address(*@active_cell)}"
729
+ request_frame
730
+ true
731
+ end
732
+
733
+ def unfreeze_panes
734
+ @workbook.set_frozen_panes(rows: 0, columns: 0)
735
+ @grid.freeze_panes(rows: 0, columns: 0)
736
+ @status = "Unfrozen panes"
737
+ request_frame
738
+ true
739
+ end
740
+
741
+ def sync_frozen_panes
742
+ rows, columns = @workbook.frozen_panes
743
+ @grid.freeze_panes(rows: rows, columns: columns)
744
+ end
745
+
746
+ def show_chart(type)
747
+ top, left, bottom, right = selected_coordinates
748
+ if bottom == top
749
+ @status = "Select a header and at least one data row"
750
+ request_frame
751
+ return false
752
+ end
753
+ series = chart_series(top, left, bottom, right)
754
+ @chart_component = case type
755
+ when :line then UI::LineChart.new(series, width: 480, height: 180)
756
+ when :bar then UI::BarChart.new(series, width: 480, height: 180)
757
+ when :pie then UI::PieChart.new(pie_values(top, left, bottom, right), width: 320, height: 180)
758
+ else raise ArgumentError, "unsupported chart type"
759
+ end
760
+ @status = "#{type.to_s.capitalize} chart"
761
+ request_frame
762
+ true
763
+ rescue ArgumentError, Rukbat::Error => error
764
+ @status = error.message
765
+ request_frame
766
+ false
767
+ end
768
+
769
+ def selected_coordinates
770
+ area = @grid.selection.last
771
+ return [*@active_cell, *@active_cell] unless area
772
+
773
+ top, left = [area.rows.begin, 1].max, [area.columns.begin, 1].max
774
+ bottom, right = [area.rows.end - 1, top].max, [area.columns.end - 1, left].max
775
+ [top, left, bottom, right]
776
+ end
777
+
778
+ def chart_series(top, left, bottom, right)
779
+ columns = left == right ? [left] : ((left + 1)..right).to_a
780
+ raise ArgumentError, "select at least one numeric series column" if columns.empty?
781
+
782
+ columns.to_h do |column|
783
+ name = @workbook.input_at(top, column).to_s
784
+ name = "Series #{column_name(column)}" if name.empty?
785
+ values = ((top + 1)..bottom).map do |row|
786
+ value = @workbook[row, column]
787
+ value.nil? ? 0 : Float(value)
788
+ rescue ArgumentError, TypeError
789
+ 0
790
+ end
791
+ [name, values]
792
+ end
793
+ end
794
+
795
+ def pie_values(top, left, bottom, right)
796
+ column = right > left ? left + 1 : left
797
+ (top + 1..bottom).to_h do |row|
798
+ label = right > left ? @workbook[row, left].to_s : row.to_s
799
+ value = @workbook[row, column]
800
+ number = value.nil? ? 0 : Float(value)
801
+ [label.empty? ? row.to_s : label, number]
802
+ rescue ArgumentError, TypeError
803
+ [label.empty? ? row.to_s : label, 0]
804
+ end
805
+ end
806
+
807
+ def add_sheet
808
+ index = @workbook.sheet_names.length + 1
809
+ name = "Sheet#{index}"
810
+ index += 1 while @workbook.sheet_names.include?(name)
811
+ @workbook.add_sheet(name)
812
+ sync_grid_visibility
813
+ sync_frozen_panes
814
+ @active_cell = [1, 1]
815
+ sync_formula_field
816
+ @status = "Added #{name}"
817
+ request_frame
818
+ end
819
+
820
+ def save
821
+ @status = @on_save ? @on_save.call.to_s : "Save is unavailable"
822
+ request_frame
823
+ true
824
+ rescue StandardError => error
825
+ @status = "Save failed: #{error.message}"
826
+ request_frame
827
+ false
828
+ end
829
+
830
+ def request_frame = @cx&.window&.request_frame
831
+
832
+ def focused_text_field?(window)
833
+ window.dispatcher.focused&.ancestors&.any? { |handle| handle.context[:in_text_field] }
834
+ end
835
+
836
+ def cell_address(row, column) = "#{column_name(column)}#{row}"
837
+
838
+ def column_name(column)
839
+ result = +""
840
+ while column.positive?
841
+ column, remainder = (column - 1).divmod(26)
842
+ result.prepend((65 + remainder).chr)
843
+ end
844
+ result
845
+ end
846
+ end
847
+ end