rukbat 0.1.0 → 0.3.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9e1adcf1d5362c707d385caf7e0035b9aa358286bc45752d67abe012606a9961
4
- data.tar.gz: ec59e5a8ac486e57bded8e8f3a54129ac7514321722132edbae4181aabcefc4f
3
+ metadata.gz: fd6ea385045b568f6f5bae52724b003fcff14728cbcd3906eff5f7fd93e16ec9
4
+ data.tar.gz: 3e89c333f415e01537198f72e4c54e668da7664d4a3d561e867c2ae95cb6445f
5
5
  SHA512:
6
- metadata.gz: 207d10683c00d7ce74ac3c101f0facd2c8e39385ee186f09e2c38dbbb15e19e7ee603e3b9f6a0ff528a5d46006a52693e12511ebc36981324e6c469d61bea5ef
7
- data.tar.gz: f2ee8e18a439ae0fc7a7ad271aa44ea783250504b1362afe82cae72af1461c3f5304861bd3334e1bec3880fe1689d9b52d7f2451b1deda883609b61e8f58f2e9
6
+ metadata.gz: 546c1d4e7ee470e5cd0b58bd3f28a4c8d74fb50eacefcb4c3875569409770a1195c2da8bcbe83ea61e985f6f093813100baf441db040a91b3baf97871c1d9a0d
7
+ data.tar.gz: 23b96cef205cd02e05b743423c9c7e2b3220487fabef9810b4389d807cf93aa4b7f959dc5ce3eb0d2c7e97d75bc0b2b2d3117eb4e4b184aabd4be420cd04ceaa
data/CHANGELOG.md CHANGED
@@ -1,5 +1,14 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.0 — 2026-09-24
4
+
5
+ - Add range-based whole-number input validation with undo support.
6
+ - Add static pivot tables with sum and count aggregation.
7
+
8
+ ## 0.2.0 — 2026-09-24
9
+
10
+ - Add donut, scatter, area, stacked-area, and stacked-bar chart options.
11
+
3
12
  ## 0.1.0 — 2026-09-23
4
13
 
5
14
  - Initial release.
data/README.md CHANGED
@@ -7,9 +7,9 @@ Furud formula engine.
7
7
  The current implementation includes a virtualized million-row grid, formula
8
8
  editing and completion, multi-sheet formulas, range summaries, undo/redo,
9
9
  structural row/column edits, formatting, sorting, filtering, duplicate removal,
10
- find/replace, comments, named ranges, conditional formatting, line/bar/pie
11
- charts, CSV/TSV import/export, and searchable PDF export. The application is
12
- still under development and has not been released as `0.1.0`. On arm64 macOS
10
+ find/replace, comments, named ranges, conditional formatting, basic pivot tables, line/bar/pie,
11
+ donut/scatter/area/stacked charts, CSV/TSV import/export, and searchable PDF
12
+ export. On arm64 macOS
13
13
  with Ruby 4.0.6, the million-row integrated scroll benchmark measured 11.383 ms
14
14
  against a 16.67 ms budget, and the 100,000-cell edit/recalculation benchmark
15
15
  measured 1.773 s against a 3 s budget. Formula compatibility, public CI, and
@@ -69,7 +69,28 @@ count, numeric count, sum, and average appear in the status bar.
69
69
 
70
70
  The toolbar provides common number, font, alignment, fill, and border formats;
71
71
  sorting, filtering, duplicate removal, search/replace, comments, named ranges,
72
- positive-value highlighting, freeze/unfreeze panes, print areas, and line/bar/pie charts.
72
+ positive-value highlighting, freeze/unfreeze panes, print areas, and line, bar,
73
+ pie, donut, scatter, area, stacked-area, and stacked-bar charts.
74
+ Select a range, enter inclusive minimum/maximum values, and choose **Apply whole-number rule**
75
+ to require each nonblank edited value (including a formula's calculated result) to be
76
+ a whole number in that range. Invalid edits are rejected atomically; clearing cells is
77
+ allowed. **Clear rule** removes validation from the selected range, including only the
78
+ selected portion of a larger rule. Rules follow row/column insertions and deletions and
79
+ are included in undo/redo. Existing values are not retroactively changed when a rule is
80
+ applied. Input validations are session metadata: CSV/TSV has no place to store them, so
81
+ they reset when the workbook is reopened. A workbook supports up to 256 validation
82
+ ranges.
83
+ For a pivot, select a source rectangle including its header row, enter the one-based
84
+ key and value column positions relative to that selection, choose **Sum** or **Count**,
85
+ and choose **Create pivot**. Formula results are used; groups match exact key values
86
+ (text is case-sensitive), retain their first-seen order, and blank keys form their own
87
+ group. **Sum** adds finite real numbers only, and **Count**
88
+ counts nonblank values in the selected value column. The output is static data in a new
89
+ `Pivot` sheet (or the next unused `Pivot2`, etc.), not a live link to the source. At most
90
+ 100,000 data rows and 10,000 groups can be summarized.
91
+ Creating a pivot leaves its source sheet active. CSV/TSV saves the active sheet only;
92
+ select the pivot sheet before saving if you want to export the summary instead. One
93
+ Undo removes the generated pivot sheet.
73
94
  Freeze panes are kept per sheet and restored by undo/redo; the selected cell and
74
95
  the row/column headers before it stay visible while scrolling. **Unfreeze** removes
75
96
  all frozen rows and columns. Clear highlights removes conditional formatting
@@ -78,8 +99,7 @@ Select a range and choose **Set print area** to constrain PDF export; **Clear
78
99
  print area** restores full-sheet output. **Export PDF** prompts for an embeddable
79
100
  font and output path, applying the current print area. The CLI can also export
80
101
  with `bundle exec rukbat --export-pdf report.pdf --font /path/to/font.ttf`.
81
- Print areas are session metadata; CSV has no place to store them, so they reset
82
- when the workbook is reopened.
102
+ Print areas are also session metadata and reset when the workbook is reopened.
83
103
  PDF export embeds the supplied font, resolves formatted font families from the
84
104
  system font database, and applies bold/italic along with cell formatting; bold
85
105
  and italic are synthesized in the PDF when the selected face has no matching
@@ -6,7 +6,7 @@ module Rukbat
6
6
  MAX_FILL_CELLS = 100_000
7
7
 
8
8
  attr_reader :grid, :formula_field, :active_cell, :status, :completion_session,
9
- :completion_candidate
9
+ :completion_candidate, :validation_min_field, :validation_max_field
10
10
 
11
11
  def initialize(workbook, on_save: nil)
12
12
  super()
@@ -37,9 +37,8 @@ module Rukbat
37
37
  @freeze_button = UI::Button.new("Freeze", size: :sm, variant: :ghost).on_click { freeze_panes }
38
38
  @unfreeze_button = UI::Button.new("Unfreeze", size: :sm, variant: :ghost).on_click { unfreeze_panes }
39
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) }
40
+ @chart_dropdown = UI::Dropdown.new("Chart", items: %w[Line Bar Pie Donut Scatter Area Stacked\ area Stacked\ bar])
41
+ .on_change { |name, *_| show_chart(name.downcase.tr(" ", "_").to_sym) }
43
42
  @font_down_button = UI::Button.new("A−", size: :sm, variant: :ghost).on_click { change_font_size(-1) }
