/* * Copyright (c) 2020-2022, the SerenityOS developers. * * SPDX-License-Identifier: BSD-2-Clause */ #include "SpreadsheetWidget.h" #include "CellSyntaxHighlighter.h" #include "HelpWindow.h" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include namespace Spreadsheet { SpreadsheetWidget::SpreadsheetWidget(GUI::Window& parent_window, Vector>&& sheets, bool should_add_sheet_if_empty) : m_workbook(make(move(sheets), parent_window)) { set_fill_with_background_color(true); set_layout(2); auto& toolbar_container = add(); auto& toolbar = toolbar_container.add(); auto& container = add(); auto& top_bar = container.add(); top_bar.set_layout(GUI::Margins {}, 1); top_bar.set_preferred_height(26); auto& current_cell_label = top_bar.add(""); current_cell_label.set_fixed_width(50); auto& help_button = top_bar.add(); help_button.set_icon(Gfx::Bitmap::load_from_file("/res/icons/16x16/app-help.png"sv).release_value_but_fixme_should_propagate_errors()); help_button.set_tooltip("Functions Help"); help_button.set_fixed_size(20, 20); help_button.on_click = [&](auto) { if (!current_view()) { GUI::MessageBox::show_error(window(), "Can only show function documentation/help when a worksheet exists and is open"sv); } else if (auto* sheet_ptr = current_worksheet_if_available()) { auto docs = sheet_ptr->gather_documentation(); auto help_window = HelpWindow::the(window()); help_window->set_docs(move(docs)); help_window->set_window_mode(GUI::WindowMode::Modeless); help_window->show(); } }; auto& cell_value_editor = top_bar.add(GUI::TextEditor::Type::SingleLine); cell_value_editor.set_font(Gfx::FontDatabase::default_fixed_width_font()); cell_value_editor.set_scrollbars_enabled(false); cell_value_editor.on_return_pressed = [this]() { current_view()->move_cursor(GUI::AbstractView::CursorMovement::Down); }; cell_value_editor.set_syntax_highlighter(make()); cell_value_editor.set_enabled(false); current_cell_label.set_enabled(false); m_tab_widget = container.add(); m_tab_widget->set_tab_position(GUI::TabWidget::TabPosition::Bottom); m_cell_value_editor = cell_value_editor; m_current_cell_label = current_cell_label; m_inline_documentation_window = GUI::Window::construct(window()); m_inline_documentation_window->set_rect(m_cell_value_editor->rect().translated(0, m_cell_value_editor->height() + 7).inflated(6, 6)); m_inline_documentation_window->set_window_type(GUI::WindowType::Tooltip); m_inline_documentation_window->set_resizable(false); auto inline_widget = m_inline_documentation_window->set_main_widget().release_value_but_fixme_should_propagate_errors(); inline_widget->set_fill_with_background_color(true); inline_widget->set_layout(4); inline_widget->set_frame_shape(Gfx::FrameShape::Box); m_inline_documentation_label = inline_widget->add(); m_inline_documentation_label->set_fill_with_background_color(true); m_inline_documentation_label->set_autosize(false); m_inline_documentation_label->set_text_alignment(Gfx::TextAlignment::CenterLeft); if (!m_workbook->has_sheets() && should_add_sheet_if_empty) m_workbook->add_sheet("Sheet 1"sv); m_tab_context_menu = GUI::Menu::construct(); m_rename_action = GUI::CommonActions::make_rename_action([this](auto&) { VERIFY(m_tab_context_menu_sheet_view); auto* sheet_ptr = m_tab_context_menu_sheet_view->sheet_if_available(); VERIFY(sheet_ptr); // How did we get here without a sheet? auto& sheet = *sheet_ptr; DeprecatedString new_name; if (GUI::InputBox::show(window(), new_name, DeprecatedString::formatted("New name for '{}'", sheet.name()), "Rename sheet"sv) == GUI::Dialog::ExecResult::OK) { sheet.set_name(new_name); sheet.update(); m_tab_widget->set_tab_title(static_cast(*m_tab_context_menu_sheet_view), String::from_deprecated_string(new_name).release_value_but_fixme_should_propagate_errors()); } }); m_tab_context_menu->add_action(*m_rename_action); m_tab_context_menu->add_action(GUI::Action::create("Add new sheet...", Gfx::Bitmap::load_from_file("/res/icons/16x16/new-tab.png"sv).release_value_but_fixme_should_propagate_errors(), [this](auto&) { DeprecatedString name; if (GUI::InputBox::show(window(), name, "Name for new sheet"sv, "Create sheet"sv) == GUI::Dialog::ExecResult::OK) { Vector> new_sheets; new_sheets.append(m_workbook->add_sheet(name)); setup_tabs(move(new_sheets)); } })); setup_tabs(m_workbook->sheets()); m_new_action = GUI::Action::create("Add New Sheet", Gfx::Bitmap::load_from_file("/res/icons/16x16/new-tab.png"sv).release_value_but_fixme_should_propagate_errors(), [&](auto&) { add_sheet(); }); m_open_action = GUI::CommonActions::make_open_action([&](auto&) { if (!request_close()) return; auto response = FileSystemAccessClient::Client::the().open_file(window()); if (response.is_error()) return; load_file(response.value().filename(), response.value().stream()); }); m_import_action = GUI::Action::create("Import sheets...", [&](auto&) { auto response = FileSystemAccessClient::Client::the().open_file(window()); if (response.is_error()) return; import_sheets(response.value().filename(), response.value().stream()); }); m_save_action = GUI::CommonActions::make_save_action([&](auto&) { if (current_filename().is_empty()) { m_save_as_action->activate(); return; } auto response = FileSystemAccessClient::Client::the().request_file(window(), current_filename(), Core::File::OpenMode::Write); if (response.is_error()) return; save(response.value().filename(), response.value().stream()); }); m_save_as_action = GUI::CommonActions::make_save_as_action([&](auto&) { DeprecatedString name = "workbook"; auto response = FileSystemAccessClient::Client::the().save_file(window(), name, "sheets"); if (response.is_error()) return; save(response.value().filename(), response.value().stream()); update_window_title(); }); m_quit_action = GUI::CommonActions::make_quit_action([&](auto&) { if (!request_close()) return; GUI::Application::the()->quit(0); }); m_cut_action = GUI::CommonActions::make_cut_action([&](auto&) { clipboard_action(true); }, window()); m_copy_action = GUI::CommonActions::make_copy_action([&](auto&) { clipboard_action(false); }, window()); m_paste_action = GUI::CommonActions::make_paste_action([&](auto&) { ScopeGuard update_after_paste { [&] { update(); } }; auto* worksheet_ptr = current_worksheet_if_available(); if (!worksheet_ptr) { GUI::MessageBox::show_error(window(), "There are no active worksheets"sv); return; } auto& sheet = *worksheet_ptr; auto& cells = sheet.selected_cells(); VERIFY(!cells.is_empty()); const auto& data = GUI::Clipboard::the().fetch_data_and_type(); if (auto spreadsheet_data = data.metadata.get("text/x-spreadsheet-data"); spreadsheet_data.has_value()) { Vector source_positions, target_positions; auto lines = spreadsheet_data.value().split_view('\n'); auto action = lines.take_first(); for (auto& line : lines) { dbgln("Paste line '{}'", line); auto position = sheet.position_from_url(line); if (position.has_value()) source_positions.append(position.release_value()); } for (auto& position : sheet.selected_cells()) target_positions.append(position); if (source_positions.is_empty()) return; auto first_position = source_positions.take_first(); auto cell_changes = sheet.copy_cells(move(source_positions), move(target_positions), first_position, action == "cut" ? Spreadsheet::Sheet::CopyOperation::Cut : Spreadsheet::Sheet::CopyOperation::Copy); undo_stack().push(make(cell_changes)); } else { for (auto& cell : sheet.selected_cells()) sheet.ensure(cell).set_data(StringView { data.data.data(), data.data.size() }); update(); } }, window()); m_insert_emoji_action = GUI::CommonActions::make_insert_emoji_action([&](auto&) { auto emoji_input_dialog = GUI::EmojiInputDialog::construct(window()); if (emoji_input_dialog->exec() != GUI::EmojiInputDialog::ExecResult::OK) return; auto emoji_code_point = emoji_input_dialog->selected_emoji_text(); if (m_cell_value_editor->has_focus_within()) { m_cell_value_editor->insert_at_cursor_or_replace_selection(emoji_code_point); } auto* worksheet_ptr = current_worksheet_if_available(); if (!worksheet_ptr) { GUI::MessageBox::show_error(window(), "There are no active worksheets"sv); return; } auto& sheet = *worksheet_ptr; for (auto& cell : sheet.selected_cells()) sheet.ensure(cell).set_data(emoji_code_point); update(); }, window()); m_undo_action = GUI::CommonActions::make_undo_action([&](auto&) { undo(); }); m_redo_action = GUI::CommonActions::make_redo_action([&](auto&) { redo(); }); m_undo_stack.on_state_change = [this] { m_undo_action->set_enabled(m_undo_stack.can_undo()); m_redo_action->set_enabled(m_undo_stack.can_redo()); }; m_undo_action->set_enabled(false); m_redo_action->set_enabled(false); m_change_background_color_action = GUI::Action::create( "&Change Background Color", { Mod_Ctrl, Key_B }, Gfx::Bitmap::load_from_file("/res/icons/pixelpaint/bucket.png"sv).release_value_but_fixme_should_propagate_errors(), [&](auto&) { change_cell_static_color_format(Spreadsheet::FormatType::Background); }, window()); m_change_foreground_color_action = GUI::Action::create( "&Change Foreground Color", { Mod_Ctrl, Key_T }, Gfx::Bitmap::load_from_file("/res/icons/16x16/text-color.png"sv).release_value_but_fixme_should_propagate_errors(), [&](auto&) { change_cell_static_color_format(Spreadsheet::FormatType::Foreground); }, window()); m_change_background_color_action->set_enabled(false); m_change_foreground_color_action->set_enabled(false); m_functions_help_action = GUI::Action::create( "&Functions Help", Gfx::Bitmap::load_from_file("/res/icons/16x16/app-help.png"sv).release_value_but_fixme_should_propagate_errors(), [&](auto&) { if (auto* worksheet_ptr = current_worksheet_if_available()) { auto docs = worksheet_ptr->gather_documentation(); auto help_window = Spreadsheet::HelpWindow::the(window()); help_window->set_docs(move(docs)); help_window->show(); } else { GUI::MessageBox::show_error(window(), "Cannot prepare documentation/help without an active worksheet"sv); } }, window()); m_search_action = GUI::CommonActions::make_command_palette_action(&parent_window); m_about_action = GUI::CommonActions::make_about_action("Spreadsheet", GUI::Icon::default_icon("app-spreadsheet"sv), &parent_window); toolbar.add_action(*m_new_action); toolbar.add_action(*m_open_action); toolbar.add_action(*m_save_action); toolbar.add_separator(); toolbar.add_action(*m_cut_action); toolbar.add_action(*m_copy_action); toolbar.add_action(*m_paste_action); toolbar.add_action(*m_undo_action); toolbar.add_action(*m_redo_action); toolbar.add_separator(); toolbar.add_action(*m_change_background_color_action); toolbar.add_action(*m_change_foreground_color_action); m_cut_action->set_enabled(false); m_copy_action->set_enabled(false); m_paste_action->set_enabled(false); m_insert_emoji_action->set_enabled(false); m_tab_widget->on_change = [this](auto& selected_widget) { // for keyboard shortcuts and command palette m_tab_context_menu_sheet_view = static_cast(selected_widget); }; m_tab_widget->on_context_menu_request = [&](auto& widget, auto& event) { m_tab_context_menu_sheet_view = static_cast(widget); m_tab_context_menu->popup(event.screen_position()); }; m_tab_widget->on_double_click = [&](auto& widget) { m_tab_context_menu_sheet_view = static_cast(widget); m_rename_action->activate(); }; } void SpreadsheetWidget::resize_event(GUI::ResizeEvent& event) { GUI::Widget::resize_event(event); if (m_inline_documentation_window && m_cell_value_editor && window()) m_inline_documentation_window->set_rect(m_cell_value_editor->screen_relative_rect().translated(0, m_cell_value_editor->height() + 7).inflated(6, 6)); } void SpreadsheetWidget::clipboard_content_did_change(DeprecatedString const& mime_type) { if (auto* sheet = current_worksheet_if_available()) m_paste_action->set_enabled(!sheet->selected_cells().is_empty() && mime_type.starts_with("text/"sv)); } void SpreadsheetWidget::setup_tabs(Vector> new_sheets) { for (auto& sheet : new_sheets) { auto& new_view = m_tab_widget->add_tab(String::from_deprecated_string(sheet->name()).release_value_but_fixme_should_propagate_errors(), sheet); new_view.model()->on_cell_data_change = [&](auto& cell, auto& previous_data) { undo_stack().push(make(cell, previous_data)); window()->set_modified(true); }; new_view.model()->on_cells_data_change = [&](Vector cell_changes) { undo_stack().push(make(cell_changes)); window()->set_modified(true); }; new_view.on_selection_changed = [&](Vector&& selection) { auto* sheet_ptr = current_worksheet_if_available(); // How did this even happen? VERIFY(sheet_ptr); auto& sheet = *sheet_ptr; VERIFY(!selection.is_empty()); m_cut_action->set_enabled(true); m_copy_action->set_enabled(true); m_paste_action->set_enabled(GUI::Clipboard::the().fetch_mime_type().starts_with("text/"sv)); m_insert_emoji_action->set_enabled(true); m_current_cell_label->set_enabled(true); m_cell_value_editor->set_enabled(true); m_change_background_color_action->set_enabled(true); m_change_foreground_color_action->set_enabled(true); if (selection.size() == 1) { auto& position = selection.first(); m_current_cell_label->set_text(position.to_cell_identifier(sheet)); auto& cell = sheet.ensure(position); m_cell_value_editor->on_change = nullptr; m_cell_value_editor->set_text(cell.source()); m_cell_value_editor->on_change = [&] { auto text = m_cell_value_editor->text(); // FIXME: Lines? auto offset = m_cell_value_editor->cursor().column(); try_generate_tip_for_input_expression(text, offset); cell.set_data(move(text)); sheet.update(); update(); }; static_cast(const_cast(m_cell_value_editor->syntax_highlighter()))->set_cell(&cell); return; } // There are many cells selected, change all of them. StringBuilder builder; builder.appendff("<{}>", selection.size()); m_current_cell_label->set_text(builder.string_view()); Vector cells; for (auto& position : selection) cells.append(sheet.ensure(position)); auto& first_cell = cells.first(); m_cell_value_editor->on_change = nullptr; m_cell_value_editor->set_text(""sv); m_should_change_selected_cells = false; m_cell_value_editor->on_focusin = [this] { m_should_change_selected_cells = true; }; m_cell_value_editor->on_focusout = [this] { m_should_change_selected_cells = false; }; m_cell_value_editor->on_change = [cells = move(cells), this]() mutable { if (m_should_change_selected_cells) { auto* sheet_ptr = current_worksheet_if_available(); if (!sheet_ptr) return; auto& sheet = *sheet_ptr; auto text = m_cell_value_editor->text(); // FIXME: Lines? auto offset = m_cell_value_editor->cursor().column(); try_generate_tip_for_input_expression(text, offset); for (auto& cell : cells) cell.set_data(text); sheet.update(); update(); } }; static_cast(const_cast(m_cell_value_editor->syntax_highlighter()))->set_cell(&first_cell); }; new_view.on_selection_dropped = [&]() { m_current_cell_label->set_enabled(false); m_current_cell_label->set_text({}); m_cell_value_editor->on_change = nullptr; m_cell_value_editor->on_focusin = nullptr; m_cell_value_editor->on_focusout = nullptr; m_cell_value_editor->set_text({}); m_cell_value_editor->set_enabled(false); m_cut_action->set_enabled(false); m_copy_action->set_enabled(false); m_paste_action->set_enabled(false); m_insert_emoji_action->set_enabled(false); static_cast(const_cast(m_cell_value_editor->syntax_highlighter()))->set_cell(nullptr); }; } } void SpreadsheetWidget::try_generate_tip_for_input_expression(StringView source, size_t cursor_offset) { auto* sheet_ptr = current_view()->sheet_if_available(); if (!sheet_ptr) return; auto& sheet = *sheet_ptr; m_inline_documentation_window->set_rect(m_cell_value_editor->screen_relative_rect().translated(0, m_cell_value_editor->height() + 7).inflated(6, 6)); if (!current_view() || !source.starts_with('=')) { m_inline_documentation_window->hide(); return; } cursor_offset = min(cursor_offset, source.length()); auto maybe_function_and_argument = get_function_and_argument_index(source.substring_view(0, cursor_offset)); if (!maybe_function_and_argument.has_value()) { m_inline_documentation_window->hide(); return; } auto& [name, index] = maybe_function_and_argument.value(); auto text = sheet.generate_inline_documentation_for(name, index); if (text.is_empty()) { m_inline_documentation_window->hide(); } else { m_inline_documentation_label->set_text(move(text)); m_inline_documentation_window->show(); } } void SpreadsheetWidget::undo() { if (!m_undo_stack.can_undo()) return; m_undo_stack.undo(); update(); } void SpreadsheetWidget::redo() { if (!m_undo_stack.can_redo()) return; m_undo_stack.redo(); update(); } void SpreadsheetWidget::change_cell_static_color_format(Spreadsheet::FormatType format_type) { VERIFY(current_worksheet_if_available()); auto dialog = GUI::ColorPicker::construct(Color::White, window(), "Select Color"); if (dialog->exec() == GUI::Dialog::ExecResult::OK) { for (auto& position : current_worksheet_if_available()->selected_cells()) { if (format_type == Spreadsheet::FormatType::Background) current_worksheet_if_available()->at(position)->type_metadata().static_format.background_color = dialog->color(); else current_worksheet_if_available()->at(position)->type_metadata().static_format.foreground_color = dialog->color(); } } } void SpreadsheetWidget::save(String const& filename, Core::File& file) { auto result = m_workbook->write_to_file(filename, file); if (result.is_error()) { GUI::MessageBox::show_error(window(), DeprecatedString::formatted("Cannot save file: {}", result.error())); return; } undo_stack().set_current_unmodified(); window()->set_modified(false); GUI::Application::the()->set_most_recently_open_file(filename); } void SpreadsheetWidget::load_file(String const& filename, Core::File& file) { auto result = m_workbook->open_file(filename, file); if (result.is_error()) { GUI::MessageBox::show_error(window(), result.error()); return; } m_cell_value_editor->on_change = nullptr; m_current_cell_label->set_text(""); m_should_change_selected_cells = false; while (auto* widget = m_tab_widget->active_widget()) { m_tab_widget->remove_tab(*widget); } setup_tabs(m_workbook->sheets()); update_window_title(); GUI::Application::the()->set_most_recently_open_file(filename); } void SpreadsheetWidget::import_sheets(String const& filename, Core::File& file) { auto result = m_workbook->import_file(filename, file); if (result.is_error()) { GUI::MessageBox::show_error(window(), result.error()); return; } if (!result.value()) return; window()->set_modified(true); m_cell_value_editor->on_change = nullptr; m_current_cell_label->set_text(""); m_should_change_selected_cells = false; while (auto* widget = m_tab_widget->active_widget()) { m_tab_widget->remove_tab(*widget); } setup_tabs(m_workbook->sheets()); update_window_title(); } bool SpreadsheetWidget::request_close() { if (!undo_stack().is_current_modified()) return true; auto result = GUI::MessageBox::ask_about_unsaved_changes(window(), current_filename()); if (result == GUI::MessageBox::ExecResult::Yes) { m_save_action->activate(); return !m_workbook->dirty(); } if (result == GUI::MessageBox::ExecResult::No) return true; return false; } void SpreadsheetWidget::add_sheet() { StringBuilder name; name.append("Sheet"sv); name.appendff(" {}", m_workbook->sheets().size() + 1); Vector> new_sheets; new_sheets.append(m_workbook->add_sheet(name.string_view())); setup_tabs(move(new_sheets)); } void SpreadsheetWidget::add_sheet(NonnullRefPtr&& sheet) { VERIFY(m_workbook == &sheet->workbook()); Vector> new_sheets; new_sheets.append(move(sheet)); m_workbook->sheets().extend(new_sheets); setup_tabs(new_sheets); } void SpreadsheetWidget::update_window_title() { StringBuilder builder; if (current_filename().is_empty()) builder.append("Untitled"sv); else builder.append(current_filename()); builder.append("[*] - Spreadsheet"sv); window()->set_title(builder.to_deprecated_string()); } void SpreadsheetWidget::clipboard_action(bool is_cut) { /// text/x-spreadsheet-data: /// - action: copy/cut /// - currently selected cell /// - selected cell+ auto* worksheet_ptr = current_worksheet_if_available(); if (!worksheet_ptr) { GUI::MessageBox::show_error(window(), "There are no active worksheets"sv); return; } auto& worksheet = *worksheet_ptr; auto& cells = worksheet.selected_cells(); VERIFY(!cells.is_empty()); StringBuilder text_builder, url_builder; url_builder.append(is_cut ? "cut\n"sv : "copy\n"sv); bool first = true; auto cursor = current_selection_cursor(); if (cursor) { Spreadsheet::Position position { (size_t)cursor->column(), (size_t)cursor->row() }; url_builder.append(position.to_url(worksheet).to_deprecated_string()); url_builder.append('\n'); } for (auto& cell : cells) { if (first && !cursor) { url_builder.append(cell.to_url(worksheet).to_deprecated_string()); url_builder.append('\n'); } url_builder.append(cell.to_url(worksheet).to_deprecated_string()); url_builder.append('\n'); auto cell_data = worksheet.at(cell); if (!first) text_builder.append('\t'); if (cell_data) text_builder.append(cell_data->data()); first = false; } HashMap metadata; metadata.set("text/x-spreadsheet-data", url_builder.to_deprecated_string()); dbgln(url_builder.to_deprecated_string()); GUI::Clipboard::the().set_data(text_builder.string_view().bytes(), "text/plain", move(metadata)); } ErrorOr SpreadsheetWidget::initialize_menubar(GUI::Window& window) { auto file_menu = TRY(window.try_add_menu("&File")); TRY(file_menu->try_add_action(*m_new_action)); TRY(file_menu->try_add_action(*m_open_action)); TRY(file_menu->try_add_action(*m_save_action)); TRY(file_menu->try_add_action(*m_save_as_action)); TRY(file_menu->try_add_separator()); TRY(file_menu->try_add_action(*m_import_action)); TRY(file_menu->try_add_separator()); TRY(file_menu->add_recent_files_list([&](auto& action) { if (!request_close()) return; auto response = FileSystemAccessClient::Client::the().request_file_read_only_approved(&window, action.text()); if (response.is_error()) return; load_file(response.value().filename(), response.value().stream()); })); TRY(file_menu->try_add_action(*m_quit_action)); auto edit_menu = TRY(window.try_add_menu("&Edit")); TRY(edit_menu->try_add_action(*m_undo_action)); TRY(edit_menu->try_add_action(*m_redo_action)); TRY(edit_menu->try_add_separator()); TRY(edit_menu->try_add_action(*m_cut_action)); TRY(edit_menu->try_add_action(*m_copy_action)); TRY(edit_menu->try_add_action(*m_paste_action)); TRY(edit_menu->try_add_action(*m_insert_emoji_action)); auto help_menu = TRY(window.try_add_menu("&Help")); TRY(help_menu->try_add_action(*m_search_action)); TRY(help_menu->try_add_action(*m_functions_help_action)); TRY(help_menu->try_add_action(*m_about_action)); return {}; } }