From 474453244b591ff8c90547831e798d66c5d2be1e Mon Sep 17 00:00:00 2001 From: AnotherTest Date: Fri, 27 Nov 2020 13:55:14 +0330 Subject: [PATCH] Spreadsheet: Implement infinit-scroll for columns This naturally also implements multi-char columns, and also integrates it into the js runtime (such columns can be named in ranges too). --- Applications/Spreadsheet/JSIntegration.cpp | 78 ++++++++++ Applications/Spreadsheet/JSIntegration.h | 2 + Applications/Spreadsheet/Spreadsheet.cpp | 148 +++++++++++++++---- Applications/Spreadsheet/Spreadsheet.h | 6 +- Applications/Spreadsheet/SpreadsheetView.cpp | 14 ++ Applications/Spreadsheet/SpreadsheetView.h | 1 + Base/res/js/Spreadsheet/runtime.js | 34 ++--- 7 files changed, 233 insertions(+), 50 deletions(-) diff --git a/Applications/Spreadsheet/JSIntegration.cpp b/Applications/Spreadsheet/JSIntegration.cpp index 26cc864cb7b..7c94cda388a 100644 --- a/Applications/Spreadsheet/JSIntegration.cpp +++ b/Applications/Spreadsheet/JSIntegration.cpp @@ -83,6 +83,8 @@ void SheetGlobalObject::initialize() GlobalObject::initialize(); define_native_function("parse_cell_name", parse_cell_name, 1); define_native_function("current_cell_position", current_cell_position, 0); + define_native_function("column_arithmetic", column_arithmetic, 2); + define_native_function("column_index", column_index, 1); } JS_DEFINE_NATIVE_FUNCTION(SheetGlobalObject::parse_cell_name) @@ -137,6 +139,82 @@ JS_DEFINE_NATIVE_FUNCTION(SheetGlobalObject::current_cell_position) return object; } +JS_DEFINE_NATIVE_FUNCTION(SheetGlobalObject::column_index) +{ + if (vm.argument_count() != 2) { + vm.throw_exception(global_object, "Expected exactly one argument to column_index()"); + return {}; + } + + auto column_name = vm.argument(0); + if (!column_name.is_string()) { + vm.throw_exception(global_object, JS::ErrorType::NotA, "String"); + return {}; + } + + auto& column_name_str = column_name.as_string().string(); + + auto* this_object = vm.this_value(global_object).to_object(global_object); + if (!this_object) + return JS::js_null(); + + if (StringView("SheetGlobalObject") != this_object->class_name()) { + vm.throw_exception(global_object, JS::ErrorType::NotA, "SheetGlobalObject"); + return {}; + } + + auto sheet_object = static_cast(this_object); + auto& sheet = sheet_object->m_sheet; + auto column_index = sheet.column_index(column_name_str); + if (!column_index.has_value()) { + vm.throw_exception(global_object, JS::TypeError::create(global_object, String::formatted("'{}' is not a valid column", column_name_str))); + return {}; + } + + return JS::Value((i32)column_index.value()); +} + +JS_DEFINE_NATIVE_FUNCTION(SheetGlobalObject::column_arithmetic) +{ + if (vm.argument_count() != 2) { + vm.throw_exception(global_object, "Expected exactly two arguments to column_arithmetic()"); + return {}; + } + + auto column_name = vm.argument(0); + if (!column_name.is_string()) { + vm.throw_exception(global_object, JS::ErrorType::NotA, "String"); + return {}; + } + + auto& column_name_str = column_name.as_string().string(); + + auto offset = vm.argument(1).to_number(global_object); + if (!offset.is_number()) + return {}; + + auto offset_number = offset.as_i32(); + + auto* this_object = vm.this_value(global_object).to_object(global_object); + if (!this_object) + return JS::js_null(); + + if (StringView("SheetGlobalObject") != this_object->class_name()) { + vm.throw_exception(global_object, JS::ErrorType::NotA, "SheetGlobalObject"); + return {}; + } + + auto sheet_object = static_cast(this_object); + auto& sheet = sheet_object->m_sheet; + auto new_column = sheet.column_arithmetic(column_name_str, offset_number); + if (!new_column.has_value()) { + vm.throw_exception(global_object, JS::TypeError::create(global_object, String::formatted("'{}' is not a valid column", column_name_str))); + return {}; + } + + return JS::js_string(vm, new_column.release_value()); +} + WorkbookObject::WorkbookObject(Workbook& workbook) : JS::Object(*JS::Object::create_empty(workbook.global_object())) , m_workbook(workbook) diff --git a/Applications/Spreadsheet/JSIntegration.h b/Applications/Spreadsheet/JSIntegration.h index 46886f95290..995694d8a1a 100644 --- a/Applications/Spreadsheet/JSIntegration.h +++ b/Applications/Spreadsheet/JSIntegration.h @@ -46,6 +46,8 @@ public: JS_DECLARE_NATIVE_FUNCTION(parse_cell_name); JS_DECLARE_NATIVE_FUNCTION(current_cell_position); + JS_DECLARE_NATIVE_FUNCTION(column_index); + JS_DECLARE_NATIVE_FUNCTION(column_arithmetic); private: Sheet& m_sheet; diff --git a/Applications/Spreadsheet/Spreadsheet.cpp b/Applications/Spreadsheet/Spreadsheet.cpp index 006ce2eec58..81dc05c7b8d 100644 --- a/Applications/Spreadsheet/Spreadsheet.cpp +++ b/Applications/Spreadsheet/Spreadsheet.cpp @@ -99,28 +99,57 @@ size_t Sheet::add_row() return m_rows++; } +static String convert_to_string(size_t value, unsigned base = 26, StringView map = {}) +{ + if (map.is_null()) + map = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; + + ASSERT(base >= 2 && base <= map.length()); + + // The '8 bits per byte' assumption may need to go? + Array buffer; + size_t i = 0; + do { + buffer[i++] = map[value % base]; + value /= base; + } while (value > 0); + + // NOTE: Weird as this may seem, the thing that comes after 'A' is 'AA', which as a number would be '00' + // to make this work, only the most significant digit has to be in a range of (1..25) as opposed to (0..25), + // but only if it's not the only digit in the string. + if (i > 1) + --buffer[i - 1]; + + for (size_t j = 0; j < i / 2; ++j) + swap(buffer[j], buffer[i - j - 1]); + + return String { ReadonlyBytes(buffer.data(), i) }; +} + +static size_t convert_from_string(StringView str, unsigned base = 26, StringView map = {}) +{ + if (map.is_null()) + map = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; + + ASSERT(base >= 2 && base <= map.length()); + + size_t value = 0; + for (size_t i = str.length(); i > 0; --i) { + auto digit_value = map.find_first_of(str[i - 1]).value_or(0); + // NOTE: Refer to the note in `convert_to_string()'. + if (i == str.length() && str.length() > 1) + ++digit_value; + value = value * base + digit_value; + } + + return value; +} + String Sheet::add_column() { - if (m_current_column_name_length == 0) { - m_current_column_name_length = 1; - m_columns.append("A"); - return "A"; - } - - if (m_current_column_name_length == 1) { - auto last_char = m_columns.last()[0]; - if (last_char == 'Z') { - m_current_column_name_length = 2; - m_columns.append("AA"); - return "AA"; - } - - last_char++; - m_columns.append({ &last_char, 1 }); - return m_columns.last(); - } - - TODO(); + auto next_column = convert_to_string(m_columns.size()); + m_columns.append(next_column); + return next_column; } void Sheet::update() @@ -207,6 +236,31 @@ Optional Sheet::parse_cell_name(const StringView& name) return Position { col, row.to_uint().value() }; } +Optional Sheet::column_index(const StringView& column_name) const +{ + auto index = convert_from_string(column_name); + if (m_columns.size() <= index || m_columns[index] != column_name) + return {}; + + return index; +} + +Optional Sheet::column_arithmetic(const StringView& column_name, int offset) +{ + auto maybe_index = column_index(column_name); + if (!maybe_index.has_value()) + return {}; + + auto index = maybe_index.value() + offset; + if (m_columns.size() > index) + return m_columns[index]; + + for (size_t i = m_columns.size(); i <= index; ++i) + add_column(); + + return m_columns.last(); +} + Cell* Sheet::from_url(const URL& url) { auto maybe_position = position_from_url(url); @@ -335,6 +389,11 @@ RefPtr Sheet::from_json(const JsonObject& object, Workbook& workbook) return IterationDecision::Continue; }); + if (sheet->m_columns.size() < default_column_count && sheet->columns_are_standard()) { + for (size_t i = sheet->m_columns.size(); i < default_column_count; ++i) + sheet->add_column(); + } + auto cells = object.get("cells").as_object(); auto json = sheet->interpreter().global_object().get("JSON"); auto& parse_function = json.as_object().get("parse").as_function(); @@ -425,15 +484,28 @@ Position Sheet::written_data_bounds() const { Position bound; for (auto& entry : m_cells) { - if (entry.key.row > bound.row) + if (entry.key.row >= bound.row) bound.row = entry.key.row; - if (entry.key.column > bound.column) + if (entry.key.column >= bound.column) bound.column = entry.key.column; } return bound; } +/// The sheet is allowed to have nonstandard column names +/// this checks whether all existing columns are 'standard' +/// (i.e. as generated by 'convert_to_string()' +bool Sheet::columns_are_standard() const +{ + for (size_t i = 0; i < m_columns.size(); ++i) { + if (m_columns[i] != convert_to_string(i)) + return false; + } + + return true; +} + JsonObject Sheet::to_json() const { JsonObject object; @@ -448,12 +520,13 @@ JsonObject Sheet::to_json() const auto bottom_right = written_data_bounds(); - auto columns = JsonArray(); - for (auto& column : m_columns) - columns.append(column); - object.set("columns", move(columns)); - - object.set("rows", bottom_right.row); + if (!columns_are_standard()) { + auto columns = JsonArray(); + for (auto& column : m_columns) + columns.append(column); + object.set("columns", move(columns)); + } + object.set("rows", bottom_right.row + 1); JsonObject cells; for (auto& it : m_cells) { @@ -520,12 +593,21 @@ Vector> Sheet::to_xsv() const auto bottom_right = written_data_bounds(); // First row = headers. - data.append(m_columns); + size_t column_count = m_columns.size(); + if (columns_are_standard()) { + column_count = convert_from_string(bottom_right.column) + 1; + Vector cols; + for (size_t i = 0; i < column_count; ++i) + cols.append(m_columns[i]); + data.append(move(cols)); + } else { + data.append(m_columns); + } for (size_t i = 0; i <= bottom_right.row; ++i) { Vector row; - row.resize(m_columns.size()); - for (size_t j = 0; j < m_columns.size(); ++j) { + row.resize(column_count); + for (size_t j = 0; j < column_count; ++j) { auto cell = at({ m_columns[j], i }); if (cell) row[j] = cell->typed_display(); @@ -546,6 +628,10 @@ RefPtr Sheet::from_xsv(const Reader::XSV& xsv, Workbook& workbook) sheet->m_columns = cols; for (size_t i = 0; i < max(rows, Sheet::default_row_count); ++i) sheet->add_row(); + if (sheet->columns_are_standard()) { + for (size_t i = sheet->m_columns.size(); i < Sheet::default_column_count; ++i) + sheet->add_column(); + } for (auto row : xsv) { for (size_t i = 0; i < cols.size(); ++i) { diff --git a/Applications/Spreadsheet/Spreadsheet.h b/Applications/Spreadsheet/Spreadsheet.h index edc2c68d605..23fb45d4b11 100644 --- a/Applications/Spreadsheet/Spreadsheet.h +++ b/Applications/Spreadsheet/Spreadsheet.h @@ -52,6 +52,8 @@ public: ~Sheet(); static Optional parse_cell_name(const StringView&); + Optional column_index(const StringView& column_name) const; + Optional column_arithmetic(const StringView& column_name, int offset); Cell* from_url(const URL&); const Cell* from_url(const URL& url) const { return const_cast(this)->from_url(url); } @@ -130,6 +132,8 @@ public: /// Gives the bottom-right corner of the smallest bounding box containing all the written data. Position written_data_bounds() const; + bool columns_are_standard() const; + private: explicit Sheet(Workbook&); explicit Sheet(const StringView& name, Workbook&); @@ -145,8 +149,6 @@ private: Cell* m_current_cell_being_evaluated { nullptr }; - size_t m_current_column_name_length { 0 }; - HashTable m_visited_cells_in_update; }; diff --git a/Applications/Spreadsheet/SpreadsheetView.cpp b/Applications/Spreadsheet/SpreadsheetView.cpp index 7361258cc05..2148b9281f5 100644 --- a/Applications/Spreadsheet/SpreadsheetView.cpp +++ b/Applications/Spreadsheet/SpreadsheetView.cpp @@ -62,10 +62,15 @@ void InfinitelyScrollableTableView::did_scroll() { TableView::did_scroll(); auto& vscrollbar = vertical_scrollbar(); + auto& hscrollbar = horizontal_scrollbar(); if (vscrollbar.is_visible() && vscrollbar.value() == vscrollbar.max()) { if (on_reaching_vertical_end) on_reaching_vertical_end(); } + if (hscrollbar.is_visible() && hscrollbar.value() == hscrollbar.max()) { + if (on_reaching_horizontal_end) + on_reaching_horizontal_end(); + } } void SpreadsheetView::update_with_model() @@ -92,6 +97,15 @@ SpreadsheetView::SpreadsheetView(Sheet& sheet) }; update_with_model(); }; + m_table_view->on_reaching_horizontal_end = [&]() { + for (size_t i = 0; i < 10; ++i) { + m_sheet->add_column(); + auto last_column_index = m_sheet->column_count() - 1; + m_table_view->set_column_width(last_column_index, 50); + m_table_view->set_column_header_alignment(last_column_index, Gfx::TextAlignment::Center); + } + update_with_model(); + }; set_focus_proxy(m_table_view); diff --git a/Applications/Spreadsheet/SpreadsheetView.h b/Applications/Spreadsheet/SpreadsheetView.h index 09bcb59251f..db86c2094c2 100644 --- a/Applications/Spreadsheet/SpreadsheetView.h +++ b/Applications/Spreadsheet/SpreadsheetView.h @@ -83,6 +83,7 @@ class InfinitelyScrollableTableView : public GUI::TableView { C_OBJECT(InfinitelyScrollableTableView) public: Function on_reaching_vertical_end; + Function on_reaching_horizontal_end; private: virtual void did_scroll() override; diff --git a/Base/res/js/Spreadsheet/runtime.js b/Base/res/js/Spreadsheet/runtime.js index 68d5c9b62d0..5f9cac58f7f 100644 --- a/Base/res/js/Spreadsheet/runtime.js +++ b/Base/res/js/Spreadsheet/runtime.js @@ -27,20 +27,20 @@ class Position { left(how_many) { how_many = how_many ?? 1; - const column = Math.min( - "Z".charCodeAt(0), - Math.max("A".charCodeAt(0), this.column.charCodeAt(0) - how_many) + return new Position( + this.sheet.column_arithmetic(this.column, -how_many), + this.row, + this.sheet ); - return new Position(String.fromCharCode(column), this.row, this.sheet); } right(how_many) { how_many = how_many ?? 1; - const column = Math.min( - "Z".charCodeAt(0), - Math.max("A".charCodeAt(0), this.column.charCodeAt(0) + how_many) + return new Position( + this.sheet.column_arithmetic(this.column, how_many), + this.row, + this.sheet ); - return new Position(String.fromCharCode(column), this.row, this.sheet); } with_column(value) { @@ -78,22 +78,21 @@ function range(start, end, columnStep, rowStep) { end = parse_cell_name(end) ?? start; } - if (end.column.length > 1 || start.column.length > 1) - throw new TypeError("Only single-letter column names are allowed (TODO)"); - const cells = []; - for ( - let col = Math.min(start.column.charCodeAt(0), end.column.charCodeAt(0)); - col <= Math.max(start.column.charCodeAt(0), end.column.charCodeAt(0)); - col += columnStep - ) { + const start_column_index = column_index(start.column); + const end_column_index = column_index(end.column); + const start_column = start_column_index > end_column_index ? end.column : start.column; + const distance = Math.abs(start_column_index - end_column_index); + + for (let col = 0; col <= distance; col += columnStep) { + const column = column_arithmetic(start_column, col); for ( let row = Math.min(start.row, end.row); row <= Math.max(start.row, end.row); row += rowStep ) { - cells.push(String.fromCharCode(col) + row); + cells.push(column + row); } } @@ -345,6 +344,7 @@ range.__documentation = JSON.stringify({ examples: { 'range("A1", "C4")': "Generate a range A1:C4", 'range("A1", "C4", 2)': "Generate a range A1:C4, skipping every other column", + 'range("AA1", "AC4", 2)': "Generate a range AA1:AC4, skipping every other column", }, });