44
43
  @font_up_button = UI::Button.new("A+", size: :sm, variant: :ghost).on_click { change_font_size(1) }
45
44
  @align_button = UI::Button.new("Align", size: :sm, variant: :ghost).on_click { cycle_alignment }
@@ -62,6 +61,19 @@ module Rukbat
62
61
  @set_print_area_button = UI::Button.new("Set print area", size: :sm, variant: :ghost).on_click { set_print_area }
63
62
  @clear_print_area_button = UI::Button.new("Clear print area", size: :sm, variant: :ghost).on_click { clear_print_area }
64
63
  @export_pdf_button = UI::Button.new("Export PDF", size: :sm, variant: :ghost).on_click { export_pdf }
64
+ @validation_min_field = UI::TextField.new("")
65
+ @validation_max_field = UI::TextField.new("")
66
+ @set_validation_button = UI::Button.new("Apply whole-number rule", size: :sm, variant: :ghost)
67
+ .on_click { apply_whole_number_validation }
68
+ @clear_validation_button = UI::Button.new("Clear rule", size: :sm, variant: :ghost)
69
+ .on_click { clear_input_validation }
70
+ @pivot_key_field = UI::TextField.new("1")
71
+ @pivot_value_field = UI::TextField.new("2")
72
+ @pivot_aggregate = :sum
73
+ @pivot_aggregate_dropdown = UI::Dropdown.new("Sum", items: %w[Sum Count])
74
+ .on_change { |value, *_| @pivot_aggregate = value.to_s.downcase.to_sym }
75
+ @create_pivot_button = UI::Button.new("Create pivot", size: :sm, variant: :secondary)
76
+ .on_click { create_pivot_table }
65
77
  @find_field = UI::TextField.new("")
66
78
  @replace_field = UI::TextField.new("")
67
79
  @find_button = UI::Button.new("Find", size: :sm, variant: :ghost).on_click { find_selection }
@@ -118,7 +130,7 @@ module Rukbat
118
130
  .child(@undo_button).child(@redo_button).child(@save_button).child(@add_sheet_button)
119
131
  .child(@number_button).child(@percent_button).child(@bold_button).child(@fill_button)
120
132
  .child(@freeze_button).child(@unfreeze_button).child(@clear_conditional_button)
121
- .child(@line_chart_button).child(@bar_chart_button).child(@pie_chart_button)
133
+ .child(@chart_dropdown)
122
134
  format_toolbar = Zaniah::Div.new.flex_row.items_center.gap(cx.theme.spacing[1])
123
135
  .child(@font_down_button).child(@font_up_button).child(@align_button)
124
136
  .child(@text_color_button).child(@border_button)
@@ -130,6 +142,16 @@ module Rukbat
130
142
  .child(@insert_row_button).child(@delete_rows_button)
131
143
  .child(@insert_column_button).child(@delete_columns_button)
132
144
  .child(@set_print_area_button).child(@clear_print_area_button).child(@export_pdf_button)
145
+ validation_toolbar = Zaniah::Div.new.flex_row.items_center.gap(cx.theme.spacing[1])
146
+ .child(UI::Label.new("Whole numbers", size: :sm))
147
+ .child(UI::Label.new("Min", size: :sm)).child(@validation_min_field.style(width: 72))
148
+ .child(UI::Label.new("Max", size: :sm)).child(@validation_max_field.style(width: 72))
149
+ .child(@set_validation_button).child(@clear_validation_button)
150
+ pivot_toolbar = Zaniah::Div.new.flex_row.items_center.gap(cx.theme.spacing[1])
151
+ .child(UI::Label.new("Pivot (selected range; first row is headers)", size: :sm))
152
+ .child(UI::Label.new("Key col", size: :sm)).child(@pivot_key_field.style(width: 56))
153
+ .child(UI::Label.new("Value col", size: :sm)).child(@pivot_value_field.style(width: 56))
154
+ .child(@pivot_aggregate_dropdown).child(@create_pivot_button)
133
155
  annotation_toolbar = Zaniah::Div.new.flex_row.items_center.gap(cx.theme.spacing[1])
134
156
  .child(@highlight_button).child(@comment_field.style(width: 150)).child(@comment_button)
135
157
  .child(@name_field.style(width: 120)).child(@name_button)
@@ -159,7 +181,7 @@ module Rukbat
159
181
  content = Zaniah::Div.new.flex_col.gap(cx.theme.spacing[1]).p(cx.theme.spacing[2])
160
182
  .style(width: percent(100), height: percent(100))
161
183
  .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)
184
+ .child(validation_toolbar).child(pivot_toolbar).child(find_toolbar).child(formula_row).child(@grid.flex_1)
163
185
  content.child(@chart_component) if @chart_component
164
186
  content.child(status_row)
165
187
  content
@@ -224,6 +246,53 @@ module Rukbat
224
246
 
225
247
  private
226
248
 
249
+ def apply_whole_number_validation
250
+ top, left, bottom, right = selected_coordinates
251
+ minimum = Integer(@validation_min_field.value, 10)
252
+ maximum = Integer(@validation_max_field.value, 10)
253
+ @workbook.set_whole_number_validation(top, left, bottom, right,
254
+ minimum: minimum, maximum: maximum)
255
+ @status = "Whole-number rule applied to #{cell_address(top, left)}:#{cell_address(bottom, right)}"
256
+ request_frame
257
+ true
258
+ rescue Rukbat::Error, ArgumentError, TypeError => error
259
+ @status = error.message
260
+ request_frame
261
+ false
262
+ end
263
+
264
+ def clear_input_validation
265
+ top, left, bottom, right = selected_coordinates
266
+ @workbook.clear_input_validation(top, left, bottom, right)
267
+ @status = "Input rule cleared from #{cell_address(top, left)}:#{cell_address(bottom, right)}"
268
+ request_frame
269
+ true
270
+ rescue Rukbat::Error, ArgumentError, TypeError => error
271
+ @status = error.message
272
+ request_frame
273
+ false
274
+ end
275
+
276
+ def create_pivot_table
277
+ top, left, bottom, right = selected_coordinates
278
+ source_cell = @active_cell
279
+ pivot = @workbook.pivot_table(top, left, bottom, right,
280
+ row_key_column: Integer(@pivot_key_field.value, 10),
281
+ value_column: Integer(@pivot_value_field.value, 10), aggregate: @pivot_aggregate)
282
+ name = @workbook.add_pivot_sheet(next_pivot_sheet_name, pivot)
283
+ sync_grid_visibility
284
+ sync_frozen_panes
285
+ @active_cell = source_cell
286
+ sync_formula_field
287
+ @status = "Created #{name}: #{pivot.rows.length} groups (#{pivot.aggregate}); select its sheet tab to view"
288
+ request_frame
289
+ true
290
+ rescue Rukbat::Error, ArgumentError, TypeError => error
291
+ @status = error.message
292
+ request_frame
293
+ false
294
+ end
295
+
227
296
  def sort_selection
228
297
  clear_filter
229
298
  top, left, bottom, right = selected_coordinates
@@ -750,14 +819,19 @@ module Rukbat
750
819
  request_frame
751
820
  return false
752
821
  end
753
- series = chart_series(top, left, bottom, right)
822
+ series = chart_series(top, left, bottom, right) unless type == :scatter
754
823
  @chart_component = case type
755
824
  when :line then UI::LineChart.new(series, width: 480, height: 180)
756
825
  when :bar then UI::BarChart.new(series, width: 480, height: 180)
757
826
  when :pie then UI::PieChart.new(pie_values(top, left, bottom, right), width: 320, height: 180)
827
+ when :donut then UI::DonutChart.new(pie_values(top, left, bottom, right), width: 320, height: 180)
828
+ when :scatter then UI::ScatterChart.new(scatter_series(top, left, bottom, right), width: 480, height: 180)
829
+ when :area then UI::AreaChart.new(series, width: 480, height: 180)
830
+ when :stacked_area then UI::AreaChart.new(series, width: 480, height: 180, stacked: true)
831
+ when :stacked_bar then UI::StackedBarChart.new(series, width: 480, height: 180)
758
832
  else raise ArgumentError, "unsupported chart type"
759
833
  end
760
- @status = "#{type.to_s.capitalize} chart"
834
+ @status = "#{type.to_s.tr("_", " ").capitalize} chart"
761
835
  request_frame
762
836
  true
763
837
  rescue ArgumentError, Rukbat::Error => error
@@ -782,16 +856,37 @@ module Rukbat
782
856
  columns.to_h do |column|
783
857
  name = @workbook.input_at(top, column).to_s
784
858
  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
859
+ values = ((top + 1)..bottom).map { |row| chart_value(row, column) }
791
860
  [name, values]
792
861
  end
793
862
  end
794
863
 
864
+ def scatter_series(top, left, bottom, right)
865
+ columns = (left..right).to_a
866
+ first_column_is_labels = ((top + 1)..bottom).none? do |row|
867
+ Float(@workbook[row, left])
868
+ true
869
+ rescue ArgumentError, TypeError
870
+ false
871
+ end
872
+ columns.shift if first_column_is_labels
873
+ raise ArgumentError, "select X and at least one Y column for a scatter chart" if columns.length < 2
874
+
875
+ x_values = ((top + 1)..bottom).map { |row| chart_value(row, columns.first) }
876
+ columns.drop(1).to_h do |column|
877
+ name = @workbook.input_at(top, column).to_s
878
+ name = "Series #{column_name(column)}" if name.empty?
879
+ [name, ((top + 1)..bottom).map { |row| [x_values[row - top - 1], chart_value(row, column)] }]
880
+ end
881
+ end
882
+
883
+ def chart_value(row, column)
884
+ value = @workbook[row, column]
885
+ value.nil? ? 0 : Float(value)
886
+ rescue ArgumentError, TypeError
887
+ 0
888
+ end
889
+
795
890
  def pie_values(top, left, bottom, right)
796
891
  column = right > left ? left + 1 : left
797
892
  (top + 1..bottom).to_h do |row|
@@ -817,6 +912,14 @@ module Rukbat
817
912
  request_frame
818
913
  end
819
914
 
915
+ def next_pivot_sheet_name
916
+ return "Pivot" unless @workbook.sheet_names.include?("Pivot")
917
+
918
+ index = 2
919
+ index += 1 while @workbook.sheet_names.include?("Pivot#{index}")
920
+ "Pivot#{index}"
921
+ end
922
+
820
923
  def save
821
924
  @status = @on_save ? @on_save.call.to_s : "Save is unavailable"
822
925
  request_frame
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Rukbat
4
- VERSION = "0.1.0"
4
+ VERSION = "0.3.0"
5
5
  end
@@ -10,7 +10,10 @@ module Rukbat
10
10
  MAX_FORMAT_CELLS = 100_000
11
11
  MAX_HIDDEN_CELLS = 100_000
12
12
  MAX_FILTER_ROWS = 100_000
13
+ MAX_PIVOT_ROWS = 100_000
14
+ MAX_PIVOT_GROUPS = 10_000
13
15
  MAX_CONDITIONAL_FORMATS = 256
16
+ MAX_INPUT_VALIDATIONS = 256
14
17
  FORMAT_KEYS = %i[number_format font_family font_size bold italic color background border_color border_width horizontal_alignment vertical_alignment].freeze
15
18
  CONDITIONAL_OPERATORS = %i[greater_than greater_than_or_equal less_than less_than_or_equal equal not_equal contains].freeze
16
19
  COLORS = {black: "#000000", blue: "#0000FF", cyan: "#00FFFF", green: "#008000",
@@ -22,6 +25,8 @@ module Rukbat
22
25
  row.between?(top, bottom) && column.between?(left, right)
23
26
  end
24
27
  end
28
+ InputValidation = Data.define(:area, :minimum, :maximum)
29
+ PivotTable = Data.define(:source_area, :row_key_column, :value_column, :aggregate, :headers, :rows)
25
30
 
26
31
  attr_reader :active_sheet
27
32
 
@@ -46,6 +51,7 @@ module Rukbat
46
51
  @hidden_columns = {}.freeze
47
52
  @frozen_panes = {name => {rows: 1, columns: 1}.freeze}.freeze
48
53
  @print_areas = {}.freeze
54
+ @input_validations = [].freeze
49
55
  @change_observers = []
50
56
  @source = CellSource.new(self)
51
57
  @engine = Furud::Engine.new(@source)
@@ -100,6 +106,7 @@ module Rukbat
100
106
  @formats = @formats.reject { |(sheet_name, _row, _column), _style| sheet_name == name }.freeze
101
107
  @comments = @comments.reject { |(sheet_name, _row, _column), _text| sheet_name == name }.freeze
102
108
  @conditional_formats = @conditional_formats.reject { |rule| rule[:area].sheet == name }.freeze
109
+ @input_validations = @input_validations.reject { |rule| rule.area.sheet == name }.freeze
103
110
  @hidden_rows = @hidden_rows.reject { |sheet_name, _| sheet_name == name }.freeze
104
111
  @hidden_columns = @hidden_columns.reject { |sheet_name, _| sheet_name == name }.freeze
105
112
  @frozen_panes = @frozen_panes.reject { |sheet_name, _| sheet_name == name }.freeze
@@ -129,15 +136,26 @@ module Rukbat
129
136
  def set(row, column, value, sheet: @active_sheet)
130
137
  ref = reference(row, column, sheet)
131
138
  current = input_at(row, column, sheet: ref.sheet)
132
- return self if current == value
133
139
  return clear(row, column, sheet: ref.sheet) if value.nil?
134
140
 
135
141
  validate_formula(value, ref)
142
+ if current == value
143
+ validate_input_values!([ref])
144
+ return self
145
+ end
146
+
136
147
  updated = update_sheet(@sheets.fetch(ref.sheet), [[row - 1, column - 1, value]])
137
- record_history
138
148
  @engine.set(ref, value)
149
+ changed = @engine.recalculate
150
+ begin
151
+ validate_input_values!([ref, *changed])
152
+ rescue Error
153
+ rebuild_engine
154
+ raise
155
+ end
156
+ record_history
139
157
  @sheets[ref.sheet] = updated
140
- update_calculated(@engine.recalculate)
158
+ update_calculated(changed)
141
159
  notify_cells_changed(ref.sheet, [[ref.row, ref.column].freeze])
142
160
  self
143
161
  end
@@ -158,12 +176,14 @@ module Rukbat
158
176
  validate_formula(value, cell)
159
177
  [[row, column, value], cell, value] if current[row - 1, column - 1] != value
160
178
  end
161
- return self if changes_to_apply.empty?
179
+ if changes_to_apply.empty?
180
+ validate_input_values!(values.values.map(&:first))
181
+ return self
182
+ end
162
183
 
163
184
  updated = update_sheet(current, changes_to_apply.map do |(row, column, value), _cell, _input|
164
185
  [row - 1, column - 1, value]
165
186
  end)
166
- record_history
167
187
  changes_to_apply.each do |_change, cell, value|
168
188
  if value.nil?
169
189
  @engine.clear(cell)
@@ -171,8 +191,16 @@ module Rukbat
171
191
  @engine.set(cell, value)
172
192
  end
173
193
  end
194
+ changed = @engine.recalculate
195
+ begin
196
+ validate_input_values!([*changes_to_apply.map { |_change, cell, _value| cell }, *changed])
197
+ rescue Error
198
+ rebuild_engine
199
+ raise
200
+ end
201
+ record_history
174
202
  @sheets[ref.sheet] = updated
175
- update_calculated(@engine.recalculate)
203
+ update_calculated(changed)
176
204
  notify_cells_changed(ref.sheet, changes_to_apply.map do |_change, cell, _value|
177
205
  [cell.row, cell.column].freeze
178
206
  end)
@@ -183,14 +211,64 @@ module Rukbat
183
211
  ref = reference(row, column, sheet)
184
212
  return self if input_at(row, column, sheet: ref.sheet).nil?
185
213
 
214
+ @engine.clear(ref)
215
+ changed = @engine.recalculate
216
+ begin
217
+ validate_input_values!([ref, *changed])
218
+ rescue Error
219
+ rebuild_engine
220
+ raise
221
+ end
186
222
  record_history
187
223
  @sheets[ref.sheet] = @sheets.fetch(ref.sheet).delete(row - 1, column - 1)
188
- @engine.clear(ref)
189
- update_calculated(@engine.recalculate)
224
+ update_calculated(changed)
190
225
  notify_cells_changed(ref.sheet, [[ref.row, ref.column].freeze])
191
226
  self
192
227
  end
193
228
 
229
+ def set_whole_number_validation(top, left, bottom, right, minimum:, maximum:, sheet: @active_sheet)
230
+ top, left, bottom, right, name = range_coordinates(top, left, bottom, right, sheet)
231
+ minimum, maximum = strict_integer(minimum), strict_integer(maximum)
232
+ raise Error, "minimum must not exceed maximum" if minimum > maximum
233
+
234
+ area = Furud::Area.new(sheet: name, top: top, left: left, bottom: bottom, right: right)
235
+ validation = InputValidation.new(area: area, minimum: minimum, maximum: maximum)
236
+ updated = @input_validations.flat_map do |existing|
237
+ existing.area.sheet == name ? subtract_input_validation(existing, top, left, bottom, right) : [existing]
238
+ end
239
+ updated << validation
240
+ raise Error, "input validation limit exceeded" if updated.length > MAX_INPUT_VALIDATIONS
241
+ return self if updated == @input_validations
242
+
243
+ record_history
244
+ @input_validations = updated.freeze
245
+ self
246
+ rescue ArgumentError, TypeError
247
+ raise Error, "validation limits must be integers"
248
+ end
249
+
250
+ def clear_input_validation(top, left, bottom, right, sheet: @active_sheet)
251
+ top, left, bottom, right, name = range_coordinates(top, left, bottom, right, sheet)
252
+ updated = @input_validations.flat_map do |existing|
253
+ existing.area.sheet == name ? subtract_input_validation(existing, top, left, bottom, right) : [existing]
254
+ end
255
+ raise Error, "input validation limit exceeded" if updated.length > MAX_INPUT_VALIDATIONS
256
+ return self if updated == @input_validations
257
+
258
+ record_history
259
+ @input_validations = updated.freeze
260
+ self
261
+ end
262
+
263
+ def input_validation_at(row, column, sheet: @active_sheet)
264
+ ref = reference(row, column, sheet)
265
+ @input_validations.find do |validation|
266
+ area = validation.area
267
+ area.sheet == ref.sheet && ref.row.between?(area.top, area.bottom) &&
268
+ ref.column.between?(area.left, area.right)
269
+ end
270
+ end
271
+
194
272
  def each_in(top, left, bottom, right, sheet: @active_sheet, &block)
195
273
  return enum_for(__method__, top, left, bottom, right, sheet: sheet) unless block
196
274
 
@@ -213,6 +291,104 @@ module Rukbat
213
291
  types: values.types)
214
292
  end
215
293
 
294
+ def pivot_table(top, left, bottom, right, row_key_column:, value_column:, aggregate: :sum, sheet: @active_sheet)
295
+ top, left, bottom, right, name = range_coordinates(top, left, bottom, right, sheet)
296
+ row_key_column, value_column = strict_integer(row_key_column), strict_integer(value_column)
297
+ width = right - left + 1
298
+ raise Error, "pivot row-key column must be between 1 and #{width}" unless row_key_column.between?(1, width)
299
+ raise Error, "pivot value column must be between 1 and #{width}" unless value_column.between?(1, width)
300
+ raise Error, "pivot aggregate must be :sum or :count" unless %i[sum count].include?(aggregate)
301
+ raise Error, "pivot source exceeds #{MAX_PIVOT_ROWS} data rows" if bottom - top > MAX_PIVOT_ROWS
302
+
303
+ key_column, measure_column = left + row_key_column - 1, left + value_column - 1
304
+ groups, keys = {}, []
305
+ ((top + 1)..bottom).each do |row|
306
+ key = @engine.value(Furud::Reference.new(sheet: name, row: row, column: key_column))
307
+ value = @engine.value(Furud::Reference.new(sheet: name, row: row, column: measure_column))
308
+ unless groups.key?(key)
309
+ raise Error, "pivot exceeds #{MAX_PIVOT_GROUPS} groups" if groups.length >= MAX_PIVOT_GROUPS
310
+
311
+ keys << (key.is_a?(String) ? key.dup.freeze : key)
312
+ groups[key] = 0
313
+ end
314
+ if aggregate == :count
315
+ groups[key] += 1 unless value.nil?
316
+ elsif finite_pivot_number?(value)
317
+ sum = groups[key] + value
318
+ raise Error, "pivot sum is not finite" unless finite_pivot_number?(sum)
319
+
320
+ groups[key] = sum
321
+ end
322
+ end
323
+
324
+ key_header = @engine.value(Furud::Reference.new(sheet: name, row: top, column: key_column))
325
+ value_header = @engine.value(Furud::Reference.new(sheet: name, row: top, column: measure_column))
326
+ key_header = "Column #{row_key_column}" if key_header.nil? || key_header.to_s.empty?
327
+ value_header = "Column #{value_column}" if value_header.nil? || value_header.to_s.empty?
328
+ headers = [key_header.to_s, "#{aggregate.to_s.capitalize} of #{value_header}"].freeze
329
+ rows = keys.map { |key| [key, groups.fetch(key)].freeze }.freeze
330
+ area = Furud::Area.new(sheet: name, top: top, left: left, bottom: bottom, right: right)
331
+ PivotTable.new(source_area: area, row_key_column: row_key_column,
332
+ value_column: value_column, aggregate: aggregate, headers: headers, rows: rows)
333
+ rescue ArgumentError, TypeError
334
+ raise Error, "pivot columns must be integers"
335
+ end
336
+
337
+ def add_pivot_sheet(name, pivot)
338
+ name = validate_name(name)
339
+ raise Error, "sheet already exists: #{name}" if @sheets.key?(name)
340
+ raise Error, "pivot must be a PivotTable" unless pivot.is_a?(PivotTable)
341
+ raise Error, "pivot source area is invalid" unless pivot.source_area.is_a?(Furud::Area)
342
+ raise Error, "unknown pivot source sheet: #{pivot.source_area.sheet}" unless @sheets.key?(pivot.source_area.sheet)
343
+ range_coordinates(pivot.source_area.top, pivot.source_area.left,
344
+ pivot.source_area.bottom, pivot.source_area.right, pivot.source_area.sheet)
345
+ source_width = pivot.source_area.right - pivot.source_area.left + 1
346
+ raise Error, "pivot columns are invalid" unless
347
+ pivot.row_key_column.is_a?(Integer) && pivot.row_key_column.between?(1, source_width) &&
348
+ pivot.value_column.is_a?(Integer) && pivot.value_column.between?(1, source_width) &&
349
+ %i[sum count].include?(pivot.aggregate)
350
+ raise Error, "pivot must have two headers and at most #{MAX_PIVOT_GROUPS} groups" unless
351
+ pivot.headers.is_a?(Array) && pivot.headers.length == 2 && pivot.rows.is_a?(Array) &&
352
+ pivot.rows.length <= MAX_PIVOT_GROUPS
353
+
354
+ rows = pivot.rows.each_with_index.map do |row, index|
355
+ raise Error, "pivot row #{index + 1} must contain a key and value" unless row.is_a?(Array) && row.length == 2
356
+
357
+ [row[0], row[1]]
358
+ end
359
+ changes = pivot.headers.each_with_index.map { |value, column| [1, column + 1, value] }
360
+ rows.each_with_index do |(key, value), index|
361
+ changes << [index + 2, 1, key]
362
+ changes << [index + 2, 2, value]
363
+ end
364
+ prepared = changes.map do |row, column, value|
365
+ row, column = strict_integer(row), strict_integer(column)
366
+ raise Error, "pivot output exceeds sheet limits" unless row.between?(1, MAX_ROWS) && column.between?(1, MAX_COLUMNS)
367
+
368
+ ref = Furud::Reference.new(sheet: name, row: row, column: column)
369
+ input = pivot_cell_input(value)
370
+ validate_formula(input, ref)
371
+ [row - 1, column - 1, input, ref]
372
+ end
373
+ updated = update_sheet(Denebola::Sheet.new, prepared.map { |row, column, value, _ref| [row, column, value] })
374
+ previous_state = snapshot
375
+ begin
376
+ @sheets = @sheets.merge(name => updated)
377
+ @calculated = @calculated.merge(name => Denebola::Sheet.new)
378
+ @frozen_panes = @frozen_panes.merge(name => {rows: 1, columns: 1}.freeze).freeze
379
+ prepared.each { |_row, _column, value, ref| @engine.set(ref, value) unless value.nil? }
380
+ changed = @engine.recalculate
381
+ rescue StandardError => error
382
+ restore(previous_state)
383
+ raise Error, "could not create pivot sheet: #{error.message}"
384
+ end
385
+ @history << previous_state
386
+ @redo.clear
387
+ update_calculated(changed)
388
+ notify_cells_changed(nil)
389
+ name
390
+ end
391
+
216
392
  def define_name(name, top, left, bottom, right, sheet: @active_sheet)
217
393
  name = name.to_s
218
394
  ast = Furud::Formula.parse("=#{name}")
@@ -348,7 +524,7 @@ module Rukbat
348
524
  named_ranges = named_ranges.map { |area| area.sheet == name ? adjusted_area(area, adjustment) : area }
349
525
  raise Error, "cannot delete an entire named range" if named_ranges.any?(&:nil?)
350
526
  end
351
- record_history
527
+ previous_state = snapshot
352
528
  runs.each do |at, count|
353
529
  @engine.delete_rows(name, at, count)
354
530
  @sheets[name] = @sheets.fetch(name).delete_rows(at - 1, count)
@@ -357,7 +533,16 @@ module Rukbat
357
533
  adjust_metadata(:delete_rows, at, count, name)
358
534
  end
359
535
  sync_formula_inputs
360
- update_calculated(@engine.recalculate)
536
+ changed = @engine.recalculate
537
+ begin
538
+ validate_input_values!(changed)
539
+ rescue Error
540
+ restore(previous_state)
541
+ raise
542
+ end
543
+ @history << previous_state
544
+ @redo.clear
545
+ update_calculated(changed)
361
546
  notify_cells_changed(name)
362
547
  duplicates.length
363
548
  end
@@ -635,6 +820,63 @@ module Rukbat
635
820
  raise Error, "row and column must be integers"
636
821
  end
637
822
 
823
+ def validate_input_values!(references)
824
+ references.uniq.each do |ref|
825
+ validation = input_validation_at(ref.row, ref.column, sheet: ref.sheet)
826
+ next unless validation
827
+
828
+ value = @engine.value(ref)
829
+ next if value.nil?
830
+
831
+ whole_number = value.is_a?(Integer) || (value.is_a?(Float) && value.finite? && value == value.to_i)
832
+ next if whole_number && value >= validation.minimum && value <= validation.maximum
833
+
834
+ address = "#{Furud::Formula.column_name(ref.column)}#{ref.row}"
835
+ raise Error, "#{address} must be a whole number between #{validation.minimum} and #{validation.maximum}"
836
+ end
837
+ end
838
+
839
+ def finite_pivot_number?(value)
840
+ value.is_a?(Numeric) && !value.is_a?(Complex) && (!value.respond_to?(:finite?) || value.finite?)
841
+ end
842
+
843
+ def pivot_cell_input(value)
844
+ case value
845
+ when String
846
+ value.start_with?("=") ? "=#{Furud::Formula.render_literal(value)}" : value
847
+ when Float
848
+ value.finite? ? value : value.to_s
849
+ when Complex
850
+ value.to_s
851
+ when NilClass, TrueClass, FalseClass, Numeric, Furud::ErrorValue
852
+ value
853
+ else
854
+ value.to_s
855
+ end
856
+ rescue StandardError => error
857
+ raise Error, "pivot value cannot be written: #{error.message}"
858
+ end
859
+
860
+ def subtract_input_validation(validation, top, left, bottom, right)
861
+ area = validation.area
862
+ intersect_top, intersect_left = [area.top, top].max, [area.left, left].max
863
+ intersect_bottom, intersect_right = [area.bottom, bottom].min, [area.right, right].min
864
+ return [validation] if intersect_top > intersect_bottom || intersect_left > intersect_right
865
+
866
+ ranges = []
867
+ ranges << [area.top, area.left, intersect_top - 1, area.right] if area.top < intersect_top
868
+ ranges << [intersect_bottom + 1, area.left, area.bottom, area.right] if intersect_bottom < area.bottom
869
+ ranges << [intersect_top, area.left, intersect_bottom, intersect_left - 1] if area.left < intersect_left
870
+ ranges << [intersect_top, intersect_right + 1, intersect_bottom, area.right] if intersect_right < area.right
871
+ ranges.map do |range_top, range_left, range_bottom, range_right|
872
+ InputValidation.new(
873
+ area: Furud::Area.new(sheet: area.sheet, top: range_top, left: range_left,
874
+ bottom: range_bottom, right: range_right),
875
+ minimum: validation.minimum, maximum: validation.maximum
876
+ )
877
+ end
878
+ end
879
+
638
880
  def range_coordinates(top, left, bottom, right, name)
639
881
  top, left, bottom, right = [top, left, bottom, right].map { |value| strict_integer(value) }
640
882
  name = (name || @active_sheet).to_s
@@ -665,7 +907,7 @@ module Rukbat
665
907
  raise Error, "index is outside sheet limits" unless at.between?(1, extent)
666
908
  preflight_structural_edit(type, at, count, name, extent)
667
909
 
668
- record_history
910
+ previous_state = snapshot
669
911
  @engine.public_send(type, name, at, count)
670
912
  denebola_at = at - 1
671
913
  current = @sheets.fetch(name)
@@ -676,7 +918,16 @@ module Rukbat
676
918
  @calculated[name] = calculated.public_send(type, denebola_at, count) if denebola_at <= calculated_extent
677
919
  adjust_metadata(type, at, count, name)
678
920
  sync_formula_inputs
679
- update_calculated(@engine.recalculate)
921
+ changed = @engine.recalculate
922
+ begin
923
+ validate_input_values!(changed)
924
+ rescue Error
925
+ restore(previous_state)
926
+ raise
927
+ end
928
+ @history << previous_state
929
+ @redo.clear
930
+ update_calculated(changed)
680
931
  notify_cells_changed(name)
681
932
  self
682
933
  rescue ArgumentError, TypeError
@@ -709,10 +960,12 @@ module Rukbat
709
960
  end
710
961
 
711
962
  def snapshot = [@sheets.dup, @active_sheet, @names.dup, @formats.dup, @comments.dup,
712
- @conditional_formats.dup, @hidden_rows.dup, @hidden_columns.dup, @frozen_panes.dup, @print_areas.dup]
963
+ @conditional_formats.dup, @hidden_rows.dup, @hidden_columns.dup, @frozen_panes.dup, @print_areas.dup,
964
+ @input_validations.dup]
713
965
 
714
966
  def restore(state)
715
- @sheets, @active_sheet, @names, @formats, @comments, @conditional_formats, @hidden_rows, @hidden_columns, @frozen_panes, @print_areas = state
967
+ @sheets, @active_sheet, @names, @formats, @comments, @conditional_formats, @hidden_rows,
968
+ @hidden_columns, @frozen_panes, @print_areas, @input_validations = state
716
969
  rebuild_engine
717
970
  end
718
971
 
@@ -817,11 +1070,16 @@ module Rukbat
817
1070
  next unless area.sheet == name
818
1071
  type.to_s.end_with?("rows") ? area.bottom : area.right
819
1072
  end.max || 0
1073
+ validation_extent = @input_validations.filter_map do |rule|
1074
+ area = rule.area
1075
+ next unless area.sheet == name
1076
+ type.to_s.end_with?("rows") ? area.bottom : area.right
1077
+ end.max || 0
820
1078
  print_extent = @print_areas.values.filter_map do |area|
821
1079
  next unless area.sheet == name
822
1080
  type.to_s.end_with?("rows") ? area.bottom : area.right
823
1081
  end.max || 0
824
- area_extent = [area_extent, print_extent].max
1082
+ area_extent = [area_extent, print_extent, validation_extent].max
825
1083
  hidden_extent = (type.to_s.end_with?("rows") ? @hidden_rows : @hidden_columns)
826
1084
  .fetch(name, Set.new).max || 0
827
1085
  panes = @frozen_panes.fetch(name, {rows: 1, columns: 1})
@@ -909,6 +1167,10 @@ module Rukbat
909
1167
  area = adjusted_area(rule[:area], operation)
910
1168
  area && rule.merge(area: area).freeze
911
1169
  end.freeze
1170
+ @input_validations = @input_validations.filter_map do |validation|
1171
+ area = adjusted_area(validation.area, operation)
1172
+ InputValidation.new(area: area, minimum: validation.minimum, maximum: validation.maximum) if area
1173
+ end.freeze
912
1174
  @print_areas = @print_areas.filter_map do |sheet_name, area|
913
1175
  adjusted = sheet_name == sheet ? adjusted_area(area, operation) : area
914
1176
  [sheet_name, adjusted] if adjusted
data/sig/rukbat.rbs CHANGED
@@ -14,6 +14,15 @@ module Rukbat
14
14
  class Workbook
15
15
  MAX_ROWS: Integer
16
16
  MAX_COLUMNS: Integer
17
+ MAX_INPUT_VALIDATIONS: Integer
18
+ MAX_PIVOT_ROWS: Integer
19
+ MAX_PIVOT_GROUPS: Integer
20
+
21
+ class InputValidation
22
+ attr_reader area: Furud::Area
23
+ attr_reader minimum: Integer
24
+ attr_reader maximum: Integer
25
+ end
17
26
 
18
27
  class CellRange
19
28
  attr_reader top: Integer
@@ -33,6 +42,15 @@ module Rukbat
33
42
  attr_reader types: Hash[Class, Integer]
34
43
  end
35
44
 
45
+ class PivotTable
46
+ attr_reader source_area: Furud::Area
47
+ attr_reader row_key_column: Integer
48
+ attr_reader value_column: Integer
49
+ attr_reader aggregate: :sum | :count
50
+ attr_reader headers: [String, String]
51
+ attr_reader rows: Array[[untyped, untyped]]
52
+ end
53
+
36
54
  attr_reader active_sheet: String
37
55
 
38
56
  def self.from_rows: (Enumerable[Array[untyped]] rows, ?sheet: String) -> Workbook
@@ -50,9 +68,14 @@ module Rukbat
50
68
  def set: (Integer row, Integer column, untyped value, ?sheet: String) -> self
51
69
  def set_many: (Enumerable[Array[untyped]] changes, ?sheet: String) -> self
52
70
  def clear: (Integer row, Integer column, ?sheet: String) -> self
71
+ def set_whole_number_validation: (Integer top, Integer left, Integer bottom, Integer right, minimum: Integer, maximum: Integer, ?sheet: String) -> self
72
+ def clear_input_validation: (Integer top, Integer left, Integer bottom, Integer right, ?sheet: String) -> self
73
+ def input_validation_at: (Integer row, Integer column, ?sheet: String) -> InputValidation?
53
74
  def each_in: (Integer top, Integer left, Integer bottom, Integer right, ?sheet: String) -> Enumerator[[Furud::Reference, untyped], self]
54
75
  | (Integer top, Integer left, Integer bottom, Integer right, ?sheet: String) { (Furud::Reference reference, untyped value) -> void } -> self
55
76
  def summary: (Integer top, Integer left, Integer bottom, Integer right, ?sheet: String) -> Summary
77
+ def pivot_table: (Integer top, Integer left, Integer bottom, Integer right, row_key_column: Integer, value_column: Integer, ?aggregate: :sum | :count, ?sheet: String) -> PivotTable
78
+ def add_pivot_sheet: (String name, PivotTable pivot) -> String
56
79
  def define_name: (String name, Integer top, Integer left, Integer bottom, Integer right, ?sheet: String) -> Furud::Area
57
80
  def find: (String query, ?top: Integer, ?left: Integer, ?bottom: Integer, ?right: Integer, ?sheet: String) -> Array[Furud::Reference]
58
81
  def filter_rows: (Integer top, Integer bottom, by: Integer, query: String, ?sheet: String) -> Array[Integer]
@@ -100,6 +123,8 @@ module Rukbat
100
123
  end
101
124
 
102
125
  class GridView
126
+ attr_reader validation_min_field: untyped
127
+ attr_reader validation_max_field: untyped
103
128
  def initialize: (Workbook workbook, ?on_save: untyped) -> void
104
129
  def undo: () -> bool
105
130
  def redo: () -> bool
metadata CHANGED
@@ -1,13 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rukbat
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Yudai Takada
8
+ autorequire:
8
9
  bindir: exe
9
10
  cert_chain: []
10
- date: 1980-01-02 00:00:00.000000000 Z
11
+ date: 2026-09-24 00:00:00.000000000 Z
11
12
  dependencies:
12
13
  - !ruby/object:Gem::Dependency
13
14
  name: denebola
@@ -181,6 +182,7 @@ metadata:
181
182
  source_code_uri: https://github.com/noxdea/rukbat/tree/main
182
183
  changelog_uri: https://github.com/noxdea/rukbat/blob/main/CHANGELOG.md
183
184
  rubygems_mfa_required: 'true'
185
+ post_install_message:
184
186
  rdoc_options: []
185
187
  require_paths:
186
188
  - lib
@@ -195,7 +197,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
195
197
  - !ruby/object:Gem::Version
196
198
  version: '0'
197
199
  requirements: []
198
- rubygems_version: 4.0.16
200
+ rubygems_version: 3.4.19
201
+ signing_key:
199
202
  specification_version: 4
200
203
  summary: Spreadsheet application with sparse storage and incremental formulas
201
204
  test_files: []