From 07c8c4634b4f82a1c918b7a73a2cb1bf8b4849a6 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 26 Oct 2026 00:27:30 +0700 Subject: [PATCH 1/4] refactor(linux-grid): rebuild the data grid as one native grid for every surface --- .../fix-issue/references/platform-linux.md | 2 +- linux/ARCHITECTURE.md | 15 +- linux/crates/app/meson.build | 2 +- .../app/src/application/accelerators.rs | 104 +- .../app/src/application/shortcut_owner.rs | 8 +- linux/crates/app/src/lib.rs | 2 +- .../src/persistence/column_layout_store.rs | 252 ++ .../app/src/persistence/column_width_store.rs | 87 +- .../src/persistence/column_widths_document.rs | 136 +- linux/crates/app/src/persistence/mod.rs | 4 +- linux/crates/app/src/services/cell_overlay.rs | 3 - .../crates/app/src/services/change_tracker.rs | 147 +- .../app/src/services/grid/cell_editability.rs | 186 ++ .../{ui => services}/grid/cell_editor_kind.rs | 142 +- .../app/src/services/grid/cell_filter.rs | 144 + .../crates/app/src/services/grid/cell_lock.rs | 79 + .../src/{ui => services}/grid/cell_preset.rs | 58 +- .../app/src/services/grid/column_layout.rs | 335 ++ .../app/src/services/grid/column_width.rs | 225 ++ .../app/src/services/grid/copy_scope.rs | 34 + .../grid/delete_availability.rs | 2 +- .../src/{ui => services}/grid/display_text.rs | 56 +- .../{ui => services}/grid/edit_navigation.rs | 0 .../grid/edit_parse.rs} | 184 +- .../app/src/services/grid/edit_policy.rs | 137 + .../app/src/services/grid/find_matches.rs | 162 + .../app/src/services/grid/grid_column.rs | 190 ++ .../src/{ui => services}/grid/grid_row_id.rs | 8 +- linux/crates/app/src/services/grid/mod.rs | 43 + .../{ui => services}/grid/row_key_builder.rs | 0 .../app/src/services/grid/row_snapshot.rs | 96 + .../app/src/services/grid/test_columns.rs | 51 + .../src/{ui => services}/grid/value_order.rs | 6 +- linux/crates/app/src/services/mod.rs | 1 + .../app/src/services/tab_tracker_handle.rs | 28 +- .../src/test_support/key_injection_error.rs | 2 + .../app/src/test_support/key_injector.rs | 55 +- .../app/src/test_support/shortcut_keys.rs | 16 +- linux/crates/app/src/ui/a11y/icon_button.rs | 2 +- linux/crates/app/src/ui/a11y/tests.rs | 5 +- .../app/src/ui/browse_tab/browse_actions.rs | 78 +- .../app/src/ui/browse_tab/browse_command.rs | 2 +- .../app/src/ui/browse_tab/browse_tab_input.rs | 50 +- .../src/ui/browse_tab/browse_tab_widgets.rs | 1 - .../src/ui/browse_tab/clipboard_handlers.rs | 154 - .../app/src/ui/browse_tab/clipboard_input.rs | 30 - .../app/src/ui/browse_tab/command_handlers.rs | 5 +- .../src/ui/browse_tab/confirming_delete.rs | 16 - .../app/src/ui/browse_tab/confirming_paste.rs | 12 - .../app/src/ui/browse_tab/data_handlers.rs | 207 +- .../app/src/ui/browse_tab/edit_handlers.rs | 383 --- .../app/src/ui/browse_tab/edit_input.rs | 40 - linux/crates/app/src/ui/browse_tab/fetch.rs | 2 +- .../app/src/ui/browse_tab/grid_handlers.rs | 124 + .../app/src/ui/browse_tab/grid_input.rs | 15 + linux/crates/app/src/ui/browse_tab/imp.rs | 8 +- linux/crates/app/src/ui/browse_tab/layout.rs | 97 +- linux/crates/app/src/ui/browse_tab/mod.rs | 369 +-- .../app/src/ui/browse_tab/page_label.rs | 3 - .../app/src/ui/browse_tab/page_state.rs | 8 +- .../crates/app/src/ui/browse_tab/pager_bar.rs | 3 - .../app/src/ui/browse_tab/paging_handlers.rs | 18 +- .../app/src/ui/browse_tab/paging_input.rs | 5 - .../crates/app/src/ui/browse_tab/paste_at.rs | 9 - .../app/src/ui/browse_tab/paste_handlers.rs | 285 -- .../app/src/ui/browse_tab/paste_read.rs | 77 - .../app/src/ui/browse_tab/row_deletion.rs | 102 - .../app/src/ui/browse_tab/save_handlers.rs | 133 +- .../app/src/ui/browse_tab/save_input.rs | 8 +- .../src/ui/browse_tab/selection_handlers.rs | 134 - .../app/src/ui/browse_tab/selection_input.rs | 7 - .../src/ui/browse_tab/selection_snapshot.rs | 155 - .../app/src/ui/browse_tab/tests/load.rs | 64 +- .../crates/app/src/ui/browse_tab/tests/mod.rs | 2813 ++++------------- .../app/src/ui/browse_tab/tests/paste.rs | 449 --- .../app/src/ui/browse_tab/tests/references.rs | 6 +- .../app/src/ui/browse_tab/tests/rows.rs | 606 ++++ .../app/src/ui/browse_tab/tests/save.rs | 246 ++ .../app/src/ui/clipboard/copy_renderer.rs | 2 +- linux/crates/app/src/ui/clipboard/tests.rs | 2 +- .../app/src/ui/data_grid/client_sorter.rs | 57 + .../app/src/ui/data_grid/column_chooser.rs | 583 ++++ linux/crates/app/src/ui/data_grid/columns.rs | 631 ++++ linux/crates/app/src/ui/data_grid/copy/mod.rs | 346 ++ .../crates/app/src/ui/data_grid/copy/tests.rs | 645 ++++ .../app/src/ui/data_grid/edit/actions.rs | 97 + .../app/src/ui/data_grid/edit/cell_editor.rs | 627 ++++ .../app/src/ui/data_grid/edit/deletion.rs | 153 + linux/crates/app/src/ui/data_grid/edit/mod.rs | 280 ++ .../crates/app/src/ui/data_grid/edit/paste.rs | 309 ++ .../app/src/ui/data_grid/edit/row_edits.rs | 241 ++ .../app/src/ui/data_grid/edit/tests/mod.rs | 1220 +++++++ .../app/src/ui/data_grid/edit/tests/paste.rs | 393 +++ .../app/src/ui/data_grid/edit/tests/rows.rs | 271 ++ linux/crates/app/src/ui/data_grid/find_bar.rs | 636 ++++ .../app/src/ui/data_grid/grid_actions.rs | 180 ++ .../crates/app/src/ui/data_grid/grid_cell.rs | 358 +++ .../app/src/ui/data_grid/grid_config.rs | 67 + .../crates/app/src/ui/data_grid/grid_menu.rs | 299 ++ .../app/src/ui/data_grid/grid_request.rs | 17 + linux/crates/app/src/ui/data_grid/grid_row.rs | 236 ++ .../crates/app/src/ui/data_grid/grid_store.rs | 321 ++ linux/crates/app/src/ui/data_grid/imp.rs | 133 + linux/crates/app/src/ui/data_grid/keys.rs | 113 + .../app/src/ui/data_grid/layout_binding.rs | 26 + linux/crates/app/src/ui/data_grid/mod.rs | 385 +++ linux/crates/app/src/ui/data_grid/pointer.rs | 203 ++ .../app/src/ui/data_grid/references/mod.rs | 348 ++ .../app/src/ui/data_grid/references/tests.rs | 454 +++ .../src/ui/data_grid/row_detail/calendar.rs | 144 + .../app/src/ui/data_grid/row_detail/fields.rs | 369 +++ .../app/src/ui/data_grid/row_detail/imp.rs | 118 + .../app/src/ui/data_grid/row_detail/mod.rs | 315 ++ .../app/src/ui/data_grid/row_detail/tests.rs | 828 +++++ .../ui/data_grid/row_detail/value_editor.rs | 491 +++ .../ui/{grid => data_grid}/row_json_dialog.rs | 2 +- .../app/src/ui/data_grid/row_number_column.rs | 215 ++ .../crates/app/src/ui/data_grid/selection.rs | 239 ++ .../app/src/ui/data_grid/server_sorter.rs | 41 + .../src/ui/{grid => data_grid}/table_keys.rs | 6 +- .../crates/app/src/ui/data_grid/test_grid.rs | 316 ++ .../app/src/ui/data_grid/tests/cells.rs | 432 +++ .../app/src/ui/data_grid/tests/columns.rs | 433 +++ .../app/src/ui/data_grid/tests/gutter.rs | 223 ++ .../app/src/ui/data_grid/tests/keyboard.rs | 331 ++ .../app/src/ui/data_grid/tests/layout.rs | 147 + .../app/src/ui/data_grid/tests/menus.rs | 296 ++ .../crates/app/src/ui/data_grid/tests/mod.rs | 329 ++ .../app/src/ui/data_grid/tests/selection.rs | 91 + .../app/src/ui/data_grid/tests/sorting.rs | 405 +++ .../app/src/ui/data_grid/tests/values.rs | 294 ++ .../crates/app/src/ui/data_grid/tests/view.rs | 76 + .../app/src/ui/data_grid/tracked_values.rs | 299 ++ linux/crates/app/src/ui/editor/imp.rs | 2 - linux/crates/app/src/ui/editor/mod.rs | 111 +- .../app/src/ui/editor/result_widgets.rs | 84 +- .../crates/app/src/ui/editor/results_pane.rs | 92 +- linux/crates/app/src/ui/editor/tests.rs | 136 +- .../app/src/ui/export/export_ledger/tests.rs | 2 +- .../app/src/ui/export/export_producer.rs | 2 +- .../crates/app/src/ui/export/export_scope.rs | 2 +- .../crates/app/src/ui/export/export_source.rs | 2 +- linux/crates/app/src/ui/filter_strip/mod.rs | 13 +- .../app/src/ui/filter_strip/rule_operators.rs | 2 +- linux/crates/app/src/ui/filter_strip/tests.rs | 23 + .../crates/app/src/ui/grid/calendar_editor.rs | 202 -- .../app/src/ui/grid/cell_accessibility.rs | 94 - linux/crates/app/src/ui/grid/cell_binding.rs | 39 - linux/crates/app/src/ui/grid/cell_edit.rs | 149 - .../app/src/ui/grid/cell_editability.rs | 157 - linux/crates/app/src/ui/grid/cell_focus.rs | 528 ---- linux/crates/app/src/ui/grid/cell_lock.rs | 33 - linux/crates/app/src/ui/grid/cell_tooltip.rs | 61 - .../crates/app/src/ui/grid/column_drawing.rs | 147 - .../crates/app/src/ui/grid/column_factory.rs | 1206 ------- .../crates/app/src/ui/grid/column_figures.rs | 418 --- linux/crates/app/src/ui/grid/column_resize.rs | 222 -- .../app/src/ui/grid/column_width_binding.rs | 36 - linux/crates/app/src/ui/grid/context_menu.rs | 309 -- linux/crates/app/src/ui/grid/edit_snapshot.rs | 13 - .../crates/app/src/ui/grid/grid_attributes.rs | 74 - linux/crates/app/src/ui/grid/grid_cell.rs | 620 ---- linux/crates/app/src/ui/grid/grid_columns.rs | 107 - linux/crates/app/src/ui/grid/grid_content.rs | 13 - linux/crates/app/src/ui/grid/grid_keyboard.rs | 593 ---- linux/crates/app/src/ui/grid/grid_menus.rs | 707 ----- linux/crates/app/src/ui/grid/grid_msg.rs | 58 - .../crates/app/src/ui/grid/grid_row_index.rs | 112 - linux/crates/app/src/ui/grid/inline_editor.rs | 260 -- linux/crates/app/src/ui/grid/menu_rows.rs | 80 - linux/crates/app/src/ui/grid/mod.rs | 77 - .../app/src/ui/grid/multiline_editor.rs | 109 - .../crates/app/src/ui/grid/reference_menu.rs | 581 ---- linux/crates/app/src/ui/grid/result_grid.rs | 381 --- .../crates/app/src/ui/grid/result_sorters.rs | 63 - linux/crates/app/src/ui/grid/row_factory.rs | 117 - linux/crates/app/src/ui/grid/row_object.rs | 145 - linux/crates/app/src/ui/grid/row_snapshot.rs | 119 - .../app/src/ui/grid/row_state_column.rs | 263 -- .../crates/app/src/ui/grid/row_state_icon.rs | 90 - linux/crates/app/src/ui/grid/row_watches.rs | 90 - linux/crates/app/src/ui/grid/selection.rs | 47 - .../app/src/ui/grid/tab_grid_context.rs | 259 -- linux/crates/app/src/ui/grid/test_grid.rs | 337 -- linux/crates/app/src/ui/grid/toggle_cell.rs | 153 - linux/crates/app/src/ui/icons.rs | 4 + linux/crates/app/src/ui/key_tab/mod.rs | 223 +- linux/crates/app/src/ui/key_tab/tests.rs | 87 +- linux/crates/app/src/ui/mod.rs | 2 +- .../preferences_dialog/data_browser_group.rs | 2 +- linux/crates/app/src/ui/result_pane/mod.rs | 83 +- linux/crates/app/src/ui/workspace/saves.rs | 4 +- linux/crates/app/src/ui/workspace/shell.rs | 10 +- .../app/src/ui/workspace/tab_factory.rs | 1 + .../app/src/ui/workspace/tests/close_tab.rs | 59 +- linux/crates/app/tests/accessibility/main.rs | 23 +- linux/crates/app/tests/kill_during_save.rs | 23 +- linux/data/resources/resources.gresource.xml | 3 + linux/data/resources/shortcuts-dialog.ui | 100 +- linux/data/resources/style.css | 61 +- linux/data/resources/ui/browse-tab.ui | 17 +- linux/data/resources/ui/column-chooser.ui | 87 + linux/data/resources/ui/data-grid.ui | 124 + linux/data/resources/ui/results-pane.ui | 16 + linux/data/resources/ui/row-detail-pane.ui | 268 ++ linux/docs/decisions/0027-one-data-grid.md | 37 + linux/docs/decisions/README.md | 1 + linux/docs/regression-tests.md | 12 +- linux/docs/storage.md | 8 +- linux/docs/testing.md | 2 +- linux/po/POTFILES.in | 147 +- 211 files changed, 21951 insertions(+), 14799 deletions(-) create mode 100644 linux/crates/app/src/persistence/column_layout_store.rs create mode 100644 linux/crates/app/src/services/grid/cell_editability.rs rename linux/crates/app/src/{ui => services}/grid/cell_editor_kind.rs (69%) create mode 100644 linux/crates/app/src/services/grid/cell_filter.rs create mode 100644 linux/crates/app/src/services/grid/cell_lock.rs rename linux/crates/app/src/{ui => services}/grid/cell_preset.rs (53%) create mode 100644 linux/crates/app/src/services/grid/column_layout.rs create mode 100644 linux/crates/app/src/services/grid/column_width.rs create mode 100644 linux/crates/app/src/services/grid/copy_scope.rs rename linux/crates/app/src/{ui => services}/grid/delete_availability.rs (98%) rename linux/crates/app/src/{ui => services}/grid/display_text.rs (83%) rename linux/crates/app/src/{ui => services}/grid/edit_navigation.rs (100%) rename linux/crates/app/src/{ui/grid/edit_commit.rs => services/grid/edit_parse.rs} (58%) create mode 100644 linux/crates/app/src/services/grid/edit_policy.rs create mode 100644 linux/crates/app/src/services/grid/find_matches.rs create mode 100644 linux/crates/app/src/services/grid/grid_column.rs rename linux/crates/app/src/{ui => services}/grid/grid_row_id.rs (88%) create mode 100644 linux/crates/app/src/services/grid/mod.rs rename linux/crates/app/src/{ui => services}/grid/row_key_builder.rs (100%) create mode 100644 linux/crates/app/src/services/grid/row_snapshot.rs create mode 100644 linux/crates/app/src/services/grid/test_columns.rs rename linux/crates/app/src/{ui => services}/grid/value_order.rs (98%) delete mode 100644 linux/crates/app/src/ui/browse_tab/clipboard_handlers.rs delete mode 100644 linux/crates/app/src/ui/browse_tab/clipboard_input.rs delete mode 100644 linux/crates/app/src/ui/browse_tab/confirming_delete.rs delete mode 100644 linux/crates/app/src/ui/browse_tab/confirming_paste.rs delete mode 100644 linux/crates/app/src/ui/browse_tab/edit_handlers.rs delete mode 100644 linux/crates/app/src/ui/browse_tab/edit_input.rs create mode 100644 linux/crates/app/src/ui/browse_tab/grid_handlers.rs create mode 100644 linux/crates/app/src/ui/browse_tab/grid_input.rs delete mode 100644 linux/crates/app/src/ui/browse_tab/paste_at.rs delete mode 100644 linux/crates/app/src/ui/browse_tab/paste_handlers.rs delete mode 100644 linux/crates/app/src/ui/browse_tab/paste_read.rs delete mode 100644 linux/crates/app/src/ui/browse_tab/row_deletion.rs delete mode 100644 linux/crates/app/src/ui/browse_tab/selection_handlers.rs delete mode 100644 linux/crates/app/src/ui/browse_tab/selection_input.rs delete mode 100644 linux/crates/app/src/ui/browse_tab/selection_snapshot.rs delete mode 100644 linux/crates/app/src/ui/browse_tab/tests/paste.rs create mode 100644 linux/crates/app/src/ui/browse_tab/tests/rows.rs create mode 100644 linux/crates/app/src/ui/browse_tab/tests/save.rs create mode 100644 linux/crates/app/src/ui/data_grid/client_sorter.rs create mode 100644 linux/crates/app/src/ui/data_grid/column_chooser.rs create mode 100644 linux/crates/app/src/ui/data_grid/columns.rs create mode 100644 linux/crates/app/src/ui/data_grid/copy/mod.rs create mode 100644 linux/crates/app/src/ui/data_grid/copy/tests.rs create mode 100644 linux/crates/app/src/ui/data_grid/edit/actions.rs create mode 100644 linux/crates/app/src/ui/data_grid/edit/cell_editor.rs create mode 100644 linux/crates/app/src/ui/data_grid/edit/deletion.rs create mode 100644 linux/crates/app/src/ui/data_grid/edit/mod.rs create mode 100644 linux/crates/app/src/ui/data_grid/edit/paste.rs create mode 100644 linux/crates/app/src/ui/data_grid/edit/row_edits.rs create mode 100644 linux/crates/app/src/ui/data_grid/edit/tests/mod.rs create mode 100644 linux/crates/app/src/ui/data_grid/edit/tests/paste.rs create mode 100644 linux/crates/app/src/ui/data_grid/edit/tests/rows.rs create mode 100644 linux/crates/app/src/ui/data_grid/find_bar.rs create mode 100644 linux/crates/app/src/ui/data_grid/grid_actions.rs create mode 100644 linux/crates/app/src/ui/data_grid/grid_cell.rs create mode 100644 linux/crates/app/src/ui/data_grid/grid_config.rs create mode 100644 linux/crates/app/src/ui/data_grid/grid_menu.rs create mode 100644 linux/crates/app/src/ui/data_grid/grid_request.rs create mode 100644 linux/crates/app/src/ui/data_grid/grid_row.rs create mode 100644 linux/crates/app/src/ui/data_grid/grid_store.rs create mode 100644 linux/crates/app/src/ui/data_grid/imp.rs create mode 100644 linux/crates/app/src/ui/data_grid/keys.rs create mode 100644 linux/crates/app/src/ui/data_grid/layout_binding.rs create mode 100644 linux/crates/app/src/ui/data_grid/mod.rs create mode 100644 linux/crates/app/src/ui/data_grid/pointer.rs create mode 100644 linux/crates/app/src/ui/data_grid/references/mod.rs create mode 100644 linux/crates/app/src/ui/data_grid/references/tests.rs create mode 100644 linux/crates/app/src/ui/data_grid/row_detail/calendar.rs create mode 100644 linux/crates/app/src/ui/data_grid/row_detail/fields.rs create mode 100644 linux/crates/app/src/ui/data_grid/row_detail/imp.rs create mode 100644 linux/crates/app/src/ui/data_grid/row_detail/mod.rs create mode 100644 linux/crates/app/src/ui/data_grid/row_detail/tests.rs create mode 100644 linux/crates/app/src/ui/data_grid/row_detail/value_editor.rs rename linux/crates/app/src/ui/{grid => data_grid}/row_json_dialog.rs (99%) create mode 100644 linux/crates/app/src/ui/data_grid/row_number_column.rs create mode 100644 linux/crates/app/src/ui/data_grid/selection.rs create mode 100644 linux/crates/app/src/ui/data_grid/server_sorter.rs rename linux/crates/app/src/ui/{grid => data_grid}/table_keys.rs (83%) create mode 100644 linux/crates/app/src/ui/data_grid/test_grid.rs create mode 100644 linux/crates/app/src/ui/data_grid/tests/cells.rs create mode 100644 linux/crates/app/src/ui/data_grid/tests/columns.rs create mode 100644 linux/crates/app/src/ui/data_grid/tests/gutter.rs create mode 100644 linux/crates/app/src/ui/data_grid/tests/keyboard.rs create mode 100644 linux/crates/app/src/ui/data_grid/tests/layout.rs create mode 100644 linux/crates/app/src/ui/data_grid/tests/menus.rs create mode 100644 linux/crates/app/src/ui/data_grid/tests/mod.rs create mode 100644 linux/crates/app/src/ui/data_grid/tests/selection.rs create mode 100644 linux/crates/app/src/ui/data_grid/tests/sorting.rs create mode 100644 linux/crates/app/src/ui/data_grid/tests/values.rs create mode 100644 linux/crates/app/src/ui/data_grid/tests/view.rs create mode 100644 linux/crates/app/src/ui/data_grid/tracked_values.rs delete mode 100644 linux/crates/app/src/ui/grid/calendar_editor.rs delete mode 100644 linux/crates/app/src/ui/grid/cell_accessibility.rs delete mode 100644 linux/crates/app/src/ui/grid/cell_binding.rs delete mode 100644 linux/crates/app/src/ui/grid/cell_edit.rs delete mode 100644 linux/crates/app/src/ui/grid/cell_editability.rs delete mode 100644 linux/crates/app/src/ui/grid/cell_focus.rs delete mode 100644 linux/crates/app/src/ui/grid/cell_lock.rs delete mode 100644 linux/crates/app/src/ui/grid/cell_tooltip.rs delete mode 100644 linux/crates/app/src/ui/grid/column_drawing.rs delete mode 100644 linux/crates/app/src/ui/grid/column_factory.rs delete mode 100644 linux/crates/app/src/ui/grid/column_figures.rs delete mode 100644 linux/crates/app/src/ui/grid/column_resize.rs delete mode 100644 linux/crates/app/src/ui/grid/column_width_binding.rs delete mode 100644 linux/crates/app/src/ui/grid/context_menu.rs delete mode 100644 linux/crates/app/src/ui/grid/edit_snapshot.rs delete mode 100644 linux/crates/app/src/ui/grid/grid_attributes.rs delete mode 100644 linux/crates/app/src/ui/grid/grid_cell.rs delete mode 100644 linux/crates/app/src/ui/grid/grid_columns.rs delete mode 100644 linux/crates/app/src/ui/grid/grid_content.rs delete mode 100644 linux/crates/app/src/ui/grid/grid_keyboard.rs delete mode 100644 linux/crates/app/src/ui/grid/grid_menus.rs delete mode 100644 linux/crates/app/src/ui/grid/grid_msg.rs delete mode 100644 linux/crates/app/src/ui/grid/grid_row_index.rs delete mode 100644 linux/crates/app/src/ui/grid/inline_editor.rs delete mode 100644 linux/crates/app/src/ui/grid/menu_rows.rs delete mode 100644 linux/crates/app/src/ui/grid/mod.rs delete mode 100644 linux/crates/app/src/ui/grid/multiline_editor.rs delete mode 100644 linux/crates/app/src/ui/grid/reference_menu.rs delete mode 100644 linux/crates/app/src/ui/grid/result_grid.rs delete mode 100644 linux/crates/app/src/ui/grid/result_sorters.rs delete mode 100644 linux/crates/app/src/ui/grid/row_factory.rs delete mode 100644 linux/crates/app/src/ui/grid/row_object.rs delete mode 100644 linux/crates/app/src/ui/grid/row_snapshot.rs delete mode 100644 linux/crates/app/src/ui/grid/row_state_column.rs delete mode 100644 linux/crates/app/src/ui/grid/row_state_icon.rs delete mode 100644 linux/crates/app/src/ui/grid/row_watches.rs delete mode 100644 linux/crates/app/src/ui/grid/selection.rs delete mode 100644 linux/crates/app/src/ui/grid/tab_grid_context.rs delete mode 100644 linux/crates/app/src/ui/grid/test_grid.rs delete mode 100644 linux/crates/app/src/ui/grid/toggle_cell.rs create mode 100644 linux/data/resources/ui/column-chooser.ui create mode 100644 linux/data/resources/ui/data-grid.ui create mode 100644 linux/data/resources/ui/row-detail-pane.ui create mode 100644 linux/docs/decisions/0027-one-data-grid.md diff --git a/.claude/skills/fix-issue/references/platform-linux.md b/.claude/skills/fix-issue/references/platform-linux.md index ffe6b27ce2..79a1f32ed2 100644 --- a/.claude/skills/fix-issue/references/platform-linux.md +++ b/.claude/skills/fix-issue/references/platform-linux.md @@ -45,7 +45,7 @@ Pass `--platform linux`, which a Linux machine picks by default. Every step runs ## Screen -Run the app from the tree with `meson devenv -C _build ./crates/app/tablepro` inside `linux/`. A development build uses its own application ID and data directories, so it never touches an installed one; point the `XDG_*_HOME` variables at a scratch directory as well when the shot must not show the user's real connections. Nothing drives the app from a script yet: only `app.*` actions reach D-Bus, and the window's actions do not. Launch it in the state the shot needs and capture with the `org.freedesktop.portal.Screenshot` portal, which GNOME allows for an unsandboxed caller; `org.gnome.Shell.Screenshot` refuses. The portal captures the whole screen, so crop to the window. Dark and light: `ADW_DEBUG_COLOR_SCHEME=force-dark` or `force-light`. +Run the app from the tree with `meson devenv -C _build ./crates/app/tablepro` inside `linux/`. A development build uses its own application ID and data directories, so it never touches an installed one; point the `XDG_*_HOME` variables at a scratch directory as well when the shot must not show the user's real connections. Nothing drives the app from a script yet: only `app.*` actions reach D-Bus, and the window's actions do not. Launch it in the state the shot needs and capture with the `org.freedesktop.portal.Screenshot` portal, which GNOME allows for an unsandboxed caller; `org.gnome.Shell.Screenshot` refuses. The portal captures the whole screen, so crop to the window. Dark and light: `ADW_DEBUG_COLOR_SCHEME=prefer-dark` or `prefer-light`; libadwaita ignores any other value. ## Ship diff --git a/linux/ARCHITECTURE.md b/linux/ARCHITECTURE.md index f0265f1613..329db679cb 100644 --- a/linux/ARCHITECTURE.md +++ b/linux/ARCHITECTURE.md @@ -144,12 +144,21 @@ See [docs/state-management.md](docs/state-management.md) for the patterns and na Every key the app binds or relies on is one `Accelerator` in `ACCELERATORS` (`crates/app/src/application/accelerators.rs`): the action it fires, its triggers, its `AcceleratorScope` and where the shortcuts dialog lists it. - **Application** keys go in with `set_accels_for_action` at startup. GtkWindow runs them in the capture phase, before the focused widget, so none of them is a key a text field or a list uses. -- **Local** keys are a `local_shortcut_controller(owner)` on the owner's widget, firing the owner's action group through `NamedAction`: `browse` on a table tab and its column view, `editor` on the SQL editor's source view and, for find and replace, on the whole editor tab, `structure`, `filter`, the sidebar's list, which opens the selected table's menu, the query history's list, which opens the focused row's menu, and a picker row, which opens its menu. A disabled action lets its key go on. Bubble phase leaves a text field inside the owner its own Ctrl+Z and Ctrl+C; the source view's keys run in the capture phase, because GtkTextView binds Ctrl+/ itself. -- **Component** keys are bound by a component's own controller (the grid cell keys, the cell editors), which reads them from the table through `component_trigger` or `component_matches`. +- **Local** keys are a `local_shortcut_controller(owner)` on the owner's widget, firing the owner's action group through `NamedAction`: `browse` on a table tab, `grid` on a data grid's column view (two owners, one per phase), `editor` on the SQL editor's source view and, for find and replace, on the whole editor tab, `structure`, `filter`, the sidebar's list, which opens the selected table's menu, the query history's list, which opens the focused row's menu, and a picker row, which opens its menu. A disabled action lets its key go on. Bubble phase leaves a text field inside the owner its own Ctrl+Z and Ctrl+C; the source view's keys run in the capture phase, because GtkTextView binds Ctrl+/ itself. +- **Component** keys are bound by a component's own controller (the grid's inline and detail pane editors), which reads them from the table through `component_trigger` or `component_matches`. - **Stock** keys belong to GTK or libadwaita: GtkListBase's selection and movement, AdwTabView's tab switching, AdwApplication's Ctrl+?. The workspace removes AdwTabView's Ctrl+Home and Ctrl+End pairs so the lists and the editor in its tabs keep them. `data/resources/shortcuts-dialog.ui` lists every listed entry under an id made from its action, and `shortcuts_dialog_matches_accelerator_table` holds the two in step both ways. Menus show a Local or Component key through `menu_item`, and `describe_icon_button` puts a button's key on its accessible node instead of in its tooltip. +### The data grid + +Every surface that shows rows, the table tab, the SQL editor's results and the key tab, shows them in one `DataGrid` (`crates/app/src/ui/data_grid/`, [ADR 0027](docs/decisions/0027-one-data-grid.md)): a `GtkColumnView` with the `.data-table` style, row selection plus a focused cell, a row number column first, and a row detail pane on the right (`AdwOverlaySplitView`, Alt+Return). The rules it applies, with no GTK, are in `crates/app/src/services/grid/`: one `GridColumn` per column, the display text, the locks and editor kinds, the edit policy, the Copy rule, the column layout, find and the filter rule from a cell. + +- **Rows are stable slots.** The grid's store holds `GridRow` objects, and a reload writes the new rows into the slots it has, so GTK keeps its row and cell widgets and only the cells draw again. A slot's cells change only as a new `Arc`, which copies and exports share off the GTK thread. A slot moves its generation when it is given another row, and anything that acts later (a menu item, an editor, a paste target, a confirmation) holds the row as a `PinnedRow`, which stops resolving once the slot holds another row. Before any slot is written the open inline or detail pane editor commits against its pinned row. A grid that sorts the rows it holds takes new rows as new slots, since its sort model hears no write into a slot. +- **One action group.** Every operation is a `grid.*` action with one enabled state, read from the `EditPolicy`, the target (the cell a menu opened on, else the focused cell, and its rows) and whether an editor is open. Menus, keys and a host's own buttons all name it; a host inserts the grid's group on its root so its toolbar reaches it. +- **Keys.** Two controllers on the column view, from the accelerator table: the `GridCell` owner in the capture phase for the keys GtkListFactoryWidget binds itself (Return, Space, Menu), and the `Grid` owner in the bubble phase, after a focused editor had its turn. A disabled action lets its key go on. Return and a double click reach the grid as the view's `activate`: an editable cell opens its editor, any other cell its value in the detail pane. Typed text opens the editor and the key is forwarded to it, so its input method sees the first key. +- **What a host hears.** The grid asks its host only for what it cannot do itself, through its `request` signal: a server sort, an export, a foreign key to follow, a filter rule from a cell, a toast, and "pending changes changed". It also says when its selection and its editing state change, so the table tab's Save stays on while a value is being typed. + ## Driver layer How the app reaches a database: the contract in `tablepro-core`, a transport that carries the bytes, TLS each driver builds from the connection's trust settings, and a session that watches the link. @@ -317,7 +326,7 @@ Each workspace owns two registries holding the in-flight edit state for its tabs | `ChangeTrackerRegistry` | `TabTrackerHandle` | Row-level INSERT / UPDATE / DELETE for Table tabs | `TabTrackerHandle::change_set` → `TrackedChanges` | | `StructureTrackerRegistry` | `StructureTrackerHandle` | The table's definition, the user's `TableDraft` and the `TableChanges` between them, for Structure tabs | `PendingStructure::plan` through the connection's `DdlDialect` → `DdlPlan` | -A handle is a newtype over `Rc>`. Every method borrows only for its own body, calls no GTK and no callbacks, and returns owned data (`CellOverlay`, `RowState`, `TrackerDelta`), so a grid bind that runs while a tab is applying a change never meets a held borrow. A row edit goes in as a `TrackerEdit` and comes back as a `TrackerDelta`: the rows to redraw, the draft rows the list store gains or loses, and the new pending count. The tab applies it to its `gio::ListStore` after the call returns. A grid row is a `RowObject` whose identity is fixed when it is made: a saved row's key and cells as fetched, or a draft's id, whose cells live only in the tracker. A tracked change bumps the row's `revision` property, and each cell bound to the row listens for it and draws again in place, so the store never swaps a row and its selection and focus stay put. The tab finds the row through a `GridRowIndex` of weak references keyed like the tracker. A grid cell resolves its row through its column view cell when an event fires, so an edit, a menu action or a toggle reaches the row it was made on even after rows above it came or went. Every pending change carries a `Touch` from the tracker's counter. An `UndoOp` holds the pending state on both sides of a step, touch included, so undo after A, B, C shows B, not the fetched value. `change_set` emits one change per row in touch order, and `plan_save` keeps that order: a row deleted and replaced by a draft with the same unique value saves, because its DELETE runs first. `WorkspaceHandle::dirty_tabs` reads both registries, with each editor's open transaction, for the close prompts, the teardown guard and the tabs' unsaved icon. CI fails on a `thread_local!` under `crates/app/src/services`, since a process-wide registry would share one window's unsaved edits with every other window. +A handle is a newtype over `Rc>`. Every method borrows only for its own body, calls no GTK and no callbacks, and returns owned data (`CellOverlay`, `RowState`, `TrackerDelta`), so a grid bind that runs while a tab is applying a change never meets a held borrow. A row edit goes in as a `TrackerEdit` and comes back as a `TrackerDelta`: the rows to redraw, the draft rows the grid gains or loses, and the new pending count. The grid applies every delta in one place, `DataGrid::apply_delta`, whether its own editor made the change or the tab did (a new save target, a save settling); a change whose delta is gone, as a close prompt's Discard, is followed by `DataGrid::resync`. Every pending change carries a `Touch` from the tracker's counter. An `UndoOp` holds the pending state on both sides of a step, touch included, so undo after A, B, C shows B, not the fetched value. `change_set` emits one change per row in touch order, and `plan_save` keeps that order: a row deleted and replaced by a draft with the same unique value saves, because its DELETE runs first. `WorkspaceHandle::dirty_tabs` reads both registries, with each editor's open transaction, for the close prompts, the teardown guard and the tabs' unsaved icon. CI fails on a `thread_local!` under `crates/app/src/services`, since a process-wide registry would share one window's unsaved edits with every other window. The Structure tab is **definition + draft**, not a per-change log. The `TableDefinition` one read returned is what the tab plans against; `diff_table` turns the draft into `TableChanges`, and the engine's `DdlDialect` plans them into statements in its own order. Discard copies the definition back over the draft, and is the only restore point. The tracker holds the latest `PendingStructure`, so the close prompt and the save dispatcher read the same change without the tab, and the save plans it again with the dialect of the connection that runs it ([ADR 0010](docs/decisions/0010-ddl-plans-and-table-definitions.md)). A save that loses something, or that has several statements on an engine that applies DDL one statement at a time, first shows the user its `ChangeReview` and runs only the statements reviewed, whether the tab's Save or a close prompt asked for it ([state-management.md](docs/state-management.md#rules-for-every-view)). diff --git a/linux/crates/app/meson.build b/linux/crates/app/meson.build index c661e31e45..ee44c97e10 100644 --- a/linux/crates/app/meson.build +++ b/linux/crates/app/meson.build @@ -114,7 +114,7 @@ test( args: gtk_args + [ '--lib', '--', - 'ui::grid::', + 'ui::data_grid::', 'ui::diagram::', 'ui::editor::editor_view::', 'ui::window_geometry::', diff --git a/linux/crates/app/src/application/accelerators.rs b/linux/crates/app/src/application/accelerators.rs index 77a320f7e0..7ab075cf21 100644 --- a/linux/crates/app/src/application/accelerators.rs +++ b/linux/crates/app/src/application/accelerators.rs @@ -7,7 +7,7 @@ use super::{Accelerator, AcceleratorScope, ShortcutListing, ShortcutOwner, Short use AcceleratorScope::{Application, Component, Local, Stock}; use ShortcutListing::{Listed, Unlisted}; use ShortcutOwner::{ - AssistantComposer, BrowseGrid, BrowseTab, CellEditor, DiagramCanvas, DiagramTab, FilterStrip, GridCell, HistoryRow, + AssistantComposer, BrowseTab, CellEditor, DiagramCanvas, DiagramTab, FilterStrip, Grid, GridCell, HistoryRow, InlineSuggestion, PickerPage, PickerRow, SidebarList, SqlEditor, SqlEditorCursors, SqlEditorTab, StructureTab, }; use ShortcutSection::{BrowseTable, DataGrid, ErDiagram, General, TableStructure, Tabs}; @@ -156,55 +156,80 @@ pub const ACCELERATORS: &[Accelerator] = &[ Listed(BrowseTable), ), stock("list.move", &["Up", "Down", "Left", "Right"], Listed(DataGrid)), - component(GridCell, "cell.edit", &["F2", "Return"], Listed(DataGrid)), - component(GridCell, "cell.toggle", &["space"], Listed(DataGrid)), - component(GridCell, "cell.menu", &["Menu", "F10"], Listed(DataGrid)), - component(GridCell, "cell.set-null", &["BackSpace"], Listed(DataGrid)), - component(GridCell, "cell.open-referenced-row", &["j"], Listed(DataGrid)), - // GtkTreeView's keys for its focused header. A column view's header - // takes no focus, so they act on the focused cell's column. - component(GridCell, "cell.widen-column", &["Right"], Listed(DataGrid)), - component(GridCell, "cell.narrow-column", &["Left"], Listed(DataGrid)), - stock("text.activate", &["Return"], Listed(DataGrid)), - component(CellEditor, "cell-editor.cancel", &["Escape"], Listed(DataGrid)), - component(CellEditor, "cell-editor.next", &["Tab"], Listed(DataGrid)), - component(CellEditor, "cell-editor.previous", &["Tab"], Listed(DataGrid)), - component( - CellEditor, - "cell-editor.save-text", - &["Return"], + // The data grid's keys. Each goes through the `grid` action it names, + // so a disabled action lets its key go on. GtkListFactoryWidget binds + // Return, Space and Menu in its class and stops them, so those are + // taken in the capture phase; the rest wait for a focused editor. + local(GridCell, CAPTURE, "grid.edit", &["F2", "Return"], Listed(DataGrid)), + local(GridCell, CAPTURE, "grid.toggle", &["space"], Listed(DataGrid)), + local( + GridCell, + CAPTURE, + "grid.menu", + &["Menu", "F10"], Listed(DataGrid), ), - component(CellEditor, "cell-editor.pick-date", &["space"], Unlisted), local( - BrowseGrid, - BUBBLE, - "browse.insert-row", - &["n"], + GridCell, + CAPTURE, + "grid.set-null", + &["BackSpace"], Listed(DataGrid), ), - local(BrowseGrid, BUBBLE, "browse.delete-rows", &["Delete"], Listed(DataGrid)), local( - BrowseGrid, - BUBBLE, - "browse.copy-rows", - &["c"], + GridCell, + CAPTURE, + "grid.follow-reference", + &["j"], Listed(DataGrid), ), + // GtkTreeView's keys for its focused header. A column view's header + // takes no focus, so they act on the focused cell's column. local( - BrowseGrid, - BUBBLE, - "browse.paste-rows", - &["v"], + GridCell, + CAPTURE, + "grid.widen-column", + &["Right"], Listed(DataGrid), ), local( - BrowseGrid, + GridCell, + CAPTURE, + "grid.narrow-column", + &["Left"], + Listed(DataGrid), + ), + local(Grid, BUBBLE, "grid.insert-row", &["n"], Listed(DataGrid)), + local(Grid, BUBBLE, "grid.delete-rows", &["Delete"], Listed(DataGrid)), + local(Grid, BUBBLE, "grid.copy", &["c"], Listed(DataGrid)), + local(Grid, BUBBLE, "grid.paste", &["v"], Listed(DataGrid)), + local(Grid, BUBBLE, "grid.clear-selection", &["Escape"], Listed(DataGrid)), + // The HIG's Properties key: F9 already shows the sidebar. + local(Grid, BUBBLE, "grid.show-details", &["Return"], Listed(DataGrid)), + // Off in a grid with no find, so in the table tab Ctrl+F goes on to + // the tab's filter. + local(Grid, BUBBLE, "grid.find", &["f"], Listed(DataGrid)), + local(Grid, BUBBLE, "grid.find-next", &["g"], Listed(DataGrid)), + local( + Grid, BUBBLE, - "browse.clear-selection", - &["Escape"], + "grid.find-previous", + &["g"], + Listed(DataGrid), + ), + stock("text.activate", &["Return"], Listed(DataGrid)), + // The inline editor and the detail pane's value editor read these + // themselves, to leave a key to an open preedit. + component(CellEditor, "cell-editor.cancel", &["Escape"], Listed(DataGrid)), + component(CellEditor, "cell-editor.next", &["Tab"], Listed(DataGrid)), + component(CellEditor, "cell-editor.previous", &["Tab"], Listed(DataGrid)), + component( + CellEditor, + "cell-editor.save-text", + &["Return"], Listed(DataGrid), ), + component(CellEditor, "cell-editor.pick-date", &["space"], Unlisted), local( SqlEditor, CAPTURE, @@ -502,8 +527,8 @@ pub fn local_shortcut_controller(owner: ShortcutOwner) -> gtk::ShortcutControlle pub const fn controller_name(owner: ShortcutOwner) -> &'static str { match owner { BrowseTab => "tablepro-browse-tab-shortcuts", - BrowseGrid => "tablepro-browse-grid-shortcuts", GridCell => "tablepro-grid-cell-shortcuts", + Grid => "tablepro-grid-shortcuts", CellEditor => "tablepro-cell-editor-shortcuts", SqlEditor => "tablepro-sql-editor-shortcuts", SqlEditorCursors => "tablepro-sql-editor-cursor-shortcuts", @@ -819,16 +844,17 @@ mod tests { }; assert_eq!( - accel(&menu_item("Set to NULL", "cell.set-null")).as_deref(), + accel(&menu_item("Set to NULL", "grid.set-null")).as_deref(), Some("BackSpace") ); - assert_eq!(accel(&menu_item("Edit Cell", "cell.edit")).as_deref(), Some("F2")); + assert_eq!(accel(&menu_item("Edit Cell", "grid.edit")).as_deref(), Some("F2")); assert_eq!( accel(&menu_item("New Window", "app.new-window")), None, "GTK shows its own" ); - assert_eq!(accel(&menu_item("Copy", "cell.copy")), None); + assert_eq!(accel(&menu_item("Copy", "grid.copy")).as_deref(), Some("c")); + assert_eq!(accel(&menu_item("Copy as JSON", "grid.copy-json")), None); } #[gtk4::test] diff --git a/linux/crates/app/src/application/shortcut_owner.rs b/linux/crates/app/src/application/shortcut_owner.rs index f2df67314e..dc2624aa4b 100644 --- a/linux/crates/app/src/application/shortcut_owner.rs +++ b/linux/crates/app/src/application/shortcut_owner.rs @@ -1,11 +1,11 @@ #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum ShortcutOwner { BrowseTab, - /// The browse tab's column view. - BrowseGrid, - /// A focused grid cell, through the grid's capture-phase controller. + /// A data grid's column view, ahead of the list's own key bindings. GridCell, - /// A cell's open inline, multi-line or date editor. + /// A data grid's column view, after a focused editor had its turn. + Grid, + /// A grid's inline editor, or its detail pane's value editor. CellEditor, /// The SQL editor's source view. SqlEditor, diff --git a/linux/crates/app/src/lib.rs b/linux/crates/app/src/lib.rs index c7f5f1b6ac..91a3dbb186 100644 --- a/linux/crates/app/src/lib.rs +++ b/linux/crates/app/src/lib.rs @@ -179,7 +179,7 @@ mod tests { ) .expect("style.css is embedded at the resource base path"); - assert!(String::from_utf8_lossy(&data).contains(".tp-cell-modified")); + assert!(String::from_utf8_lossy(&data).contains(".tp-connection-tag")); } #[gtk4::test] diff --git a/linux/crates/app/src/persistence/column_layout_store.rs b/linux/crates/app/src/persistence/column_layout_store.rs new file mode 100644 index 0000000000..a3e8d9ed2a --- /dev/null +++ b/linux/crates/app/src/persistence/column_layout_store.rs @@ -0,0 +1,252 @@ +use std::rc::Rc; + +use tablepro_session::runtime::Tasks; +use uuid::Uuid; + +use super::StateFile; +use super::column_widths_document::ColumnWidthsDocument; +use crate::services::grid::ColumnLayout; + +/// Each table's column order, hidden columns and widths, remembered per +/// connection, schema and table. +#[derive(Clone)] +pub struct ColumnLayoutStore { + file: Rc>, +} + +impl std::fmt::Debug for ColumnLayoutStore { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ColumnLayoutStore").finish_non_exhaustive() + } +} + +impl ColumnLayoutStore { + pub fn load(path: std::path::PathBuf, tasks: &Tasks) -> Self { + Self { + file: Rc::new(StateFile::load(path, tasks)), + } + } + + pub fn layout(&self, connection_id: Uuid, schema: Option<&str>, table: &str) -> ColumnLayout { + self.read(connection_id, schema, table, |layout| { + layout.cloned().unwrap_or_default() + }) + } + + /// Keep `layout` as the table's whole layout. The grid merges what it + /// shows into the stored layout before it saves. + pub fn save(&self, connection_id: Uuid, schema: Option<&str>, table: &str, layout: &ColumnLayout) { + self.change(connection_id, schema, table, |stored| { + if stored == layout { + return false; + } + stored.clone_from(layout); + true + }); + } + + /// Drop everything for a connection the user deleted, so the file + /// does not grow with entries nothing can reach. + pub fn forget_connection(&self, connection_id: Uuid) { + self.file + .update(|document| document.connections.remove(&connection_id.to_string()).is_some()); + } + + pub fn flush(&self) -> impl Future + Send + use<> { + self.file.flush() + } + + pub(super) fn read( + &self, + connection_id: Uuid, + schema: Option<&str>, + table: &str, + with: impl FnOnce(Option<&ColumnLayout>) -> R, + ) -> R { + self.file.read(|document| { + with( + document + .connections + .get(&connection_id.to_string()) + .and_then(|schemas| schemas.get(schema_key(schema))) + .and_then(|tables| tables.get(table)), + ) + }) + } + + /// Change one table's layout. `change` returns whether anything moved. + /// A layout left with nothing in it is removed with any map it empties, + /// so a reset shrinks the file back. + pub(super) fn change( + &self, + connection_id: Uuid, + schema: Option<&str>, + table: &str, + change: impl FnOnce(&mut ColumnLayout) -> bool, + ) { + let mut layout = self.layout(connection_id, schema, table); + if !change(&mut layout) { + return; + } + self.file.update(|document| match layout == ColumnLayout::default() { + true => prune(document, connection_id, schema, table), + false => { + document + .connections + .entry(connection_id.to_string()) + .or_default() + .entry(schema_key(schema).to_owned()) + .or_default() + .insert(table.to_owned(), layout); + true + } + }); + } +} + +/// `None` is stored as the empty string so every JSON key is concrete. +fn schema_key(schema: Option<&str>) -> &str { + schema.unwrap_or("") +} + +fn prune(document: &mut ColumnWidthsDocument, connection_id: Uuid, schema: Option<&str>, table: &str) -> bool { + let connection_key = connection_id.to_string(); + let Some(schemas) = document.connections.get_mut(&connection_key) else { + return false; + }; + let mut removed = false; + if let Some(tables) = schemas.get_mut(schema_key(schema)) { + removed = tables.remove(table).is_some(); + if tables.is_empty() { + schemas.remove(schema_key(schema)); + } + } + if schemas.is_empty() { + document.connections.remove(&connection_key); + } + removed +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::services::grid::LayoutSlot; + use crate::test_support::{paused_tasks, read_text_file, write_text_file}; + + fn store_at(path: &std::path::Path) -> ColumnLayoutStore { + ColumnLayoutStore::load(path.to_path_buf(), &paused_tasks()) + } + + fn arranged() -> ColumnLayout { + ColumnLayout::default() + .with_order(vec![ + LayoutSlot::Column("email".to_owned()), + LayoutSlot::RowNumbers, + LayoutSlot::Column("id".to_owned()), + ]) + .with_hidden("secret", true) + .with_width("email", 220) + } + + #[tokio::test] + async fn a_version_1_file_opens_with_its_widths() { + let root = tempfile::tempdir().expect("tempdir"); + let path = root.path().join("column_widths.json"); + let id = Uuid::new_v4(); + write_text_file( + &path, + &format!(r#"{{"version": 1, "connections": {{"{id}": {{"public": {{"users": {{"name": 240}}}}}}}}}}"#), + ); + + let store = store_at(&path); + + assert_eq!( + store.layout(id, Some("public"), "users"), + ColumnLayout::default().with_width("name", 240) + ); + assert!( + read_text_file(&path).contains("\"version\": 1"), + "a read rewrites nothing" + ); + store.save(id, Some("public"), "users", &arranged()); + store.flush().await; + assert!( + read_text_file(&path).contains("\"version\": 2"), + "the next save writes version 2" + ); + } + + #[tokio::test] + async fn a_saved_layout_survives_a_reopen() { + let root = tempfile::tempdir().expect("tempdir"); + let path = root.path().join("column_widths.json"); + let id = Uuid::new_v4(); + let store = store_at(&path); + + store.save(id, Some("public"), "users", &arranged()); + store.flush().await; + let reopened = store_at(&path); + + assert_eq!(reopened.layout(id, Some("public"), "users"), arranged()); + assert_eq!(reopened.layout(id, None, "users"), ColumnLayout::default()); + } + + #[tokio::test] + async fn a_save_replaces_one_tables_layout_and_leaves_the_rest() { + let root = tempfile::tempdir().expect("tempdir"); + let store = store_at(&root.path().join("column_widths.json")); + let id = Uuid::new_v4(); + store.save(id, Some("public"), "users", &arranged()); + store.save( + id, + Some("audit"), + "users", + &ColumnLayout::default().with_width("id", 60), + ); + + let narrower = ColumnLayout::default().with_width("email", 90); + store.save(id, Some("public"), "users", &narrower); + + assert_eq!( + store.layout(id, Some("public"), "users"), + narrower, + "the store keeps what it is given" + ); + assert_eq!( + store.layout(id, Some("audit"), "users"), + ColumnLayout::default().with_width("id", 60) + ); + } + + #[tokio::test] + async fn an_empty_layout_removes_the_entry() { + let root = tempfile::tempdir().expect("tempdir"); + let path = root.path().join("column_widths.json"); + let store = store_at(&path); + let id = Uuid::new_v4(); + store.save(id, Some("public"), "users", &arranged()); + + store.save(id, Some("public"), "users", &ColumnLayout::default()); + store.flush().await; + + assert!( + !read_text_file(&path).contains(&id.to_string()), + "a reset left the connection's map in the file" + ); + } + + #[tokio::test] + async fn forget_connection_drops_only_that_connection() { + let root = tempfile::tempdir().expect("tempdir"); + let store = store_at(&root.path().join("column_widths.json")); + let kept = Uuid::new_v4(); + let dropped = Uuid::new_v4(); + store.save(kept, None, "users", &arranged()); + store.save(dropped, None, "users", &arranged()); + + store.forget_connection(dropped); + + assert_eq!(store.layout(kept, None, "users"), arranged()); + assert_eq!(store.layout(dropped, None, "users"), ColumnLayout::default()); + } +} diff --git a/linux/crates/app/src/persistence/column_width_store.rs b/linux/crates/app/src/persistence/column_width_store.rs index 6311d3dfa9..b39af34626 100644 --- a/linux/crates/app/src/persistence/column_width_store.rs +++ b/linux/crates/app/src/persistence/column_width_store.rs @@ -1,34 +1,32 @@ -use std::rc::Rc; - use tablepro_session::runtime::Tasks; use uuid::Uuid; -use super::StateFile; -use super::column_widths_document::ColumnWidthsDocument; +use super::ColumnLayoutStore; /// The widths the user dragged, remembered per connection, schema and -/// table. +/// table. It reads and writes the widths of the layouts in the same file, +/// so a width and the rest of a table's layout never overwrite each other. #[derive(Clone)] pub struct ColumnWidthStore { - file: Rc>, + layouts: ColumnLayoutStore, } impl ColumnWidthStore { pub fn load(path: std::path::PathBuf, tasks: &Tasks) -> Self { Self { - file: Rc::new(StateFile::load(path, tasks)), + layouts: ColumnLayoutStore::load(path, tasks), } } + /// The layouts this store keeps the widths of, for a grid that keeps a + /// table's whole layout. + pub fn layouts(&self) -> &ColumnLayoutStore { + &self.layouts + } + pub fn width(&self, connection_id: Uuid, schema: Option<&str>, table: &str, column: &str) -> Option { - self.file.read(|document| { - document - .connections - .get(&connection_id.to_string())? - .get(schema_key(schema))? - .get(table)? - .get(column) - .copied() + self.layouts.read(connection_id, schema, table, |layout| { + layout.and_then(|layout| layout.widths.get(column).copied()) }) } @@ -38,54 +36,29 @@ impl ColumnWidthStore { if width <= 0 { return; } - self.file.update(|document| { - let slot = document - .connections - .entry(connection_id.to_string()) - .or_default() - .entry(schema_key(schema).to_owned()) - .or_default() - .entry(table.to_owned()) - .or_default() - .entry(column.to_owned()) - .or_default(); - if *slot == width { - return false; - } - *slot = width; - true + self.layouts.change(connection_id, schema, table, |layout| { + layout.widths.insert(column.to_owned(), width) != Some(width) }); } /// Drop the column's width, so it opens at its default again. pub fn forget(&self, connection_id: Uuid, schema: Option<&str>, table: &str, column: &str) { - self.file.update(|document| { - document - .connections - .get_mut(&connection_id.to_string()) - .and_then(|schemas| schemas.get_mut(schema_key(schema))) - .and_then(|tables| tables.get_mut(table)) - .is_some_and(|columns| columns.remove(column).is_some()) + self.layouts.change(connection_id, schema, table, |layout| { + layout.widths.remove(column).is_some() }); } /// Drop everything for a connection the user deleted, so the file /// does not grow with entries nothing can reach. pub fn forget_connection(&self, connection_id: Uuid) { - self.file - .update(|document| document.connections.remove(&connection_id.to_string()).is_some()); + self.layouts.forget_connection(connection_id); } pub fn flush(&self) -> impl Future + Send + use<> { - self.file.flush() + self.layouts.flush() } } -/// `None` is stored as the empty string so every JSON key is concrete. -fn schema_key(schema: Option<&str>) -> &str { - schema.unwrap_or("") -} - #[cfg(test)] mod tests { use super::*; @@ -167,4 +140,26 @@ mod tests { assert_eq!(reopened.width(id, Some("public"), "users", "name"), Some(240)); } + + #[tokio::test] + async fn a_width_merges_into_the_tables_layout() { + use crate::services::grid::{ColumnLayout, LayoutSlot}; + + let root = tempfile::tempdir().expect("tempdir"); + let store = store(&root); + let id = Uuid::new_v4(); + let arranged = ColumnLayout::default() + .with_order(vec![LayoutSlot::Column("email".to_owned()), LayoutSlot::RowNumbers]) + .with_hidden("secret", true); + store.layouts().save(id, None, "users", &arranged); + + store.record(id, None, "users", "email", 200); + store.forget(id, None, "users", "missing"); + + assert_eq!( + store.layouts().layout(id, None, "users"), + arranged.with_width("email", 200), + "the order and the hidden column stay" + ); + } } diff --git a/linux/crates/app/src/persistence/column_widths_document.rs b/linux/crates/app/src/persistence/column_widths_document.rs index 42985e9ab6..15ab3050e3 100644 --- a/linux/crates/app/src/persistence/column_widths_document.rs +++ b/linux/crates/app/src/persistence/column_widths_document.rs @@ -1,24 +1,144 @@ use std::collections::HashMap; use serde::{Deserialize, Serialize}; -use tablepro_storage::document::VersionedDocument; +use serde_json::Value; +use tablepro_storage::document::{Upgrade, VersionedDocument}; -/// Column widths, keyed connection, schema, table, column. +use crate::services::grid::ColumnLayout; + +/// Each table's column layout, keyed connection, schema, table. /// /// The schema is part of the key so `public.users` and `audit.users` do -/// not share one set of widths on a multi-schema database. `None` is -/// stored as the empty string, so every JSON key is concrete. -#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)] +/// not share one layout on a multi-schema database. `None` is stored as +/// the empty string, so every JSON key is concrete. +#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)] pub struct ColumnWidthsDocument { #[serde(default)] pub connections: HashMap, } pub type Schemas = HashMap; -pub type Tables = HashMap; -pub type Widths = HashMap; +pub type Tables = HashMap; impl VersionedDocument for ColumnWidthsDocument { const KIND: &'static str = "column-widths"; - const VERSION: u32 = 1; + const VERSION: u32 = 2; + const UPGRADES: &'static [Upgrade] = &[widths_to_layouts]; +} + +/// Version 1 kept only each column's width. +#[derive(Deserialize)] +struct WidthsV1 { + #[serde(default)] + connections: HashMap, +} + +type SchemasV1 = HashMap>>; + +fn widths_to_layouts(document: Value) -> Result { + let old: WidthsV1 = serde_json::from_value(document).map_err(|error| error.to_string())?; + let connections = old + .connections + .into_iter() + .map(|(connection, schemas)| (connection, schemas_to_layouts(schemas))) + .collect(); + serde_json::to_value(ColumnWidthsDocument { connections }).map_err(|error| error.to_string()) +} + +fn schemas_to_layouts(schemas: SchemasV1) -> Schemas { + schemas + .into_iter() + .map(|(schema, tables)| { + let tables = tables + .into_iter() + .map(|(table, widths)| { + let layout = ColumnLayout { + widths: widths.into_iter().filter(|(_, width)| *width > 0).collect(), + ..ColumnLayout::default() + }; + (table, layout) + }) + .collect(); + (schema, tables) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use std::path::Path; + + use tablepro_storage::document::{decode_document, encode_document}; + + use super::*; + + fn decode(text: &str) -> ColumnWidthsDocument { + decode_document::(Path::new("/tmp/column_widths.json"), text.as_bytes()) + .expect("the file reads") + } + + fn layout_of<'a>(document: &'a ColumnWidthsDocument, schema: &str, table: &str) -> &'a ColumnLayout { + document + .connections + .get("c1") + .and_then(|schemas| schemas.get(schema)) + .and_then(|tables| tables.get(table)) + .expect("the table's layout") + } + + #[test] + fn a_version_1_file_keeps_its_widths_as_layouts() { + let document = decode( + r#"{"version": 1, "connections": {"c1": { + "public": {"users": {"name": 240, "email": 180}}, + "": {"notes": {"body": 400, "broken": 0}} + }}}"#, + ); + + let users = layout_of(&document, "public", "users"); + assert_eq!(users.widths.get("name"), Some(&240)); + assert_eq!(users.widths.get("email"), Some(&180)); + assert!(users.order.is_empty() && users.hidden.is_empty()); + let notes = layout_of(&document, "", "notes"); + assert_eq!(notes.widths.len(), 1, "a width no column can have is dropped"); + } + + #[test] + fn a_layout_writes_back_as_version_2() { + let mut document = ColumnWidthsDocument::default(); + document + .connections + .entry("c1".to_owned()) + .or_default() + .entry("public".to_owned()) + .or_default() + .insert("users".to_owned(), ColumnLayout::default().with_hidden("secret", true)); + + let bytes = encode_document(&document).expect("encode"); + let text = String::from_utf8_lossy(&bytes); + + assert!(text.contains("\"version\": 2"), "{text}"); + assert_eq!(decode(&text), document); + } + + #[test] + fn a_version_1_file_that_is_not_widths_fails_the_upgrade() { + let problem = decode_document::( + Path::new("/tmp/column_widths.json"), + br#"{"version": 1, "connections": {"c1": {"public": {"users": {"name": "wide"}}}}}"#, + ) + .expect_err("a width that is no number"); + + assert!( + matches!( + problem.kind, + tablepro_storage::DocumentProblemKind::UpgradeFailed { + found: 1, + expected: 2, + .. + } + ), + "{problem:?}" + ); + } } diff --git a/linux/crates/app/src/persistence/mod.rs b/linux/crates/app/src/persistence/mod.rs index 1c3f1b504d..2072a89563 100644 --- a/linux/crates/app/src/persistence/mod.rs +++ b/linux/crates/app/src/persistence/mod.rs @@ -1,16 +1,18 @@ //! The small versioned JSON files the app keeps beside the user's -//! state: column widths and per-table filters. +//! state: column layouts and per-table filters. //! //! Each is read once at startup and answered from memory after that, so //! nothing the user does waits on the disk. A file that cannot be read, //! or that a newer TablePro wrote, is left exactly as it is. +mod column_layout_store; mod column_width_store; mod column_widths_document; mod filter_settings_document; mod filter_settings_store; mod state_file; +pub use column_layout_store::ColumnLayoutStore; pub use column_width_store::ColumnWidthStore; pub use filter_settings_store::FilterSettingsStore; pub use state_file::StateFile; diff --git a/linux/crates/app/src/services/cell_overlay.rs b/linux/crates/app/src/services/cell_overlay.rs index 99cd7702c1..563ce42fa1 100644 --- a/linux/crates/app/src/services/cell_overlay.rs +++ b/linux/crates/app/src/services/cell_overlay.rs @@ -13,8 +13,6 @@ pub struct CellOverlay { /// draft's cell is always the tracker's. pub pending: Option, pub state: CellState, - /// The row produced the statement a save just failed on. - pub error_flash: bool, } /// What tints a cell, without its pending value: every bind asks this @@ -24,7 +22,6 @@ pub struct CellOverlay { pub struct CellMarks { pub row_deleted: bool, pub state: CellState, - pub error_flash: bool, } #[cfg(test)] diff --git a/linux/crates/app/src/services/change_tracker.rs b/linux/crates/app/src/services/change_tracker.rs index 30f5298704..68d0deabc1 100644 --- a/linux/crates/app/src/services/change_tracker.rs +++ b/linux/crates/app/src/services/change_tracker.rs @@ -97,7 +97,7 @@ pub enum RowState { } /// Which grid row a change came from, so a save that fails at one -/// statement can scroll to and flash the row the user edited. +/// statement can scroll to and mark the row the user edited. #[derive(Debug, Clone)] pub enum StatementSource { Insert { draft_id: u64 }, @@ -105,17 +105,6 @@ pub enum StatementSource { Delete { row_key: RowKey }, } -/// A flash `flash_error_row` started. -#[derive(Debug, Clone, PartialEq)] -#[must_use = "the row a flash replaced keeps its mark until it is redrawn"] -pub struct ErrorFlash { - /// Goes back to `clear_error_row_if` when the flash ends. - pub generation: u64, - /// Another row's flash this one cut short. That flash's own timer - /// no longer clears it, so its row needs a redraw now. - pub replaced: Option, -} - /// The save that is running: where its edits end, and the value it /// writes to each cell. #[derive(Debug)] @@ -146,11 +135,9 @@ pub struct TabChangeTracker { redo: VecDeque, next_draft_id: u64, next_touch: Touch, - /// Row behind the statement the last save failed on. + /// Row behind the statement the last save failed on, until it is + /// changed or saved again. error_row: Option, - // A flash that starts inside an older flash's timeout must not be - // cleared by that older timer. - error_row_gen: u64, /// The table and columns the tab last read, which a save is planned /// against. Every column position the tracker holds is one in these. target: Option, @@ -169,7 +156,7 @@ impl TabChangeTracker { /// An edit the running save holds back comes back as a redraw of its /// row, so the grid stops showing what was typed into it. pub fn apply(&mut self, edit: TrackerEdit) -> TrackerDelta { - match edit { + let delta = match edit { TrackerEdit::DeleteRows { persisted, drafts } => self.delete_rows(persisted, drafts), edit if self.edit_is_in_flight(&edit) => self.delta(edit.row_keys()), TrackerEdit::Cell { @@ -180,7 +167,9 @@ impl TabChangeTracker { } => self.track_cell_edit(row_key, col, original, value), TrackerEdit::DraftCell { draft_id, col, input } => self.track_draft_cell_edit(draft_id, col, input), TrackerEdit::InsertDrafts { rows } => self.track_insert(rows), - } + }; + self.unmark_changed_error_row(&delta); + delta } /// An edit that would change what the running save already sent. @@ -200,6 +189,12 @@ impl TabChangeTracker { } } + /// Whether the running save writes `col` of `row_key`, or inserts or + /// deletes the row. + pub fn cell_is_saving(&self, row_key: &RowKey, col: usize) -> bool { + self.is_being_written(&(row_key.clone(), col)) || self.row_in_flight(row_key) + } + fn draft_in_flight(&self, draft_id: u64) -> bool { self.saving.as_ref().is_some_and(|save| { self.drafts @@ -444,6 +439,7 @@ impl TabChangeTracker { UndoOp::Insert { drafts } => self.remove_drafts(drafts), UndoOp::Delete { steps } => self.put_delete_marks(steps, true), }; + self.unmark_changed_error_row(&delta); self.redo.push_back(op); Some(delta) } @@ -467,6 +463,7 @@ impl TabChangeTracker { UndoOp::Insert { drafts } => self.put_drafts(drafts.clone()), UndoOp::Delete { steps } => self.put_delete_marks(steps, false), }; + self.unmark_changed_error_row(&delta); self.undo.push_back(op); Some(delta) } @@ -500,17 +497,18 @@ impl TabChangeTracker { self.set_aside.clear(); self.undo.clear(); self.redo.clear(); - TrackerDelta { + let delta = TrackerDelta { removed_drafts, ..self.delta(rows) - } + }; + self.unmark_changed_error_row(&delta); + delta } pub fn cell_overlay(&self, row_key: &RowKey, col: usize) -> CellOverlay { CellOverlay { pending: self.pending_input(row_key, col), state: self.cell_state(row_key, col), - error_flash: self.is_error_row(row_key), } } @@ -518,7 +516,6 @@ impl TabChangeTracker { CellMarks { row_deleted: self.deletes.contains_key(row_key), state: self.cell_state(row_key, col), - error_flash: self.is_error_row(row_key), } } @@ -577,29 +574,23 @@ impl TabChangeTracker { &self.drafts } - /// Mark the row a failed save points at. - pub fn flash_error_row(&mut self, key: RowKey) -> ErrorFlash { - self.error_row_gen = self.error_row_gen.wrapping_add(1); - let replaced = self.error_row.take().filter(|previous| *previous != key); - self.error_row = Some(key); - ErrorFlash { - generation: self.error_row_gen, - replaced, - } + /// Mark the row a failed save points at. The row marked before, if + /// another, loses its mark, so both draw again. + pub fn mark_error_row(&mut self, key: RowKey) -> TrackerDelta { + let replaced = self.error_row.replace(key.clone()).filter(|previous| *previous != key); + self.delta(std::iter::once(key).chain(replaced).collect()) } pub fn is_error_row(&self, key: &RowKey) -> bool { self.error_row.as_ref() == Some(key) } - /// Clear the flash unless a newer one has started. Returns whether - /// it cleared, so the caller knows the row needs a redraw. - pub fn clear_error_row_if(&mut self, generation: u64) -> bool { - if self.error_row_gen != generation { - return false; + /// A change to the marked row is the user's answer to the failure, + /// so the row no longer reads as not saved. + fn unmark_changed_error_row(&mut self, delta: &TrackerDelta) { + if self.error_row.as_ref().is_some_and(|row| delta.changed.contains(row)) { + self.error_row = None; } - self.error_row = None; - true } /// A draft's values, with a cell left to the server as NULL. @@ -867,6 +858,7 @@ impl TabChangeTracker { return Ok(None); }; let snapshot = SaveSnapshot(self.next_touch); + self.error_row = None; let written = self .updates .iter() @@ -2160,35 +2152,76 @@ mod tests { } #[test] - fn an_older_flash_timer_does_not_clear_a_newer_flash() { + fn a_failed_row_keeps_its_mark_until_it_is_changed() { let mut t = TabChangeTracker::default(); - let first = rk(&[Value::Int(1)]); - let second = RowKey::Draft(3); - let old = t.flash_error_row(first.clone()).generation; - let new = t.flash_error_row(second.clone()).generation; + let failed = rk(&[Value::Int(1)]); + let other = rk(&[Value::Int(2)]); + t.track_cell_edit(failed.clone(), 1, text("a"), text("b")); + + let marked = t.mark_error_row(failed.clone()); + assert_eq!(marked.changed, std::slice::from_ref(&failed)); + let _ = t.apply(TrackerEdit::Cell { + row_key: other, + col: 1, + original: text("c"), + value: text("d"), + }); + assert!(t.is_error_row(&failed), "an edit of another row took the mark off"); - assert!(!t.clear_error_row_if(old)); - assert!(t.cell_overlay(&second, 0).error_flash); - assert!(!t.cell_overlay(&first, 0).error_flash); - assert!(t.clear_error_row_if(new)); - assert!(!t.cell_overlay(&second, 0).error_flash); + let _ = t.apply(TrackerEdit::Cell { + row_key: failed.clone(), + col: 1, + original: text("a"), + value: text("e"), + }); + assert!(!t.is_error_row(&failed)); } #[test] - fn a_flash_names_the_row_whose_flash_it_cut_short() { + fn undo_and_discard_of_the_failed_row_take_its_mark_off() { + let mut t = TabChangeTracker::default(); + let failed = rk(&[Value::Int(1)]); + t.track_cell_edit(failed.clone(), 1, text("a"), text("b")); + let _ = t.mark_error_row(failed.clone()); + + let _ = t.undo(); + assert!(!t.is_error_row(&failed), "an undo of the row left it marked"); + + let draft = RowKey::Draft(insert(&mut t, vec![Value::Null, text("x")])); + let _ = t.mark_error_row(draft.clone()); + let _ = t.discard(); + assert!(!t.is_error_row(&draft), "a discard left a row marked"); + } + + #[test] + fn a_new_failed_row_takes_the_mark_and_redraws_the_one_it_replaced() { let mut t = TabChangeTracker::default(); let first = rk(&[Value::Int(1)]); let second = RowKey::Draft(3); - assert_eq!(t.flash_error_row(first.clone()).replaced, None); - assert_eq!(t.flash_error_row(first.clone()).replaced, None, "a row replaced itself"); - assert_eq!(t.flash_error_row(second.clone()).replaced, Some(first)); - let ended = t.flash_error_row(second).generation; - assert!(t.clear_error_row_if(ended)); + assert_eq!(t.mark_error_row(first.clone()).changed, std::slice::from_ref(&first)); + assert_eq!( + t.mark_error_row(first.clone()).changed, + std::slice::from_ref(&first), + "a row replaced itself" + ); assert_eq!( - t.flash_error_row(RowKey::Draft(4)).replaced, - None, - "an ended flash was replaced" + t.mark_error_row(second.clone()).changed, + [second.clone(), first.clone()] ); + assert!(t.is_error_row(&second)); + assert!(!t.is_error_row(&first)); + } + + #[test] + fn starting_a_save_takes_the_mark_off() { + let mut t = saving_on(vec![pk_col("id"), data_col("name")]); + let failed = rk(&[Value::Int(1)]); + t.track_cell_edit(failed.clone(), 1, text("a"), text("b")); + let _ = t.mark_error_row(failed.clone()); + + assert!(t.begin_save().is_ok_and(|batch| batch.is_some())); + + assert!(!t.is_error_row(&failed)); } } diff --git a/linux/crates/app/src/services/grid/cell_editability.rs b/linux/crates/app/src/services/grid/cell_editability.rs new file mode 100644 index 0000000000..e53f230a8a --- /dev/null +++ b/linux/crates/app/src/services/grid/cell_editability.rs @@ -0,0 +1,186 @@ +use tablepro_core::ColumnInfo; +use tablepro_core::edit::read_only_reason; +use tablepro_core::meta::RowIdentity; + +use super::{CellLock, GridColumn, GridRowId}; + +/// Whether this cell takes a value. A draft may set the key a saved row +/// may not, but an auto-increment column stays locked on a draft too: +/// SQL Server IDENTITY and PostgreSQL GENERATED ALWAYS refuse a value. +pub fn cell_editability( + column: &GridColumn, + row: &GridRowId, + identity: &RowIdentity, + read_only: bool, + pending_delete: bool, +) -> Result<(), CellLock> { + match row { + GridRowId::Result => return Err(CellLock::ResultRow), + GridRowId::Unkeyed => return Err(CellLock::UnkeyedRow), + GridRowId::Persisted(_) | GridRowId::Draft(_) => {} + } + if read_only { + return Err(CellLock::ReadOnlySession); + } + if pending_delete { + return Err(CellLock::PendingDelete); + } + if !column.addressable { + return Err(CellLock::Unaddressable); + } + let Some(catalog) = column.catalog.as_ref() else { + return Err(CellLock::ResultRow); + }; + if catalog.is_generated { + return Err(CellLock::Generated); + } + if let Some(reason) = read_only_reason(&catalog.column_type.kind) { + return Err(CellLock::Value(reason)); + } + if catalog.is_auto_increment { + return Err(CellLock::AutoIncrement); + } + if matches!(row, GridRowId::Persisted(_)) && names_rows(catalog, identity) { + return Err(CellLock::KeyColumn); + } + Ok(()) +} + +fn names_rows(column: &ColumnInfo, identity: &RowIdentity) -> bool { + match identity { + RowIdentity::UniqueKey { columns } => columns.contains(&column.name), + RowIdentity::SortingKey { expressions } => expressions.contains(&column.name), + RowIdentity::EngineRowId(_) | RowIdentity::Unordered => false, + } +} + +#[cfg(test)] +mod tests { + use tablepro_core::column::ColumnKind; + use tablepro_core::edit::ReadOnlyReason; + use tablepro_core::meta::RowIdentity; + + use super::cell_editability; + use crate::services::grid::test_columns::{grid, info, persisted, result_only}; + use crate::services::grid::{CellLock, GridColumn, GridRowId}; + + fn column(name: &str, data_type: &str) -> GridColumn { + grid(info(name, data_type)) + } + + fn keyed_by_id() -> RowIdentity { + RowIdentity::UniqueKey { + columns: vec!["id".to_owned()], + } + } + + fn with_catalog(column: &str, data_type: &str, change: impl Fn(&mut tablepro_core::ColumnInfo)) -> GridColumn { + let mut info = info(column, data_type); + change(&mut info); + grid(info) + } + + #[test] + fn cell_editability_matrix() { + let identity = keyed_by_id(); + let id = column("id", "integer"); + let name = column("name", "text"); + let serial = with_catalog("seq", "integer", |info| info.is_auto_increment = true); + let total = with_catalog("total", "integer", |info| info.is_generated = true); + let blob = column("data", "bytea"); + let draft = GridRowId::Draft(0); + let check = |column: &GridColumn, row: &GridRowId, read_only: bool| { + cell_editability(column, row, &identity, read_only, false) + }; + + assert_eq!(check(&name, &persisted(), false), Ok(())); + assert_eq!(check(&id, &persisted(), false), Err(CellLock::KeyColumn)); + assert_eq!(check(&id, &draft, false), Ok(()), "a draft sets its own key"); + assert_eq!(check(&total, &draft, false), Err(CellLock::Generated)); + assert_eq!(check(&total, &persisted(), false), Err(CellLock::Generated)); + assert_eq!(check(&serial, &persisted(), false), Err(CellLock::AutoIncrement)); + assert_eq!( + check(&serial, &draft, false), + Err(CellLock::AutoIncrement), + "an insert leaves an auto-increment column to the server" + ); + assert_eq!(check(&name, &GridRowId::Unkeyed, false), Err(CellLock::UnkeyedRow)); + assert_eq!(check(&name, &GridRowId::Result, false), Err(CellLock::ResultRow)); + assert_eq!(check(&name, &persisted(), true), Err(CellLock::ReadOnlySession)); + assert_eq!(check(&name, &draft, true), Err(CellLock::ReadOnlySession)); + assert_eq!(check(&blob, &draft, false), Err(CellLock::Value(ReadOnlyReason::Bytes))); + } + + #[test] + fn a_sorting_key_column_is_locked_on_saved_rows() { + let identity = RowIdentity::SortingKey { + expressions: vec!["day".to_owned()], + }; + + assert_eq!( + cell_editability(&column("day", "date"), &persisted(), &identity, false, false), + Err(CellLock::KeyColumn) + ); + assert_eq!( + cell_editability(&column("hits", "integer"), &persisted(), &identity, false, false), + Ok(()) + ); + } + + #[test] + fn a_new_row_takes_no_value_in_a_column_the_server_fills_or_with_no_text() { + let identity = keyed_by_id(); + let total = with_catalog("total", "integer", |info| info.is_generated = true); + let serial = with_catalog("seq", "integer", |info| info.is_auto_increment = true); + let draft = |column: &GridColumn, read_only: bool| { + cell_editability(column, &GridRowId::Draft(0), &identity, read_only, false).is_ok() + }; + + assert!(draft(&column("id", "integer"), false)); + assert!(!draft(&total, false)); + assert!(!draft(&serial, false)); + assert!(!draft(&column("data", "blob"), false)); + assert!(!draft(&column("name", "text"), true)); + } + + #[test] + fn a_row_marked_for_deletion_takes_no_edit() { + let identity = keyed_by_id(); + let name = column("name", "text"); + + assert_eq!( + cell_editability(&name, &persisted(), &identity, false, true), + Err(CellLock::PendingDelete) + ); + assert_eq!( + cell_editability(&name, &GridRowId::Draft(1), &identity, false, true), + Err(CellLock::PendingDelete) + ); + } + + #[test] + fn an_unaddressable_or_uncatalogued_column_takes_no_edit() { + let identity = keyed_by_id(); + let dotted = GridColumn { + addressable: false, + ..column("a.b", "text") + }; + + assert_eq!( + cell_editability(&dotted, &persisted(), &identity, false, false), + Err(CellLock::Unaddressable) + ); + assert_eq!( + cell_editability( + &result_only("x", ColumnKind::Other), + &persisted(), + &identity, + false, + false + ), + Err(CellLock::ResultRow), + "a column the catalogue does not know" + ); + assert!(cell_editability(&dotted, &GridRowId::Draft(0), &identity, false, false).is_err()); + } +} diff --git a/linux/crates/app/src/ui/grid/cell_editor_kind.rs b/linux/crates/app/src/services/grid/cell_editor_kind.rs similarity index 69% rename from linux/crates/app/src/ui/grid/cell_editor_kind.rs rename to linux/crates/app/src/services/grid/cell_editor_kind.rs index ca6a89dd8d..7788d2ef73 100644 --- a/linux/crates/app/src/ui/grid/cell_editor_kind.rs +++ b/linux/crates/app/src/services/grid/cell_editor_kind.rs @@ -1,53 +1,63 @@ -use gtk4 as gtk; - -use tablepro_core::ColumnInfo; -use tablepro_core::column::{BoolStorage, ColumnKind, IntegerKind}; +use tablepro_core::column::{ColumnKind, IntegerKind}; use tablepro_core::edit::{EditText, ReadOnlyReason, read_only_reason}; -use super::CellLock; +use super::{CellLock, GridColumn}; -/// Past this many bytes a value opens in the multi-line editor: a -/// one-line entry lays all of it out at once and shows almost none. +/// Past this many bytes a value opens in the details editor: a one-line +/// entry lays all of it out at once and shows almost none. pub const INLINE_EDIT_MAX_BYTES: usize = 4096; /// Past this many bytes a value does not open for editing at all: the -/// multi-line editor lays out every line of it on the GTK thread, and a -/// value of megabytes stalls the window. +/// details editor lays out every line on the GTK thread and would stall. pub const EDIT_MAX_BYTES: usize = 1_000_000; /// The editor a cell opens with. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum CellEditorKind { - Toggle(BoolStorage), Inline { accepts_line_breaks: bool, - purpose: gtk::InputPurpose, - }, - Multiline { - language: Option<&'static str>, + purpose: TextPurpose, }, - Calendar, + Toggle, + Details(DetailsEditor), ReadOnly(CellLock), } +/// The on-screen keyboard an inline editor asks for, as `gtk::InputPurpose`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TextPurpose { + FreeForm, + Number, + Digits, +} + +/// The value editor of the details pane a cell opens in. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DetailsEditor { + Text, + Json, + Xml, + Date, +} + impl CellEditorKind { - pub fn for_cell(column: &ColumnInfo, editability: Result<(), CellLock>) -> Self { + pub fn for_cell(column: &GridColumn, editability: Result<(), CellLock>) -> Self { if let Err(lock) = editability { return Self::ReadOnly(lock); } - let kind = &column.column_type.kind; + let kind = &column.value_type().kind; let inline = |purpose| Self::Inline { accepts_line_breaks: kind.accepts_line_breaks(), purpose, }; match kind { - ColumnKind::Bool(storage) => Self::Toggle(*storage), - ColumnKind::Json { .. } => Self::Multiline { language: Some("json") }, - ColumnKind::Xml => Self::Multiline { language: Some("xml") }, - ColumnKind::Date => Self::Calendar, - ColumnKind::Integer(IntegerKind { signed: false, .. }) => inline(gtk::InputPurpose::Digits), + ColumnKind::Bool(_) => Self::Toggle, + ColumnKind::Json { .. } => Self::Details(DetailsEditor::Json), + ColumnKind::Xml => Self::Details(DetailsEditor::Xml), + ColumnKind::Date => Self::Details(DetailsEditor::Date), + ColumnKind::Integer(IntegerKind { signed: false, .. }) => inline(TextPurpose::Digits), ColumnKind::Integer(_) | ColumnKind::Float(_) | ColumnKind::Decimal { .. } | ColumnKind::Money { .. } => { - inline(gtk::InputPurpose::Number) + inline(TextPurpose::Number) } ColumnKind::Text { .. } | ColumnKind::Enum { .. } @@ -57,7 +67,7 @@ impl CellEditorKind { | ColumnKind::Time { .. } | ColumnKind::Timestamp { .. } | ColumnKind::BitString { .. } - | ColumnKind::Other => inline(gtk::InputPurpose::FreeForm), + | ColumnKind::Other => inline(TextPurpose::FreeForm), ColumnKind::Bytes { .. } | ColumnKind::Spatial | ColumnKind::Array { .. } @@ -69,14 +79,14 @@ impl CellEditorKind { } /// The editor for this value: none for a value with no text form, - /// and the multi-line one for text a one-line entry would mangle. + /// and the details editor for text a one-line entry would mangle. pub fn for_text(self, text: &EditText) -> Self { match (self, text) { (_, EditText::ReadOnly(reason)) => Self::ReadOnly(CellLock::Value(*reason)), (Self::Inline { .. }, EditText::Editable(text)) if text.contains(['\n', '\r']) || text.len() > INLINE_EDIT_MAX_BYTES => { - Self::Multiline { language: None } + Self::Details(DetailsEditor::Text) } (kind, EditText::Editable(_)) => kind, } @@ -85,32 +95,15 @@ impl CellEditorKind { #[cfg(test)] mod tests { - use gtk4 as gtk; - use tablepro_core::ColumnInfo; use tablepro_core::column::{ - BoolStorage, ColumnDefault, ColumnKind, ColumnType, FloatWidth, IntegerKind, IntegerWidth, NetworkFamily, - TemporalZone, TextLength, TimeRange, + BoolStorage, ColumnKind, FloatWidth, IntegerKind, IntegerWidth, NetworkFamily, TemporalZone, TextLength, + TimeRange, }; use tablepro_core::edit::{EditText, ReadOnlyReason}; - use super::{CellEditorKind, INLINE_EDIT_MAX_BYTES}; - use crate::ui::grid::CellLock; - - fn column(kind: ColumnKind) -> ColumnInfo { - let mut column_type = ColumnType::untyped_text("t"); - column_type.kind = kind; - ColumnInfo { - name: "c".to_owned(), - column_type, - nullable: true, - primary_key: false, - is_auto_increment: false, - is_generated: false, - partition_scoped: false, - default: ColumnDefault::None, - comment: None, - } - } + use super::{CellEditorKind, DetailsEditor, INLINE_EDIT_MAX_BYTES, TextPurpose}; + use crate::services::grid::CellLock; + use crate::services::grid::test_columns::grid_of_kind; fn integer(signed: bool) -> ColumnKind { ColumnKind::Integer(IntegerKind { @@ -121,20 +114,20 @@ mod tests { const NUMBER: CellEditorKind = CellEditorKind::Inline { accepts_line_breaks: false, - purpose: gtk::InputPurpose::Number, + purpose: TextPurpose::Number, }; fn free(accepts_line_breaks: bool) -> CellEditorKind { CellEditorKind::Inline { accepts_line_breaks, - purpose: gtk::InputPurpose::FreeForm, + purpose: TextPurpose::FreeForm, } } fn assert_editors(cases: Vec<(ColumnKind, CellEditorKind)>) { for (kind, expected) in cases { assert_eq!( - CellEditorKind::for_cell(&column(kind.clone()), Ok(())), + CellEditorKind::for_cell(&grid_of_kind("c", kind.clone()), Ok(())), expected, "{kind:?}" ); @@ -144,19 +137,13 @@ mod tests { #[test] fn booleans_toggle_and_numbers_take_a_number_keyboard() { assert_editors(vec![ - ( - ColumnKind::Bool(BoolStorage::Native), - CellEditorKind::Toggle(BoolStorage::Native), - ), - ( - ColumnKind::Bool(BoolStorage::Integer), - CellEditorKind::Toggle(BoolStorage::Integer), - ), + (ColumnKind::Bool(BoolStorage::Native), CellEditorKind::Toggle), + (ColumnKind::Bool(BoolStorage::Integer), CellEditorKind::Toggle), ( integer(false), CellEditorKind::Inline { accepts_line_breaks: false, - purpose: gtk::InputPurpose::Digits, + purpose: TextPurpose::Digits, }, ), (integer(true), NUMBER), @@ -222,10 +209,10 @@ mod tests { assert_editors(vec![ ( ColumnKind::Json { binary: true }, - CellEditorKind::Multiline { language: Some("json") }, + CellEditorKind::Details(DetailsEditor::Json), ), - (ColumnKind::Xml, CellEditorKind::Multiline { language: Some("xml") }), - (ColumnKind::Date, CellEditorKind::Calendar), + (ColumnKind::Xml, CellEditorKind::Details(DetailsEditor::Xml)), + (ColumnKind::Date, CellEditorKind::Details(DetailsEditor::Date)), ( ColumnKind::Bytes { length: TextLength::Unbounded, @@ -255,9 +242,12 @@ mod tests { #[test] fn a_locked_cell_is_read_only_for_its_lock() { - let text = column(ColumnKind::Text { - length: TextLength::Unbounded, - }); + let text = grid_of_kind( + "c", + ColumnKind::Text { + length: TextLength::Unbounded, + }, + ); for lock in [ CellLock::Value(ReadOnlyReason::Undecodable), CellLock::KeyColumn, @@ -266,6 +256,8 @@ mod tests { CellLock::UnkeyedRow, CellLock::ResultRow, CellLock::ReadOnlySession, + CellLock::PendingDelete, + CellLock::Unaddressable, ] { assert_eq!( CellEditorKind::for_cell(&text, Err(lock)), @@ -276,24 +268,22 @@ mod tests { #[test] fn inline_escalates_to_multiline_for_line_breaks_and_4096_bytes() { - let inline = CellEditorKind::Inline { - accepts_line_breaks: true, - purpose: gtk::InputPurpose::FreeForm, - }; - let multiline = CellEditorKind::Multiline { language: None }; + let inline = free(true); + let details = CellEditorKind::Details(DetailsEditor::Text); let editable = |text: String| EditText::Editable(text); assert_eq!(inline.for_text(&editable("one line".to_owned())), inline); - assert_eq!(inline.for_text(&editable("two\nlines".to_owned())), multiline); - assert_eq!(inline.for_text(&editable("carriage\rreturn".to_owned())), multiline); + assert_eq!(inline.for_text(&editable("two\nlines".to_owned())), details); + assert_eq!(inline.for_text(&editable("carriage\rreturn".to_owned())), details); assert_eq!(inline.for_text(&editable("x".repeat(INLINE_EDIT_MAX_BYTES))), inline); assert_eq!( inline.for_text(&editable("x".repeat(INLINE_EDIT_MAX_BYTES + 1))), - multiline + details ); + let date = CellEditorKind::Details(DetailsEditor::Date); assert_eq!( - CellEditorKind::Calendar.for_text(&editable("x".repeat(INLINE_EDIT_MAX_BYTES + 1))), - CellEditorKind::Calendar, + date.for_text(&editable("x".repeat(INLINE_EDIT_MAX_BYTES + 1))), + date, "only the one-line editor gives way" ); assert_eq!( diff --git a/linux/crates/app/src/services/grid/cell_filter.rs b/linux/crates/app/src/services/grid/cell_filter.rs new file mode 100644 index 0000000000..d2ef46a9d2 --- /dev/null +++ b/linux/crates/app/src/services/grid/cell_filter.rs @@ -0,0 +1,144 @@ +use tablepro_core::edit::{EditText, edit_text}; +use tablepro_core::{FilterOp, FilterRule, FilterValue, Value}; + +use super::{CellDisplay, GridColumn}; + +/// The rule for Filter by Value, or Exclude Value with `exclude`. The +/// value is written as a user would type it, so the rule reads back in +/// the filter strip. `None` where no rule could name the value. +pub fn cell_filter_rule(column: &GridColumn, value: &Value, exclude: bool) -> Option { + if !column.addressable { + return None; + } + let (op, value) = match value { + Value::Null => (if exclude { FilterOp::IsNotNull } else { FilterOp::IsNull }, None), + _ if matches!(column.display(), CellDisplay::Binary | CellDisplay::Json) => return None, + value => match edit_text(value, column.value_type()) { + EditText::Editable(text) => ( + if exclude { FilterOp::NotEq } else { FilterOp::Eq }, + Some(FilterValue::Single(text)), + ), + EditText::ReadOnly(_) => return None, + }, + }; + Some(FilterRule { + column: column.name.clone(), + op, + value, + }) +} + +#[cfg(test)] +mod tests { + use tablepro_core::column::{ColumnKind, IntegerKind, IntegerWidth, TextLength}; + use tablepro_core::value::{JsonText, UndecodableReason, UndecodedValue}; + use tablepro_core::{FilterOp, FilterRule, FilterValue, Value}; + + use super::cell_filter_rule; + use crate::services::grid::GridColumn; + use crate::services::grid::test_columns::{grid_of_kind, result_only}; + + fn integer() -> ColumnKind { + ColumnKind::Integer(IntegerKind { + width: IntegerWidth::W32, + signed: true, + }) + } + + fn rule(column: &str, op: FilterOp, value: Option<&str>) -> Option { + Some(FilterRule { + column: column.to_owned(), + op, + value: value.map(|text| FilterValue::Single(text.to_owned())), + }) + } + + #[test] + fn a_value_filters_by_its_typed_text() { + let age = grid_of_kind("age", integer()); + let name = result_only( + "name", + ColumnKind::Text { + length: TextLength::Unbounded, + }, + ); + + assert_eq!( + cell_filter_rule(&age, &Value::Int(42), false), + rule("age", FilterOp::Eq, Some("42")) + ); + assert_eq!( + cell_filter_rule(&age, &Value::Int(42), true), + rule("age", FilterOp::NotEq, Some("42")) + ); + assert_eq!( + cell_filter_rule(&name, &Value::Text("Ada".into()), false), + rule("name", FilterOp::Eq, Some("Ada")) + ); + } + + #[test] + fn null_filters_with_a_null_test() { + let age = grid_of_kind("age", integer()); + let doc = grid_of_kind("doc", ColumnKind::Json { binary: true }); + + assert_eq!( + cell_filter_rule(&age, &Value::Null, false), + rule("age", FilterOp::IsNull, None) + ); + assert_eq!( + cell_filter_rule(&age, &Value::Null, true), + rule("age", FilterOp::IsNotNull, None) + ); + assert_eq!( + cell_filter_rule(&doc, &Value::Null, false), + rule("doc", FilterOp::IsNull, None), + "a document column can still test for NULL" + ); + } + + #[test] + fn binary_json_unreadable_and_unaddressable_values_give_no_rule() { + let blob = grid_of_kind( + "data", + ColumnKind::Bytes { + length: TextLength::Unbounded, + }, + ); + let doc = grid_of_kind("doc", ColumnKind::Json { binary: true }); + let json = Value::Json(JsonText::parse("{\"a\":1}".to_owned()).expect("valid json")); + let unreadable = Value::Undecodable(Box::new(UndecodedValue { + type_name: "geography".to_owned(), + reason: UndecodableReason::UnsupportedType, + })); + let dotted = GridColumn { + addressable: false, + ..grid_of_kind("a.b", integer()) + }; + + assert_eq!(cell_filter_rule(&blob, &Value::Bytes(vec![1, 2]), false), None); + assert_eq!(cell_filter_rule(&doc, &json, false), None); + assert_eq!( + cell_filter_rule(&grid_of_kind("g", ColumnKind::Other), &unreadable, false), + None + ); + assert_eq!( + cell_filter_rule(&grid_of_kind("t", ColumnKind::Other), &Value::Bytes(vec![0]), false), + None, + "a blob in a column that types per row" + ); + assert_eq!(cell_filter_rule(&dotted, &Value::Int(1), false), None); + assert_eq!(cell_filter_rule(&dotted, &Value::Null, false), None); + } + + #[test] + fn a_value_with_no_text_form_gives_only_a_null_rule() { + let shape = grid_of_kind("shape", ColumnKind::Spatial); + + assert_eq!(cell_filter_rule(&shape, &Value::Text("POINT(1 2)".into()), false), None); + assert_eq!( + cell_filter_rule(&shape, &Value::Null, false), + rule("shape", FilterOp::IsNull, None) + ); + } +} diff --git a/linux/crates/app/src/services/grid/cell_lock.rs b/linux/crates/app/src/services/grid/cell_lock.rs new file mode 100644 index 0000000000..a1b5aa8e26 --- /dev/null +++ b/linux/crates/app/src/services/grid/cell_lock.rs @@ -0,0 +1,79 @@ +use tablepro_core::edit::{EditParseError, ReadOnlyReason}; + +use crate::i18n::gettext; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CellLock { + /// The column's values have no text form to type. + Value(ReadOnlyReason), + /// The row is named by this column, so changing it in place would + /// lose the row it names. + KeyColumn, + Generated, + AutoIncrement, + UnkeyedRow, + ResultRow, + ReadOnlySession, + /// The row is marked for deletion. + PendingDelete, + /// An update cannot name the column (ADR 0026). + Unaddressable, + /// The running save writes this cell. An editor left open across the + /// save would commit against the value read before it. + Saving, +} + +impl CellLock { + /// Why the cell takes no value. `Value` is worded by the UI's + /// `edit_parse_message`, passed in, so the sentence keeps one home. + pub fn explanation(self, parse_message: impl Fn(&EditParseError) -> String) -> String { + match self { + Self::Value(reason) => parse_message(&EditParseError::NotEditable(reason)), + Self::KeyColumn => gettext("A saved row's key can't be changed here"), + Self::Generated => gettext("The database computes this column's values"), + Self::AutoIncrement => gettext("The database numbers this column's values"), + Self::UnkeyedRow => gettext("Rows of a table without a primary key can't be edited"), + Self::ResultRow => gettext("Query results can't be edited here"), + Self::ReadOnlySession => gettext("This connection is read-only."), + Self::PendingDelete => gettext("Rows marked for deletion can't be edited"), + Self::Unaddressable => { + gettext("A field whose name has a dot or starts with $ can only be changed in the editor") + } + Self::Saving => gettext("This row is being saved. Change it again once the save finishes."), + } + } +} + +#[cfg(test)] +mod tests { + use std::collections::HashSet; + + use tablepro_core::edit::{EditParseError, ReadOnlyReason}; + + use super::CellLock; + + #[test] + fn every_lock_explains_itself_and_a_value_lock_asks_the_ui() { + let locks = [ + CellLock::KeyColumn, + CellLock::Generated, + CellLock::AutoIncrement, + CellLock::UnkeyedRow, + CellLock::ResultRow, + CellLock::ReadOnlySession, + CellLock::PendingDelete, + CellLock::Unaddressable, + CellLock::Saving, + ]; + let unused = |_: &EditParseError| String::new(); + + let texts: HashSet = locks.iter().map(|lock| lock.explanation(unused)).collect(); + + assert_eq!(texts.len(), locks.len(), "two locks share a sentence"); + assert!(texts.iter().all(|text| !text.is_empty())); + assert_eq!( + CellLock::Value(ReadOnlyReason::Bytes).explanation(|error| format!("{error:?}")), + "NotEditable(Bytes)" + ); + } +} diff --git a/linux/crates/app/src/ui/grid/cell_preset.rs b/linux/crates/app/src/services/grid/cell_preset.rs similarity index 53% rename from linux/crates/app/src/ui/grid/cell_preset.rs rename to linux/crates/app/src/services/grid/cell_preset.rs index 05517599f4..6a349f4571 100644 --- a/linux/crates/app/src/ui/grid/cell_preset.rs +++ b/linux/crates/app/src/services/grid/cell_preset.rs @@ -1,6 +1,4 @@ -use tablepro_core::ColumnInfo; - -use super::GridRowId; +use super::{GridColumn, GridRowId}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum CellPreset { @@ -10,7 +8,7 @@ pub enum CellPreset { Default, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub struct CellPresets { pub empty: bool, pub null: bool, @@ -18,38 +16,34 @@ pub struct CellPresets { } /// Only an INSERT can leave a column out, so Default is for new rows -/// alone, and only where the server has something to fill in. -pub fn cell_presets(column: &ColumnInfo, row: &GridRowId) -> CellPresets { - let server_fills = !column.default.is_none() || column.is_auto_increment || column.is_generated; +/// alone, and only where the server has something to fill in. A column +/// the catalogue does not know takes none. +pub fn cell_presets(column: &GridColumn, row: &GridRowId) -> CellPresets { + let Some(catalog) = column.catalog.as_ref() else { + return CellPresets::default(); + }; + let server_fills = !catalog.default.is_none() || catalog.is_auto_increment || catalog.is_generated; CellPresets { - empty: column.column_type.kind.accepts_empty_string(), - null: column.nullable, + empty: catalog.column_type.kind.accepts_empty_string(), + null: catalog.nullable, default: matches!(row, GridRowId::Draft(_)) && server_fills, } } #[cfg(test)] mod tests { - use tablepro_core::column::{ColumnDefault, ColumnKind, ColumnType, IntegerKind, IntegerWidth, TextLength}; - use tablepro_core::{ColumnInfo, Value}; + use tablepro_core::ColumnInfo; + use tablepro_core::column::{ColumnDefault, ColumnKind, IntegerKind, IntegerWidth, TextLength}; use super::{CellPresets, cell_presets}; - use crate::services::change_tracker::RowKey; - use crate::ui::grid::GridRowId; + use crate::services::grid::GridRowId; + use crate::services::grid::test_columns::{grid, info_of_kind, persisted, result_only}; fn column(kind: ColumnKind, nullable: bool, default: ColumnDefault) -> ColumnInfo { - let mut column_type = ColumnType::untyped_text("t"); - column_type.kind = kind; ColumnInfo { - name: "c".to_owned(), - column_type, nullable, - primary_key: false, - is_auto_increment: false, - is_generated: false, - partition_scoped: false, default, - comment: None, + ..info_of_kind("c", kind) } } @@ -67,30 +61,36 @@ mod tests { signed: true, }); let now = ColumnDefault::Expression(tablepro_core::column::SqlExpression::from_catalog_text("now()")); - let saved = GridRowId::Persisted(RowKey::from_pk_values(&[Value::Int(1)]).expect("a key")); + let saved = persisted(); let draft = GridRowId::Draft(1); let presets = |empty, null, default| CellPresets { empty, null, default }; + let of = |info: ColumnInfo, row: &GridRowId| cell_presets(&grid(info), row); assert_eq!( - cell_presets(&column(text.clone(), true, ColumnDefault::None), &saved), + of(column(text.clone(), true, ColumnDefault::None), &saved), presets(true, true, false) ); assert_eq!( - cell_presets(&column(text, false, ColumnDefault::None), &draft), + of(column(text, false, ColumnDefault::None), &draft), presets(true, false, false), "a column with nothing to fill offered Default" ); assert_eq!( - cell_presets(&column(integer.clone(), false, now.clone()), &draft), + of(column(integer.clone(), false, now.clone()), &draft), presets(false, false, true) ); assert_eq!( - cell_presets(&column(integer.clone(), true, now), &saved), + of(column(integer.clone(), true, now), &saved), presets(false, true, false), "a saved row offered Default" ); - let mut serial = column(integer, false, ColumnDefault::None); + let mut serial = column(integer.clone(), false, ColumnDefault::None); serial.is_auto_increment = true; - assert_eq!(cell_presets(&serial, &draft), presets(false, false, true)); + assert_eq!(of(serial, &draft), presets(false, false, true)); + assert_eq!( + cell_presets(&result_only("c", integer), &draft), + CellPresets::default(), + "a column the catalogue does not know" + ); } } diff --git a/linux/crates/app/src/services/grid/column_layout.rs b/linux/crates/app/src/services/grid/column_layout.rs new file mode 100644 index 0000000000..fa23001e5f --- /dev/null +++ b/linux/crates/app/src/services/grid/column_layout.rs @@ -0,0 +1,335 @@ +use std::collections::{BTreeMap, BTreeSet, HashMap, VecDeque}; + +use serde::{Deserialize, Serialize}; + +/// A table's saved column order, hidden columns and widths, all by +/// column name so they survive a column added or dropped on the server. +#[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq)] +pub struct ColumnLayout { + #[serde(default)] + pub order: Vec, + #[serde(default)] + pub hidden: BTreeSet, + #[serde(default)] + pub widths: BTreeMap, +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] +#[serde(tag = "kind", content = "name", rename_all = "snake_case")] +pub enum LayoutSlot { + RowNumbers, + Column(String), +} + +/// One column of the grid in display order. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Placement { + RowNumbers, + Column { + index: usize, + visible: bool, + width: Option, + }, +} + +impl ColumnLayout { + /// `names` are the result's columns in result order. Saved slots come + /// first, then columns the layout has not seen, in result order. + pub fn arrange(&self, names: &[&str]) -> Vec { + let mut unplaced: HashMap<&str, VecDeque> = HashMap::new(); + for (index, name) in names.iter().enumerate() { + unplaced.entry(name).or_default().push_back(index); + } + let mut placed = vec![false; names.len()]; + let mut placements = Vec::with_capacity(names.len() + 1); + let mut row_numbers = false; + for slot in &self.order { + match slot { + LayoutSlot::RowNumbers if !row_numbers => { + row_numbers = true; + placements.push(Placement::RowNumbers); + } + LayoutSlot::RowNumbers => {} + LayoutSlot::Column(name) => { + if let Some(index) = unplaced.get_mut(name.as_str()).and_then(VecDeque::pop_front) { + placed[index] = true; + placements.push(self.placement(index, name)); + } + } + } + } + for (index, name) in names.iter().enumerate() { + if !placed[index] { + placements.push(self.placement(index, name)); + } + } + if !row_numbers { + placements.insert(0, Placement::RowNumbers); + } + show_one(&mut placements); + placements + } + + pub fn with_order(&self, slots: Vec) -> Self { + Self { + order: slots, + ..self.clone() + } + } + + pub fn with_hidden(&self, name: &str, hidden: bool) -> Self { + let mut layout = self.clone(); + if hidden { + layout.hidden.insert(name.to_owned()); + } else { + layout.hidden.remove(name); + } + layout + } + + pub fn with_width(&self, name: &str, width: i32) -> Self { + let mut layout = self.clone(); + layout.widths.insert(name.to_owned(), width); + layout + } + + pub fn without_width(&self, name: &str) -> Self { + let mut layout = self.clone(); + layout.widths.remove(name); + layout + } + + /// What changed from `before` to this layout, laid onto `latest`: a + /// grid on the same table in another tab may have saved since. + pub fn rebased(&self, before: &ColumnLayout, latest: &ColumnLayout) -> ColumnLayout { + let mut merged = latest.clone(); + for name in before.hidden.symmetric_difference(&self.hidden) { + match self.hidden.contains(name) { + true => merged.hidden.insert(name.clone()), + false => merged.hidden.remove(name), + }; + } + for name in before.widths.keys().chain(self.widths.keys()) { + if before.widths.get(name) == self.widths.get(name) { + continue; + } + match self.widths.get(name) { + Some(width) => merged.widths.insert(name.clone(), *width), + None => merged.widths.remove(name), + }; + } + if self.order != before.order { + merged.order = merge_order(&latest.order, self.order.clone()); + } + merged + } + + fn placement(&self, index: usize, name: &str) -> Placement { + Placement::Column { + index, + visible: !self.hidden.contains(name), + width: self.widths.get(name).copied(), + } + } +} + +/// The shown slots in their new order, with each slot the view lacks put +/// back after the slot it followed before. +pub fn merge_order(stored: &[LayoutSlot], shown: Vec) -> Vec { + let mut merged = shown; + let mut previous: Option = None; + for slot in stored { + if !merged.contains(slot) { + let at = previous + .as_ref() + .and_then(|previous| merged.iter().position(|slot| slot == previous)) + .map_or(merged.len(), |position| position + 1); + merged.insert(at, slot.clone()); + } + previous = Some(slot.clone()); + } + merged +} + +/// A grid of no columns has nothing to focus and no header to bring one +/// back from, so when the columns a layout kept shown are gone the first +/// column on screen shows. +fn show_one(placements: &mut [Placement]) { + if placements + .iter() + .any(|placement| matches!(placement, Placement::Column { visible: true, .. })) + { + return; + } + if let Some(Placement::Column { visible, .. }) = placements + .iter_mut() + .find(|placement| matches!(placement, Placement::Column { .. })) + { + *visible = true; + } +} + +#[cfg(test)] +mod tests { + use super::{ColumnLayout, LayoutSlot, Placement}; + + fn shown(index: usize) -> Placement { + Placement::Column { + index, + visible: true, + width: None, + } + } + + fn column(name: &str) -> LayoutSlot { + LayoutSlot::Column(name.to_owned()) + } + + #[test] + fn an_empty_layout_shows_every_column_in_result_order() { + assert_eq!( + ColumnLayout::default().arrange(&["id", "name"]), + vec![Placement::RowNumbers, shown(0), shown(1)] + ); + assert_eq!(ColumnLayout::default().arrange(&[]), vec![Placement::RowNumbers]); + } + + #[test] + fn renamed_and_dropped_columns_fall_out_and_new_ones_append() { + let layout = ColumnLayout::default().with_order(vec![ + LayoutSlot::RowNumbers, + column("email"), + column("gone"), + column("id"), + ]); + + assert_eq!( + layout.arrange(&["id", "full_name", "email", "added"]), + vec![Placement::RowNumbers, shown(2), shown(0), shown(1), shown(3)] + ); + } + + #[test] + fn a_change_lands_on_what_another_grid_saved_since() { + let before = ColumnLayout::default().with_width("id", 60); + let latest = before.with_width("name", 240).with_hidden("email", true); + let mine = before.with_width("id", 90).with_hidden("note", true); + + let saved = mine.rebased(&before, &latest); + + assert_eq!( + saved, + latest.with_width("id", 90).with_hidden("note", true), + "the other grid's width and hidden column were lost" + ); + assert_eq!( + before.rebased(&before, &latest), + latest, + "no change of mine wrote over theirs" + ); + } + + #[test] + fn a_reorder_keeps_the_columns_only_the_saved_layout_knows() { + let before = ColumnLayout::default().with_order(vec![LayoutSlot::RowNumbers, column("a"), column("b")]); + let latest = before.with_order(vec![LayoutSlot::RowNumbers, column("a"), column("gone"), column("b")]); + let mine = before.with_order(vec![LayoutSlot::RowNumbers, column("b"), column("a")]); + + assert_eq!( + mine.rebased(&before, &latest).order, + [LayoutSlot::RowNumbers, column("b"), column("a"), column("gone")] + ); + } + + #[test] + fn hidden_columns_and_widths_follow_the_name() { + let layout = ColumnLayout::default() + .with_hidden("secret", true) + .with_width("name", 240) + .with_width("id", 60) + .without_width("id"); + + assert_eq!( + layout.arrange(&["id", "name", "secret"]), + vec![ + Placement::RowNumbers, + shown(0), + Placement::Column { + index: 1, + visible: true, + width: Some(240), + }, + Placement::Column { + index: 2, + visible: false, + width: None, + }, + ] + ); + assert_eq!(layout.with_hidden("secret", false).hidden.len(), 0); + } + + #[test] + fn a_layout_whose_shown_columns_are_gone_shows_the_first_column() { + let layout = ColumnLayout::default().with_hidden("a", true).with_hidden("b", true); + + assert_eq!( + layout.arrange(&["a", "b"]), + vec![ + Placement::RowNumbers, + shown(0), + Placement::Column { + index: 1, + visible: false, + width: None, + }, + ] + ); + } + + #[test] + fn duplicate_names_take_the_first_unplaced_column() { + let layout = ColumnLayout::default().with_order(vec![column("n"), LayoutSlot::RowNumbers, column("n")]); + + assert_eq!( + layout.arrange(&["n", "x", "n"]), + vec![shown(0), Placement::RowNumbers, shown(2), shown(1)] + ); + } + + #[test] + fn the_row_number_slot_moves_and_is_placed_once() { + let moved = + ColumnLayout::default().with_order(vec![column("b"), LayoutSlot::RowNumbers, LayoutSlot::RowNumbers]); + + assert_eq!( + moved.arrange(&["a", "b"]), + vec![shown(1), Placement::RowNumbers, shown(0)] + ); + let without = ColumnLayout::default().with_order(vec![column("b")]); + assert_eq!( + without.arrange(&["a", "b"]), + vec![Placement::RowNumbers, shown(1), shown(0)] + ); + } + + #[test] + fn a_layout_reads_back_from_its_json() { + let layout = ColumnLayout::default() + .with_order(vec![LayoutSlot::RowNumbers, column("id")]) + .with_hidden("note", true) + .with_width("id", 80); + + let json = serde_json::to_string(&layout).expect("serialised"); + + assert_eq!( + json, + r#"{"order":[{"kind":"row_numbers"},{"kind":"column","name":"id"}],"hidden":["note"],"widths":{"id":80}}"# + ); + assert_eq!(serde_json::from_str::(&json).expect("parsed"), layout); + assert_eq!( + serde_json::from_str::("{}").expect("parsed"), + ColumnLayout::default() + ); + } +} diff --git a/linux/crates/app/src/services/grid/column_width.rs b/linux/crates/app/src/services/grid/column_width.rs new file mode 100644 index 0000000000..426b5478e5 --- /dev/null +++ b/linux/crates/app/src/services/grid/column_width.rs @@ -0,0 +1,225 @@ +use tablepro_core::Value; +use tablepro_core::column::{ColumnKind, TemporalZone}; + +use super::display_text::display_text; +use super::{CellDisplay, GridColumn}; + +const MIN_CHARS: u32 = 4; +const TEXT_MAX_CHARS: u32 = 40; +/// `false`, the longer of the two values. +const BOOL_CHARS: u32 = 5; +const SAMPLE_ROWS: usize = 200; + +/// A column's starting width in characters: its header or its widest +/// sampled value. Text is capped so one long value does not push the rest +/// off screen; a number or a date is never cut. +pub fn default_width_chars<'a>(column: &GridColumn, sample: impl IntoIterator) -> u32 { + let (header, widest) = header_and_widest(column, sample); + match column.display() { + CellDisplay::Bool => header.max(BOOL_CHARS), + CellDisplay::Number => header.max(widest), + CellDisplay::Temporal => header.max(widest).max(temporal_chars(&column.column_type.kind)), + CellDisplay::Text | CellDisplay::Json | CellDisplay::Binary => header.max(widest).min(TEXT_MAX_CHARS), + } +} + +/// The width of a column that turned out to hold only numbers, as an +/// untyped PostgreSQL result does: a number is never cut. +pub fn number_width_chars<'a>(column: &GridColumn, sample: impl IntoIterator) -> u32 { + let (header, widest) = header_and_widest(column, sample); + header.max(widest) +} + +fn header_and_widest<'a>(column: &GridColumn, sample: impl IntoIterator) -> (u32, u32) { + let header = char_count(&column.name).clamp(MIN_CHARS, TEXT_MAX_CHARS); + let mut scratch = String::new(); + let widest = sample + .into_iter() + .take(SAMPLE_ROWS) + .filter(|value| !value.is_null()) + .map(|value| char_count(display_text(value, &column.column_type, &mut scratch).text)) + .max() + .unwrap_or(0); + (header, widest) +} + +/// The sampled value with the most characters, as the cell draws it and +/// cut at the cap: a count of average characters runs short of capitals +/// and digits, so the caller measures this one. A number column is +/// measured in tabular digits instead. +pub fn widest_text<'a>(column: &GridColumn, sample: impl IntoIterator) -> Option { + if column.display() == CellDisplay::Number { + return None; + } + let cap = usize::try_from(TEXT_MAX_CHARS).unwrap_or(usize::MAX); + let mut scratch = String::new(); + sample + .into_iter() + .take(SAMPLE_ROWS) + .filter(|value| !value.is_null()) + .map(|value| { + display_text(value, &column.column_type, &mut scratch) + .text + .chars() + .take(cap) + .collect::() + }) + .max_by_key(|text| char_count(text)) +} + +/// The width of a value without fractional seconds, which the sample adds +/// when the column holds them. +fn temporal_chars(kind: &ColumnKind) -> u32 { + const OFFSET: u32 = "+00:00".len() as u32; + match kind { + ColumnKind::Date => "2026-01-01".len() as u32, + ColumnKind::Time { with_offset, .. } => "00:00:00".len() as u32 + if *with_offset { OFFSET } else { 0 }, + ColumnKind::Timestamp { zone, .. } => { + "2026-01-01 00:00:00".len() as u32 + if *zone == TemporalZone::Offset { OFFSET } else { 0 } + } + _ => 0, + } +} + +fn char_count(text: &str) -> u32 { + u32::try_from(text.chars().count()).unwrap_or(u32::MAX) +} + +#[cfg(test)] +mod tests { + use tablepro_core::Value; + use tablepro_core::column::{BoolStorage, ColumnKind, IntegerKind, IntegerWidth, TemporalZone, TextLength}; + use tablepro_core::value::{SqlDecimal, Temporal}; + + use super::{default_width_chars, number_width_chars, widest_text}; + use crate::services::grid::test_columns::result_only; + + fn text() -> ColumnKind { + ColumnKind::Text { + length: TextLength::Unbounded, + } + } + + fn texts(values: &[&str]) -> Vec { + values.iter().map(|value| Value::Text((*value).to_owned())).collect() + } + + #[test] + fn text_widths_take_the_header_or_the_widest_value_within_bounds() { + let note = result_only("note", text()); + + assert_eq!(default_width_chars(¬e, &texts(&["a", "bb"])), 4, "the minimum"); + assert_eq!(default_width_chars(¬e, &texts(&["a", "twelve chars"])), 12); + assert_eq!( + default_width_chars(¬e, &texts(&["x".repeat(500).as_str()])), + 40, + "the cap" + ); + assert_eq!( + default_width_chars(&result_only("a_rather_long_column_name", text()), &texts(&["a"])), + 25 + ); + assert_eq!( + default_width_chars(¬e, &[Value::Null, Value::Null]), + 4, + "a NULL is a placeholder, not a value" + ); + } + + #[test] + fn the_widest_text_is_the_longest_value_within_the_cap() { + let note = result_only("note", text()); + let integer = ColumnKind::Integer(IntegerKind { + width: IntegerWidth::W64, + signed: true, + }); + + assert_eq!( + widest_text( + ¬e, + &[Value::Text("CF-1001".into()), Value::Null, Value::Text("a".into())] + ), + Some("CF-1001".to_owned()) + ); + assert_eq!( + widest_text(¬e, &texts(&["x".repeat(500).as_str()])).map(|text| text.chars().count()), + Some(40) + ); + assert_eq!(widest_text(¬e, &[Value::Null]), None); + assert_eq!(widest_text(&result_only("id", integer), &[Value::Int(7)]), None); + } + + #[test] + fn only_the_first_200_rows_are_sampled() { + let note = result_only("note", text()); + let mut values = texts(&["short"; 200]); + values.push(Value::Text("x".repeat(30))); + + assert_eq!(default_width_chars(¬e, &values), 5); + } + + #[test] + fn a_number_is_never_cut() { + let integer = ColumnKind::Integer(IntegerKind { + width: IntegerWidth::W64, + signed: true, + }); + let wide: SqlDecimal = "1234567890123456789012345678901234567890.5".parse().expect("a decimal"); + let decimal = ColumnKind::Decimal { + precision: None, + scale: None, + }; + + assert_eq!( + default_width_chars(&result_only("id", integer.clone()), &[Value::Int(7)]), + 4 + ); + assert_eq!( + default_width_chars(&result_only("id", integer), &[Value::Int(-1_234_567_890)]), + 11 + ); + assert_eq!( + default_width_chars(&result_only("n", decimal), &[Value::Decimal(wide)]), + 42 + ); + } + + #[test] + fn an_untyped_column_of_numbers_is_never_cut() { + let untyped = result_only("n", ColumnKind::Other); + let wide = [Value::Text("9".repeat(50))]; + + assert_eq!(default_width_chars(&untyped, &wide), 40, "as text it is capped"); + assert_eq!(number_width_chars(&untyped, &wide), 50); + } + + #[test] + fn a_bool_is_as_wide_as_false() { + let flag = ColumnKind::Bool(BoolStorage::Native); + + assert_eq!( + default_width_chars(&result_only("ok", flag.clone()), &[Value::Bool(true)]), + 5 + ); + assert_eq!(default_width_chars(&result_only("is_active", flag), &[]), 9); + } + + #[test] + fn a_temporal_column_is_as_wide_as_its_format() { + let stamped = ColumnKind::Timestamp { + fractional_digits: None, + zone: TemporalZone::Offset, + }; + let day = chrono::NaiveDate::from_ymd_opt(2026, 4, 26).expect("a date"); + + assert_eq!(default_width_chars(&result_only("day", ColumnKind::Date), &[]), 10); + assert_eq!( + default_width_chars( + &result_only("day", ColumnKind::Date), + &[Value::Date(Temporal::Finite(day))] + ), + 10 + ); + assert_eq!(default_width_chars(&result_only("at", stamped), &[]), 25); + } +} diff --git a/linux/crates/app/src/services/grid/copy_scope.rs b/linux/crates/app/src/services/grid/copy_scope.rs new file mode 100644 index 0000000000..2d0eeaa760 --- /dev/null +++ b/linux/crates/app/src/services/grid/copy_scope.rs @@ -0,0 +1,34 @@ +/// What Copy takes (inventory D3). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CopyScope { + /// The focused cell's value, by column index. + Value(usize), + Rows, +} + +/// One selected row, or none with a focused cell, copies that cell's +/// value; several rows copy the rows. +pub fn copy_scope(selected_rows: usize, focused_column: Option) -> CopyScope { + match (selected_rows, focused_column) { + (0 | 1, Some(column)) => CopyScope::Value(column), + _ => CopyScope::Rows, + } +} + +#[cfg(test)] +mod tests { + use super::{CopyScope, copy_scope}; + + #[test] + fn one_row_copies_the_focused_value_and_several_copy_rows() { + assert_eq!(copy_scope(1, Some(2)), CopyScope::Value(2)); + assert_eq!( + copy_scope(0, Some(0)), + CopyScope::Value(0), + "a focused cell with no selection" + ); + assert_eq!(copy_scope(2, Some(2)), CopyScope::Rows); + assert_eq!(copy_scope(500, None), CopyScope::Rows); + assert_eq!(copy_scope(1, None), CopyScope::Rows, "a row with no focused cell"); + } +} diff --git a/linux/crates/app/src/ui/grid/delete_availability.rs b/linux/crates/app/src/services/grid/delete_availability.rs similarity index 98% rename from linux/crates/app/src/ui/grid/delete_availability.rs rename to linux/crates/app/src/services/grid/delete_availability.rs index d5c462712c..ce8eaddaa0 100644 --- a/linux/crates/app/src/ui/grid/delete_availability.rs +++ b/linux/crates/app/src/services/grid/delete_availability.rs @@ -54,7 +54,7 @@ mod tests { use super::{DeleteAvailability, DeleteBlock, delete_availability}; use crate::services::change_tracker::RowKey; - use crate::ui::grid::GridRowId; + use crate::services::grid::GridRowId; #[test] fn delete_availability_blocks_unkeyed_result_and_read_only() { diff --git a/linux/crates/app/src/ui/grid/display_text.rs b/linux/crates/app/src/services/grid/display_text.rs similarity index 83% rename from linux/crates/app/src/ui/grid/display_text.rs rename to linux/crates/app/src/services/grid/display_text.rs index 2148a7821a..a0ee4209b2 100644 --- a/linux/crates/app/src/ui/grid/display_text.rs +++ b/linux/crates/app/src/services/grid/display_text.rs @@ -1,18 +1,18 @@ use std::fmt::Write; use tablepro_core::Value; -use tablepro_core::column::ColumnType; +use tablepro_core::column::{BoolStorage, ColumnKind, ColumnType}; /// A million-character value would hang the main thread in Pango /// layout, and the cell shapes all it is given though a line of it /// shows. The model keeps the whole value; only the cell is cut, past /// what its tooltip quotes. -pub(super) const DISPLAY_MAX_CHARS: usize = 1_000; +pub const DISPLAY_MAX_CHARS: usize = 1_000; /// What a cell shows for a value. `text` borrows the value where it /// can, so a text cell draws with no copy of its own. #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(super) struct DisplayText<'a> { +pub struct DisplayText<'a> { pub text: &'a str, /// The text stands in for a value rather than spelling it. pub placeholder: bool, @@ -21,7 +21,7 @@ pub(super) struct DisplayText<'a> { } impl<'a> DisplayText<'a> { - pub(super) fn placeholder(text: &'a str) -> Self { + pub fn placeholder(text: &'a str) -> Self { Self { text, placeholder: true, @@ -31,7 +31,7 @@ impl<'a> DisplayText<'a> { } /// The brackets keep it apart from a literal `NULL` text value. -pub(super) fn editable_null_sentinel() -> String { +pub fn editable_null_sentinel() -> String { crate::i18n::gettext("") } @@ -41,14 +41,14 @@ const READONLY_NULL: &str = "NULL"; /// For a column the server fills in on INSERT, so a new draft row does /// not read as if its id were stored as NULL. -pub(super) fn auto_filled_sentinel() -> String { +pub fn auto_filled_sentinel() -> String { crate::i18n::gettext("(auto)") } /// The text of `value` as the cell draws it. Text, JSON and a value the /// driver kept as the server's text are borrowed; anything else is /// written into `scratch`, the cell's own buffer. -pub(super) fn display_text<'a>(value: &'a Value, column: &ColumnType, scratch: &'a mut String) -> DisplayText<'a> { +pub fn display_text<'a>(value: &'a Value, column: &ColumnType, scratch: &'a mut String) -> DisplayText<'a> { scratch.clear(); match value { Value::Null => DisplayText::placeholder(READONLY_NULL), @@ -81,6 +81,23 @@ pub(super) fn display_text<'a>(value: &'a Value, column: &ColumnType, scratch: & } } +/// The text `display_text` spells, whole: past the cell's cut and without +/// its note of what it left out, for a search that must reach the rest. A +/// placeholder is the placeholder. +pub fn whole_text<'a>(value: &'a Value, column: &ColumnType, scratch: &'a mut String) -> &'a str { + match value { + Value::Text(text) => text, + Value::Json(json) => json.as_str(), + Value::Other(other) => &other.text, + Value::Null | Value::Bytes(_) | Value::Undecodable(_) => display_text(value, column, scratch).text, + other => { + scratch.clear(); + write_value(other, column, scratch); + scratch + } + } +} + /// Everything else renders the way it exports, so what the grid shows /// and what a copy produces are the same text. The common scalars are /// written straight into the buffer. @@ -88,6 +105,14 @@ fn write_value(value: &Value, column: &ColumnType, scratch: &mut String) { // Writing to a String cannot fail. let _ = match value { Value::Bool(flag) => scratch.write_str(if *flag { "true" } else { "false" }), + // A boolean kept in an integer reads as one; any other number it + // holds is not a boolean and shows as itself. + Value::Int(flag @ (0 | 1)) if column.kind == ColumnKind::Bool(BoolStorage::Integer) => { + scratch.write_str(if *flag == 1 { "true" } else { "false" }) + } + Value::UInt(flag @ (0 | 1)) if column.kind == ColumnKind::Bool(BoolStorage::Integer) => { + scratch.write_str(if *flag == 1 { "true" } else { "false" }) + } Value::Int(number) => write!(scratch, "{number}"), Value::UInt(number) => write!(scratch, "{number}"), Value::Float32(number) if number.is_finite() => write!(scratch, "{number}"), @@ -178,7 +203,7 @@ fn plural_count(count: usize) -> u32 { #[cfg(test)] mod tests { use tablepro_core::Value; - use tablepro_core::column::ColumnType; + use tablepro_core::column::{BoolStorage, ColumnKind, ColumnType}; use tablepro_core::value::{JsonText, OffsetTimestamp, SqlTime, Temporal, UndecodableReason, UndecodedValue}; use super::{DISPLAY_MAX_CHARS, DisplayText, display_text}; @@ -292,6 +317,21 @@ mod tests { assert_eq!(shown(&Value::Text("hello".into())), "hello"); } + #[test] + fn an_integer_boolean_reads_true_or_false_and_other_numbers_as_themselves() { + let flag = ColumnType { + kind: ColumnKind::Bool(BoolStorage::Integer), + ..ColumnType::untyped_text("tinyint(1)") + }; + let mut scratch = String::new(); + let mut text = |value: Value| display_text(&value, &flag, &mut scratch).text.to_owned(); + + assert_eq!(text(Value::Int(1)), "true"); + assert_eq!(text(Value::Int(0)), "false"); + assert_eq!(text(Value::UInt(1)), "true"); + assert_eq!(text(Value::Int(2)), "2"); + } + #[test] fn display_text_temporal_variants() { let date = chrono::NaiveDate::from_ymd_opt(2026, 4, 26).expect("a date"); diff --git a/linux/crates/app/src/ui/grid/edit_navigation.rs b/linux/crates/app/src/services/grid/edit_navigation.rs similarity index 100% rename from linux/crates/app/src/ui/grid/edit_navigation.rs rename to linux/crates/app/src/services/grid/edit_navigation.rs diff --git a/linux/crates/app/src/ui/grid/edit_commit.rs b/linux/crates/app/src/services/grid/edit_parse.rs similarity index 58% rename from linux/crates/app/src/ui/grid/edit_commit.rs rename to linux/crates/app/src/services/grid/edit_parse.rs index 6d158c91a7..69b36a0bed 100644 --- a/linux/crates/app/src/ui/grid/edit_commit.rs +++ b/linux/crates/app/src/services/grid/edit_parse.rs @@ -1,19 +1,30 @@ +use tablepro_core::Value; use tablepro_core::edit::{ - CellInput, EditContext, EditText, EmptyInputMeaning, edit_text, empty_input_meaning, is_unchanged, parse_edit_text, + CellInput, EditContext, EditParseError, EditText, EmptyInputMeaning, edit_text, empty_input_meaning, is_unchanged, + parse_edit_text, }; -use tablepro_core::{ColumnInfo, Value}; -use super::{EditSnapshot, GridCell, GridMsg, GridRowId}; +use super::{GridColumn, GridRowId}; use crate::services::tracker_edit::TrackerEdit; -use crate::ui::error_text::edit_parse_message; + +/// An edit as it stood when it began. The commit goes to `row`, not to +/// whatever row the cell shows by then. +#[derive(Debug, Clone, PartialEq)] +pub struct EditStart { + pub row: GridRowId, + /// The cell as the page read it, which the tracker saves against. + pub read: Value, + /// The cell with any pending edit, which the editor opened with. + pub original: CellInput, +} /// What committing an editor's text comes to. #[derive(Debug, Clone, PartialEq)] pub enum CommitResult { Edit(TrackerEdit), Unchanged, - /// The text is not a value of the column, with what to tell the user. - Rejected(String), + /// The text is not a value of the column. The UI words the error. + Rejected(EditParseError), } /// Only a new row can leave a column to the server. @@ -25,49 +36,49 @@ pub fn edit_context(row: &GridRowId) -> EditContext { } /// The text an editor ends with, as an edit of the row it began on. -/// Every editor commits through here, and none reads the cell's display. -/// /// Text left as the editor opened it is no edit: NULL and the empty -/// string open as the same empty field, and committing it untouched must -/// not turn one into the other. -pub fn commit_edit(column: &ColumnInfo, snapshot: &EditSnapshot, input: &str) -> CommitResult { - let opened_with = match &snapshot.original { - CellInput::Value(value) => edit_text(value, &column.column_type), +/// string open as the same empty field, and must not turn into each other. +pub fn commit_edit(column: &GridColumn, start: &EditStart, input: &str) -> CommitResult { + let Some(catalog) = column.catalog.as_ref() else { + return CommitResult::Unchanged; + }; + let opened_with = match &start.original { + CellInput::Value(value) => edit_text(value, &catalog.column_type), CellInput::Default => EditText::Editable(String::new()), }; if matches!(&opened_with, EditText::Editable(text) if text == input) { return CommitResult::Unchanged; } - match parse_edit_text(input, column, edit_context(snapshot.row.id())) { - Ok(parsed) => commit_input(column, snapshot, parsed), + match parse_edit_text(input, catalog, edit_context(&start.row)) { + Ok(parsed) => commit_input(column, start, parsed), Err(error) => { - tracing::debug!(%error, column = %column.name, "refused an edit"); - CommitResult::Rejected(edit_parse_message(&error)) + tracing::debug!(%error, column = %catalog.name, "refused an edit"); + CommitResult::Rejected(error) } } } -/// `input` as an edit of the row the snapshot names, or no edit when it +/// `input` as an edit of the row `start` names, or no edit when it /// leaves the cell as it was. -pub fn commit_input(column: &ColumnInfo, snapshot: &EditSnapshot, input: CellInput) -> CommitResult { - let unchanged = match (&snapshot.original, &input) { - (CellInput::Value(original), CellInput::Value(value)) => is_unchanged(original, value, &column.column_type), +pub fn commit_input(column: &GridColumn, start: &EditStart, input: CellInput) -> CommitResult { + let unchanged = match (&start.original, &input) { + (CellInput::Value(original), CellInput::Value(value)) => is_unchanged(original, value, column.value_type()), (CellInput::Default, CellInput::Default) => true, _ => false, }; if unchanged { return CommitResult::Unchanged; } - match (snapshot.row.id(), input) { + match (&start.row, input) { (GridRowId::Draft(draft_id), input) => CommitResult::Edit(TrackerEdit::DraftCell { draft_id: *draft_id, - col: snapshot.column, + col: column.index, input, }), (GridRowId::Persisted(row_key), CellInput::Value(value)) => CommitResult::Edit(TrackerEdit::Cell { row_key: row_key.clone(), - col: snapshot.column, - original: snapshot.row.cell(snapshot.column).cloned().unwrap_or(Value::Null), + col: column.index, + original: start.read.clone(), value, }), (GridRowId::Persisted(_), CellInput::Default) => { @@ -78,30 +89,11 @@ pub fn commit_input(column: &ColumnInfo, snapshot: &EditSnapshot, input: CellInp } } -/// Hand the outcome to the tab. A refused value is said in a toast and -/// the cell draws its value again. -pub(super) fn send_commit(cell: &GridCell, snapshot: EditSnapshot, result: CommitResult) { - let Some(sink) = cell.sink() else { - return; - }; - match result { - CommitResult::Edit(edit) => sink(GridMsg::Edit { - row: snapshot.row, - column: snapshot.column, - edit, - }), - CommitResult::Unchanged => {} - CommitResult::Rejected(message) => { - sink(GridMsg::ShowToast(message)); - cell.render(); - } - } -} - /// What an empty editor commits, said where the text would be. The /// keywords are SQL and stay untranslated. -pub fn empty_placeholder(column: &ColumnInfo, row: &GridRowId) -> Option { - match empty_input_meaning(column, edit_context(row)) { +pub fn empty_placeholder(column: &GridColumn, row: &GridRowId) -> Option { + let catalog = column.catalog.as_ref()?; + match empty_input_meaning(catalog, edit_context(row)) { EmptyInputMeaning::EmptyText => None, EmptyInputMeaning::Default => Some("DEFAULT".to_owned()), EmptyInputMeaning::Null => Some("NULL".to_owned()), @@ -111,53 +103,45 @@ pub fn empty_placeholder(column: &ColumnInfo, row: &GridRowId) -> Option #[cfg(test)] mod tests { - use tablepro_core::column::{ColumnDefault, ColumnType}; - use tablepro_core::edit::CellInput; + use tablepro_core::column::{ColumnDefault, ColumnKind}; + use tablepro_core::edit::{CellInput, EditParseError}; use tablepro_core::{ColumnInfo, Value}; - use super::{CommitResult, commit_edit, empty_placeholder}; + use super::{CommitResult, EditStart, commit_edit, empty_placeholder}; use crate::services::change_tracker::RowKey; + use crate::services::grid::test_columns::{grid, info, persisted, result_only}; + use crate::services::grid::{GridColumn, GridRowId}; use crate::services::tracker_edit::TrackerEdit; - use crate::ui::grid::{EditSnapshot, GridRowId, RowObject}; #[expect( clippy::disallowed_methods, reason = "a test fixture stands in for the catalogue, which is where this text comes from in production" )] - fn column(data_type: &str, nullable: bool, default: Option<&str>) -> ColumnInfo { - let kind = tablepro_core::column::classify_type_name(data_type); - ColumnInfo { - name: "c".to_owned(), - column_type: ColumnType { - kind, - ..ColumnType::untyped_text(data_type) - }, + fn column(data_type: &str, nullable: bool, default: Option<&str>) -> GridColumn { + let mut column = grid(ColumnInfo { nullable, - primary_key: false, - is_auto_increment: false, - is_generated: false, - partition_scoped: false, default: match default { Some(text) => ColumnDefault::Expression(tablepro_core::column::SqlExpression::from_catalog_text(text)), None => ColumnDefault::None, }, - comment: None, - } + ..info("c", data_type) + }); + column.index = 1; + column } - fn saved(value: Value) -> EditSnapshot { - let key = RowKey::from_pk_values(&[Value::Int(1)]).expect("a key"); - EditSnapshot { - row: RowObject::persisted(Some(key), vec![Value::Int(1), value.clone()].into()), - column: 1, + fn saved(value: Value) -> EditStart { + EditStart { + row: persisted(), + read: value.clone(), original: CellInput::Value(value), } } - fn draft(original: CellInput) -> EditSnapshot { - EditSnapshot { - row: RowObject::draft(3), - column: 1, + fn draft(original: CellInput) -> EditStart { + EditStart { + row: GridRowId::Draft(3), + read: Value::Null, original, } } @@ -209,11 +193,9 @@ mod tests { Some(CellInput::Default), "a new row leaves an empty cell to the server" ); - assert!( - matches!( - commit_edit(&stamped, &saved(Value::Int(1)), ""), - CommitResult::Rejected(_) - ), + assert_eq!( + commit_edit(&stamped, &saved(Value::Int(1)), ""), + CommitResult::Rejected(EditParseError::Required), "an UPDATE cannot ask for the default" ); } @@ -221,48 +203,60 @@ mod tests { #[test] fn a_commit_names_the_row_and_the_value_it_was_read_with() { let text = column("text", true, None); - let snapshot = saved(Value::Text("old".into())); + let start = EditStart { + row: persisted(), + read: Value::Text("read".into()), + original: CellInput::Value(Value::Text("old".into())), + }; - let result = commit_edit(&text, &snapshot, "new"); + let result = commit_edit(&text, &start, "new"); assert_eq!( result, CommitResult::Edit(TrackerEdit::Cell { row_key: RowKey::from_pk_values(&[Value::Int(1)]).expect("a key"), col: 1, - original: Value::Text("old".into()), + original: Value::Text("read".into()), value: Value::Text("new".into()), }) ); - assert_eq!(commit_edit(&text, &snapshot, "old"), CommitResult::Unchanged); + assert_eq!(commit_edit(&text, &start, "old"), CommitResult::Unchanged); } #[test] fn a_value_the_column_cannot_hold_is_refused_with_its_reason() { let integer = column("integer", false, None); - let CommitResult::Rejected(message) = commit_edit(&integer, &saved(Value::Int(1)), "thirty") else { - panic!("text in an integer column was accepted"); - }; - - assert_eq!(message, "Enter a whole number"); + assert_eq!( + commit_edit(&integer, &saved(Value::Int(1)), "thirty"), + CommitResult::Rejected(EditParseError::InvalidInteger) + ); } #[test] fn a_cell_that_has_no_row_to_write_to_records_nothing() { let text = column("text", true, None); - let snapshot = EditSnapshot { - row: RowObject::result(vec![Value::Text("a".into())].into()), - column: 0, + let start = EditStart { + row: GridRowId::Result, + read: Value::Text("a".into()), original: CellInput::Value(Value::Text("a".into())), }; - assert_eq!(commit_edit(&text, &snapshot, "b"), CommitResult::Unchanged); + assert_eq!(commit_edit(&text, &start, "b"), CommitResult::Unchanged); + assert_eq!( + commit_edit( + &result_only("c", ColumnKind::Other), + &saved(Value::Text("a".into())), + "b" + ), + CommitResult::Unchanged, + "a column the catalogue does not know" + ); } #[test] fn the_placeholder_says_what_empty_commits() { - let saved_row = GridRowId::Persisted(RowKey::from_pk_values(&[Value::Int(1)]).expect("a key")); + let saved_row = persisted(); let draft_row = GridRowId::Draft(1); assert_eq!(empty_placeholder(&column("text", true, None), &saved_row), None); @@ -278,5 +272,9 @@ mod tests { empty_placeholder(&column("integer", false, None), &saved_row).as_deref(), Some("Required") ); + assert_eq!( + empty_placeholder(&result_only("c", ColumnKind::Other), &saved_row), + None + ); } } diff --git a/linux/crates/app/src/services/grid/edit_policy.rs b/linux/crates/app/src/services/grid/edit_policy.rs new file mode 100644 index 0000000000..2ed340b109 --- /dev/null +++ b/linux/crates/app/src/services/grid/edit_policy.rs @@ -0,0 +1,137 @@ +use crate::i18n::{gettext, gettext_f, ngettext_f}; + +/// Why the grid refuses a change. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EditRefusal { + ReadOnly, + /// Saved rows are named by their primary key, so without one no + /// change can be saved (inventory D1). + NoPrimaryKey, + /// The table's columns have not been read yet. + NoColumns, +} + +impl EditRefusal { + /// `None` where the state explains itself. + pub fn message(self) -> Option { + match self { + Self::ReadOnly => Some(gettext("This connection is read-only.")), + Self::NoPrimaryKey => Some(gettext("This table has no primary key, so editing is turned off.")), + Self::NoColumns => None, + } + } +} + +/// What the grid may change. Every entry point (menu, key, toolbar) +/// reads the same answer. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct EditPolicy { + pub cells: Result<(), EditRefusal>, + pub insert: Result<(), EditRefusal>, + pub duplicate: Result<(), EditRefusal>, + pub delete: Result<(), EditRefusal>, + pub paste: Result<(), EditRefusal>, +} + +pub fn edit_policy(read_only: bool, has_primary_key: bool, has_columns: bool) -> EditPolicy { + let verdict = match (read_only, has_columns, has_primary_key) { + (true, _, _) => Err(EditRefusal::ReadOnly), + (false, false, _) => Err(EditRefusal::NoColumns), + (false, true, false) => Err(EditRefusal::NoPrimaryKey), + (false, true, true) => Ok(()), + }; + EditPolicy { + cells: verdict, + insert: verdict, + duplicate: verdict, + delete: verdict, + paste: verdict, + } +} + +/// The count shown once several rows are selected. It names the Delete +/// key only where Delete would act (inventory D12). +pub fn selection_hint(selected: u32, delete: Result<(), EditRefusal>) -> Option { + if selected <= 1 { + return None; + } + let count = selected.to_string(); + Some(match delete { + Ok(()) => gettext_f("{n} selected · press Delete to remove", &[("n", &count)]), + Err(_) => ngettext_f("{n} selected", "{n} selected", selected, &[("n", &count)]), + }) +} + +#[cfg(test)] +mod tests { + use super::{EditPolicy, EditRefusal, edit_policy, selection_hint}; + + fn all(verdict: Result<(), EditRefusal>) -> EditPolicy { + EditPolicy { + cells: verdict, + insert: verdict, + duplicate: verdict, + delete: verdict, + paste: verdict, + } + } + + #[test] + fn a_keyed_writable_table_takes_every_change() { + assert_eq!(edit_policy(false, true, true), all(Ok(()))); + } + + #[test] + fn a_read_only_connection_refuses_everything_first() { + for (has_primary_key, has_columns) in [(true, true), (false, true), (true, false), (false, false)] { + assert_eq!( + edit_policy(true, has_primary_key, has_columns), + all(Err(EditRefusal::ReadOnly)), + "key {has_primary_key}, columns {has_columns}" + ); + } + } + + #[test] + fn a_table_without_a_primary_key_refuses_insert_and_duplicate_too() { + let policy = edit_policy(false, false, true); + + assert_eq!(policy, all(Err(EditRefusal::NoPrimaryKey))); + assert_eq!(policy.insert, Err(EditRefusal::NoPrimaryKey), "D1: the menu inserted"); + assert_eq!(policy.duplicate, Err(EditRefusal::NoPrimaryKey)); + } + + #[test] + fn a_table_whose_columns_are_unread_refuses_quietly() { + assert_eq!(edit_policy(false, true, false), all(Err(EditRefusal::NoColumns))); + assert_eq!(edit_policy(false, false, false), all(Err(EditRefusal::NoColumns))); + assert_eq!(EditRefusal::NoColumns.message(), None); + } + + #[test] + fn each_refusal_says_today_s_sentence() { + assert_eq!( + EditRefusal::ReadOnly.message().as_deref(), + Some("This connection is read-only.") + ); + assert_eq!( + EditRefusal::NoPrimaryKey.message().as_deref(), + Some("This table has no primary key, so editing is turned off.") + ); + } + + #[test] + fn the_selection_hint_names_delete_only_where_it_acts() { + assert_eq!(selection_hint(0, Ok(())), None); + assert_eq!(selection_hint(1, Ok(())), None); + assert_eq!( + selection_hint(3, Ok(())).as_deref(), + Some("3 selected · press Delete to remove") + ); + assert_eq!( + selection_hint(3, Err(EditRefusal::NoPrimaryKey)).as_deref(), + Some("3 selected"), + "D12: the hint offered a Delete that was off" + ); + } +} diff --git a/linux/crates/app/src/services/grid/find_matches.rs b/linux/crates/app/src/services/grid/find_matches.rs new file mode 100644 index 0000000000..aabac8d56b --- /dev/null +++ b/linux/crates/app/src/services/grid/find_matches.rs @@ -0,0 +1,162 @@ +use super::display_text::whole_text; +use super::{GridColumn, RowSnapshot}; + +const CANCEL_CHECK_ROWS: usize = 256; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct FindMatch { + pub row: usize, + /// The column's index in a row. + pub column: usize, +} + +/// Cells whose whole text holds `query`, ignoring case, row by row and +/// along each row in `order`, the positions in `columns` as displayed. +/// Stops early, with what it found, once `cancelled` says so. +pub fn find_matches( + query: &str, + rows: &[RowSnapshot], + columns: &[GridColumn], + order: &[usize], + cancelled: &dyn Fn() -> bool, +) -> Vec { + if query.is_empty() { + return Vec::new(); + } + let needle = query.to_lowercase(); + let shown: Vec<&GridColumn> = order.iter().filter_map(|position| columns.get(*position)).collect(); + let mut scratch = String::new(); + let mut matches = Vec::new(); + for (row_index, row) in rows.iter().enumerate() { + if row_index % CANCEL_CHECK_ROWS == 0 && cancelled() { + break; + } + for column in &shown { + let value = row.value(column.index); + if value.is_null() { + continue; + } + let text = whole_text(value, &column.column_type, &mut scratch); + if text.to_lowercase().contains(&needle) { + matches.push(FindMatch { + row: row_index, + column: column.index, + }); + } + } + } + matches +} + +#[cfg(test)] +mod tests { + use std::cell::Cell; + use std::sync::Arc; + + use tablepro_core::Value; + use tablepro_core::column::ColumnKind; + + use super::{FindMatch, find_matches}; + use crate::services::grid::test_columns::result_only; + use crate::services::grid::{GridColumn, RowSnapshot}; + + fn columns(count: usize) -> Vec { + (0..count) + .map(|index| GridColumn { + index, + ..result_only(&format!("c{index}"), ColumnKind::Other) + }) + .collect() + } + + fn row(values: &[Value]) -> RowSnapshot { + RowSnapshot::new(Arc::from(values.to_vec()), Vec::new()) + } + + fn text(value: &str) -> Value { + Value::Text(value.to_owned()) + } + + fn at(row: usize, column: usize) -> FindMatch { + FindMatch { row, column } + } + + /// The cell cuts a long value and notes what it left out; the search + /// reads the value itself. + #[test] + fn a_long_value_matches_past_the_cells_cut_and_never_on_its_note() { + let long = format!("{}needle", "x".repeat(2_000)); + let rows = [row(&[text(&long)]), row(&[text(&"y".repeat(2_000))])]; + + assert_eq!( + find_matches("needle", &rows, &columns(1), &[0], &|| false), + vec![at(0, 0)] + ); + assert!(find_matches("more bytes", &rows, &columns(1), &[0], &|| false).is_empty()); + } + + #[test] + fn matches_come_in_row_order_then_display_order() { + let rows = [ + row(&[text("apple"), text("Pineapple"), text("pear")]), + row(&[text("grape"), Value::Int(42), text("APPLE pie")]), + ]; + + let found = find_matches("apple", &rows, &columns(3), &[2, 0, 1], &|| false); + + assert_eq!(found, vec![at(0, 0), at(0, 1), at(1, 2)]); + assert_eq!( + find_matches("4", &rows, &columns(3), &[0, 1, 2], &|| false), + vec![at(1, 1)], + "a number matches as it is shown" + ); + } + + #[test] + fn null_and_an_empty_query_never_match() { + let rows = [row(&[Value::Null, text("null")])]; + + assert_eq!( + find_matches("null", &rows, &columns(2), &[0, 1], &|| false), + vec![at(0, 1)] + ); + assert_eq!(find_matches("", &rows, &columns(2), &[0, 1], &|| false), Vec::new()); + } + + #[test] + fn case_folds_past_ascii() { + let rows = [row(&[text("Ärger"), text("straße")])]; + + assert_eq!( + find_matches("äR", &rows, &columns(2), &[0, 1], &|| false), + vec![at(0, 0)] + ); + assert_eq!( + find_matches("STRAßE", &rows, &columns(2), &[0, 1], &|| false), + vec![at(0, 1)] + ); + } + + #[test] + fn a_hidden_column_is_not_searched() { + let rows = [row(&[text("x"), text("x")])]; + + assert_eq!(find_matches("x", &rows, &columns(2), &[1], &|| false), vec![at(0, 1)]); + } + + #[test] + fn a_cancel_stops_at_the_next_check_with_what_it_found() { + let rows: Vec = (0..1_000).map(|_| row(&[text("hit")])).collect(); + let checks = Cell::new(0); + let cancelled = || { + checks.set(checks.get() + 1); + checks.get() > 1 + }; + + let found = find_matches("hit", &rows, &columns(1), &[0], &cancelled); + + assert_eq!(found.len(), 256, "rows past the cancel were searched"); + assert_eq!(checks.get(), 2); + assert_eq!(find_matches("hit", &rows, &columns(1), &[0], &|| true), Vec::new()); + } +} diff --git a/linux/crates/app/src/services/grid/grid_column.rs b/linux/crates/app/src/services/grid/grid_column.rs new file mode 100644 index 0000000000..8fbd56da29 --- /dev/null +++ b/linux/crates/app/src/services/grid/grid_column.rs @@ -0,0 +1,190 @@ +use std::sync::Arc; + +use tablepro_core::ColumnInfo; +use tablepro_core::column::{ColumnKind, ColumnType, ResultColumn}; + +/// One column of a grid: the result column, plus the catalogue entry +/// when the host has one. +#[derive(Debug, Clone, PartialEq)] +pub struct GridColumn { + /// Where the column's value sits in a row. + pub index: usize, + pub name: String, + /// The type the result set gives the column, which a value is + /// written out in. + pub column_type: ColumnType, + pub catalog: Option, + /// Whether a filter, a sort or an update can name the column (ADR 0026). + pub addressable: bool, +} + +/// How a column's cells draw. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CellDisplay { + Text, + Number, + Bool, + Temporal, + Json, + Binary, +} + +impl GridColumn { + pub fn result_column(&self) -> ResultColumn { + ResultColumn::new(self.name.clone(), self.column_type.clone()) + } + + pub fn display(&self) -> CellDisplay { + match &self.column_type.kind { + ColumnKind::Bool(_) => CellDisplay::Bool, + ColumnKind::Integer(_) | ColumnKind::Float(_) | ColumnKind::Decimal { .. } | ColumnKind::Money { .. } => { + CellDisplay::Number + } + ColumnKind::Date | ColumnKind::Time { .. } | ColumnKind::Timestamp { .. } => CellDisplay::Temporal, + ColumnKind::Json { .. } => CellDisplay::Json, + ColumnKind::Bytes { .. } => CellDisplay::Binary, + ColumnKind::Text { .. } + | ColumnKind::Enum { .. } + | ColumnKind::Uuid + | ColumnKind::Interval + | ColumnKind::BitString { .. } + | ColumnKind::Network(_) + | ColumnKind::Xml + | ColumnKind::Spatial + | ColumnKind::Array { .. } + | ColumnKind::Variant + | ColumnKind::Composite + | ColumnKind::Other => CellDisplay::Text, + } + } + + /// The type a value is typed and parsed in: the catalogue's where the + /// grid has it, since that is what an UPDATE writes. + pub fn value_type(&self) -> &ColumnType { + self.catalog + .as_ref() + .map_or(&self.column_type, |catalog| &catalog.column_type) + } + + /// The server fills these in, so a new row shows a placeholder + /// rather than an empty cell. + pub fn auto_filled(&self) -> bool { + self.catalog + .as_ref() + .is_some_and(|catalog| catalog.is_auto_increment || catalog.is_generated) + } + + pub fn sortable(&self) -> bool { + self.addressable + } +} + +/// The grid's columns, each matched to the catalogue entry at its own +/// index, as the browse page reads them in catalogue order. +pub fn grid_columns( + result: &[ResultColumn], + catalog: &[ColumnInfo], + addressable: impl Fn(&str) -> bool, +) -> Arc<[GridColumn]> { + result + .iter() + .enumerate() + .map(|(index, column)| GridColumn { + index, + name: column.name.clone(), + column_type: column.column_type.clone(), + catalog: catalog.get(index).cloned(), + addressable: addressable(&column.name), + }) + .collect() +} + +#[cfg(test)] +mod tests { + use tablepro_core::column::{ + BoolStorage, ColumnKind, ColumnType, FloatWidth, IntegerKind, IntegerWidth, ResultColumn, TemporalZone, + TextLength, + }; + + use super::{CellDisplay, grid_columns}; + use crate::services::grid::test_columns::{grid, grid_of_kind, info, result_only}; + + fn display(kind: ColumnKind) -> CellDisplay { + result_only("c", kind).display() + } + + #[test] + fn display_follows_the_column_kind() { + let integer = ColumnKind::Integer(IntegerKind { + width: IntegerWidth::W64, + signed: true, + }); + + assert_eq!(display(integer), CellDisplay::Number); + assert_eq!(display(ColumnKind::Float(FloatWidth::F32)), CellDisplay::Number); + assert_eq!( + display(ColumnKind::Decimal { + precision: None, + scale: None + }), + CellDisplay::Number + ); + assert_eq!(display(ColumnKind::Money { scale: Some(2) }), CellDisplay::Number); + assert_eq!(display(ColumnKind::Bool(BoolStorage::Integer)), CellDisplay::Bool); + assert_eq!(display(ColumnKind::Date), CellDisplay::Temporal); + assert_eq!( + display(ColumnKind::Timestamp { + fractional_digits: None, + zone: TemporalZone::Offset + }), + CellDisplay::Temporal + ); + assert_eq!(display(ColumnKind::Json { binary: true }), CellDisplay::Json); + assert_eq!( + display(ColumnKind::Bytes { + length: TextLength::Unbounded + }), + CellDisplay::Binary + ); + assert_eq!(display(ColumnKind::Other), CellDisplay::Text); + assert_eq!(display(ColumnKind::Interval), CellDisplay::Text); + } + + #[test] + fn grid_columns_match_the_catalogue_by_index() { + let result = [ + ResultColumn::new("id", ColumnType::untyped_text("int")), + ResultColumn::new("a.b", ColumnType::untyped_text("text")), + ]; + let catalog = [info("id", "integer")]; + + let columns = grid_columns(&result, &catalog, |name| !name.contains('.')); + + assert_eq!(columns.len(), 2); + assert_eq!( + columns[0].catalog.as_ref().map(|column| column.name.as_str()), + Some("id") + ); + assert_eq!(columns[1].index, 1); + assert_eq!(columns[1].catalog, None, "a column past the catalogue"); + assert!(columns[0].sortable()); + assert!(!columns[1].addressable); + assert!(!columns[1].sortable(), "an unaddressable field sorted"); + assert_eq!(columns[1].result_column(), result[1]); + assert_eq!(columns[0].value_type(), &catalog[0].column_type); + assert_eq!(columns[1].value_type(), &result[1].column_type); + } + + #[test] + fn auto_filled_needs_a_catalogue_that_says_so() { + let mut serial = info("id", "integer"); + serial.is_auto_increment = true; + let mut total = info("total", "integer"); + total.is_generated = true; + + assert!(grid(serial).auto_filled()); + assert!(grid(total).auto_filled()); + assert!(!grid_of_kind("name", ColumnKind::Other).auto_filled()); + assert!(!result_only("id", ColumnKind::Other).auto_filled()); + } +} diff --git a/linux/crates/app/src/ui/grid/grid_row_id.rs b/linux/crates/app/src/services/grid/grid_row_id.rs similarity index 88% rename from linux/crates/app/src/ui/grid/grid_row_id.rs rename to linux/crates/app/src/services/grid/grid_row_id.rs index f7c10011c1..97718351c1 100644 --- a/linux/crates/app/src/ui/grid/grid_row_id.rs +++ b/linux/crates/app/src/services/grid/grid_row_id.rs @@ -1,8 +1,7 @@ use crate::services::change_tracker::RowKey; /// What a grid row is to the tab that tracks its edits. Fixed when the -/// row object is made, so an event that reaches the row after others -/// moved around it still names the same row. +/// row is made, so a late event still names the same row. #[derive(Debug, Clone, PartialEq, Eq)] pub enum GridRowId { /// An editor result row, which nothing tracks. @@ -14,8 +13,7 @@ pub enum GridRowId { } impl GridRowId { - /// The key of a persisted row, which is what an UPDATE or DELETE - /// names it by. + /// The key an UPDATE or DELETE names a saved row by. pub fn key(&self) -> Option<&RowKey> { match self { Self::Persisted(key) => Some(key), @@ -57,5 +55,7 @@ mod tests { assert_eq!(GridRowId::Persisted(key.clone()).key(), Some(&key)); assert_eq!(GridRowId::Draft(3).tracker_key(), Some(RowKey::Draft(3))); assert_eq!(GridRowId::Result.tracker_key(), None); + assert_eq!(GridRowId::Draft(3).draft_id(), Some(3)); + assert_eq!(GridRowId::Persisted(key).draft_id(), None); } } diff --git a/linux/crates/app/src/services/grid/mod.rs b/linux/crates/app/src/services/grid/mod.rs new file mode 100644 index 0000000000..9d40609789 --- /dev/null +++ b/linux/crates/app/src/services/grid/mod.rs @@ -0,0 +1,43 @@ +//! The grid's rules, with no GTK: what a column is, what a cell shows and +//! takes, and what the grid may change. + +mod cell_editability; +mod cell_editor_kind; +mod cell_filter; +mod cell_lock; +mod cell_preset; +mod column_layout; +mod column_width; +mod copy_scope; +mod delete_availability; +pub mod display_text; +mod edit_navigation; +mod edit_parse; +mod edit_policy; +mod find_matches; +mod grid_column; +mod grid_row_id; +mod row_key_builder; +mod row_snapshot; +#[cfg(test)] +mod test_columns; +pub mod value_order; + +pub use self::cell_editability::cell_editability; +pub use self::cell_editor_kind::{CellEditorKind, EDIT_MAX_BYTES, TextPurpose}; +pub use self::cell_filter::cell_filter_rule; +pub use self::cell_lock::CellLock; +pub use self::cell_preset::{CellPreset, CellPresets, cell_presets}; +pub use self::column_layout::{ColumnLayout, LayoutSlot, Placement, merge_order}; +pub use self::column_width::{default_width_chars, number_width_chars, widest_text}; +pub use self::copy_scope::{CopyScope, copy_scope}; +pub use self::delete_availability::{DeleteAvailability, delete_availability}; +pub use self::display_text::{auto_filled_sentinel, display_text}; +pub use self::edit_navigation::{EditDirection, next_edit_target}; +pub use self::edit_parse::{CommitResult, EditStart, commit_edit, commit_input, edit_context, empty_placeholder}; +pub use self::edit_policy::{EditPolicy, EditRefusal, edit_policy, selection_hint}; +pub use self::find_matches::{FindMatch, find_matches}; +pub use self::grid_column::{CellDisplay, GridColumn, grid_columns}; +pub use self::grid_row_id::GridRowId; +pub use self::row_key_builder::RowKeyBuilder; +pub use self::row_snapshot::RowSnapshot; diff --git a/linux/crates/app/src/ui/grid/row_key_builder.rs b/linux/crates/app/src/services/grid/row_key_builder.rs similarity index 100% rename from linux/crates/app/src/ui/grid/row_key_builder.rs rename to linux/crates/app/src/services/grid/row_key_builder.rs diff --git a/linux/crates/app/src/services/grid/row_snapshot.rs b/linux/crates/app/src/services/grid/row_snapshot.rs new file mode 100644 index 0000000000..bbd228220f --- /dev/null +++ b/linux/crates/app/src/services/grid/row_snapshot.rs @@ -0,0 +1,96 @@ +use std::sync::Arc; + +use tablepro_core::Value; + +static NULL: Value = Value::Null; + +/// A row as the grid shows it, pending edits included. It shares the +/// cells the page read and owns only the changed ones, so it can leave +/// the GTK thread cheaply. +#[derive(Debug, Clone)] +pub struct RowSnapshot { + cells: Arc<[Value]>, + /// Pending values by column, in column order. + overrides: Box<[(usize, Value)]>, +} + +impl RowSnapshot { + pub fn new(cells: Arc<[Value]>, mut overrides: Vec<(usize, Value)>) -> Self { + // `value` looks overrides up by binary search. + overrides.sort_by_key(|(column, _)| *column); + Self { + cells, + overrides: overrides.into_boxed_slice(), + } + } + + /// NULL for a column past the row's end. + pub fn value(&self, column: usize) -> &Value { + match self.overrides.binary_search_by_key(&column, |(col, _)| *col) { + Ok(index) => &self.overrides[index].1, + Err(_) => self.cells.get(column).unwrap_or(&NULL), + } + } + + pub fn len(&self) -> usize { + self.cells.len() + } + + pub fn is_empty(&self) -> bool { + self.cells.is_empty() + } + + pub fn values(&self) -> impl Iterator { + (0..self.len()).map(|column| self.value(column)) + } + + pub fn to_values(&self) -> Vec { + self.values().cloned().collect() + } + + #[cfg(test)] + pub(crate) fn shares_cells_with(&self, cells: &Arc<[Value]>) -> bool { + Arc::ptr_eq(&self.cells, cells) + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use tablepro_core::Value; + + use super::RowSnapshot; + + #[test] + fn row_snapshot_shares_unmodified_cells() { + let cells: Arc<[Value]> = vec![Value::Int(1), Value::Text("Ada".into()), Value::Text("old".into())].into(); + + let snapshot = RowSnapshot::new(cells.clone(), vec![(2, Value::Text("new".into()))]); + + assert!(snapshot.shares_cells_with(&cells), "the row's cells were copied"); + assert_eq!(snapshot.overrides.len(), 1, "an unedited cell was copied"); + assert_eq!( + snapshot.to_values(), + vec![Value::Int(1), Value::Text("Ada".into()), Value::Text("new".into())] + ); + assert_eq!(snapshot.value(9), &Value::Null); + assert_eq!(snapshot.len(), 3); + } + + #[test] + fn overrides_given_out_of_order_still_apply() { + let cells: Arc<[Value]> = vec![Value::Int(1), Value::Int(2), Value::Int(3)].into(); + + let snapshot = RowSnapshot::new(cells, vec![(2, Value::Int(30)), (0, Value::Null)]); + + assert_eq!(snapshot.to_values(), vec![Value::Null, Value::Int(2), Value::Int(30)]); + } + + #[test] + fn a_snapshot_can_leave_the_gtk_thread() { + fn sendable() {} + + sendable::(); + } +} diff --git a/linux/crates/app/src/services/grid/test_columns.rs b/linux/crates/app/src/services/grid/test_columns.rs new file mode 100644 index 0000000000..ac0468c8b4 --- /dev/null +++ b/linux/crates/app/src/services/grid/test_columns.rs @@ -0,0 +1,51 @@ +use tablepro_core::column::{ColumnDefault, ColumnKind, ColumnType, classify_type_name}; +use tablepro_core::{ColumnInfo, Value}; + +use super::{GridColumn, GridRowId}; +use crate::services::change_tracker::RowKey; + +pub fn info(name: &str, data_type: &str) -> ColumnInfo { + info_of_kind(name, classify_type_name(data_type)) +} + +pub fn info_of_kind(name: &str, kind: ColumnKind) -> ColumnInfo { + ColumnInfo { + name: name.to_owned(), + column_type: ColumnType { + kind, + ..ColumnType::untyped_text("t") + }, + nullable: true, + primary_key: false, + is_auto_increment: false, + is_generated: false, + partition_scoped: false, + default: ColumnDefault::None, + comment: None, + } +} + +pub fn grid(info: ColumnInfo) -> GridColumn { + GridColumn { + index: 0, + name: info.name.clone(), + column_type: info.column_type.clone(), + catalog: Some(info), + addressable: true, + } +} + +pub fn grid_of_kind(name: &str, kind: ColumnKind) -> GridColumn { + grid(info_of_kind(name, kind)) +} + +pub fn result_only(name: &str, kind: ColumnKind) -> GridColumn { + GridColumn { + catalog: None, + ..grid_of_kind(name, kind) + } +} + +pub fn persisted() -> GridRowId { + GridRowId::Persisted(RowKey::from_pk_values(&[Value::Int(1)]).expect("a key")) +} diff --git a/linux/crates/app/src/ui/grid/value_order.rs b/linux/crates/app/src/services/grid/value_order.rs similarity index 98% rename from linux/crates/app/src/ui/grid/value_order.rs rename to linux/crates/app/src/services/grid/value_order.rs index 0fc932aaef..5d57d05af6 100644 --- a/linux/crates/app/src/ui/grid/value_order.rs +++ b/linux/crates/app/src/services/grid/value_order.rs @@ -13,7 +13,7 @@ use tablepro_core::value::{SqlDecimal, SqlInterval, Temporal, TimeWithOffset}; /// would on a number line. Each value is ordered by what it holds /// rather than by its column's declared type, since a SQLite column /// can hold any kind. -pub(crate) fn compare(a: &Value, b: &Value) -> Ordering { +pub fn compare(a: &Value, b: &Value) -> Ordering { match (a, b) { (Value::Null, Value::Null) => Ordering::Equal, (Value::Null, _) => Ordering::Less, @@ -29,7 +29,7 @@ pub(crate) fn compare(a: &Value, b: &Value) -> Ordering { /// value, then text, then blobs, then the rest as `compare` orders /// them. Two texts are `Equal` here, so the collating sorter after this /// one orders them. -pub(crate) fn storage_class_order(a: &Value, b: &Value) -> Ordering { +pub fn storage_class_order(a: &Value, b: &Value) -> Ordering { let class = storage_class(a); match class.cmp(&storage_class(b)) { Ordering::Equal => match class { @@ -43,7 +43,7 @@ pub(crate) fn storage_class_order(a: &Value, b: &Value) -> Ordering { /// The text a collating sorter orders `value` by. `None` for a value /// that is not text, which some other sorter has already placed. -pub(crate) fn sort_text(value: &Value) -> Option<&str> { +pub fn sort_text(value: &Value) -> Option<&str> { let text = match value { Value::Text(text) => text, Value::Json(json) => json.as_str(), diff --git a/linux/crates/app/src/services/mod.rs b/linux/crates/app/src/services/mod.rs index 9db04c2df4..2673053ec2 100644 --- a/linux/crates/app/src/services/mod.rs +++ b/linux/crates/app/src/services/mod.rs @@ -13,6 +13,7 @@ pub mod duplicate_name; pub mod file_location; mod file_location_kind; pub mod file_resolution; +pub mod grid; pub mod history_availability; pub mod history_preferences; pub mod history_service; diff --git a/linux/crates/app/src/services/tab_tracker_handle.rs b/linux/crates/app/src/services/tab_tracker_handle.rs index cc7b034db5..3a82b7b5f6 100644 --- a/linux/crates/app/src/services/tab_tracker_handle.rs +++ b/linux/crates/app/src/services/tab_tracker_handle.rs @@ -6,7 +6,7 @@ use tablepro_core::edit::CellInput; use tablepro_core::{ColumnInfo, Value}; use super::cell_overlay::{CellMarks, CellOverlay}; -use super::change_tracker::{DraftRow, ErrorFlash, RowKey, RowState, TabChangeTracker, TrackedChanges}; +use super::change_tracker::{DraftRow, RowKey, RowState, TabChangeTracker, TrackedChanges}; use super::save_batch::{SaveBatch, SaveTarget}; use super::tracker_delta::TrackerDelta; use super::tracker_edit::TrackerEdit; @@ -40,6 +40,10 @@ impl TabTrackerHandle { self.0.borrow_mut().apply(edit) } + pub fn cell_is_saving(&self, row_key: &RowKey, col: usize) -> bool { + self.0.borrow().cell_is_saving(row_key, col) + } + /// Whether `apply` would hold the edit back for the running save. pub fn refuses(&self, edit: &TrackerEdit) -> bool { self.0.borrow().edit_is_in_flight(edit) @@ -96,12 +100,9 @@ impl TabTrackerHandle { self.0.borrow_mut().discard() } - pub fn flash_error_row(&self, row_key: RowKey) -> ErrorFlash { - self.0.borrow_mut().flash_error_row(row_key) - } - - pub fn clear_error_row_if(&self, generation: u64) -> bool { - self.0.borrow_mut().clear_error_row_if(generation) + #[must_use = "the grid shows stale rows until the delta is applied"] + pub fn mark_error_row(&self, row_key: RowKey) -> TrackerDelta { + self.0.borrow_mut().mark_error_row(row_key) } pub fn is_error_row(&self, row_key: &RowKey) -> bool { @@ -189,6 +190,19 @@ mod tests { SaveOutcome::RolledBack(RollbackCause::Driver(DriverError::server("no"))) } + #[test] + fn a_cell_the_save_writes_is_saving_until_it_settles() { + let tracker = edited(); + assert!(!tracker.cell_is_saving(&row(1), 1)); + let batch = tracker.begin_save().expect("a save").expect("changes to save"); + + assert!(tracker.cell_is_saving(&row(1), 1)); + assert!(!tracker.cell_is_saving(&row(1), 0), "a cell the save leaves alone"); + assert!(!tracker.cell_is_saving(&row(4), 1), "a row the save leaves alone"); + let _ = tracker.settle_save(&SaveOutcome::Committed { rows: Some(1) }, batch.snapshot); + assert!(!tracker.cell_is_saving(&row(1), 1)); + } + #[test] fn frozen_tracker_rejects_begin_save() { let tracker = edited(); diff --git a/linux/crates/app/src/test_support/key_injection_error.rs b/linux/crates/app/src/test_support/key_injection_error.rs index 005f7238a7..1bef1ff368 100644 --- a/linux/crates/app/src/test_support/key_injection_error.rs +++ b/linux/crates/app/src/test_support/key_injection_error.rs @@ -8,6 +8,8 @@ pub(crate) enum KeyInjectionError { Reply(#[from] x11rb::errors::ReplyError), #[error("no keycode carries keysym {0:#x}")] KeysymNotMapped(u32), + #[error("the widget is not on screen")] + NotShown, /// XTEST reaches GDK only through an X11 display; set GDK_BACKEND=x11. #[error("the display is {0}, not X11")] NotX11(String), diff --git a/linux/crates/app/src/test_support/key_injector.rs b/linux/crates/app/src/test_support/key_injector.rs index b5e74cfb65..b068cee045 100644 --- a/linux/crates/app/src/test_support/key_injector.rs +++ b/linux/crates/app/src/test_support/key_injector.rs @@ -1,8 +1,11 @@ -use gtk4::gdk; use gtk4::glib::translate::IntoGlib; use gtk4::prelude::*; +use gtk4::{gdk, graphene}; use x11rb::connection::Connection; -use x11rb::protocol::xproto::{ConnectionExt as _, KEY_PRESS_EVENT, KEY_RELEASE_EVENT}; +use x11rb::protocol::xproto::{ + BUTTON_PRESS_EVENT, BUTTON_RELEASE_EVENT, ConnectionExt as _, KEY_PRESS_EVENT, KEY_RELEASE_EVENT, + MOTION_NOTIFY_EVENT, +}; use x11rb::protocol::xtest::ConnectionExt as _; use x11rb::rust_connection::RustConnection; @@ -10,9 +13,9 @@ use super::KeyInjectionError; const X11_DISPLAY: &str = "GdkX11Display"; -/// Presses keys through XTEST, which the X server delivers as it does a -/// keyboard's, so the press meets every capture, class and bubble -/// handler in the order GTK runs them. GDK focuses a presented window +/// Presses keys and clicks through XTEST, which the X server delivers as it +/// does a keyboard's and a mouse's, so the event meets every capture, class +/// and bubble handler in the order GTK runs them. GDK focuses a presented window /// itself when no window manager runs, as under Xvfb. pub(crate) struct KeyInjector { connection: RustConnection, @@ -69,6 +72,48 @@ impl KeyInjector { Ok(()) } + /// Click the primary button `count` times at `at` in `widget`'s own + /// coordinates, in the window that has the keyboard. A widget not laid + /// out yet sits at the window's corner, so it is refused. + pub(crate) fn click( + &self, + widget: &impl IsA, + at: (f32, f32), + count: u8, + ) -> Result<(), KeyInjectionError> { + let widget = widget.as_ref(); + if !widget.is_mapped() || widget.width() <= 0 { + return Err(KeyInjectionError::NotShown); + } + let native = widget.native().ok_or(KeyInjectionError::NotShown)?; + let point = widget + .compute_point(&native, &graphene::Point::new(at.0, at.1)) + .ok_or(KeyInjectionError::NotShown)?; + let (left, top) = native.surface_transform(); + let scale = f64::from(native.surface().map_or(1, |surface| surface.scale_factor())); + let window = self.connection.get_input_focus()?.reply()?.focus; + let root = self + .connection + .setup() + .roots + .first() + .map(|screen| screen.root) + .ok_or(KeyInjectionError::NotShown)?; + let origin = self.connection.translate_coordinates(window, root, 0, 0)?.reply()?; + let on_root = |origin: i16, logical: f64| origin.saturating_add((logical * scale).round() as i16); + let x = on_root(origin.dst_x, f64::from(point.x()) + left); + let y = on_root(origin.dst_y, f64::from(point.y()) + top); + self.connection + .xtest_fake_input(MOTION_NOTIFY_EVENT, 0, x11rb::CURRENT_TIME, root, x, y, 0)?; + for _ in 0..count { + self.fake(BUTTON_PRESS_EVENT, 1)?; + self.fake(BUTTON_RELEASE_EVENT, 1)?; + } + self.connection.flush()?; + self.connection.get_input_focus()?.reply()?; + Ok(()) + } + /// The keycode whose unshifted keysym is `keyval`, or failing that /// one that carries it at another level. fn keycode(&self, keyval: gdk::Key) -> Result { diff --git a/linux/crates/app/src/test_support/shortcut_keys.rs b/linux/crates/app/src/test_support/shortcut_keys.rs index a37570e2d1..940d8287c7 100644 --- a/linux/crates/app/src/test_support/shortcut_keys.rs +++ b/linux/crates/app/src/test_support/shortcut_keys.rs @@ -5,7 +5,7 @@ use gtk4::gdk; use gtk4::prelude::*; use super::descendants; -use crate::application::{ACCELERATORS, AcceleratorScope, ShortcutOwner, keypad_twins}; +use crate::application::{ShortcutOwner, keypad_twins}; /// A key with its modifiers, keyval lowered as GtkKeyvalTrigger stores it. pub(crate) type Keystroke = (gdk::Key, gdk::ModifierType); @@ -108,17 +108,3 @@ pub(crate) fn class_keys_on_focus_paths( .flat_map(class_binding_keys) .collect() } - -/// Every key the table gives `owner`, with keypad twins for the ones it -/// installs. -pub(crate) fn table_keys(owner: ShortcutOwner) -> HashSet { - ACCELERATORS - .iter() - .filter(|entry| match entry.scope { - AcceleratorScope::Local { owner: of, .. } | AcceleratorScope::Component(of) => of == owner, - AcceleratorScope::Application | AcceleratorScope::Stock => false, - }) - .flat_map(|entry| entry.triggers.iter()) - .flat_map(|accel| keystrokes_with_twins(accel)) - .collect() -} diff --git a/linux/crates/app/src/ui/a11y/icon_button.rs b/linux/crates/app/src/ui/a11y/icon_button.rs index c8920f6116..e97ffb450a 100644 --- a/linux/crates/app/src/ui/a11y/icon_button.rs +++ b/linux/crates/app/src/ui/a11y/icon_button.rs @@ -34,7 +34,7 @@ mod tests { fn icon_button_gets_label_tooltip_and_its_actions_keys() { let button = gtk::Button::from_icon_name("list-add-symbolic"); - describe_icon_button(&button, "Insert Row", Some("browse.insert-row")); + describe_icon_button(&button, "Insert Row", Some("grid.insert-row")); assert_eq!(button.tooltip_text().as_deref(), Some("Insert Row")); assert!(gtk::test_accessible_has_property(&button, AccessibleProperty::Label)); diff --git a/linux/crates/app/src/ui/a11y/tests.rs b/linux/crates/app/src/ui/a11y/tests.rs index 0931f56dd9..42809ff296 100644 --- a/linux/crates/app/src/ui/a11y/tests.rs +++ b/linux/crates/app/src/ui/a11y/tests.rs @@ -7,7 +7,7 @@ use crate::test_support::{ ButtonFace, button_face, descendants, has_accessible_label, test_backend_records_properties, }; use crate::ui::{ - PreferencesDialog, SessionWindow, browse_tab, connect_dialog, diagram, editor, export, filter_strip, + PreferencesDialog, SessionWindow, browse_tab, connect_dialog, data_grid, diagram, editor, export, filter_strip, history_dialog, preferences_dialog, session_window, ssh_section, structure_tab, welcome_view, workspace, }; @@ -20,6 +20,9 @@ fn template_classes() -> Vec { browse_tab::PagerBar::static_type(), browse_tab::PendingChangesBar::static_type(), connect_dialog::ConnectDialog::static_type(), + data_grid::ColumnChooser::static_type(), + data_grid::DataGrid::static_type(), + data_grid::RowDetailPane::static_type(), diagram::DiagramTab::static_type(), editor::SqlEditor::static_type(), export::ExportOperationsButton::static_type(), diff --git a/linux/crates/app/src/ui/browse_tab/browse_actions.rs b/linux/crates/app/src/ui/browse_tab/browse_actions.rs index a63bc9df60..c51a908c00 100644 --- a/linux/crates/app/src/ui/browse_tab/browse_actions.rs +++ b/linux/crates/app/src/ui/browse_tab/browse_actions.rs @@ -1,11 +1,8 @@ -use gtk4 as gtk; use gtk4::prelude::*; use gtk4::subclass::prelude::*; use gtk4::{gio, glib}; -use super::{ - BrowseModel, BrowseTab, BrowseTabInput, ClipboardInput, EditInput, PagingInput, SaveInput, SelectionInput, -}; +use super::{BrowseTab, BrowseTabInput, GridInput, PagingInput, SaveInput}; use crate::application::{ShortcutOwner, local_shortcut_controller}; use crate::i18n::gettext; use crate::services::tab_tracker_handle::TabTrackerHandle; @@ -14,16 +11,13 @@ use crate::ui::a11y::{announce_key_shortcuts, describe_icon_button}; const ACTION_GROUP: &str = "browse"; /// The `browse` actions whose state follows the tab. A disabled one lets -/// its key go on, and greys out the button that names it. +/// its key go on, and greys out the button that names it. Rows are the +/// grid's: its `grid` group sits on the tab beside this one. #[derive(Clone)] pub(super) struct BrowseActions { pub(super) save: gio::SimpleAction, pub(super) undo: gio::SimpleAction, pub(super) redo: gio::SimpleAction, - pub(super) insert_row: gio::SimpleAction, - pub(super) delete_rows: gio::SimpleAction, - pub(super) copy_rows: gio::SimpleAction, - pub(super) clear_selection: gio::SimpleAction, /// The More menu's Count Rows, on where the pager offers it. pub(super) count_rows: gio::SimpleAction, /// The More menu's page sizes, its state the size the tab reads. @@ -31,22 +25,18 @@ pub(super) struct BrowseActions { } impl BrowseActions { - pub(super) fn follow_tracker(&self, tracker: &TabTrackerHandle, saving: bool) { - self.save.set_enabled(!saving && tracker.pending_count() > 0); + /// Save stays on while an edit is open, so Ctrl+S reaches a first + /// value still in its editor. + pub(super) fn follow_tracker(&self, tracker: &TabTrackerHandle, saving: bool, editing: bool) { + self.save + .set_enabled(!saving && (editing || tracker.pending_count() > 0)); self.undo.set_enabled(tracker.can_undo()); self.redo.set_enabled(tracker.can_redo()); } - - pub(super) fn follow_selection(&self, selected: u32) { - self.copy_rows.set_enabled(selected > 0); - // Clearing a single focused row strands the focus indicator. - self.clear_selection.set_enabled(selected > 1); - } } /// The tab's `browse` group, the tab's own keys for it and the keys its -/// buttons announce. The grid's keys go on each column view the tab -/// builds, through `install_grid_shortcuts`. +/// buttons announce. The grid's keys are its own. pub(super) fn install_browse_actions(tab: &BrowseTab, page_size: u64) -> BrowseActions { let group = gio::SimpleActionGroup::new(); let add = |name: &str, input: fn() -> BrowseTabInput| { @@ -62,7 +52,7 @@ pub(super) fn install_browse_actions(tab: &BrowseTab, page_size: u64) -> BrowseA }; add("filter", || PagingInput::ShowFilterStrip.into()); add("toggle-filter", || PagingInput::ToggleFilterStrip.into()); - add("export", || ClipboardInput::Export(None).into()); + add("export", || GridInput::Export.into()); let page_size = gio::SimpleAction::new_stateful("page-size", Some(glib::VariantTy::UINT64), &page_size.to_variant()); let weak = tab.downgrade(); @@ -73,50 +63,17 @@ pub(super) fn install_browse_actions(tab: &BrowseTab, page_size: u64) -> BrowseA }); group.add_action(&page_size); add("refresh", || PagingInput::RequestRefresh.into()); - let paste_rows = gio::SimpleAction::new("paste-rows", None); - let weak = tab.downgrade(); - paste_rows.connect_activate(move |_, _| { - let Some(tab) = weak.upgrade() else { - return; - }; - let at = tab.read(BrowseModel::focused_cell).flatten(); - tab.send(ClipboardInput::Paste(at)); - }); - group.add_action(&paste_rows); let previous = add("previous-page", || PagingInput::PrevPage.into()); let next = add("next-page", || PagingInput::NextPage.into()); tab.imp().pager_bar.follow_page_actions(ACTION_GROUP, &previous, &next); - let delete_rows = gio::SimpleAction::new("delete-rows", None); - let weak = tab.downgrade(); - delete_rows.connect_activate(move |_, _| { - let Some(tab) = weak.upgrade() else { - return; - }; - let rows = tab.read(BrowseModel::selected_rows).unwrap_or_default(); - tab.send(EditInput::DeleteRows(rows)); - }); - group.add_action(&delete_rows); let actions = BrowseActions { save: add("save", || SaveInput::CommitSave.into()), - undo: add("undo", || EditInput::Undo.into()), - redo: add("redo", || EditInput::Redo.into()), - insert_row: add("insert-row", || EditInput::InsertRow.into()), - delete_rows, - copy_rows: add("copy-rows", || ClipboardInput::CopySelectedRowsAsTsv.into()), - clear_selection: add("clear-selection", || SelectionInput::ClearSelection.into()), + undo: add("undo", || GridInput::Undo.into()), + redo: add("redo", || GridInput::Redo.into()), count_rows: add("count-rows", || PagingInput::CountRows.into()), page_size, }; - for action in [ - &actions.save, - &actions.undo, - &actions.redo, - &actions.insert_row, - &actions.delete_rows, - &actions.copy_rows, - &actions.clear_selection, - &actions.count_rows, - ] { + for action in [&actions.save, &actions.undo, &actions.redo, &actions.count_rows] { action.set_enabled(false); } tab.imp().pending_bar.announce_save_keys(ACTION_GROUP, &actions.save); @@ -131,19 +88,16 @@ pub(super) fn install_browse_actions(tab: &BrowseTab, page_size: u64) -> BrowseA pub(super) fn describe_buttons(tab: &BrowseTab) { let imp = tab.imp(); for (button, label, action) in [ - (&imp.insert_button, gettext("Insert Row"), Some("browse.insert-row")), + (&imp.insert_button, gettext("Insert Row"), Some("grid.insert-row")), ( &imp.delete_button, gettext("Delete Selected Rows"), - Some("browse.delete-rows"), + Some("grid.delete-rows"), ), (&imp.export_button, gettext("Export Rows…"), None), ] { describe_icon_button(&button.get(), &label, action); } announce_key_shortcuts(&imp.filter_button.get(), "browse.filter"); -} - -pub(super) fn install_grid_shortcuts(view: >k::ColumnView) { - view.add_controller(local_shortcut_controller(ShortcutOwner::BrowseGrid)); + announce_key_shortcuts(&imp.details_toggle.get(), "grid.show-details"); } diff --git a/linux/crates/app/src/ui/browse_tab/browse_command.rs b/linux/crates/app/src/ui/browse_tab/browse_command.rs index 3f1a0dbcd2..0fd254bc64 100644 --- a/linux/crates/app/src/ui/browse_tab/browse_command.rs +++ b/linux/crates/app/src/ui/browse_tab/browse_command.rs @@ -6,7 +6,7 @@ use tablepro_session::OperationError; use super::page_reply::PageReply; use crate::runtime::Stamped; -use crate::ui::grid::TableKeys; +use crate::ui::data_grid::TableKeys; use crate::ui::tab_load::LoadFailure; /// The table's columns from the session's catalog, and how its rows are diff --git a/linux/crates/app/src/ui/browse_tab/browse_tab_input.rs b/linux/crates/app/src/ui/browse_tab/browse_tab_input.rs index 69a63a1176..3205f86421 100644 --- a/linux/crates/app/src/ui/browse_tab/browse_tab_input.rs +++ b/linux/crates/app/src/ui/browse_tab/browse_tab_input.rs @@ -1,11 +1,7 @@ -use super::clipboard_input::ClipboardInput; use super::data_input::DataInput; -use super::edit_input::EditInput; +use super::grid_input::GridInput; use super::paging_input::PagingInput; -use super::paste_at::PasteAt; use super::save_input::SaveInput; -use super::selection_input::SelectionInput; -use crate::ui::grid::GridMsg; /// An input to the tab, grouped by the state it changes, each group /// handled on its own. @@ -13,10 +9,8 @@ use crate::ui::grid::GridMsg; pub(crate) enum BrowseTabInput { Data(DataInput), Paging(PagingInput), - Edit(EditInput), Save(SaveInput), - Selection(SelectionInput), - Clipboard(ClipboardInput), + Grid(GridInput), } impl From for BrowseTabInput { @@ -31,48 +25,14 @@ impl From for BrowseTabInput { } } -impl From for BrowseTabInput { - fn from(input: EditInput) -> Self { - Self::Edit(input) - } -} - impl From for BrowseTabInput { fn from(input: SaveInput) -> Self { Self::Save(input) } } -impl From for BrowseTabInput { - fn from(input: SelectionInput) -> Self { - Self::Selection(input) - } -} - -impl From for BrowseTabInput { - fn from(input: ClipboardInput) -> Self { - Self::Clipboard(input) - } -} - -impl From for BrowseTabInput { - fn from(msg: GridMsg) -> Self { - match msg { - GridMsg::SortChanged(col_idx, ascending) => PagingInput::SortChanged { col_idx, ascending }.into(), - GridMsg::SortCleared => PagingInput::SortCleared.into(), - GridMsg::Edit { row, column, edit } => EditInput::GridEdit { row, column, edit }.into(), - GridMsg::Copy(request) => ClipboardInput::GridCopy(request).into(), - GridMsg::ShowRowJson(request) => ClipboardInput::GridShowRowJson(request).into(), - GridMsg::ShowToast(text) => ClipboardInput::GridShowToast(text).into(), - GridMsg::CopyRowsAsInsert { rows } => ClipboardInput::GridCopyRowsAsInsert(rows).into(), - GridMsg::CopyInClause(values) => ClipboardInput::GridCopyInClause(values).into(), - GridMsg::SetCellValue { row, column, preset } => EditInput::GridSetCellValue { row, column, preset }.into(), - GridMsg::ExportResults(page) => ClipboardInput::Export(Some(page)).into(), - GridMsg::DeleteRows { rows } => EditInput::DeleteRows(rows).into(), - GridMsg::InsertRow => EditInput::InsertRow.into(), - GridMsg::DuplicateRows { rows } => EditInput::DuplicateRows(rows).into(), - GridMsg::Paste { at } => ClipboardInput::Paste(at.map(|(row, column)| PasteAt { row, column })).into(), - GridMsg::FollowReference(reference) => ClipboardInput::GridFollowReference(reference).into(), - } +impl From for BrowseTabInput { + fn from(input: GridInput) -> Self { + Self::Grid(input) } } diff --git a/linux/crates/app/src/ui/browse_tab/browse_tab_widgets.rs b/linux/crates/app/src/ui/browse_tab/browse_tab_widgets.rs index 808d02d6c1..6c82634d8f 100644 --- a/linux/crates/app/src/ui/browse_tab/browse_tab_widgets.rs +++ b/linux/crates/app/src/ui/browse_tab/browse_tab_widgets.rs @@ -7,7 +7,6 @@ use crate::ui::tab_load::StaleBanner; pub(super) struct BrowseTabWidgets { pub(super) inner_stack: gtk::Stack, - pub(super) grid_holder: gtk::Box, pub(super) loading_page: adw::StatusPage, pub(super) error_page: adw::StatusPage, pub(super) stale_banner: StaleBanner, diff --git a/linux/crates/app/src/ui/browse_tab/clipboard_handlers.rs b/linux/crates/app/src/ui/browse_tab/clipboard_handlers.rs deleted file mode 100644 index dfbdabe5d5..0000000000 --- a/linux/crates/app/src/ui/browse_tab/clipboard_handlers.rs +++ /dev/null @@ -1,154 +0,0 @@ -use gtk4::prelude::*; - -use super::{BrowseModel, BrowseTabOutput, ClipboardInput, Effects, qualified}; -use crate::ui::clipboard::{CopyKind, CopyRequest, InsertCopy, InsertRow}; -use crate::ui::export::{ExportOffer, ExportOrigin, ExportQuery, ExportSource}; -use crate::ui::grid::{GridRowId, RowObject, RowSnapshot, export_snapshot}; - -impl BrowseModel { - pub(super) fn on_clipboard(&mut self, input: ClipboardInput, fx: &mut Effects) { - match input { - ClipboardInput::Export(page) => self.offer_export(page, fx), - ClipboardInput::GridCopy(request) => fx.output(BrowseTabOutput::Copy(request)), - ClipboardInput::GridCopyRowsAsInsert(rows) => { - if !self.grid_is_stale() { - self.copy_rows_as_insert(&rows, fx); - } - } - ClipboardInput::GridCopyInClause(values) => match self.session.current_dialect() { - Some(dialect) => fx.output(BrowseTabOutput::Copy(CopyRequest::InClause { dialect, values })), - None => fx.output(BrowseTabOutput::ShowToast(crate::i18n::gettext( - "Connect to a database first.", - ))), - }, - ClipboardInput::CopySelectedRowsAsTsv => self.copy_selected_rows(fx), - ClipboardInput::Paste(at) => self.paste(at, fx), - ClipboardInput::PasteRead(read) => self.paste_read(*read, fx), - ClipboardInput::PasteAnswered { ticket, confirmed } => self.paste_answered(ticket, confirmed, fx), - ClipboardInput::GridShowRowJson(request) => fx.output(BrowseTabOutput::ShowRowJson(request)), - ClipboardInput::GridShowToast(message) => fx.output(BrowseTabOutput::ShowToast(message)), - ClipboardInput::GridFollowReference(reference) => fx.output(BrowseTabOutput::FollowReference(reference)), - } - } - - /// The table a browse tab's rows came from, so an export can - /// write them back as `INSERT` statements. - fn sql_target(&self) -> crate::ui::export::dialog::SqlTarget { - crate::ui::export::dialog::SqlTarget { - table: self.table_ref(), - generated: self - .current_columns - .iter() - .filter(|column| column.is_generated) - .map(|column| column.name.clone()) - .collect(), - } - } - - fn export_name(&self) -> String { - qualified(self.schema.as_deref(), &self.table) - } - - /// The page the paginator's Export button writes: the grid's rows, - /// so it matches the context menu's Export row for row. - fn export_payload(&self) -> Option { - let page = self.shown_page()?; - let rows = self - .current_column_view - .as_ref() - .map(|column_view| export_snapshot(column_view, &self.grid_context())) - .unwrap_or_default(); - Some(ExportSource::Rows { - columns: page.result_columns.clone(), - rows, - }) - } - - /// Ask the window to export the page, or every row the filter - /// selects. Rows behind an error page are not what the table holds - /// under the current filter, so they are never offered. - fn offer_export(&self, page: Option, fx: &mut Effects) { - let page = match (self.shown_page(), page) { - (Some(_), Some(page)) => Some(page), - (Some(_), None) => self.export_payload(), - (None, _) => None, - }; - let (Some(page), Some(table)) = (page, self.table_export()) else { - fx.output(BrowseTabOutput::ShowToast(crate::i18n::gettext("Nothing to export"))); - return; - }; - fx.output(BrowseTabOutput::Export(Box::new(ExportOffer { - origin: ExportOrigin::Browse { - page, - query: ExportQuery::Table(table), - total: self.total.clone(), - has_unsaved_changes: self.tracker.pending_count() > 0, - }, - session: Some(self.session.clone()), - name: self.export_name(), - target: Some(self.sql_target()), - }))); - } - - /// Each row as an INSERT for the server the tab is connected to, - /// pending edits included. A draft's is the INSERT its save would - /// run, so the columns the server fills stay out of it. - fn copy_rows_as_insert(&self, rows: &[RowObject], fx: &mut Effects) { - let Some(dialect) = self.session.current_dialect() else { - fx.output(BrowseTabOutput::ShowToast(crate::i18n::gettext( - "Connect to a database first.", - ))); - return; - }; - fx.output(BrowseTabOutput::Copy(CopyRequest::Insert(InsertCopy { - dialect, - table: self.table_ref(), - columns: self.current_columns.iter().cloned().collect(), - rows: self.insert_rows(rows), - }))); - } - - /// The rows an INSERT copy renders off the GTK thread. A saved row - /// shares the page's cells; only a draft's are copied here. - pub(super) fn insert_rows(&self, rows: &[RowObject]) -> Vec { - let ctx = self.grid_context(); - rows.iter() - .map(|row| match row.id() { - GridRowId::Draft(draft_id) => InsertRow::Draft( - self.tracker - .draft_cells(*draft_id, &self.current_columns) - .unwrap_or_default(), - ), - _ => InsertRow::Saved(ctx.snapshot(row)), - }) - .collect() - } - - /// Same request as the context menu's Copy As > Rows: one selection - /// cannot produce two different clipboard payloads. - fn copy_selected_rows(&self, fx: &mut Effects) { - let (Some(selection), Some(_)) = (self.current_selection.as_ref(), self.shown_page()) else { - return; - }; - let ctx = self.grid_context(); - let rows: Vec = crate::ui::grid::selected_positions(selection) - .iter() - .filter_map(|position| selection.item(*position).and_downcast::()) - .map(|row| ctx.snapshot(&row)) - .collect(); - if rows.is_empty() { - return; - } - fx.output(BrowseTabOutput::Copy(CopyRequest::Rows { - columns: result_columns(&self.current_columns), - rows, - kind: CopyKind::Rows, - })); - } -} - -/// The result-column view of a catalogue column list, for a result the -/// grid renders from the schema it already has. -fn result_columns(columns: &[tablepro_core::ColumnInfo]) -> std::sync::Arc<[tablepro_core::column::ResultColumn]> { - columns.iter().map(|column| column.result_column()).collect() -} diff --git a/linux/crates/app/src/ui/browse_tab/clipboard_input.rs b/linux/crates/app/src/ui/browse_tab/clipboard_input.rs deleted file mode 100644 index eb1d654177..0000000000 --- a/linux/crates/app/src/ui/browse_tab/clipboard_input.rs +++ /dev/null @@ -1,30 +0,0 @@ -use tablepro_core::browse::RowReference; - -use super::paste_at::PasteAt; -use super::paste_read::PasteRead; -use crate::ui::clipboard::{CopyRequest, InClauseRows}; -use crate::ui::export::ExportSource; -use crate::ui::grid::{RowJsonRequest, RowObject}; - -/// Copies in each format, pastes, exports and the grid's notices. -#[derive(Debug)] -pub(crate) enum ClipboardInput { - /// The paginator's Export button, or the grid's Export with the page - /// as the grid holds it. - Export(Option), - GridCopy(CopyRequest), - GridCopyRowsAsInsert(Vec), - GridCopyInClause(InClauseRows), - CopySelectedRowsAsTsv, - /// Ctrl+V or a menu's Paste, at the cell it names. - Paste(Option), - PasteRead(Box), - /// The user answered the question a large paste asked. - PasteAnswered { - ticket: u64, - confirmed: bool, - }, - GridShowRowJson(RowJsonRequest), - GridShowToast(String), - GridFollowReference(RowReference), -} diff --git a/linux/crates/app/src/ui/browse_tab/command_handlers.rs b/linux/crates/app/src/ui/browse_tab/command_handlers.rs index b9f6e740aa..7b24f50b5e 100644 --- a/linux/crates/app/src/ui/browse_tab/command_handlers.rs +++ b/linux/crates/app/src/ui/browse_tab/command_handlers.rs @@ -77,7 +77,10 @@ impl BrowseModel { } self.failed.record(BrowseRequestKind::Keys, &value); match value { - Ok(keys) => self.keys.set(keys), + Ok(keys) => { + self.keys.set(keys); + self.grid.keys_changed(); + } Err(error) => { if error != OperationError::Cancelled { tracing::warn!(category = ?error.category(), "browse foreign keys failed"); diff --git a/linux/crates/app/src/ui/browse_tab/confirming_delete.rs b/linux/crates/app/src/ui/browse_tab/confirming_delete.rs deleted file mode 100644 index 95011dc730..0000000000 --- a/linux/crates/app/src/ui/browse_tab/confirming_delete.rs +++ /dev/null @@ -1,16 +0,0 @@ -use libadwaita as adw; -use tablepro_core::ColumnInfo; - -use super::row_deletion::RowDeletion; - -/// A bulk delete the user has not answered yet. -/// -/// Its rows are keyed by the primary key of the columns they were read -/// under, so a change to those columns moves them or cancels the delete -/// before it lands. -pub(super) struct ConfirmingDelete { - pub(super) ticket: u64, - pub(super) deletion: RowDeletion, - pub(super) columns: Vec, - pub(super) dialog: adw::AlertDialog, -} diff --git a/linux/crates/app/src/ui/browse_tab/confirming_paste.rs b/linux/crates/app/src/ui/browse_tab/confirming_paste.rs deleted file mode 100644 index 8a92ff467a..0000000000 --- a/linux/crates/app/src/ui/browse_tab/confirming_paste.rs +++ /dev/null @@ -1,12 +0,0 @@ -use libadwaita as adw; -use tablepro_core::ColumnInfo; -use tablepro_core::import::PastedRows; - -/// A paste large enough to ask about, waiting for the answer. Its cells -/// sit in the order of the columns they were read against. -pub(super) struct ConfirmingPaste { - pub(super) ticket: u64, - pub(super) rows: PastedRows, - pub(super) columns: Vec, - pub(super) dialog: adw::AlertDialog, -} diff --git a/linux/crates/app/src/ui/browse_tab/data_handlers.rs b/linux/crates/app/src/ui/browse_tab/data_handlers.rs index 619037aa18..3ed7a75cb7 100644 --- a/linux/crates/app/src/ui/browse_tab/data_handlers.rs +++ b/linux/crates/app/src/ui/browse_tab/data_handlers.rs @@ -3,16 +3,18 @@ use std::sync::Arc; use gtk4 as gtk; use gtk4::glib; use gtk4::prelude::*; +use tablepro_core::column::ResultColumn; +use tablepro_core::sql_syntax::SqlGrammar; +use tablepro_core::sql_syntax::mongo::is_addressable_field; use tablepro_core::{ColumnInfo, QueryResult}; use tablepro_session::LinkGeneration; -use super::browse_actions::install_grid_shortcuts; use super::columns_fetch::ColumnFreshness; -use super::selection_handlers::{selected_rows, show_delete_availability, update_selection_chrome}; use super::{BrowseModel, BrowseTabOutput, DataInput, Effects, PageReply, PageState, PageView, loading_description}; use crate::services::change_tracker::pk_identity; +use crate::services::grid::{GridColumn, RowKeyBuilder, grid_columns}; use crate::services::save_batch::SaveTarget; -use crate::ui::grid::{GridContent, GridRowIndex, RowKeyBuilder, RowObject, build_column_view, data_columns}; +use crate::ui::data_grid::{EditRules, RowData}; use crate::ui::tab_load::{ContentPage, LoadFailure, LoadPresentation}; impl BrowseModel { @@ -32,7 +34,6 @@ impl BrowseModel { DataInput::Command(command) => self.on_command(command, fx), DataInput::LinkChanged(generation) => self.on_link_changed(generation, fx), DataInput::SchemaChanged => { - self.capture_selection(); self.begin_read(fx); self.stop_count(true); self.reload_from_columns(ColumnFreshness::Cached); @@ -51,6 +52,7 @@ impl BrowseModel { self.filter_strip.set_dialect(self.dialect.clone()); if !self.sorts_rows() { self.current_sort = None; + self.grid.set_sort(None); } self.retry_failed(fx); } @@ -98,15 +100,31 @@ impl BrowseModel { } } self.widgets.stale_banner.show(presentation.stale.as_ref()); + self.show_grid_actions(); self.sync_pager_buttons(); self.sync_total_chrome(); - self.sync_delete_action(); + self.sync_selection(); if self.tab_loading != presentation.tab_loading { self.tab_loading = presentation.tab_loading; fx.output(BrowseTabOutput::LoadingChanged(presentation.tab_loading)); } } + /// The grid's actions reach the tab's buttons only while its rows are + /// on screen: rows behind the loading or the error page are not what + /// the table holds under the tab's filter. + fn show_grid_actions(&mut self) { + let shown = self.load.has_content(); + if self.grid_actions_shown == shown { + return; + } + self.grid_actions_shown = shown; + if let Some(tab) = self.this.upgrade() { + let actions = shown.then(|| self.grid.actions()); + tab.insert_action_group("grid", actions.as_ref()); + } + } + /// Reveal at most ONE banner at a time. Read-only takes priority /// over no-PK because read-only blocks every kind of edit. pub(super) fn refresh_banner_visibility(&self) { @@ -127,27 +145,30 @@ impl BrowseModel { /// banner; with no rows, or rows of another page, the error page says /// why. The paginator stays, so the user can go back. pub(super) fn on_load_failed(&mut self, failure: LoadFailure, fx: &mut Effects) { - self.pending_restore = None; let presentation = self.load.fail(failure); self.present(presentation, fx); } pub(super) fn on_columns_loaded(&mut self, columns: Vec, fx: &mut Effects) { - // A page read under other columns would put their names in the - // headers, so the grid waits for the page read under these. if self.current_columns != columns { self.page = None; self.shown_view = None; - self.drop_grid(fx); self.pager.forget_paging_states(); + self.current_columns = columns.clone(); + // The grid takes the new columns before the tracker does, so an + // edit still open is filed under the columns it was typed in. + // A page read under the old ones would put their names in the + // headers, so the rows wait for the page read under these. + let catalog: Vec = columns.iter().map(ColumnInfo::result_column).collect(); + self.show_columns(self.grid_columns_for(&catalog)); + let presentation = self.load.drop_content(); + self.present(presentation, fx); } - self.current_columns = columns.clone(); - self.rebase_confirming_delete(fx); let delta = self.tracker.set_target(SaveTarget { table: self.table_ref(), columns: columns.clone(), }); - self.apply_tracker_delta(&delta, fx); + self.grid.apply_delta(&delta); self.refresh_crud_buttons(); // Operator lists narrow per type, so the strip rebuilds against // the new columns. @@ -155,10 +176,10 @@ impl BrowseModel { self.render_grid_if_ready(fx); } - /// Take the page apart into rows the store owns. Each row's cells + /// Take the page apart into rows the grid owns. Each row's cells /// move into it, and the identity cells after them name the row and /// are dropped, so nothing past the visible columns reaches a copy - /// or an export. The tab's drafts stay on top. + /// or an export. pub(super) fn on_rows_loaded(&mut self, offset: u64, reply: PageReply, fx: &mut Effects) { let PageReply { position, @@ -173,27 +194,24 @@ impl BrowseModel { // names it here too. let keys = RowKeyBuilder::for_page(&pk_identity(&self.current_columns), &self.current_columns, &layout); let row_count = rows.len(); - let drafts = self.tracker.drafts(); - let mut objects = Vec::with_capacity(drafts.len() + row_count); - objects.extend(drafts.iter().rev().map(|draft| RowObject::draft(draft.draft_id))); let visible_end = layout.visible.end; - objects.extend(rows.into_iter().map(|mut cells| { - let identity = cells.split_off(visible_end.min(cells.len())); - let key = keys.as_ref().and_then(|keys| keys.key_for(&cells, &identity)); - RowObject::persisted(key, cells.into()) - })); - let result_columns = match columns.len() == layout.visible.len() { - true => columns, - false => columns.get(layout.visible.clone()).map(Arc::from).unwrap_or_default(), - }; + let rows: Vec = rows + .into_iter() + .map(|mut cells| { + let identity = cells.split_off(visible_end.min(cells.len())); + let key = keys.as_ref().and_then(|keys| keys.key_for(&cells, &identity)); + RowData::persisted(key, cells.into()) + }) + .collect(); + let visible = columns.get(layout.visible.clone()).unwrap_or_default(); + self.show_columns(self.grid_columns_for(visible)); self.page = Some(PageState { - result_columns, row_count, start_ordinal: offset, filtered: !self.current_filter.is_empty(), }); self.shown_view = Some(self.page_view()); - self.show_page(objects, fx); + self.show_page(rows, offset, fx); } fn page_view(&self) -> PageView { @@ -205,116 +223,53 @@ impl BrowseModel { } } + /// The grid's columns for `result`, each with the catalogue entry at + /// its place. A document's field whose name a filter, a sort or an + /// update cannot spell takes none of them (ADR 0026). + fn grid_columns_for(&self, result: &[ResultColumn]) -> Arc<[GridColumn]> { + let documents = self + .dialect + .as_ref() + .is_some_and(|dialect| dialect.grammar() == SqlGrammar::Mongo); + grid_columns(result, &self.current_columns, |name| { + !documents || is_addressable_field(name) + }) + } + + /// Give the grid `columns` when it shows others. Its rows go with the + /// columns they were read under. + fn show_columns(&self, columns: Arc<[GridColumn]>) { + if *self.grid.grid_columns() == *columns { + return; + } + let rules = EditRules { + identity: pk_identity(&self.current_columns), + policy: self.edit_policy(), + }; + self.grid.set_columns(columns, self.grid_sorting(), Some(rules)); + } + /// The columns came back the same, so the page on screen still /// stands under them: only the chrome follows. fn render_grid_if_ready(&mut self, fx: &mut Effects) { - if self.page.is_none() || !self.column_view_matches_current_columns() { + if self.page.is_none() { return; } self.refresh_grid_chrome(); fx.output(BrowseTabOutput::StateChanged); } - /// Put a page's rows on screen. Under the same columns the store - /// takes them in one splice, which the column view sees as a single - /// change; otherwise the view is built for them. - fn show_page(&mut self, rows: Vec, fx: &mut Effects) { - let Some(page) = self.page.as_ref() else { - return; - }; - if self.column_view_matches_current_columns() - && let Some(store) = self.list_store() - { - store.splice(0, store.n_items(), &rows); - self.reindex_rows(); - self.sync_selection(); - self.refresh_grid_chrome(); - let presentation = self.load.succeed(); - self.present(presentation, fx); - self.restore_selection(); - fx.output(BrowseTabOutput::StateChanged); - return; - } - - clear_box(&self.widgets.grid_holder); - let content = GridContent { - columns: page.result_columns.clone(), - rows, - }; - let (column_view, selection) = build_column_view( - content, - &self.current_columns, - self.grid_sink.clone(), - self.grid_sorting(), - self.grid_context(), - ); - // A screen reader names the grid as the tab does. - column_view.update_property(&[gtk::accessible::Property::Label(&self.table_label())]); - self.current_selection = Some(selection); - self.current_column_view = Some(column_view.clone()); - self.view_columns = data_columns(&column_view); - self.rendered_columns = self.current_columns.clone(); - - // The selection model is new per rebuild, so the previous - // handler goes with the old one. The tab holds the selection, so - // the handler holds the tab's widgets weakly. - if let Some(sel) = self.current_selection.as_ref() { - let label = self.widgets.selection_label.downgrade(); - let button = self.widgets.delete_button.downgrade(); - let actions = self.actions.clone(); - let read_only = self.read_only; - sel.connect_selection_changed(move |sel, _, _| { - let (Some(label), Some(button)) = (label.upgrade(), button.upgrade()) else { - return; - }; - let n = u32::try_from(sel.selection().size()).unwrap_or(u32::MAX); - update_selection_chrome(&label, n); - actions.follow_selection(n); - show_delete_availability(&actions.delete_rows, &button, &selected_rows(sel), read_only); - }); - } - install_grid_shortcuts(&column_view); - - self.reindex_rows(); - self.sync_selection(); - - let scrolled = gtk::ScrolledWindow::builder() - .child(&column_view) - .hexpand(true) - .vexpand(true) - .build(); - self.widgets.grid_holder.append(&scrolled); + /// Put a page's rows on screen. They take the slots the grid has, so + /// no row or cell widget is built again, and the selection and the + /// focused cell stay on their rows by key. + fn show_page(&mut self, rows: Vec, row_base: u64, fx: &mut Effects) { + self.grid.replace_rows(rows, row_base); self.refresh_grid_chrome(); let presentation = self.load.succeed(); self.present(presentation, fx); - self.restore_selection(); fx.output(BrowseTabOutput::StateChanged); } - /// The grid was built for columns the table no longer has, so it - /// goes before a click or a key in it lands on a moved column. - fn drop_grid(&mut self, fx: &mut Effects) { - if self.current_column_view.take().is_none() { - return; - } - clear_box(&self.widgets.grid_holder); - self.current_selection = None; - self.view_columns.clear(); - self.rendered_columns.clear(); - self.row_index = GridRowIndex::default(); - self.pending_restore = None; - self.sync_selection(); - let presentation = self.load.drop_content(); - self.present(presentation, fx); - } - - fn reindex_rows(&mut self) { - match self.list_store() { - Some(store) => self.row_index.rebuild(&store), - None => self.row_index = GridRowIndex::default(), - } - } - /// Paginator label, button sensitivity and stack child: chrome that /// depends on the page but not the column structure. fn refresh_grid_chrome(&self) { @@ -324,9 +279,3 @@ impl BrowseModel { self.suppress_combo_emit.set(false); } } - -fn clear_box(b: >k::Box) { - while let Some(child) = b.first_child() { - b.remove(&child); - } -} diff --git a/linux/crates/app/src/ui/browse_tab/edit_handlers.rs b/linux/crates/app/src/ui/browse_tab/edit_handlers.rs deleted file mode 100644 index 4dc8cd4ee4..0000000000 --- a/linux/crates/app/src/ui/browse_tab/edit_handlers.rs +++ /dev/null @@ -1,383 +0,0 @@ -use std::sync::Arc; - -use gtk4::glib; -use gtk4::prelude::*; -use libadwaita as adw; -use libadwaita::prelude::*; -use tablepro_core::Value; -use tablepro_core::edit::{CellInput, EditParseError, new_draft_cell, parse_edit_text}; - -use super::confirming_delete::ConfirmingDelete; -use super::row_deletion::{RowDeletion, plan_row_deletion}; -use super::{BULK_DELETE_CONFIRM_THRESHOLD, BrowseModel, BrowseTabOutput, EditInput, Effects, apply_draft_changes}; -use crate::services::change_tracker::{RowKey, pk_identity}; -use crate::services::column_relayout::ColumnRelayout; -use crate::services::tracker_delta::TrackerDelta; -use crate::services::tracker_edit::TrackerEdit; -use crate::ui::grid::{CellPreset, GridRowId, RowObject, cell_editability, cell_presets, edit_context, edit_row}; - -impl BrowseModel { - pub(super) fn on_edit(&mut self, input: EditInput, fx: &mut Effects) { - match input { - EditInput::InsertRow => { - if self.read_only || self.current_columns.is_empty() { - return; - } - let cells = self.current_columns.iter().map(new_draft_cell).collect(); - self.apply_edit(TrackerEdit::InsertDrafts { rows: vec![cells] }, fx); - fx.input(EditInput::FocusInsertedDraft); - } - EditInput::FocusInsertedDraft => self.focus_inserted_draft(), - EditInput::SelectPastedDrafts(count) => self.select_pasted_drafts(count), - EditInput::DeleteRows(rows) => self.delete_rows(&rows, fx), - EditInput::GridEdit { row, column, edit } => { - // The cell shows what was typed until its row is drawn again. - if self.refuses_edit(&row, column) { - row.bump_revision(); - return; - } - self.apply_edit(edit, fx); - } - EditInput::GridSetCellValue { row, column, preset } => self.set_cell_value(&row, column, preset, fx), - EditInput::DuplicateRows(rows) => self.duplicate_rows(&rows, fx), - EditInput::Undo => { - if let Some(delta) = self.tracker.undo() { - self.apply_tracker_delta(&delta, fx); - } - } - EditInput::Redo => { - if let Some(delta) = self.tracker.redo() { - self.apply_tracker_delta(&delta, fx); - } - } - EditInput::MarkRowsDeleted(deletion) => { - self.apply_edit( - TrackerEdit::DeleteRows { - persisted: deletion.persisted, - drafts: deletion.drafts, - }, - fx, - ); - // A bulk action ends the selection it acted on. - if let Some(selection) = self.current_selection.as_ref() { - selection.unselect_all(); - } - } - EditInput::BulkDeleteAnswered { ticket, confirmed } => self.bulk_delete_answered(ticket, confirmed, fx), - } - } - - fn set_cell_value(&mut self, row: &RowObject, column: usize, preset: CellPreset, fx: &mut Effects) { - if self.refuses_edit(row, column) { - return; - } - let input = match self.resolve_cell_preset(preset, row, column) { - Some(Ok(input)) => input, - Some(Err(message)) => { - fx.output(BrowseTabOutput::ShowToast(message)); - return; - } - None => return, - }; - self.edit_cell(row, column, input, fx); - } - - fn duplicate_rows(&mut self, rows: &[RowObject], fx: &mut Effects) { - if self.read_only || self.current_columns.is_empty() || self.grid_is_stale() || rows.is_empty() { - return; - } - let ctx = self.grid_context(); - let copies = rows - .iter() - .map(|row| { - // With its pending edits, since the copy is of the - // row the user is looking at. - let source = ctx.effective_inputs(row); - // The duplicate is a new row, so values the database - // owns (key, identity, generated) are left to it. - self.current_columns - .iter() - .enumerate() - .map(|(i, col)| { - if col.primary_key || col.is_auto_increment || col.is_generated { - new_draft_cell(col) - } else { - source.get(i).cloned().unwrap_or(CellInput::Value(Value::Null)) - } - }) - .collect() - }) - .collect(); - self.apply_edit(TrackerEdit::InsertDrafts { rows: copies }, fx); - fx.input(EditInput::FocusInsertedDraft); - } - - fn bulk_delete_answered(&mut self, ticket: u64, confirmed: bool, fx: &mut Effects) { - // An answer to a confirmation the tab already cancelled - // or replaced. - if self - .confirming_delete - .as_ref() - .is_none_or(|pending| pending.ticket != ticket) - { - return; - } - if !confirmed { - self.confirming_delete = None; - return; - } - self.rebase_confirming_delete(fx); - if let Some(pending) = self.confirming_delete.take() { - fx.input(EditInput::MarkRowsDeleted(pending.deletion)); - } - } - - /// Checked again here because a keyboard shortcut or a late event - /// can reach a locked cell. - fn refuses_edit(&self, row: &RowObject, column: usize) -> bool { - if self.grid_is_stale() { - return true; - } - let Some(info) = self.current_columns.get(column) else { - return true; - }; - let locked = cell_editability(info, row.id(), &pk_identity(&self.current_columns), self.read_only); - if let Err(lock) = locked { - tracing::debug!(?lock, column, "refused an edit to a locked cell"); - return true; - } - false - } - - /// Resolve a preset against the column it lands in. The menu offers - /// only the ones the cell can take; the keyboard's Set NULL reaches - /// here for any cell, and a column that refuses the value says why, - /// as committing an empty cell would. - fn resolve_cell_preset( - &self, - preset: CellPreset, - row: &RowObject, - col: usize, - ) -> Option> { - let Some(column) = self.current_columns.get(col) else { - tracing::error!(col, "a preset reached a column the tab does not have"); - return None; - }; - let presets = cell_presets(column, row.id()); - Some(match preset { - CellPreset::Empty => parse_edit_text("", column, edit_context(row.id())) - .map_err(|error| crate::ui::error_text::edit_parse_message(&error)), - CellPreset::Null if presets.null => Ok(CellInput::Value(Value::Null)), - CellPreset::Null => Err(crate::ui::error_text::edit_parse_message(&EditParseError::Required)), - CellPreset::Default if presets.default => Ok(CellInput::Default), - CellPreset::Default => Err(crate::i18n::gettext("This cell can't take the column's default")), - }) - } - - /// Apply what a tracker call changed: the draft rows the store gains - /// or loses, then a redraw of each touched row, then the footer. A - /// row keeps its object, so the selection and the focus stay on it. - pub(super) fn apply_tracker_delta(&mut self, delta: &TrackerDelta, fx: &mut Effects) { - if let Some(store) = self.list_store() { - apply_draft_changes(&store, &mut self.row_index, delta); - } - for key in &delta.changed { - if let Some(row) = self.row_index.get(key) { - row.bump_revision(); - } - } - self.sync_pending_chrome(delta.pending_count, fx); - self.sync_delete_action(); - } - - pub(super) fn apply_edit(&mut self, edit: TrackerEdit, fx: &mut Effects) { - if self.tracker.refuses(&edit) && !std::mem::replace(&mut fx.told_edit_held, true) { - fx.output(BrowseTabOutput::ShowToast(crate::i18n::gettext( - "This row is being saved. Change it again once the save finishes.", - ))); - } - let delta = self.tracker.apply(edit); - self.apply_tracker_delta(&delta, fx); - } - - /// A preset value for one cell, against the row the edit was made - /// on, wherever it sits now. - fn edit_cell(&mut self, row: &RowObject, col: usize, input: CellInput, fx: &mut Effects) { - match (row.id(), input) { - (GridRowId::Draft(draft_id), input) => { - self.apply_edit( - TrackerEdit::DraftCell { - draft_id: *draft_id, - col, - input, - }, - fx, - ); - } - (GridRowId::Persisted(row_key), CellInput::Value(value)) => { - let Some(original) = row.cell(col).cloned() else { - return; - }; - self.apply_edit( - TrackerEdit::Cell { - row_key: row_key.clone(), - col, - original, - value, - }, - fx, - ); - } - (GridRowId::Persisted(_), CellInput::Default) | (GridRowId::Result | GridRowId::Unkeyed, _) => {} - } - } - - pub(super) fn refresh_crud_buttons(&self) { - let has_columns = !self.current_columns.is_empty(); - let has_pk = self.has_primary_key(); - if self.read_only { - self.widgets.insert_button.set_visible(false); - self.actions.insert_row.set_enabled(false); - return; - } - self.widgets.insert_button.set_visible(true); - // No-PK tables don't get inline editing because RowKey can't be - // formed without a PK. Disable instead of hide so the affordance - // stays discoverable; the tooltip explains the gate. - self.actions.insert_row.set_enabled(has_columns && has_pk); - if has_columns && !has_pk { - self.widgets.insert_button.set_tooltip_text(Some(&crate::i18n::gettext( - "This table has no primary key, so editing is turned off.", - ))); - } else { - self.widgets - .insert_button - .set_tooltip_text(Some(&crate::i18n::gettext("Insert Row"))); - } - self.refresh_banner_visibility(); - } - - /// Edit the first editable cell of the new draft, which is always - /// the top row. Queued on idle so the inner-stack flip (empty to - /// grid) has allocated the ScrolledWindow's adjustments before the - /// grid scrolls: in the same tick GTK aborts with - /// `gtk_list_base_update_adjustments: bounds.y == 0`. - fn focus_inserted_draft(&self) { - let Some(cv) = self.current_column_view.clone() else { - return; - }; - glib::idle_add_local_once(move || { - // F5 between the insert and this idle detaches the column - // view, and a failed read hides it behind the error page. The - // row was inserted; only the edit is missed. - if cv.is_mapped() { - edit_row(&cv, 0); - } - }); - } - - /// Mark saved rows for deletion and drop drafts. Nothing leaves the - /// tab until Save. The action is off for rows this cannot delete, so - /// such rows reaching here is a bug, which changes nothing. - fn delete_rows(&mut self, rows: &[RowObject], fx: &mut Effects) { - if self.grid_is_stale() { - return; - } - if self.read_only { - tracing::error!("Delete reached a read-only tab"); - return; - } - let deletion = plan_row_deletion(rows); - if deletion.unkeyed > 0 { - tracing::error!(unkeyed = deletion.unkeyed, "Delete reached rows with no key"); - return; - } - if deletion.is_empty() { - return; - } - if deletion.len() < BULK_DELETE_CONFIRM_THRESHOLD || !self.settings.confirm_bulk_delete() { - fx.input(EditInput::MarkRowsDeleted(deletion)); - return; - } - self.confirm_delete(deletion); - } - - fn confirm_delete(&mut self, deletion: RowDeletion) { - let count = deletion.len(); - let title = crate::i18n::ngettext_f( - "Delete {n} Row?", - "Delete {n} Rows?", - u32::try_from(count).unwrap_or(u32::MAX), - &[("n", &count.to_string())], - ); - let body = deletion.confirmation_body(); - let dialog = adw::AlertDialog::new(Some(&title), Some(&body)); - dialog.add_response("cancel", &crate::i18n::gettext("Cancel")); - dialog.add_response("delete", &crate::i18n::gettext("Delete")); - dialog.set_response_appearance("delete", adw::ResponseAppearance::Destructive); - dialog.set_default_response(Some("cancel")); - dialog.set_close_response("cancel"); - // A second Delete while one waits replaces it. - self.cancel_confirming_delete(); - let ticket = self.next_confirmation; - self.next_confirmation += 1; - let tab = self.this.clone(); - dialog.connect_response(None, move |_, response| { - if let Some(tab) = tab.upgrade() { - tab.send(EditInput::BulkDeleteAnswered { - ticket, - confirmed: response == "delete", - }); - } - }); - dialog.present(Some(&self.widgets.inner_stack)); - self.confirming_delete = Some(ConfirmingDelete { - ticket, - deletion, - columns: self.current_columns.clone(), - dialog, - }); - } - - fn cancel_confirming_delete(&mut self) { - if let Some(pending) = self.confirming_delete.take() { - pending.dialog.force_close(); - } - } - - /// Moves a waiting delete's rows onto the columns the tab read - /// since. A key read under another primary key can name another - /// row, so then the delete is cancelled rather than sent with it. - pub(super) fn rebase_confirming_delete(&mut self, fx: &mut Effects) { - let Some(pending) = self.confirming_delete.as_mut() else { - return; - }; - if pending.columns == self.current_columns { - return; - } - let layout = ColumnRelayout::new(&pending.columns, &self.current_columns); - let moved: Option)>> = pending - .deletion - .persisted - .iter() - .map(|(key, values)| { - layout - .key(key) - .ok() - .map(|()| (key.clone(), layout.fetched(values).into())) - }) - .collect(); - match moved { - Some(rows) => { - pending.deletion.persisted = rows; - pending.columns = self.current_columns.clone(); - } - None => { - self.cancel_confirming_delete(); - fx.output(BrowseTabOutput::ShowToast(crate::i18n::gettext( - "The table's primary key changed, so no rows were marked for deletion. Select them again.", - ))); - } - } - } -} diff --git a/linux/crates/app/src/ui/browse_tab/edit_input.rs b/linux/crates/app/src/ui/browse_tab/edit_input.rs deleted file mode 100644 index 81446c9ed7..0000000000 --- a/linux/crates/app/src/ui/browse_tab/edit_input.rs +++ /dev/null @@ -1,40 +0,0 @@ -use super::row_deletion::RowDeletion; -use crate::services::tracker_edit::TrackerEdit; -use crate::ui::grid::{CellPreset, RowObject}; - -/// What changes the tab's pending edits: a cell, a new or duplicated -/// row, a delete, undo and redo. -#[derive(Debug)] -pub(crate) enum EditInput { - InsertRow, - /// Queued after an insert, so the focus lands on the column view - /// as it is once the insert is drawn. - FocusInsertedDraft, - /// Queued after a paste, as `FocusInsertedDraft` is after an insert. - SelectPastedDrafts(u32), - DeleteRows(Vec), - /// Checked against the cell's lock before the tracker takes it. - GridEdit { - row: RowObject, - column: usize, - edit: TrackerEdit, - }, - /// A preset from a cell's menu or Ctrl+Backspace. The tab resolves it - /// against the column, so both paths reach the same value. - GridSetCellValue { - row: RowObject, - column: usize, - preset: CellPreset, - }, - DuplicateRows(Vec), - Undo, - Redo, - /// A delete the user confirmed, or one small enough to need no - /// confirmation. - MarkRowsDeleted(RowDeletion), - /// The user answered the bulk delete confirmation `ticket` opened. - BulkDeleteAnswered { - ticket: u64, - confirmed: bool, - }, -} diff --git a/linux/crates/app/src/ui/browse_tab/fetch.rs b/linux/crates/app/src/ui/browse_tab/fetch.rs index a38318f6e1..2c067452cf 100644 --- a/linux/crates/app/src/ui/browse_tab/fetch.rs +++ b/linux/crates/app/src/ui/browse_tab/fetch.rs @@ -10,8 +10,8 @@ use tablepro_session::{OperationError, OperationKind}; use super::columns_fetch::{ColumnFreshness, load_columns}; use super::{BrowseCommand, BrowseModel, BrowseRequestKind, ColumnsReply, DataInput, Effects, PageReply}; use crate::runtime::{RequestTicket, Stamped}; +use crate::ui::data_grid::TableKeys; use crate::ui::export::TableExport; -use crate::ui::grid::TableKeys; use crate::ui::tab_load::LoadFailure; /// What a page or a count reads, copied off the tab so the work owns diff --git a/linux/crates/app/src/ui/browse_tab/grid_handlers.rs b/linux/crates/app/src/ui/browse_tab/grid_handlers.rs new file mode 100644 index 0000000000..518f3e6911 --- /dev/null +++ b/linux/crates/app/src/ui/browse_tab/grid_handlers.rs @@ -0,0 +1,124 @@ +use gtk4::prelude::*; + +use super::{BrowseModel, BrowseTabOutput, Effects, GridInput, qualified}; +use crate::i18n::gettext; +use crate::services::grid::{ + DeleteAvailability, EditPolicy, EditRefusal, GridRowId, delete_availability, edit_policy, selection_hint, +}; +use crate::ui::data_grid::{GridRequest, GridRow}; +use crate::ui::export::{ExportOffer, ExportOrigin, ExportQuery, ExportSource}; + +/// What the grid asks of the tab, and the tab's chrome that follows it. +impl BrowseModel { + pub(super) fn on_grid(&mut self, input: GridInput, fx: &mut Effects) { + match input { + GridInput::Request(request) => self.on_grid_request(request, fx), + GridInput::SelectionChanged => self.sync_selection(), + GridInput::EditingChanged => self.follow_tracker(), + GridInput::Export => self.offer_export(None, fx), + GridInput::Undo => self.grid.undo(), + GridInput::Redo => self.grid.redo(), + } + } + + fn on_grid_request(&mut self, request: GridRequest, fx: &mut Effects) { + match request { + GridRequest::Sort(sort) => self.sort_by(sort, fx), + GridRequest::Export(source) => self.offer_export(Some(source), fx), + GridRequest::FollowReference(reference) => fx.output(BrowseTabOutput::FollowReference(reference)), + GridRequest::FilterByValue(rule) => self.filter_strip.add_and_apply(rule), + GridRequest::Toast(text) => fx.output(BrowseTabOutput::ShowToast(text)), + GridRequest::PendingChanged => { + self.sync_pending_chrome(self.tracker.pending_count(), fx); + self.sync_selection(); + } + } + } + + /// What the grid may change in the table's rows, which the toolbar + /// explains with the grid's own sentences. + pub(super) fn edit_policy(&self) -> EditPolicy { + edit_policy(self.read_only, self.has_primary_key(), !self.current_columns.is_empty()) + } + + pub(super) fn follow_tracker(&self) { + self.actions + .follow_tracker(&self.tracker, self.saving.get(), self.grid.is_editing()); + } + + /// A read-only tab hides Insert. A table without a key keeps it, + /// greyed out, with the reason on its tooltip, so the affordance stays + /// discoverable. + pub(super) fn refresh_crud_buttons(&self) { + let insert = &self.widgets.insert_button; + insert.set_visible(!self.read_only); + let reason = self.edit_policy().insert.err().and_then(EditRefusal::message); + insert.set_tooltip_text(Some(&reason.unwrap_or_else(|| gettext("Insert Row")))); + self.refresh_banner_visibility(); + } + + /// The count in the bottom bar and the Delete button's reason when it + /// is off. GtkMultiSelection says nothing when a page takes the + /// selected rows away, so a page and a load state come through here + /// too. Rows behind an error page count as none. + pub(super) fn sync_selection(&self) { + let rows: Vec = match self.shown_page() { + Some(_) => self.grid.selected_rows(), + None => Vec::new(), + }; + let policy = self.edit_policy(); + let selected = u32::try_from(rows.len()).unwrap_or(u32::MAX); + let hint = selection_hint(selected, policy.delete); + let label = &self.widgets.selection_label; + label.set_label(hint.as_deref().unwrap_or_default()); + label.set_visible(hint.is_some()); + let ids: Vec = rows.iter().map(GridRow::id).collect(); + let blocked = match delete_availability(&ids, self.read_only) { + DeleteAvailability::Unavailable(block) => block.explanation(), + DeleteAvailability::Available => None, + }; + let reason = blocked.or_else(|| policy.delete.err().and_then(EditRefusal::message)); + self.widgets + .delete_button + .set_tooltip_text(Some(&reason.unwrap_or_else(|| gettext("Delete Selected Rows")))); + } + + /// The table a browse tab's rows came from, so an export can + /// write them back as `INSERT` statements. + fn sql_target(&self) -> crate::ui::export::dialog::SqlTarget { + crate::ui::export::dialog::SqlTarget { + table: self.table_ref(), + generated: self + .current_columns + .iter() + .filter(|column| column.is_generated) + .map(|column| column.name.clone()) + .collect(), + } + } + + /// Ask the window to export the page, as the grid shows it unless the + /// grid's own Export hands it over, or every row the filter selects. + /// Rows behind an error page are not what the table holds under the + /// current filter, so they are never offered. + pub(super) fn offer_export(&self, page: Option, fx: &mut Effects) { + let page = self + .shown_page() + .map(|_| page.unwrap_or_else(|| self.grid.export_source())); + let (Some(page), Some(table)) = (page, self.table_export()) else { + fx.output(BrowseTabOutput::ShowToast(gettext("Nothing to export"))); + return; + }; + fx.output(BrowseTabOutput::Export(Box::new(ExportOffer { + origin: ExportOrigin::Browse { + page, + query: ExportQuery::Table(table), + total: self.total.clone(), + has_unsaved_changes: self.tracker.pending_count() > 0, + }, + session: Some(self.session.clone()), + name: qualified(self.schema.as_deref(), &self.table), + target: Some(self.sql_target()), + }))); + } +} diff --git a/linux/crates/app/src/ui/browse_tab/grid_input.rs b/linux/crates/app/src/ui/browse_tab/grid_input.rs new file mode 100644 index 0000000000..0129f16acc --- /dev/null +++ b/linux/crates/app/src/ui/browse_tab/grid_input.rs @@ -0,0 +1,15 @@ +use crate::ui::data_grid::GridRequest; + +/// What the grid tells the tab, and what the tab's own buttons hand the +/// grid: Export, Undo and Redo. +#[derive(Debug)] +pub(crate) enum GridInput { + Request(GridRequest), + SelectionChanged, + /// An inline or detail pane edit opened or closed. + EditingChanged, + /// The Export button: the page as the grid shows it. + Export, + Undo, + Redo, +} diff --git a/linux/crates/app/src/ui/browse_tab/imp.rs b/linux/crates/app/src/ui/browse_tab/imp.rs index 0644841016..0cbacd6ce8 100644 --- a/linux/crates/app/src/ui/browse_tab/imp.rs +++ b/linux/crates/app/src/ui/browse_tab/imp.rs @@ -1,4 +1,4 @@ -use std::cell::{Cell, RefCell}; +use std::cell::{Cell, OnceCell, RefCell}; use std::collections::VecDeque; use std::sync::OnceLock; @@ -14,6 +14,7 @@ use tablepro_session::runtime::WatchForwarder; use super::pager_bar::PagerBar; use super::pending_changes_bar::PendingChangesBar; use super::{BrowseModel, BrowseTabInput}; +use crate::ui::data_grid::DataGrid; #[derive(Default, gtk::CompositeTemplate)] #[template(resource = "/app/tablepro/TablePro/ui/browse-tab.ui")] @@ -49,6 +50,8 @@ pub struct BrowseTab { #[template_child] pub export_button: TemplateChild, #[template_child] + pub details_toggle: TemplateChild, + #[template_child] pub filter_button: TemplateChild, #[template_child] pub filter_badge: TemplateChild, @@ -57,6 +60,9 @@ pub struct BrowseTab { #[template_child] pub pending_bar: TemplateChild, + /// The tab's one grid, reachable while an input is being handled + /// and the model is borrowed. + pub(super) grid: OnceCell, pub(super) model: RefCell>, pub(super) queue: RefCell>, pub(super) dispatching: Cell, diff --git a/linux/crates/app/src/ui/browse_tab/layout.rs b/linux/crates/app/src/ui/browse_tab/layout.rs index b7d7b67f91..20a97fe76a 100644 --- a/linux/crates/app/src/ui/browse_tab/layout.rs +++ b/linux/crates/app/src/ui/browse_tab/layout.rs @@ -7,18 +7,22 @@ use gtk4::subclass::prelude::*; use gtk4::{gio, glib}; use tablepro_core::FilterSet; use tablepro_core::dialect::SqlDialect; +use tablepro_core::meta::TableRef; use tablepro_core::sql_syntax::SqlGrammar; +use tablepro_session::Session; +use tablepro_session::runtime::Tasks; +use tablepro_storage::AppSettings; use super::browse_tab_widgets::BrowseTabWidgets; use super::pager_bar::PagerRequest; use super::pending_changes_bar::PendingChangesRequest; -use super::{ - BrowseTab, BrowseTabInput, ClipboardInput, PAGE_SIZE_OPTIONS, PagingInput, SaveInput, loading_description, -}; +use super::{BrowseTab, BrowseTabInput, GridInput, PAGE_SIZE_OPTIONS, PagingInput, SaveInput, loading_description}; use crate::i18n::gettext; +use crate::persistence::ColumnLayoutStore; +use crate::services::tab_tracker_handle::TabTrackerHandle; +use crate::ui::data_grid::{DataGrid, DialectSource, GridConfig, GridSorting, KeysHandle, LayoutBinding}; use crate::ui::filter_strip::FilterStrip; use crate::ui::format::format_thousands; -use crate::ui::grid::GridSink; use crate::ui::tab_load::StaleBanner; pub(super) struct LayoutInit<'a> { @@ -40,7 +44,6 @@ pub(super) fn build(tab: &BrowseTab, init: &LayoutInit<'_>) -> BrowseTabWidgets connect_controls(tab, page_sizes.clone()); BrowseTabWidgets { inner_stack: imp.inner_stack.get(), - grid_holder: imp.grid_holder.get(), loading_page: imp.loading_page.get(), error_page: imp.error_page.get(), stale_banner: StaleBanner::new(imp.stale_banner.get(), sender(tab, || PagingInput::Refresh)), @@ -101,16 +104,80 @@ pub(super) fn filter_strip(tab: &BrowseTab, initial: &FilterSet, dialect: Option strip } -/// Handled at once: a tracked change redraws the cells bound to its -/// row in place, so the cell that sent it is still there for the Tab -/// that ended the edit to move focus from. -pub(super) fn grid_sink(tab: &BrowseTab) -> GridSink { - let tab = tab.downgrade(); - Rc::new(move |msg| { - if let Some(tab) = tab.upgrade() { - tab.send(BrowseTabInput::from(msg)); +/// What the tab's grid is built from. +pub(super) struct GridInit { + pub(super) table: TableRef, + pub(super) label: String, + pub(super) connection_id: Option, + pub(super) sorting: GridSorting, + pub(super) tracker: TabTrackerHandle, + pub(super) settings: Rc, + pub(super) layouts: ColumnLayoutStore, + pub(super) keys: KeysHandle, + /// Held weakly: the session outlives its tabs, and a grid must not + /// keep a closed one. + pub(super) session: std::sync::Weak, + pub(super) tasks: Tasks, +} + +/// The grid the tab shows its rows in for its whole life. It starts with +/// no columns; each read of another column set gives it new ones. Its +/// `grid` actions reach the tab's buttons through the tab. +pub(super) fn grid(tab: &BrowseTab, init: GridInit) -> DataGrid { + let session = init.session; + let dialect: DialectSource = Rc::new(move || session.upgrade()?.current_dialect()); + let layout = init.connection_id.map(|connection_id| LayoutBinding { + store: init.layouts, + connection_id, + schema: init.table.schema.clone(), + table: init.table.name.clone(), + }); + let grid = DataGrid::new(GridConfig { + columns: Arc::from([]), + sorting: init.sorting, + rules: None, + tracker: Some(init.tracker), + settings: init.settings, + layout, + references: Some(init.keys), + dialect: Some(dialect), + table: Some(init.table), + // Ctrl+F is the filter strip's in a table tab. + find: false, + filter_by_value: true, + tasks: init.tasks, + label: init.label, + }); + grid.set_vexpand(true); + grid.set_hexpand(true); + let imp = tab.imp(); + imp.grid_holder.append(&grid); + let _ = imp.grid.set(grid.clone()); + connect_grid(tab, &grid); + grid +} + +/// Only queued: the grid emits these while it writes its rows, and the +/// tab may be handling an input that set it off. +fn connect_grid(tab: &BrowseTab, grid: &DataGrid) { + let weak = tab.downgrade(); + grid.connect_request(move |_, request| { + if let Some(tab) = weak.upgrade() { + tab.send(GridInput::Request(request)); + } + }); + let weak = tab.downgrade(); + grid.connect_selection_changed(move |_| { + if let Some(tab) = weak.upgrade() { + tab.send(GridInput::SelectionChanged); } - }) + }); + let weak = tab.downgrade(); + grid.connect_editing_changed(move |_| { + if let Some(tab) = weak.upgrade() { + tab.send(GridInput::EditingChanged); + } + }); } /// The usual sizes, and the tab's own when Preferences or a saved tab @@ -166,7 +233,7 @@ fn connect_controls(tab: &BrowseTab, page_sizes: Vec) { ] { imp.pending_bar.connect_request(request, move |_| send()); } - let export = sender(tab, || ClipboardInput::Export(None)); + let export = sender(tab, || GridInput::Export); imp.export_button.connect_clicked(move |_| export()); let retry = sender(tab, || PagingInput::Refresh); imp.retry_button.connect_clicked(move |_| retry()); diff --git a/linux/crates/app/src/ui/browse_tab/mod.rs b/linux/crates/app/src/ui/browse_tab/mod.rs index 05307ea657..9afcdb4bce 100644 --- a/linux/crates/app/src/ui/browse_tab/mod.rs +++ b/linux/crates/app/src/ui/browse_tab/mod.rs @@ -15,20 +15,16 @@ mod browse_pager; mod browse_request_kind; mod browse_tab_input; mod browse_tab_widgets; -mod clipboard_handlers; -mod clipboard_input; mod column_recheck; mod columns_fetch; mod command_handlers; -mod confirming_delete; -mod confirming_paste; mod count_failure_text; mod count_policy; mod data_handlers; mod data_input; -mod edit_handlers; -mod edit_input; mod fetch; +mod grid_handlers; +mod grid_input; mod imp; mod layout; mod page_label; @@ -37,24 +33,17 @@ mod page_state; mod pager_bar; mod paging_handlers; mod paging_input; -mod paste_at; -mod paste_handlers; -mod paste_read; mod pending_changes_bar; mod row_count; -mod row_deletion; mod row_total; mod save_handlers; mod save_input; -mod selection_handlers; -mod selection_input; -mod selection_snapshot; mod table_filter; #[cfg(test)] mod tests; use std::cell::Cell; -use std::collections::{BTreeSet, VecDeque}; +use std::collections::BTreeSet; use std::rc::Rc; use std::sync::Arc; @@ -78,42 +67,28 @@ use self::browse_pager::BrowsePager; pub(crate) use self::browse_request_kind::BrowseRequestKind; pub(crate) use self::browse_tab_input::BrowseTabInput; use self::browse_tab_widgets::BrowseTabWidgets; -pub(crate) use self::clipboard_input::ClipboardInput; use self::column_recheck::ColumnRecheck; -use self::confirming_delete::ConfirmingDelete; -use self::confirming_paste::ConfirmingPaste; pub(crate) use self::data_input::DataInput; -pub(crate) use self::edit_input::EditInput; +use self::grid_input::GridInput; pub(crate) use self::page_reply::PageReply; use self::page_state::PageState; #[cfg(test)] pub(super) use self::pager_bar::PagerBar; pub(crate) use self::paging_input::PagingInput; -use self::paste_read::{PasteOutcome, PasteRead, PasteRequest, PasteSource, read_pasted}; #[cfg(test)] pub(super) use self::pending_changes_bar::PendingChangesBar; pub(crate) use self::row_total::RowTotal; pub(crate) use self::save_input::SaveInput; -pub(crate) use self::selection_input::SelectionInput; -use self::selection_snapshot::SelectionSnapshot; pub(crate) use self::table_filter::TableFilter; +use super::data_grid::{DataGrid, GridSorting, KeysHandle}; use super::filter_strip::FilterStrip; -use super::grid::{ - ColumnWidthBinding, GridRowIndex, GridSink, GridSorting, KeysHandle, RowJsonRequest, RowObject, TabGridContext, - present_row_json, -}; use super::tab_load::{ContentLoad, FirstLoad, start_first_load}; use crate::runtime::{FailedRequests, RequestSequencer}; -use crate::services::change_tracker::{RowKey, pk_identity}; use crate::services::tab_tracker_handle::TabTrackerHandle; -use crate::services::tracker_delta::TrackerDelta; -use crate::ui::clipboard::CopyRequest; use crate::ui::export::ExportOffer; const PAGE_SIZE_OPTIONS: &[u64] = &[100, 500, 1_000, 5_000, 10_000]; const DEFAULT_PAGE_SIZE: u64 = 1_000; -/// A delete of fewer rows never asks, since Undo and Discard bring them back. -pub(crate) const BULK_DELETE_CONFIRM_THRESHOLD: usize = 10; glib::wrapper! { pub struct BrowseTab(ObjectSubclass) @@ -139,6 +114,8 @@ pub struct BrowseTabInit { pub tasks: Tasks, /// The session's deadlines, which a count that ran out names. pub deadlines: Arc, + /// Read when the grid needs them, so a change in Preferences reaches + /// open tabs. pub settings: Rc, pub first_load: FirstLoad, } @@ -146,18 +123,18 @@ pub struct BrowseTabInit { impl BrowseTab { pub fn new(init: BrowseTabInit) -> Self { let tab: Self = glib::Object::new(); - let imp = tab.imp(); - // The model and the inline strip start with the same FilterSet. let (initial_filter, reveal_filter) = init.filter.opening(|| { init.connection_id .map(|id| init.filter_settings.filters(id, init.schema.as_deref(), &init.table)) .unwrap_or_default() }); + let table = TableRef::new(init.schema.clone(), init.table.clone()); + let table_label = qualified(init.schema.as_deref(), &init.table); let widgets = layout::build( &tab, &layout::LayoutInit { - table_label: &qualified(init.schema.as_deref(), &init.table), + table_label: &table_label, read_only: init.read_only, page_size: init.page_size, }, @@ -168,14 +145,29 @@ impl BrowseTab { filter_strip.set_revealed(true); } let actions = browse_actions::install_browse_actions(&tab, init.page_size); - let link_generation = init.session.link_generation(); // A sort saved with the tab cannot reach an engine that reads // in its own order. let initial_sort = init.initial_sort.filter(|_| sorts_rows(dialect.as_deref())); + let keys = KeysHandle::new(table.clone()); + let grid = layout::grid( + &tab, + layout::GridInit { + table, + label: table_label, + connection_id: init.connection_id, + sorting: grid_sorting(dialect.as_deref(), initial_sort), + tracker: init.tracker.clone(), + settings: init.settings, + layouts: init.column_widths.layouts().clone(), + keys: keys.clone(), + session: Arc::downgrade(&init.session), + tasks: init.tasks.clone(), + }, + ); let link_changes = init.session.link_changes(); - let keys = KeysHandle::new(TableRef::new(init.schema.clone(), init.table.clone())); let model = BrowseModel { this: tab.downgrade(), + link_generation: init.session.link_generation(), session: init.session, tasks: init.tasks, sequencer: RequestSequencer::new(), @@ -183,7 +175,6 @@ impl BrowseTab { after_columns: BTreeSet::new(), recheck: ColumnRecheck::default(), issued_on: std::collections::BTreeMap::new(), - link_generation, identity: None, dialect, keys, @@ -192,43 +183,39 @@ impl BrowseTab { table: init.table, connection_id: init.connection_id, read_only: init.read_only, - column_widths: init.column_widths, filter_settings: init.filter_settings, pager: BrowsePager::new(init.page_size, init.initial_offset), deadlines: init.deadlines, - settings: init.settings, current_sort: initial_sort, current_filter: initial_filter, current_columns: Vec::new(), page: None, shown_view: None, - current_selection: None, total: RowTotal::Unknown, load: ContentLoad::default(), tab_loading: false, content_had_focus: false, - current_column_view: None, - view_columns: Vec::new(), - row_index: GridRowIndex::default(), - rendered_columns: Vec::new(), + grid, + grid_actions_shown: false, was_dirty: Cell::new(false), - pending_restore: None, filter_strip, actions, saving: Cell::new(false), - grid_sink: layout::grid_sink(&tab), suppress_combo_emit: Cell::new(true), - confirming_delete: None, - next_confirmation: 0, - next_paste: 0, - confirming_paste: None, widgets, }; - imp.model.replace(Some(model)); - imp.link_watch.replace(Some(WatchForwarder::new( + tab.imp().model.replace(Some(model)); + tab.start(link_changes, init.first_load); + tab + } + + /// Follow reconnects for as long as the tab is open, and read the + /// table once the tab is first shown, or at once. + fn start(&self, link_changes: tokio::sync::watch::Receiver, first_load: FirstLoad) { + self.imp().link_watch.replace(Some(WatchForwarder::new( link_changes, { - let tab = tab.downgrade(); + let tab = self.downgrade(); move |generation: LinkGeneration| { if let Some(tab) = tab.upgrade() { tab.send(DataInput::LinkChanged(generation)); @@ -239,15 +226,14 @@ impl BrowseTab { glib::spawn_future_local(future); }, ))); - start_first_load(&tab, init.first_load, { - let tab = tab.downgrade(); + start_first_load(self, first_load, { + let tab = self.downgrade(); move || { if let Some(tab) = tab.upgrade() { tab.send(DataInput::Start); } } }); - tab } /// Hand the tab an input. One that arrives while another is being @@ -276,7 +262,6 @@ impl BrowseTab { for output in effects.outputs { self.emit_output(output); } - imp.queue.borrow_mut().extend(effects.inputs); } imp.dispatching.set(false); } @@ -297,11 +282,16 @@ impl BrowseTab { /// Commit the cell edit open in the grid, as Return does, so the close /// prompts count what was typed. pub fn commit_cell_edit(&self) { - if let Some(view) = self.read(|model| model.current_column_view.clone()).flatten() { - super::grid::commit_open_edit(&view); + if let Some(grid) = self.imp().grid.get() { + grid.commit_edit(); } } + #[cfg(test)] + pub(crate) fn grid(&self) -> DataGrid { + self.imp().grid.get().cloned().expect("the tab builds its grid") + } + /// `None` once the tab is closed, and while it is handling an input. fn read(&self, read: impl FnOnce(&BrowseModel) -> T) -> Option { self.imp().model.try_borrow().ok()?.as_ref().map(read) @@ -428,21 +418,10 @@ impl BrowseTab { fn emit_output(&self, output: BrowseTabOutput) { match output { BrowseTabOutput::StateChanged => self.emit_by_name::<()>("state-changed", &[]), - BrowseTabOutput::Copy(request) => self.copy(request), - BrowseTabOutput::ShowRowJson(request) => { - if let Some(tasks) = self.read(|model| model.tasks.clone()) { - let tab = self.clone().upcast(); - glib::spawn_future_local(async move { - present_row_json(tab, tasks, request).await; - }); - } - } BrowseTabOutput::Export(offer) => { self.emit_by_name::<()>("export-results", &[&glib::BoxedAnyObject::new(*offer)]); } - BrowseTabOutput::ShowSelectionAlert { title, body } => { - self.emit_by_name::<()>("alert", &[&title, &body]); - } + BrowseTabOutput::ShowAlert { title, body } => self.emit_by_name::<()>("alert", &[&title, &body]), BrowseTabOutput::ShowToast(text) => self.emit_by_name::<()>("toast", &[&text]), BrowseTabOutput::DirtyChanged(dirty) => self.emit_by_name::<()>("dirty-changed", &[&dirty]), BrowseTabOutput::SaveRequested => self.emit_by_name::<()>("save-requested", &[]), @@ -452,75 +431,20 @@ impl BrowseTab { BrowseTabOutput::FollowReference(reference) => { self.emit_by_name::<()>("follow-reference", &[&glib::BoxedAnyObject::new(reference)]); } - BrowseTabOutput::ReadClipboard(request) => self.read_paste(request), } } - - /// Read the clipboard and turn what it holds into rows off the GTK - /// thread, then hand the tab what it came to. - fn read_paste(&self, request: PasteRequest) { - let Some(tasks) = self.read(|model| model.tasks.clone()) else { - return; - }; - let clipboard = self.clipboard(); - let tab = self.downgrade(); - glib::spawn_future_local(async move { - let outcome = match read_clipboard(&clipboard).await { - Ok(source) => { - let columns = request.columns.clone(); - let in_order = request.in_order.clone(); - let at_cell = request.at.is_some(); - match tasks - .spawn_blocking_task(move || read_pasted(&source, &columns, &in_order, at_cell)) - .await - { - Ok(outcome) => outcome, - Err(failure) => { - tracing::warn!(%failure, "pasted text could not be read"); - PasteOutcome::Failed - } - } - } - Err(outcome) => outcome, - }; - if let Some(tab) = tab.upgrade() { - tab.send(ClipboardInput::PasteRead(Box::new(PasteRead { request, outcome }))); - } - }); - } - - /// Render a copy off the GTK thread, and toast how it went. - fn copy(&self, request: CopyRequest) { - let Some(tasks) = self.read(|model| model.tasks.clone()) else { - return; - }; - let tab = self.downgrade(); - crate::ui::clipboard::copy_rows(self, &tasks, request, move |text| { - if let Some(tab) = tab.upgrade() { - tab.emit_by_name::<()>("toast", &[&text]); - } - }); - } } -/// What one input left to do once it was handled: signals to emit and -/// inputs to handle next. +/// The signals one input left to emit once it was handled. #[derive(Default)] pub(crate) struct Effects { outputs: Vec, - inputs: VecDeque, - /// A bulk delete over rows in the running save says so once. - told_edit_held: bool, } impl Effects { fn output(&mut self, output: BrowseTabOutput) { self.outputs.push(output); } - - fn input(&mut self, input: impl Into) { - self.inputs.push_back(input.into()); - } } /// Which page of the table a read asks for. @@ -554,8 +478,7 @@ pub(crate) struct BrowseModel { /// The current connection's dialect, which says whether a filter can /// be written for it. Read again after every reconnect. dialect: Option>, - /// The table's foreign keys, which every grid the tab builds reads - /// as its menu opens. + /// The table's foreign keys, which the grid's menus read as they open. keys: KeysHandle, tracker: TabTrackerHandle, @@ -563,25 +486,21 @@ pub(crate) struct BrowseModel { table: String, connection_id: Option, read_only: bool, - column_widths: crate::persistence::ColumnWidthStore, filter_settings: crate::persistence::FilterSettingsStore, /// Where the page the tab reads starts, and how many rows it holds. pager: BrowsePager, deadlines: Arc, - /// Read at each delete, so a change in Preferences reaches open tabs. - settings: Rc, current_sort: Option<(usize, bool)>, /// The WHERE every page and count reads under, kept per connection, /// schema and table. current_filter: tablepro_core::FilterSet, current_columns: Vec, - /// The page on screen, whose rows the grid's store holds. + /// The page on screen, whose rows the grid holds. page: Option, /// What `page` was read under. A read that fails while asking for /// that same page again leaves it on screen. shown_view: Option, - current_selection: Option, /// The table's row count under the filter, as far as the tab knows. total: RowTotal, load: ContentLoad, @@ -591,36 +510,20 @@ pub(crate) struct BrowseModel { /// to focus, took their place. content_had_focus: bool, - current_column_view: Option, - /// The view's columns in the order of a row's cells, which a drag in - /// the header leaves alone. - view_columns: Vec, - /// The store's rows by tracker key, so a tracked change finds the - /// row to redraw without a scan of the page. - row_index: GridRowIndex, - /// The columns the current view was built for. A page under the - /// same columns reuses the view and only swaps its rows; a schema - /// change that renames or retypes one builds the view again. - rendered_columns: Vec, + /// The tab's one grid, for its whole life. + grid: DataGrid, + /// The grid's actions are on the tab, for its buttons and menu. + grid_actions_shown: bool, /// Last-emitted dirty state, so App hears only real transitions and /// not every count change (2 → 3). was_dirty: Cell, - /// The selection and the focused cell before a sort, save or - /// refresh, so the page read again puts them back. - pending_restore: Option, filter_strip: FilterStrip, actions: BrowseActions, /// Between the save starting and settling. saving: Cell, - grid_sink: GridSink, /// Set until the first page lands, so the page-size dropdown being /// set from a restored state does not count as the user choosing. suppress_combo_emit: Cell, - confirming_delete: Option, - next_confirmation: u64, - /// The ticket of the latest paste, the only one that may land. - next_paste: u64, - confirming_paste: Option, widgets: BrowseTabWidgets, } @@ -629,10 +532,9 @@ pub(crate) enum BrowseTabOutput { /// Something the window keeps for the tab changed: the page, the /// sort, the page size. StateChanged, - Copy(CopyRequest), - ShowRowJson(RowJsonRequest), Export(Box), - ShowSelectionAlert { + /// What a toast is too short to say, as why a save failed. + ShowAlert { title: String, body: String, }, @@ -646,8 +548,6 @@ pub(crate) enum BrowseTabOutput { RefreshRequested, /// The grid asked to open the rows a foreign key leads to. FollowReference(RowReference), - /// A paste the tab can take, for the tab to read the clipboard for. - ReadClipboard(PasteRequest), } impl BrowseModel { @@ -655,10 +555,8 @@ impl BrowseModel { match msg { BrowseTabInput::Data(input) => self.on_data(input, fx), BrowseTabInput::Paging(input) => self.on_paging(input, fx), - BrowseTabInput::Edit(input) => self.on_edit(input, fx), BrowseTabInput::Save(input) => self.on_save(input, fx), - BrowseTabInput::Selection(input) => self.on_selection(input), - BrowseTabInput::Clipboard(input) => self.on_clipboard(input, fx), + BrowseTabInput::Grid(input) => self.on_grid(input, fx), } } @@ -670,35 +568,8 @@ impl BrowseModel { sorts_rows(self.dialect.as_deref()) } - /// The server sorts the page, except on an engine that reads a table - /// only in its own order. fn grid_sorting(&self) -> GridSorting { - match self.sorts_rows() { - true => GridSorting::Server { - initial: self.current_sort, - }, - false => GridSorting::EngineOrder, - } - } - - /// The tracker context the grid renders through. Copy and export - /// read the same context, so what leaves the tab is what the user - /// is looking at, pending edits included. - fn grid_context(&self) -> TabGridContext { - TabGridContext { - tracker: Some(self.tracker.clone()), - editable: !self.read_only, - identity: pk_identity(&self.current_columns), - column_widths: self.connection_id.map(|connection_id| ColumnWidthBinding { - store: self.column_widths.clone(), - connection_id, - schema: self.schema.clone(), - table: self.table.clone(), - }), - // An engine of documents has no SQL list to copy rows into. - in_clause: self.dialect.as_ref().is_none_or(|dialect| dialect.grammar().is_sql()), - keys: Some(self.keys.clone()), - } + grid_sorting(self.dialect.as_deref(), self.current_sort) } fn table_label(&self) -> String { @@ -706,31 +577,12 @@ impl BrowseModel { } /// The page whose rows the grid shows. A failed read of another page - /// leaves the rows it replaced in the store, behind the error page, + /// leaves the rows it replaced in the grid, behind the error page, /// where nothing may export, copy or delete them. fn shown_page(&self) -> Option<&PageState> { self.page.as_ref().filter(|_| self.load.has_content()) } - fn list_store(&self) -> Option { - let selection = self.current_selection.as_ref()?; - selection.model()?.downcast::().ok() - } - - fn column_view_matches_current_columns(&self) -> bool { - self.current_column_view.is_some() && self.rendered_columns == self.current_columns - } - - /// A row event from a grid built for other columns names its cells - /// by positions that now hold other columns. - fn grid_is_stale(&self) -> bool { - let stale = !self.column_view_matches_current_columns(); - if stale { - tracing::debug!("dropped a row event from a grid built for other columns"); - } - stale - } - fn rows_on_page(&self) -> u64 { self.shown_page().map_or(0, |page| page.row_count as u64) } @@ -740,52 +592,23 @@ impl BrowseModel { } } -/// What the clipboard holds: the cells a grid copy offers, which keep -/// each value and NULL as they were, or else its text. Either is read -/// only up to the import's limit, since another program's clipboard can -/// hold anything. -async fn read_clipboard(clipboard: >k::gdk::Clipboard) -> Result { - use crate::ui::clipboard::CELLS_MIME_TYPE; - use crate::ui::file_read::{FileReadError, read_capped}; - - let cells = clipboard.formats().contain_mime_type(CELLS_MIME_TYPE); - let wanted: &[&str] = match cells { - true => &[CELLS_MIME_TYPE], - false => &["text/plain;charset=utf-8", "text/plain"], - }; - let stream = match clipboard.read_future(wanted, glib::Priority::DEFAULT).await { - Ok((stream, _)) => stream, - Err(error) => { - tracing::debug!(%error, "the clipboard holds nothing to paste"); - return Err(PasteOutcome::Nothing); - } - }; - let bytes = match read_capped(&stream, tablepro_core::import::MAX_CSV_FILE_BYTES).await { - Ok(bytes) => bytes, - Err(FileReadError::TooLarge) => return Err(PasteOutcome::TooLarge), - Err(error) => { - tracing::warn!(?error, "the clipboard could not be read"); - return Err(PasteOutcome::Failed); - } - }; - match cells { - true => Ok(PasteSource::Cells(bytes)), - false => String::from_utf8(bytes).map(PasteSource::Text).map_err(|error| { - PasteOutcome::Unreadable(tablepro_core::import::ImportError::NotUtf8 { - offset: error.utf8_error().valid_up_to(), - }) - }), - } -} - -/// A status page reads its description as markup, and a table's name -/// can hold an `&` or a `<`. /// Whether a page of a table can be sorted on its engine. Without a /// connection yet, a sort is kept for when one opens. fn sorts_rows(dialect: Option<&dyn SqlDialect>) -> bool { dialect.is_none_or(|dialect| dialect.table_paging() == TablePaging::InStatement) } +/// The server sorts the page, except on an engine that reads a table +/// only in its own order. +fn grid_sorting(dialect: Option<&dyn SqlDialect>, sort: Option<(usize, bool)>) -> GridSorting { + match sorts_rows(dialect) { + true => GridSorting::Server { initial: sort }, + false => GridSorting::None, + } +} + +/// A status page reads its description as markup, and a table's name +/// can hold an `&` or a `<`. fn loading_description(table: &str) -> glib::GString { glib::markup_escape_text(&crate::i18n::gettext_f( "Fetching rows from {table}", @@ -799,51 +622,3 @@ fn qualified(schema: Option<&str>, table: &str) -> String { None => table.to_owned(), } } - -/// The one place the store gains or loses draft rows. A paste can add -/// or an undo remove thousands at once, so each run of rows is one -/// change to the store rather than one per row. -fn apply_draft_changes(store: >k::gio::ListStore, index: &mut GridRowIndex, delta: &TrackerDelta) { - if !delta.removed_drafts.is_empty() { - remove_drafts(store, &delta.removed_drafts); - for draft_id in &delta.removed_drafts { - index.remove(&RowKey::Draft(*draft_id)); - } - } - if !delta.inserted_drafts.is_empty() { - // The tracker lists drafts bottom up: the last one goes on top. - let rows: Vec = delta - .inserted_drafts - .iter() - .rev() - .map(|draft_id| RowObject::draft(*draft_id)) - .collect(); - for row in &rows { - index.insert(row); - } - store.splice(0, 0, &rows); - } -} - -fn remove_drafts(store: >k::gio::ListStore, removed: &[u64]) { - let removed: std::collections::HashSet = removed.iter().copied().collect(); - let is_removed = |position: u32| { - store - .item(position) - .and_downcast::() - .and_then(|row| row.draft_id()) - .is_some_and(|draft_id| removed.contains(&draft_id)) - }; - let mut position = store.n_items(); - while position > 0 { - position -= 1; - if !is_removed(position) { - continue; - } - let end = position + 1; - while position > 0 && is_removed(position - 1) { - position -= 1; - } - store.splice(position, end - position, &[] as &[RowObject]); - } -} diff --git a/linux/crates/app/src/ui/browse_tab/page_label.rs b/linux/crates/app/src/ui/browse_tab/page_label.rs index 58a8d080c4..94aefc2f11 100644 --- a/linux/crates/app/src/ui/browse_tab/page_label.rs +++ b/linux/crates/app/src/ui/browse_tab/page_label.rs @@ -21,8 +21,6 @@ fn label_with(page: &PageState, total: &RowTotal, format: impl Fn(u64) -> String #[cfg(test)] mod tests { - use std::sync::Arc; - use super::*; use crate::ui::format::Thousands; @@ -33,7 +31,6 @@ mod tests { fn labelled(start: u64, rows: usize, filtered: bool, total: &RowTotal) -> String { let english = Thousands::for_posix_locale("en_US.UTF-8"); let page = PageState { - result_columns: Arc::from([]), row_count: rows, start_ordinal: start, filtered, diff --git a/linux/crates/app/src/ui/browse_tab/page_state.rs b/linux/crates/app/src/ui/browse_tab/page_state.rs index 2bc8d32870..aa171fe30c 100644 --- a/linux/crates/app/src/ui/browse_tab/page_state.rs +++ b/linux/crates/app/src/ui/browse_tab/page_state.rs @@ -1,13 +1,7 @@ -use std::sync::Arc; - -use tablepro_core::column::ResultColumn; - /// What the tab knows of the page on screen. Its rows are the grid's: -/// the store holds the only copy. +/// the grid's store holds the only copy. #[derive(Debug, Clone)] pub(crate) struct PageState { - /// The page's visible columns. - pub result_columns: Arc<[ResultColumn]>, pub row_count: usize, /// How many rows of the table come before the page. pub start_ordinal: u64, diff --git a/linux/crates/app/src/ui/browse_tab/pager_bar.rs b/linux/crates/app/src/ui/browse_tab/pager_bar.rs index ce917f61c6..5e19a53d64 100644 --- a/linux/crates/app/src/ui/browse_tab/pager_bar.rs +++ b/linux/crates/app/src/ui/browse_tab/pager_bar.rs @@ -301,8 +301,6 @@ impl PagerBar { #[cfg(test)] mod tests { - use std::sync::Arc; - use gtk4::AccessibleProperty; use super::*; @@ -316,7 +314,6 @@ mod tests { fn page(start_ordinal: u64, row_count: usize) -> PageState { PageState { - result_columns: Arc::from([]), row_count, start_ordinal, filtered: false, diff --git a/linux/crates/app/src/ui/browse_tab/paging_handlers.rs b/linux/crates/app/src/ui/browse_tab/paging_handlers.rs index 072b915460..cca30c6acf 100644 --- a/linux/crates/app/src/ui/browse_tab/paging_handlers.rs +++ b/linux/crates/app/src/ui/browse_tab/paging_handlers.rs @@ -10,7 +10,12 @@ impl BrowseModel { pub(super) fn on_paging(&mut self, input: PagingInput, fx: &mut Effects) { match input { PagingInput::Refresh => self.reload(fx), - PagingInput::RequestRefresh => fx.output(BrowseTabOutput::RefreshRequested), + // The window asks first when the tab has unsaved changes, and + // what is typed in an open editor is one of them. + PagingInput::RequestRefresh => { + self.grid.commit_edit(); + fx.output(BrowseTabOutput::RefreshRequested); + } PagingInput::ToggleFilterStrip => self.filter_strip.toggle(), PagingInput::ShowFilterStrip => self.filter_strip.open(), PagingInput::FilterApplied(set) => self.apply_filter(set, fx), @@ -18,8 +23,6 @@ impl BrowseModel { PagingInput::PrevPage => self.turn_page(self.pager.prev(), fx), PagingInput::NextPage => self.turn_page(self.pager.next(self.rows_on_page()), fx), PagingInput::LastPage => self.turn_page(self.pager.last(&self.total), fx), - PagingInput::SortChanged { col_idx, ascending } => self.sort_by(Some((col_idx, ascending)), fx), - PagingInput::SortCleared => self.sort_by(None, fx), PagingInput::PageSizeChanged(size) => { if self.suppress_combo_emit.get() || self.pager.page_size() == size { return; @@ -37,7 +40,6 @@ impl BrowseModel { /// tab, then the page and the count built from them. The rows on /// screen stay until the new page lands. pub(super) fn reload(&mut self, fx: &mut Effects) { - self.capture_selection(); self.begin_read(fx); self.stop_count(true); self.reload_from_columns(ColumnFreshness::Refetch); @@ -63,15 +65,15 @@ impl BrowseModel { fx.output(BrowseTabOutput::StateChanged); } - fn sort_by(&mut self, next: Option<(usize, bool)>, fx: &mut Effects) { - // The sorter notifies twice for one click on a new column - // (column, then order), with the same final pair. + /// A sort the headers did not make, which they show too. + /// Read the first page in `next` order. + pub(super) fn sort_by(&mut self, next: Option<(usize, bool)>, fx: &mut Effects) { + // One header click can report the same sort twice. if self.current_sort == next { return; } self.current_sort = next; self.pager.restart(); - self.capture_selection(); self.fetch(BrowseRequestKind::Page, fx); fx.output(BrowseTabOutput::StateChanged); } diff --git a/linux/crates/app/src/ui/browse_tab/paging_input.rs b/linux/crates/app/src/ui/browse_tab/paging_input.rs index 9ffb8efaa9..4f708960f4 100644 --- a/linux/crates/app/src/ui/browse_tab/paging_input.rs +++ b/linux/crates/app/src/ui/browse_tab/paging_input.rs @@ -18,11 +18,6 @@ pub(crate) enum PagingInput { PrevPage, NextPage, LastPage, - SortChanged { - col_idx: usize, - ascending: bool, - }, - SortCleared, PageSizeChanged(u64), /// Count Rows, or Try Again after a count failed. CountRows, diff --git a/linux/crates/app/src/ui/browse_tab/paste_at.rs b/linux/crates/app/src/ui/browse_tab/paste_at.rs deleted file mode 100644 index b44898906e..0000000000 --- a/linux/crates/app/src/ui/browse_tab/paste_at.rs +++ /dev/null @@ -1,9 +0,0 @@ -use crate::ui::grid::RowObject; - -/// The cell a paste starts at: one value goes into it, and rows fill -/// the table's columns from its column on. -#[derive(Debug, Clone)] -pub(crate) struct PasteAt { - pub(crate) row: RowObject, - pub(crate) column: usize, -} diff --git a/linux/crates/app/src/ui/browse_tab/paste_handlers.rs b/linux/crates/app/src/ui/browse_tab/paste_handlers.rs deleted file mode 100644 index d77374f301..0000000000 --- a/linux/crates/app/src/ui/browse_tab/paste_handlers.rs +++ /dev/null @@ -1,285 +0,0 @@ -use gtk4 as gtk; -use gtk4::glib; -use gtk4::prelude::*; -use libadwaita as adw; -use libadwaita::prelude::*; -use tablepro_core::edit::parse_edit_text; -use tablepro_core::import::{ImportError, PastedRows}; - -use super::confirming_paste::ConfirmingPaste; -use super::paste_at::PasteAt; -use super::paste_read::{PasteOutcome, PasteRead, PasteRequest}; -use super::{BrowseModel, BrowseTabOutput, ClipboardInput, EditInput, Effects}; -use crate::i18n::{gettext, gettext_f, ngettext_f}; -use crate::services::change_tracker::pk_identity; -use crate::services::tracker_edit::TrackerEdit; -use crate::ui::format::{format_thousands, plural_count}; -use crate::ui::grid::{ - CellPreset, CommitResult, EditSnapshot, GridCell, cell_editability, commit_input, data_columns, edit_context, - select_top_rows, -}; - -/// A paste of more rows asks first: each becomes a pending row, and -/// Import CSV is the tool for a lot of data. -pub(crate) const PASTE_CONFIRM_ROWS: usize = 1_000; - -impl BrowseModel { - pub(super) fn paste(&mut self, at: Option, fx: &mut Effects) { - if self.read_only { - fx.output(BrowseTabOutput::ShowToast(gettext("This connection is read-only."))); - return; - } - if self.current_columns.is_empty() || self.grid_is_stale() { - return; - } - if !self.has_primary_key() { - fx.output(BrowseTabOutput::ShowToast(gettext( - "Rows can only be pasted into a table with a primary key.", - ))); - return; - } - self.next_paste += 1; - let in_order = self.columns_from(at.as_ref().map(|at| at.column)); - fx.output(BrowseTabOutput::ReadClipboard(PasteRequest { - ticket: self.next_paste, - at, - columns: self.current_columns.clone(), - in_order, - })); - } - - /// The columns as the grid shows them, left to right after any drag - /// of a header, from `start` on, each by its place in a row. - fn columns_from(&self, start: Option) -> Vec { - let Some(view) = self.current_column_view.as_ref() else { - return Vec::new(); - }; - let shown: Vec = data_columns(view) - .iter() - .filter(|column| column.is_visible()) - .filter_map(|column| self.view_columns.iter().position(|cell_column| cell_column == column)) - .collect(); - let first = start - .and_then(|start| shown.iter().position(|index| *index == start)) - .unwrap_or_default(); - shown.get(first..).unwrap_or_default().to_vec() - } - - pub(super) fn focused_cell(&self) -> Option { - let view = self.current_column_view.as_ref()?; - let window = view.root().and_downcast::()?; - let focus = gtk::prelude::GtkWindowExt::focus(&window)?; - if !focus.is_ancestor(view) { - return None; - } - let cell = GridCell::from_focus(&focus)?; - Some(PasteAt { - row: cell.row()?, - column: cell.column(), - }) - } - - pub(super) fn paste_read(&mut self, read: PasteRead, fx: &mut Effects) { - let PasteRead { request, outcome } = read; - if request.ticket != self.next_paste { - return; - } - if request.columns != self.current_columns || self.grid_is_stale() { - fx.output(BrowseTabOutput::ShowToast(gettext( - "The table changed while pasting. Paste again.", - ))); - return; - } - match outcome { - PasteOutcome::Nothing => { - fx.output(BrowseTabOutput::ShowToast(gettext( - "The clipboard has no rows to paste.", - ))); - } - PasteOutcome::Failed => fx.output(BrowseTabOutput::ShowToast(gettext("Couldn't read the clipboard."))), - PasteOutcome::TooLarge => fx.output(BrowseTabOutput::ShowToast(gettext( - "The clipboard holds too much to paste. Import CSV takes large amounts of data.", - ))), - PasteOutcome::Unreadable(error) => paste_alert(unreadable_text(&error), fx), - PasteOutcome::Refused(error) => paste_alert(crate::ui::error_text::row_error_message(&error), fx), - PasteOutcome::Value(text) => match request.at { - Some(at) => self.paste_value(at, text.as_deref(), fx), - None => tracing::error!("a single value came back for a paste with no cell"), - }, - PasteOutcome::Rows(rows) if rows.rows.len() > PASTE_CONFIRM_ROWS => self.confirm_paste(rows), - PasteOutcome::Rows(rows) => self.insert_pasted(rows, fx), - } - } - - /// One value goes into the cell, as if typed there, and a NULL as - /// Set to NULL puts it. - fn paste_value(&mut self, at: PasteAt, text: Option<&str>, fx: &mut Effects) { - let PasteAt { row, column } = at; - // A page read while the clipboard answered replaces the row's - // object, and the old one holds the values it had then. - let Some(row) = row.id().tracker_key().and_then(|key| self.row_index.get(&key)) else { - fx.output(BrowseTabOutput::ShowToast(gettext( - "The table changed while pasting. Paste again.", - ))); - return; - }; - let Some(info) = self.current_columns.get(column) else { - return; - }; - if let Err(lock) = cell_editability(info, row.id(), &pk_identity(&self.current_columns), self.read_only) { - fx.output(BrowseTabOutput::ShowToast(lock.explanation())); - return; - } - let Some(text) = text else { - fx.input(EditInput::GridSetCellValue { - row, - column, - preset: CellPreset::Null, - }); - return; - }; - let input = match parse_edit_text(text, info, edit_context(row.id())) { - Ok(input) => input, - Err(error) => { - return fx.output(BrowseTabOutput::ShowToast(crate::ui::error_text::edit_parse_message( - &error, - ))); - } - }; - // As a typed edit commits, so a value equal to the cell's, in - // the spelling the column keeps, changes nothing. - let snapshot = EditSnapshot { - original: self.grid_context().edit_input(&row, column), - row, - column, - }; - match commit_input(info, &snapshot, input) { - CommitResult::Edit(edit) => self.apply_edit(edit, fx), - CommitResult::Unchanged => {} - CommitResult::Rejected(message) => fx.output(BrowseTabOutput::ShowToast(message)), - } - } - - fn insert_pasted(&mut self, pasted: PastedRows, fx: &mut Effects) { - let PastedRows { rows, skipped } = pasted; - let count = rows.len(); - if count == 0 { - fx.output(BrowseTabOutput::ShowToast(gettext( - "The clipboard has no rows to paste.", - ))); - return; - } - self.apply_edit(TrackerEdit::InsertDrafts { rows }, fx); - fx.input(EditInput::SelectPastedDrafts(u32::try_from(count).unwrap_or(u32::MAX))); - let n = count as u64; - fx.output(BrowseTabOutput::ShowToast(ngettext_f( - "Pasted {n} row", - "Pasted {n} rows", - plural_count(n), - &[("n", &format_thousands(n))], - ))); - if skipped > 0 { - let n = skipped as u64; - fx.output(BrowseTabOutput::ShowToast(ngettext_f( - "Left out {n} value no column could take", - "Left out {n} values no column could take", - plural_count(n), - &[("n", &format_thousands(n))], - ))); - } - } - - /// Select the rows a paste added, which sit on top, once the grid - /// has drawn them. - pub(super) fn select_pasted_drafts(&self, count: u32) { - let Some(view) = self.current_column_view.clone() else { - return; - }; - glib::idle_add_local_once(move || { - if view.is_mapped() { - select_top_rows(&view, count); - } - }); - } - - fn confirm_paste(&mut self, rows: PastedRows) { - let count = rows.rows.len() as u64; - let title = ngettext_f( - "Paste {n} Row?", - "Paste {n} Rows?", - plural_count(count), - &[("n", &format_thousands(count))], - ); - let body = gettext( - "Each one is added as a new row that waits until you save. Import CSV, in the table's menu in the sidebar, writes large amounts of data straight to the table.", - ); - let dialog = adw::AlertDialog::new(Some(&title), Some(&body)); - dialog.add_response("cancel", &gettext("Cancel")); - dialog.add_response("paste", &gettext("Paste")); - dialog.set_response_appearance("paste", adw::ResponseAppearance::Suggested); - dialog.set_default_response(Some("paste")); - dialog.set_close_response("cancel"); - if let Some(pending) = self.confirming_paste.take() { - pending.dialog.force_close(); - } - let ticket = self.next_paste; - let tab = self.this.clone(); - dialog.connect_response(None, move |_, response| { - if let Some(tab) = tab.upgrade() { - tab.send(ClipboardInput::PasteAnswered { - ticket, - confirmed: response == "paste", - }); - } - }); - dialog.present(Some(&self.widgets.inner_stack)); - self.confirming_paste = Some(ConfirmingPaste { - ticket, - rows, - columns: self.current_columns.clone(), - dialog, - }); - } - - pub(super) fn paste_answered(&mut self, ticket: u64, confirmed: bool, fx: &mut Effects) { - if self - .confirming_paste - .as_ref() - .is_none_or(|pending| pending.ticket != ticket) - { - return; - } - let Some(pending) = self.confirming_paste.take() else { - return; - }; - if !confirmed { - return; - } - if pending.columns != self.current_columns { - fx.output(BrowseTabOutput::ShowToast(gettext( - "The table changed while pasting. Paste again.", - ))); - return; - } - self.insert_pasted(pending.rows, fx); - } -} - -fn paste_alert(body: String, fx: &mut Effects) { - fx.output(BrowseTabOutput::ShowSelectionAlert { - title: gettext("Couldn't Paste"), - body, - }); -} - -fn unreadable_text(error: &ImportError) -> String { - match error { - ImportError::TooManyFields { line, limit } => gettext_f( - "Line {line} of the pasted text has more than {limit} values, more than any table holds.", - &[("line", &line.to_string()), ("limit", &limit.to_string())], - ), - ImportError::NotUtf8 { .. } | ImportError::Malformed { .. } | ImportError::NoColumns => { - gettext("The pasted text couldn't be read as rows.") - } - } -} diff --git a/linux/crates/app/src/ui/browse_tab/paste_read.rs b/linux/crates/app/src/ui/browse_tab/paste_read.rs deleted file mode 100644 index 8442105708..0000000000 --- a/linux/crates/app/src/ui/browse_tab/paste_read.rs +++ /dev/null @@ -1,77 +0,0 @@ -use tablepro_core::ColumnInfo; -use tablepro_core::import::{CsvRowError, ImportError, PastedRows, PastedTable}; - -use super::paste_at::PasteAt; - -#[derive(Debug)] -pub(crate) struct PasteRequest { - /// Only the latest paste lands: the clipboard of another program can - /// take any time to answer, or never. - pub(crate) ticket: u64, - pub(crate) at: Option, - pub(crate) columns: Vec, - /// The columns values fill one after another, by their place in a - /// row: as the grid shows them, from the one the paste starts at. - pub(crate) in_order: Vec, -} - -#[derive(Debug)] -pub(crate) enum PasteOutcome { - /// No text, or text with no rows in it. - Nothing, - /// One value alone, for the cell the paste started at. `None` is a - /// NULL. - Value(Option), - Rows(PastedRows), - Unreadable(ImportError), - Refused(CsvRowError), - /// Reading the text failed in a way the user can only retry. - Failed, - /// More than a paste reads at once. - TooLarge, -} - -/// What the clipboard held: the cells a grid copy offers beside its -/// text, or text alone. -#[derive(Debug)] -pub(crate) enum PasteSource { - Cells(Vec), - Text(String), -} - -#[derive(Debug)] -pub(crate) struct PasteRead { - pub(crate) request: PasteRequest, - pub(crate) outcome: PasteOutcome, -} - -/// Read what the clipboard held against `columns`. With a cell to start -/// at, one value alone is that cell's. -pub(super) fn read_pasted( - source: &PasteSource, - columns: &[ColumnInfo], - in_order: &[usize], - at_cell: bool, -) -> PasteOutcome { - let read = match source { - PasteSource::Cells(json) => PastedTable::from_cells(json), - PasteSource::Text(text) => PastedTable::read(text), - }; - let table = match read { - Ok(table) => table, - Err(error) => return PasteOutcome::Unreadable(error), - }; - if table.is_empty() { - return PasteOutcome::Nothing; - } - if table.too_large_for(columns.len()) { - return PasteOutcome::TooLarge; - } - if at_cell && let Some(value) = table.single_value() { - return PasteOutcome::Value(value.map(str::to_owned)); - } - match table.rows(columns, in_order) { - Ok(rows) => PasteOutcome::Rows(rows), - Err(error) => PasteOutcome::Refused(error), - } -} diff --git a/linux/crates/app/src/ui/browse_tab/row_deletion.rs b/linux/crates/app/src/ui/browse_tab/row_deletion.rs deleted file mode 100644 index bf0f56331a..0000000000 --- a/linux/crates/app/src/ui/browse_tab/row_deletion.rs +++ /dev/null @@ -1,102 +0,0 @@ -use std::sync::Arc; - -use tablepro_core::Value; - -use crate::services::change_tracker::RowKey; -use crate::ui::grid::{GridRowId, RowObject}; - -#[derive(Debug, Clone, Default, PartialEq)] -pub struct RowDeletion { - /// Each with the row as it was read. - pub persisted: Vec<(RowKey, Arc<[Value]>)>, - pub drafts: Vec, - /// Rows a DELETE has no key to name by. - pub unkeyed: usize, -} - -impl RowDeletion { - pub fn len(&self) -> usize { - self.persisted.len() + self.drafts.len() - } - - pub fn is_empty(&self) -> bool { - self.len() == 0 - } - - /// A draft is dropped at once with its undo history, so a set that - /// holds one cannot be described as waiting for Save. - pub fn confirmation_body(&self) -> String { - match (self.persisted.is_empty(), self.drafts.is_empty()) { - (_, true) => crate::i18n::gettext( - "These rows will be marked for deletion. They aren't removed until you click Save.", - ), - (true, false) => crate::i18n::gettext( - "These rows were never saved. They're removed now, and Undo won't bring them back.", - ), - (false, false) => crate::i18n::gettext( - "Saved rows will be marked for deletion and aren't removed until you click Save. Rows never saved are removed now, and Undo won't bring them back.", - ), - } - } -} - -pub fn plan_row_deletion(rows: &[RowObject]) -> RowDeletion { - let mut deletion = RowDeletion::default(); - for row in rows { - match row.id() { - GridRowId::Draft(draft_id) => deletion.drafts.push(*draft_id), - GridRowId::Persisted(key) => deletion.persisted.push((key.clone(), row.cells().unwrap_or_default())), - GridRowId::Unkeyed | GridRowId::Result => deletion.unkeyed += 1, - } - } - deletion -} - -#[cfg(test)] -mod tests { - use tablepro_core::Value; - - use super::plan_row_deletion; - use crate::services::change_tracker::RowKey; - use crate::ui::grid::RowObject; - - #[test] - fn plan_row_deletion_partitions_drafts_persisted_and_unkeyed() { - let key = RowKey::from_pk_values(&[Value::Int(1)]).expect("a key"); - let rows = [ - RowObject::draft(4), - RowObject::persisted(Some(key.clone()), vec![Value::Int(1), Value::Text("a".into())].into()), - RowObject::persisted(None, vec![Value::Int(2)].into()), - RowObject::result(vec![Value::Int(3)].into()), - RowObject::draft(5), - ]; - - let deletion = plan_row_deletion(&rows); - - assert_eq!(deletion.drafts, vec![4, 5]); - assert_eq!( - deletion.persisted, - vec![(key, vec![Value::Int(1), Value::Text("a".into())].into())] - ); - assert_eq!(deletion.unkeyed, 2); - assert_eq!(deletion.len(), 3); - } - - #[test] - fn the_confirmation_says_drafts_go_at_once() { - let saved = RowObject::persisted( - Some(RowKey::from_pk_values(&[Value::Int(1)]).expect("a key")), - vec![Value::Int(1)].into(), - ); - - let only_saved = plan_row_deletion(std::slice::from_ref(&saved)).confirmation_body(); - let only_drafts = plan_row_deletion(&[RowObject::draft(1)]).confirmation_body(); - let mixed = plan_row_deletion(&[saved, RowObject::draft(1)]).confirmation_body(); - - assert!(only_saved.contains("click Save"), "{only_saved}"); - assert!(!only_saved.contains("removed now"), "{only_saved}"); - assert!(only_drafts.contains("removed now"), "{only_drafts}"); - assert!(!only_drafts.contains("click Save"), "{only_drafts}"); - assert!(mixed.contains("click Save") && mixed.contains("removed now"), "{mixed}"); - } -} diff --git a/linux/crates/app/src/ui/browse_tab/save_handlers.rs b/linux/crates/app/src/ui/browse_tab/save_handlers.rs index c534e060a8..6d11a10458 100644 --- a/linux/crates/app/src/ui/browse_tab/save_handlers.rs +++ b/linux/crates/app/src/ui/browse_tab/save_handlers.rs @@ -1,8 +1,5 @@ -use gtk4 as gtk; -use gtk4::glib; -use gtk4::prelude::*; - use super::{BrowseModel, BrowseRequestKind, BrowseTabOutput, Effects, SaveInput}; +use crate::i18n::gettext; use crate::services::change_origins::ChangeOrigins; use crate::services::change_tracker::{RowKey, StatementSource}; use crate::ui::discard_alert::confirm_discard; @@ -10,45 +7,45 @@ use crate::ui::discard_alert::confirm_discard; impl BrowseModel { pub(super) fn on_save(&mut self, input: SaveInput, fx: &mut Effects) { match input { - SaveInput::CommitSave => fx.output(BrowseTabOutput::SaveRequested), - SaveInput::SaveStarted => self.show_saving(true), + // What is typed in an open editor is part of the save. + SaveInput::CommitSave => { + self.grid.commit_edit(); + fx.output(BrowseTabOutput::SaveRequested); + } + SaveInput::SaveStarted => { + self.show_saving(true); + // Starting the save took a failed row's mark off. + self.grid.redraw(); + } SaveInput::StopSaving => fx.output(BrowseTabOutput::StopSaveRequested), SaveInput::ConfirmDiscard => self.confirm_discard(), SaveInput::DiscardAll => { - let delta = self.tracker.discard(); - self.apply_tracker_delta(&delta, fx); + self.discard(); self.fetch(BrowseRequestKind::Page, fx); } SaveInput::DiscardAndRefresh => { - let delta = self.tracker.discard(); - self.apply_tracker_delta(&delta, fx); + self.discard(); self.reload(fx); } SaveInput::SaveCommitted(origins) => self.save_committed(&origins, fx), - SaveInput::SaveFailed(message) => { - self.show_saving(false); - fx.output(BrowseTabOutput::ShowSelectionAlert { - title: crate::i18n::gettext("Save Failed"), - body: message, - }); - } + SaveInput::SaveFailed(message) => self.save_failed(gettext("Save Failed"), message, fx), SaveInput::SaveOutcomeUnknown(message) => { - self.show_saving(false); - fx.output(BrowseTabOutput::ShowSelectionAlert { - title: crate::i18n::gettext("Save Result Unknown"), - body: message, - }); - } - SaveInput::FlashErrorRow(source) => self.flash_error_row(&source), - SaveInput::ErrorFlashEnded(key) => { - if let Some(row) = self.row_index.get(&key) { - row.bump_revision(); - } + self.save_failed(gettext("Save Result Unknown"), message, fx); } + SaveInput::MarkErrorRow(source) => self.mark_error_row(&source), } } + /// The window's close prompt discards through the tracker and keeps + /// no delta, so the grid rebuilds its drafts from the tracker either + /// way. + fn discard(&self) { + self.grid.discard(); + self.grid.resync(); + } + fn confirm_discard(&self) { + self.grid.commit_edit(); let pending = self.tracker.pending_count(); if pending == 0 { return; @@ -61,16 +58,15 @@ impl BrowseModel { }); } + /// The tracker already let go of what the save wrote, and the page + /// read next replaces every row. The rows are not drawn again before + /// it lands: a saved draft would draw empty. fn save_committed(&mut self, origins: &ChangeOrigins, fx: &mut Effects) { self.show_saving(false); - // The tracker already let go of what the save wrote. The - // reload replaces every row; dropping the saved drafts - // from the store first would blank them until it lands. self.sync_pending_chrome(self.tracker.pending_count(), fx); - self.capture_selection(); - // The save says how the total moved, so nothing is - // counted again. A delete that emptied the last page - // leaves an exact total that moves the tab back a page. + // The save says how the total moved, so nothing is counted + // again. A delete that emptied the last page leaves an exact + // total that moves the tab back a page. self.total .adjust_after_save(origins.inserts, origins.deletes, !self.current_filter.is_empty()); self.clamp_to_total(fx); @@ -78,21 +74,27 @@ impl BrowseModel { self.fetch(BrowseRequestKind::Page, fx); } + /// The changes stay pending, rebased by the tracker on what was + /// read, so every row draws again. + fn save_failed(&self, title: String, body: String, fx: &mut Effects) { + self.show_saving(false); + self.grid.redraw(); + fx.output(BrowseTabOutput::ShowAlert { title, body }); + } + /// While a save runs, Save and Discard are off, so a double click /// cannot send two, and Stop Saving takes their place. fn show_saving(&self, saving: bool) { self.saving.set(saving); - self.actions.follow_tracker(&self.tracker, saving); + self.follow_tracker(); self.widgets.pending_bar.show_saving(saving); } pub(super) fn refresh_pending_bar(&self, count: usize) { // The footer slides away once nothing is left to save, and the // grid holds what the user was working on. - if count == 0 - && let Some(grid) = self.current_column_view.as_ref() - { - crate::ui::a11y::pass_focus(&self.widgets.pending_bar, grid); + if count == 0 { + crate::ui::a11y::pass_focus(&self.widgets.pending_bar, &self.grid.column_view()); } self.widgets.pending_bar.show_pending(count); self.refresh_banner_visibility(); @@ -102,57 +104,24 @@ impl BrowseModel { /// title bullet. App hears only real transitions. pub(super) fn sync_pending_chrome(&self, count: usize, fx: &mut Effects) { self.refresh_pending_bar(count); - self.actions.follow_tracker(&self.tracker, self.saving.get()); + self.follow_tracker(); let dirty = count > 0; if dirty != self.was_dirty.replace(dirty) { fx.output(BrowseTabOutput::DirtyChanged(dirty)); } } - /// Scroll to the row behind a failed statement and flash it. A row - /// paged, sorted or filtered away is not found, and the alert alone - /// tells the user which statement failed. - fn flash_error_row(&mut self, source: &StatementSource) { + /// Mark the row behind a failed statement and bring it into view. It + /// keeps the mark until it is changed or saved again. A row paged, + /// sorted or filtered away is not found, and the alert alone tells + /// the user which statement failed. + fn mark_error_row(&self, source: &StatementSource) { let key = match source { StatementSource::Insert { draft_id } => RowKey::Draft(*draft_id), StatementSource::Update { row_key } | StatementSource::Delete { row_key } => row_key.clone(), }; - let Some(store) = self.list_store() else { - return; - }; - let Some(row) = self.row_index.get(&key) else { - return; - }; - let Some(position) = store.find(&row) else { - return; - }; - if let Some(selection) = self.current_selection.as_ref() { - selection.select_item(position, true); - } - if let Some(cv) = self.current_column_view.as_ref() { - cv.scroll_to( - position, - None, - gtk::ListScrollFlags::FOCUS | gtk::ListScrollFlags::SELECT, - None, - ); - } - let flash = self.tracker.flash_error_row(key.clone()); - row.bump_revision(); - if let Some(replaced) = flash.replaced.and_then(|replaced| self.row_index.get(&replaced)) { - replaced.bump_revision(); - } - let generation = flash.generation; - let tracker = self.tracker.clone(); - let tab = self.this.clone(); - // Matches the length of the flash animation in the stylesheet. The - // row is looked up again then, since a reload may have replaced it. - glib::timeout_add_local_once(std::time::Duration::from_millis(1800), move || { - if tracker.clear_error_row_if(generation) - && let Some(tab) = tab.upgrade() - { - tab.send(SaveInput::ErrorFlashEnded(key)); - } - }); + let delta = self.tracker.mark_error_row(key.clone()); + self.grid.apply_delta(&delta); + self.grid.reveal_row(&key); } } diff --git a/linux/crates/app/src/ui/browse_tab/save_input.rs b/linux/crates/app/src/ui/browse_tab/save_input.rs index e7b11dbd71..bee4fcdcd4 100644 --- a/linux/crates/app/src/ui/browse_tab/save_input.rs +++ b/linux/crates/app/src/ui/browse_tab/save_input.rs @@ -1,5 +1,5 @@ use crate::services::change_origins::ChangeOrigins; -use crate::services::change_tracker::{RowKey, StatementSource}; +use crate::services::change_tracker::StatementSource; /// What changes where the tab's save stands: Save, Discard, Stop Saving /// and how the window's save of the tab went. @@ -22,7 +22,7 @@ pub(crate) enum SaveInput { /// The save may have landed. The changes stay, and the tracker /// refuses to save them again until the table is read again. SaveOutcomeUnknown(String), - FlashErrorRow(StatementSource), - /// The flash on a failed statement's row ran its course. - ErrorFlashEnded(RowKey), + /// The row behind the statement the save failed on, which keeps its + /// mark until it is changed or saved again. + MarkErrorRow(StatementSource), } diff --git a/linux/crates/app/src/ui/browse_tab/selection_handlers.rs b/linux/crates/app/src/ui/browse_tab/selection_handlers.rs deleted file mode 100644 index f6880e29e2..0000000000 --- a/linux/crates/app/src/ui/browse_tab/selection_handlers.rs +++ /dev/null @@ -1,134 +0,0 @@ -use gtk4 as gtk; -use gtk4::gio; -use gtk4::prelude::*; - -use super::{BrowseModel, SelectionInput, SelectionSnapshot}; -use crate::ui::grid::{DeleteAvailability, RowObject, delete_availability}; - -impl BrowseModel { - pub(super) fn on_selection(&self, input: SelectionInput) { - match input { - SelectionInput::ClearSelection => { - let Some(sel) = self.current_selection.as_ref() else { - return; - }; - // Only a true multi-row selection; clearing a single - // focused row strands the focus indicator. - if sel.selection().size() < 2 { - return; - } - sel.unselect_all(); - } - } - } - - pub(super) fn selected_rows(&self) -> Vec { - self.current_selection.as_ref().map(selected_rows).unwrap_or_default() - } - - /// GtkMultiSelection emits no selection-changed when its rows are - /// spliced, so a page that drops the selected rows comes through here. - pub(super) fn sync_selection(&self) { - let selected = self - .current_selection - .as_ref() - .map_or(0, |sel| u32::try_from(sel.selection().size()).unwrap_or(u32::MAX)); - update_selection_chrome(&self.widgets.selection_label, selected); - self.sync_delete_action(); - } - - /// The rows on screen can change under the selection without it - /// changing, so this runs on every delta, page and load state too. - /// Rows behind an error page count as none. - pub(super) fn sync_delete_action(&self) { - let rows = match self.shown_page() { - Some(_) => self.selected_rows(), - None => Vec::new(), - }; - self.actions - .follow_selection(u32::try_from(rows.len()).unwrap_or(u32::MAX)); - show_delete_availability( - &self.actions.delete_rows, - &self.widgets.delete_button, - &rows, - self.read_only, - ); - } - - /// Keep the selection and the focused cell by row key before a sort, - /// save or refresh reads the page again. - pub(super) fn capture_selection(&mut self) { - // A capture still pending (sort and save in quick succession) - // is kept: the user was where the chain started. - if self.pending_restore.is_some() { - return; - } - let (Some(selection), Some(column_view)) = (self.current_selection.as_ref(), self.current_column_view.as_ref()) - else { - return; - }; - let snapshot = SelectionSnapshot::capture(selection, column_view); - if !snapshot.is_empty() { - self.pending_restore = Some(snapshot); - } - } - - /// Put back the selection captured before the page was read again, - /// on the rows that are still on it. The capture is used up either - /// way, so it cannot steer a later read. - pub(super) fn restore_selection(&mut self) { - let Some(snapshot) = self.pending_restore.take() else { - return; - }; - let (Some(store), Some(selection), Some(column_view)) = ( - self.list_store(), - self.current_selection.clone(), - self.current_column_view.clone(), - ) else { - return; - }; - snapshot.restore(&store, &selection, &column_view, &self.view_columns); - } -} - -pub(super) fn selected_rows(selection: >k::MultiSelection) -> Vec { - crate::ui::grid::selected_positions(selection) - .into_iter() - .filter_map(|position| selection.item(position).and_downcast::()) - .collect() -} - -/// The Delete key and button follow the action; the button's tooltip -/// says why it is off. -pub(super) fn show_delete_availability( - action: &gio::SimpleAction, - button: >k::Button, - rows: &[RowObject], - read_only: bool, -) { - let availability = delete_availability(rows.iter().map(RowObject::id), read_only); - action.set_enabled(availability == DeleteAvailability::Available); - let explanation = match availability { - DeleteAvailability::Unavailable(block) => block.explanation(), - DeleteAvailability::Available => None, - }; - let tooltip = explanation.unwrap_or_else(|| crate::i18n::gettext("Delete Selected Rows")); - button.set_tooltip_text(Some(&tooltip)); -} - -/// Update the selection-count badge in response to a `MultiSelection` -/// change. Hidden for 0–1 rows; shows "{n} selected · press Delete to -/// remove" once the user multi-selects, so the affordance stays -/// discoverable without a toolbar Delete button. -pub(super) fn update_selection_chrome(label: >k::Label, n: u32) { - if n <= 1 { - label.set_visible(false); - return; - } - let count = n.to_string(); - label.set_label(&crate::i18n::gettext_f( - "{n} selected · press Delete to remove", - &[("n", &count)], - )); - label.set_visible(true); -} diff --git a/linux/crates/app/src/ui/browse_tab/selection_input.rs b/linux/crates/app/src/ui/browse_tab/selection_input.rs deleted file mode 100644 index 49a2ff9c03..0000000000 --- a/linux/crates/app/src/ui/browse_tab/selection_input.rs +++ /dev/null @@ -1,7 +0,0 @@ -/// What changes the rows selected in the grid. -#[derive(Debug)] -pub(crate) enum SelectionInput { - /// Esc. A single selected row stays selected: clearing it would - /// strand the keyboard focus indicator. - ClearSelection, -} diff --git a/linux/crates/app/src/ui/browse_tab/selection_snapshot.rs b/linux/crates/app/src/ui/browse_tab/selection_snapshot.rs deleted file mode 100644 index 83b6d21667..0000000000 --- a/linux/crates/app/src/ui/browse_tab/selection_snapshot.rs +++ /dev/null @@ -1,155 +0,0 @@ -use std::collections::{HashMap, HashSet}; - -use gtk4 as gtk; -use gtk4::gio; -use gtk4::prelude::*; - -use crate::services::change_tracker::RowKey; -use crate::ui::grid::{GridCell, RowObject, selected_positions}; - -/// The selected rows and the focused cell, by row key, so a page read -/// again can put them back wherever the rows land in it. -#[derive(Debug, Clone, Default, PartialEq)] -pub(crate) struct SelectionSnapshot { - keys: Vec, - focus: Option<(RowKey, usize)>, -} - -impl SelectionSnapshot { - pub(crate) fn capture(selection: >k::MultiSelection, column_view: >k::ColumnView) -> Self { - let keys = selected_positions(selection) - .into_iter() - .filter_map(|position| selection.item(position).and_downcast::()) - .filter_map(|row| row.id().tracker_key()) - .collect(); - let focus = column_view - .root() - .and_downcast::() - .and_then(|window| gtk::prelude::GtkWindowExt::focus(&window)) - .filter(|focus| focus.is_ancestor(column_view)) - .and_then(|focus| GridCell::from_focus(&focus)) - .and_then(|cell| Some((cell.row()?.id().tracker_key()?, cell.column()))); - Self { keys, focus } - } - - pub(crate) fn is_empty(&self) -> bool { - self.keys.is_empty() && self.focus.is_none() - } - - /// Rows no longer on the page stay unselected. `columns` are the - /// view's columns in the order of the row's cells, which a drag in - /// the header does not change. - pub(crate) fn restore( - &self, - store: &gio::ListStore, - selection: >k::MultiSelection, - column_view: >k::ColumnView, - columns: &[gtk::ColumnViewColumn], - ) { - let positions = self.positions_in(store); - let selected = gtk::Bitset::new_empty(); - for key in &self.keys { - if let Some(position) = positions.get(key) { - selected.add(*position); - } - } - let mask = gtk::Bitset::new_range(0, selection.n_items()); - selection.set_selection(&selected, &mask); - let focused = self - .focus - .as_ref() - .and_then(|(key, column)| Some((*positions.get(key)?, *column))); - match focused { - Some((position, column)) => { - column_view.scroll_to(position, columns.get(column), gtk::ListScrollFlags::FOCUS, None); - } - // A sort can move the selection anywhere on the page. - None if !selected.is_empty() => { - column_view.scroll_to(selected.minimum(), None, gtk::ListScrollFlags::NONE, None); - } - None => {} - } - } - - /// Where each captured row sits in `store` now, in one pass over it: - /// a lookup per row would scan the page once for every row of a - /// Select All. - fn positions_in(&self, store: &gio::ListStore) -> HashMap { - let wanted: HashSet<&RowKey> = self - .keys - .iter() - .chain(self.focus.as_ref().map(|(key, _)| key)) - .collect(); - (0..store.n_items()) - .filter_map(|position| { - let key = store.item(position).and_downcast::()?.id().tracker_key()?; - wanted.contains(&key).then_some((key, position)) - }) - .collect() - } -} - -#[cfg(test)] -mod tests { - use std::time::{Duration, Instant}; - - use gtk4 as gtk; - use gtk4::gio; - use tablepro_core::Value; - - use super::SelectionSnapshot; - use crate::services::change_tracker::RowKey; - use crate::ui::grid::{RowObject, selected_positions}; - - fn key(id: i64) -> RowKey { - RowKey::from_pk_values(&[Value::Int(id)]).expect("a key") - } - - fn page(ids: impl Iterator) -> gio::ListStore { - let store = gio::ListStore::new::(); - let rows: Vec = ids - .map(|id| RowObject::persisted(Some(key(id)), vec![Value::Int(id)].into())) - .collect(); - store.splice(0, 0, &rows); - store - } - - fn restored(snapshot: &SelectionSnapshot, store: &gio::ListStore) -> Vec { - let selection = gtk::MultiSelection::new(Some(store.clone())); - let view = gtk::ColumnView::new(Some(selection.clone())); - snapshot.restore(store, &selection, &view, &[]); - selected_positions(&selection) - } - - #[gtk4::test] - fn the_selection_follows_its_rows_to_where_they_landed() { - let snapshot = SelectionSnapshot { - keys: vec![key(3), key(9), key(1)], - focus: None, - }; - - assert_eq!(restored(&snapshot, &page([9, 5, 3, 7].into_iter())), [0, 2]); - } - - /// Select All on a large page, then a refresh: each row's place used - /// to be a scan of the whole page. - #[gtk4::test] - fn a_whole_large_page_selection_comes_back_in_one_pass() { - let rows = 20_000; - let snapshot = SelectionSnapshot { - keys: (0..rows).map(key).collect(), - focus: Some((key(rows - 1), 0)), - }; - let store = page((0..rows).rev()); - let started = Instant::now(); - - let selected = restored(&snapshot, &store); - - assert_eq!(selected.len(), 20_000); - assert!( - started.elapsed() < Duration::from_secs(2), - "restoring the selection took {:?}", - started.elapsed() - ); - } -} diff --git a/linux/crates/app/src/ui/browse_tab/tests/load.rs b/linux/crates/app/src/ui/browse_tab/tests/load.rs index a476cb0f9d..ab04ce809b 100644 --- a/linux/crates/app/src/ui/browse_tab/tests/load.rs +++ b/linux/crates/app/src/ui/browse_tab/tests/load.rs @@ -8,13 +8,13 @@ use libadwaita as adw; use tablepro_core::{ColumnInfo, DriverError, LossPhase, QueryResult, Value}; use tablepro_test_support::{CallCounters, FakeReply}; -use super::super::{ClipboardInput, PagingInput}; +use super::super::{GridInput, PagingInput}; use super::{ - Harness, Shown, WAIT, column, column_view, delete_action, full_page, header_titles, key, not_null_name, rows, - serving_table, text, toasts, + Harness, Shown, WAIT, column, column_view, full_page, header_titles, key, not_null_name, rows, serving_table, text, + toasts, }; use crate::test_support::{after_next_frame, drain_main_context, wait_until}; -use crate::ui::grid::GridCell; +use crate::ui::data_grid::GridCell; fn banner(harness: &Harness) -> adw::Banner { harness.tab().imp().stale_banner.get() @@ -179,15 +179,8 @@ fn refresh_restores_multi_row_selection_and_focus_by_key() { let harness = Harness::open(script, []); wait_until(WAIT, || harness.shown().len() == 5).expect("the first page"); let shown = Shown::new(harness.tab()); - let view = column_view(harness.tab()); - let name = crate::ui::grid::data_columns(&view) - .into_iter() - .nth(1) - .expect("the name column"); - view.scroll_to(3, Some(&name), gtk4::ListScrollFlags::FOCUS, None); - view.grab_focus(); + super::focus_cell(&harness, 3, 1); let focused_cell = || gtk4::prelude::GtkWindowExt::focus(&shown.0).and_then(|focus| GridCell::from_focus(&focus)); - wait_until(WAIT, || focused_cell().is_some_and(|cell| cell.position() == Some(3))).expect("a cell takes the focus"); let selection = harness.selection(); let picked = gtk4::Bitset::new_empty(); picked.add(1); @@ -198,14 +191,9 @@ fn refresh_restores_multi_row_selection_and_focus_by_key() { wait_until(WAIT, || harness.shown().len() == 6).expect("the refreshed page"); after_next_frame(&shown.0); - let selected: Vec<_> = crate::ui::grid::selected_positions(&harness.selection()) - .into_iter() - .map(|position| harness.grid_row(position).key().cloned()) - .collect(); - assert_eq!(selected, [Some(key(1)), Some(key(3))]); + assert_eq!(selected_keys(&harness), [Some(key(1)), Some(key(3))]); wait_until(WAIT, || { - focused_cell() - .is_some_and(|cell| cell.column() == 1 && cell.row().and_then(|row| row.key().cloned()) == Some(key(3))) + focused_cell().is_some_and(|cell| cell.column() == 1 && cell.row().and_then(|row| row.key()) == Some(key(3))) }) .expect("the focus did not come back to the cell it was in"); } @@ -230,12 +218,8 @@ fn a_selection_is_not_put_back_after_a_failed_refresh() { wait_until(WAIT, || !banner(&harness).is_revealed()).expect("the second refresh"); drain_main_context(); - let selected: Vec<_> = crate::ui::grid::selected_positions(&harness.selection()) - .into_iter() - .map(|position| harness.grid_row(position).key().cloned()) - .collect(); assert_eq!( - selected, + selected_keys(&harness), [Some(key(2))], "the selection from before the failed refresh came back" ); @@ -391,7 +375,8 @@ fn the_rows_behind_the_error_page_take_no_action() { let harness = Harness::open(script, []); wait_until(WAIT, || harness.shown() == text(&["unfiltered"])).expect("the first page"); harness.selection().select_item(0, true); - assert!(delete_action(harness.tab()).is_enabled()); + drain_main_context(); + assert!(harness.grid_enabled("delete-rows")); let exported = Rc::new(RefCell::new(0)); let sink = exported.clone(); harness @@ -401,16 +386,18 @@ fn the_rows_behind_the_error_page_take_no_action() { harness.tab().send(PagingInput::FilterApplied(not_null_name())); wait_until(WAIT, || harness.visible_page().as_deref() == Some("error")).expect("the error page"); - harness.tab().send(ClipboardInput::Export(None)); + harness.tab().send(GridInput::Export); assert_eq!(*exported.borrow(), 0, "Export wrote rows the filter does not match"); - assert!( - !copy_rows_action(harness.tab()).is_enabled(), - "Copy stayed on for rows behind the error page" - ); + for action in ["grid.copy", "grid.delete-rows", "grid.insert-row"] { + assert!( + harness.tab().activate_action(action, None).is_err(), + "{action} still reached rows behind the error page" + ); + } assert_eq!(*toasts.borrow(), ["Nothing to export"]); assert!( - !delete_action(harness.tab()).is_enabled(), + !harness.tab().imp().delete_button.is_sensitive(), "Delete stayed on for rows behind the error page" ); assert_eq!(harness.tab().imp().pager_bar.imp().paginator_label.label(), ""); @@ -515,10 +502,7 @@ fn a_failed_column_read_leaves_no_read_for_a_later_one_to_start() { harness.tab().send(PagingInput::Refresh); wait_until(WAIT, || banner(&harness).is_revealed()).expect("the failed refresh"); - harness.tab().send(PagingInput::SortChanged { - col_idx: 0, - ascending: false, - }); + harness.grid().sort_by(Some((0, false))); wait_until(WAIT, || harness.shown() == text(&["back"])).expect("the page after the columns were read again"); std::thread::sleep(Duration::from_millis(200)); drain_main_context(); @@ -531,14 +515,8 @@ fn a_failed_column_read_leaves_no_read_for_a_later_one_to_start() { ); } -fn copy_rows_action(tab: &super::super::BrowseTab) -> gtk4::gio::SimpleAction { - use gtk4::subclass::prelude::ObjectSubclassIsExt; - tab.imp() - .model - .borrow() - .as_ref() - .map(|model| model.actions.copy_rows.clone()) - .expect("the tab has a model") +fn selected_keys(harness: &Harness) -> Vec> { + harness.grid().selected_rows().iter().map(|row| row.key()).collect() } /// The status page reads its description as markup, where the `&` diff --git a/linux/crates/app/src/ui/browse_tab/tests/mod.rs b/linux/crates/app/src/ui/browse_tab/tests/mod.rs index 1b55a4e63b..c8a5e07a50 100644 --- a/linux/crates/app/src/ui/browse_tab/tests/mod.rs +++ b/linux/crates/app/src/ui/browse_tab/tests/mod.rs @@ -1,15 +1,14 @@ mod load; -mod paste; mod references; +mod rows; +mod save; mod total; use std::sync::Arc; use std::time::Duration; -use gtk4::glib; use gtk4::prelude::*; use tablepro_core::column::{ColumnDefault, ColumnType}; -use tablepro_core::edit::CellInput; use tablepro_core::filter::{FilterOp, FilterRule}; use tablepro_core::meta::{RowCountEstimate, RowIdentity, TableEstimate}; use tablepro_core::{ColumnInfo, DriverError, FilterSet, LossPhase, QueryResult, Value}; @@ -17,18 +16,13 @@ use tablepro_session::Session; use tablepro_session::fakes::FakeEstablish; use tablepro_test_support::{CallCounters, ConnectionScript, FakeConnection, FakeReply}; -use super::row_deletion::RowDeletion; -use super::{ - BrowseTab, BrowseTabInit, ClipboardInput, DataInput, EditInput, Effects, PagingInput, RowTotal, SaveInput, - TableFilter, apply_draft_changes, -}; -use crate::services::change_tracker::{RowKey, StatementSource}; +use super::{BrowseTab, BrowseTabInit, DataInput, GridInput, PagingInput, RowTotal, SaveInput, TableFilter}; +use crate::services::change_tracker::RowKey; use crate::services::tab_tracker_handle::TabTrackerHandle; use crate::services::tracker_delta::TrackerDelta; use crate::services::tracker_edit::TrackerEdit; -use crate::test_support::{FakeSession, LogLines, MemorySettings, after_next_frame, drain_main_context, wait_until}; -use crate::ui::clipboard::InsertRow; -use crate::ui::grid::{GridCell, GridRowIndex, RowObject}; +use crate::test_support::{FakeSession, LogLines, MemorySettings, drain_main_context, wait_until}; +use crate::ui::data_grid::{DataGrid, GridCell, GridRequest, GridRow}; pub(super) const WAIT: Duration = Duration::from_secs(10); @@ -65,7 +59,7 @@ fn col(data_type: &str, nullable: bool) -> ColumnInfo { clippy::disallowed_methods, reason = "a test fixture stands in for the catalogue, which is where this text comes from in production" )] -fn col_with_default(data_type: &str, default: &str) -> ColumnInfo { +pub(super) fn col_with_default(data_type: &str, default: &str) -> ColumnInfo { let mut c = col(data_type, false); c.default = tablepro_core::column::ColumnDefault::Expression( tablepro_core::column::SqlExpression::from_catalog_text(default), @@ -73,34 +67,6 @@ fn col_with_default(data_type: &str, default: &str) -> ColumnInfo { c } -fn row_at(store: >k4::gio::ListStore, position: u32) -> RowObject { - store.item(position).and_downcast::().expect("a row") -} - -#[test] -fn draft_changes_add_and_remove_draft_rows() { - let store = gtk4::gio::ListStore::new::(); - store.append(&RowObject::persisted(None, vec![Value::Int(1)].into())); - let mut index = GridRowIndex::default(); - let inserted = TrackerDelta { - inserted_drafts: vec![4], - ..TrackerDelta::default() - }; - let removed = TrackerDelta { - removed_drafts: vec![4], - ..TrackerDelta::default() - }; - - apply_draft_changes(&store, &mut index, &inserted); - assert_eq!(row_at(&store, 0).draft_id(), Some(4)); - assert_eq!(index.get(&RowKey::Draft(4)), Some(row_at(&store, 0))); - - apply_draft_changes(&store, &mut index, &removed); - assert_eq!(store.n_items(), 1); - assert_eq!(row_at(&store, 0).draft_id(), None); - assert_eq!(index.get(&RowKey::Draft(4)), None); -} - pub(super) fn column(name: &str, primary_key: bool) -> ColumnInfo { ColumnInfo { name: name.to_owned(), @@ -185,7 +151,7 @@ impl Harness { Self::open_with(script, [], saved, filter, false) } - fn open_read_only(script: ConnectionScript) -> Self { + pub(super) fn open_read_only(script: ConnectionScript) -> Self { Self::open_with(script, [], FilterSet::default(), TableFilter::Remembered, true) } @@ -230,6 +196,10 @@ impl Harness { self.tab.as_ref().expect("the tab is open") } + pub(super) fn grid(&self) -> DataGrid { + self.tab().grid() + } + /// Another tab on the same table and session. pub(super) fn open_another(&mut self) -> BrowseTab { let runtime = self.runtime.as_ref().expect("the runtime is running"); @@ -243,25 +213,29 @@ impl Harness { pub(super) fn shown(&self) -> Vec { self.saved_rows() .iter() - .filter_map(|row| row.cell(1).cloned()) + .filter_map(|row| row.cells()?.get(1).cloned()) .collect() } /// The saved rows the grid holds for the page on screen. - pub(super) fn saved_rows(&self) -> Vec { + pub(super) fn saved_rows(&self) -> Vec { use gtk4::subclass::prelude::ObjectSubclassIsExt; - let model = self.tab().imp().model.borrow(); - let Some(selection) = model + let shows_page = self + .tab() + .imp() + .model + .borrow() .as_ref() - .filter(|model| model.page.is_some()) - .and_then(|model| model.current_selection.clone()) - else { - return Vec::new(); - }; - (0..selection.n_items()) - .filter_map(|position| selection.item(position).and_downcast::()) - .filter(|row| row.draft_id().is_none()) - .collect() + .is_some_and(|model| model.page.is_some()); + match shows_page { + true => self + .grid() + .store() + .rows() + .filter(|row| row.draft_id().is_none()) + .collect(), + false => Vec::new(), + } } pub(super) fn visible_page(&self) -> Option { @@ -269,38 +243,49 @@ impl Harness { } /// The row the grid shows at `position`, drafts included. - pub(super) fn grid_row(&self, position: u32) -> RowObject { - self.selection() - .item(position) - .and_downcast::() - .expect("a row") + pub(super) fn grid_row(&self, position: u32) -> GridRow { + self.grid().store().row(position).expect("a row") } pub(super) fn selection(&self) -> gtk4::MultiSelection { - use gtk4::subclass::prelude::ObjectSubclassIsExt; - self.tab() - .imp() - .model - .borrow() - .as_ref() - .and_then(|model| model.current_selection.clone()) - .expect("the grid is built") + self.grid().selection() } pub(super) fn store(&self) -> gtk4::gio::ListStore { - self.selection() - .model() - .and_downcast::() - .expect("a list store") + self.grid().store().list().clone() } - /// What the tab does with the delta of an edit it made itself. - fn apply_delta(&self, delta: &TrackerDelta) { - use gtk4::subclass::prelude::ObjectSubclassIsExt; - let mut effects = Effects::default(); - if let Some(model) = self.tab().imp().model.borrow_mut().as_mut() { - model.apply_tracker_delta(delta, &mut effects); - } + pub(super) fn drafts(&self) -> u32 { + self.grid().store().draft_count() + } + + /// What the tab does with the delta of a tracker write it made. + pub(super) fn apply_delta(&self, delta: &TrackerDelta) { + self.grid().apply_delta(delta); + drain_main_context(); + } + + /// Run `grid.` as the tab's buttons and menus reach it. + pub(super) fn activate_grid(&self, name: &str) { + self.tab() + .activate_action(&format!("grid.{name}"), None) + .unwrap_or_else(|_| panic!("the tab reaches no grid.{name}")); + drain_main_context(); + } + + pub(super) fn grid_enabled(&self, name: &str) -> bool { + self.grid() + .actions() + .lookup_action(name) + .is_some_and(|action| action.is_enabled()) + } + + /// Select the rows at `positions` and nothing else. + pub(super) fn select(&self, positions: std::ops::Range) { + let selection = self.selection(); + let picked = gtk4::Bitset::new_range(positions.start, positions.end - positions.start); + selection.set_selection(&picked, >k4::Bitset::new_range(0, selection.n_items())); + drain_main_context(); } } @@ -443,6 +428,141 @@ pub(super) fn not_null_name() -> FilterSet { not_null("name") } +pub(super) fn key(id: i64) -> RowKey { + RowKey::from_pk_values(&[Value::Int(id)]).expect("a key") +} + +/// `names` keyed from `first` on, as the next page of `rows` would be. +pub(super) fn rows_from(first: i64, names: &[&str]) -> QueryResult { + let mut page = rows(names); + for (row, id) in page.rows.iter_mut().zip(first..) { + row[0] = Value::Int(id); + } + page +} + +/// A browse page served first, then `next` for every page after it. +pub(super) fn paging(first: QueryResult, next: QueryResult) -> ConnectionScript { + let mut script = serving_table(); + script.count_rows.always(FakeReply::ok(1_000)); + script + .query_bound + .once(FakeReply::ok(first)) + .always(FakeReply::ok(next)); + script +} + +/// Rows enough for a full page, so Next has somewhere to go. +pub(super) fn full_page(first: i64) -> QueryResult { + let names: Vec = (0..100).map(|n| format!("row {n}")).collect(); + let names: Vec<&str> = names.iter().map(String::as_str).collect(); + rows_from(first, &names) +} + +pub(super) fn toasts(tab: &BrowseTab) -> std::rc::Rc>> { + let toasts = std::rc::Rc::new(std::cell::RefCell::new(Vec::new())); + let sink = toasts.clone(); + tab.connect_toast(move |_, text| sink.borrow_mut().push(text)); + toasts +} + +/// The titles of the data columns on screen, without the row numbers. +pub(super) fn header_titles(tab: &BrowseTab) -> Vec { + let grid = tab.grid(); + let numbers = grid.row_numbers_column(); + let columns = grid.column_view().columns(); + (0..columns.n_items()) + .filter_map(|position| columns.item(position).and_downcast::()) + .filter(|column| Some(column) != numbers.as_ref()) + .filter_map(|column| column.title().map(|title| title.to_string())) + .collect() +} + +pub(super) fn headers_are(tab: &BrowseTab, expected: &[&str]) -> bool { + header_titles(tab) == expected +} + +pub(super) fn column_view(tab: &BrowseTab) -> gtk4::ColumnView { + tab.grid().column_view() +} + +/// The cell showing `column` of the saved row keyed `id`, once drawn. +pub(super) fn grid_cell_of(tab: &BrowseTab, id: i64, column: usize) -> GridCell { + let key = key(id); + let mut found = None; + wait_until(WAIT, || { + found = crate::test_support::descendants(&column_view(tab)) + .into_iter() + .filter_map(|widget| widget.downcast::().ok()) + .find(|cell| cell.column() == column && cell.row().is_some_and(|row| row.key().as_ref() == Some(&key))); + found.is_some() + }) + .expect("the row's cell is drawn"); + found.expect("a cell") +} + +/// Focus the cell of data column `column` at `position`, as the arrow +/// keys do. +pub(super) fn focus_cell(harness: &Harness, position: u32, column: usize) -> GridCell { + let mut focused = None; + wait_until(WAIT, || { + focused = harness.grid().focus_cell(position, column); + focused.is_some() + }) + .expect("the cell takes the focus"); + focused.expect("a focused cell") +} + +/// Open `cell`'s one-line editor and type `text` over its value. +pub(super) fn type_into(harness: &Harness, cell: &GridCell, text: &str) -> gtk4::Text { + let grid = harness.grid(); + assert!(grid.edit().edit_cell(cell), "the cell took no edit"); + let editor = grid + .edit() + .open_text() + .expect("the cell did not open its one-line editor"); + editor.set_text(text); + editor +} + +pub(super) fn rename(id: i64, from: &str, to: &str) -> TrackerEdit { + TrackerEdit::Cell { + row_key: key(id), + col: 1, + original: Value::Text(from.to_owned()), + value: Value::Text(to.to_owned()), + } +} + +/// What a page and the tab's own reads asked for, in order. +pub(super) fn page_queries(harness: &Harness) -> Vec { + harness + .connection + .observed_sql("query_bound") + .into_iter() + .flatten() + .collect() +} + +pub(super) fn pending_bar_shown(tab: &BrowseTab) -> bool { + use gtk4::subclass::prelude::ObjectSubclassIsExt; + tab.imp().pending_bar.imp().revealer.reveals_child() +} + +fn action_enabled(tab: &BrowseTab, name: &str) -> bool { + use gtk4::subclass::prelude::ObjectSubclassIsExt; + let model = tab.imp().model.borrow(); + let actions = &model.as_ref().expect("the tab has a model").actions; + let action = match name { + "save" => &actions.save, + "undo" => &actions.undo, + "redo" => &actions.redo, + "count-rows" => &actions.count_rows, + _ => panic!("browse.{name} does not follow the tab"), + }; + action.is_enabled() +} + #[gtk4::test] fn stale_page_reply_ignored() { let mut script = serving_table(); @@ -546,19 +666,8 @@ fn schema_change_reads_the_columns_again_then_the_page() { assert_eq!(CallCounters::get(&counters.fetch_columns), 2); } -pub(super) fn header_titles(tab: &BrowseTab) -> Vec { - let Some(view) = crate::test_support::first_descendant_of_type::(tab) else { - return Vec::new(); - }; - let columns = view.columns(); - (0..columns.n_items()) - .filter_map(|position| columns.item(position).and_downcast::()) - .filter_map(|column| column.title().map(|title| title.to_string())) - .collect() -} - #[gtk4::test] -fn a_renamed_column_builds_the_grid_again() { +fn a_renamed_column_reaches_the_grid_headers() { let renamed = [column("id", true), column("full_name", false)]; let mut script = serving_table(); script @@ -576,15 +685,18 @@ fn a_renamed_column_builds_the_grid_again() { ) .always(FakeReply::ok(rows(&["Ada"]))); let harness = Harness::open(script, []); - wait_until(WAIT, || header_titles(harness.tab()).contains(&"name".to_owned())).expect("the first grid"); + wait_until(WAIT, || headers_are(harness.tab(), &["id", "name"])).expect("the first grid"); + let grid = harness.grid(); harness .session .invalidate_table(&tablepro_core::TableRef::unqualified("users")); harness.tab().send(DataInput::SchemaChanged); - wait_until(WAIT, || header_titles(harness.tab()).contains(&"full_name".to_owned())) + wait_until(WAIT, || headers_are(harness.tab(), &["id", "full_name"])) .expect("the grid kept the old column's header"); + wait_until(WAIT, || harness.shown() == text(&["Ada"])).expect("the page under the new columns"); + assert_eq!(harness.grid(), grid, "the tab built another grid"); } #[gtk4::test] @@ -620,13 +732,6 @@ fn a_tab_that_opens_reads_the_columns_the_table_has_now() { assert_eq!(CallCounters::get(&harness.connection.counters().fetch_columns), 2); } -pub(super) fn toasts(tab: &BrowseTab) -> std::rc::Rc>> { - let toasts = std::rc::Rc::new(std::cell::RefCell::new(Vec::new())); - let sink = toasts.clone(); - tab.connect_toast(move |_, text| sink.borrow_mut().push(text)); - toasts -} - #[gtk4::test] fn a_failed_page_under_a_new_filter_shows_the_error() { let mut script = serving_table(); @@ -662,7 +767,7 @@ fn a_saved_filter_on_a_dropped_column_shows_the_error() { } #[gtk4::test] -fn a_filter_column_renamed_away_drops_the_grid_for_the_error() { +fn a_filter_column_renamed_away_drops_the_rows_for_the_error() { let renamed = [column("id", true), column("full_name", false)]; let mut script = serving_table(); script @@ -679,8 +784,13 @@ fn a_filter_column_renamed_away_drops_the_grid_for_the_error() { harness.tab().send(DataInput::SchemaChanged); wait_until(WAIT, || harness.visible_page().as_deref() == Some("error")) - .expect("the grid read under the old columns stayed on screen"); + .expect("the rows read under the old columns stayed on screen"); assert!(harness.shown().is_empty()); + assert_eq!( + harness.grid().row_count(), + 0, + "the grid kept rows read under the old columns" + ); assert_eq!(harness.tab().columns(), renamed); } @@ -718,12 +828,7 @@ fn no_dim_label_class_remains() { script.query_bound.always(FakeReply::ok(rows(&["Ada", "Grace"]))); let harness = Harness::open(script, []); let _shown = Shown::new(harness.tab()); - wait_until(WAIT, || { - crate::test_support::descendants(harness.tab()) - .iter() - .any(|widget| widget.is::()) - }) - .expect("the grid draws its cells"); + grid_cell_of(harness.tab(), 0, 1); harness.tab().send(PagingInput::ToggleFilterStrip); let dimmed: Vec = crate::test_support::descendants(harness.tab()) @@ -754,14 +859,45 @@ fn export_and_save_reach_the_window_as_signals() { .tab() .connect_save_requested(move |_| save_sink.set(save_sink.get() + 1)); - harness.tab().send(ClipboardInput::Export(None)); - harness.tab().send(EditInput::InsertRow); + harness.tab().send(GridInput::Export); + harness.activate_grid("insert-row"); harness.tab().send(SaveInput::CommitSave); assert_eq!(*exported.borrow(), [("users".to_owned(), Some(2))]); assert_eq!(saved.get(), 1); } +/// The grid's own Export hands over the rows as it shows them, which +/// the tab offers as the page of its table. +#[gtk4::test] +fn the_grids_export_goes_out_as_the_tabs_offer() { + let mut script = serving_table(); + script.query_bound.always(FakeReply::ok(rows(&["Ada", "Grace"]))); + let harness = Harness::open(script, []); + wait_until(WAIT, || harness.shown().len() == 2).expect("the first page"); + let offers = std::rc::Rc::new(std::cell::RefCell::new(Vec::new())); + let sink = offers.clone(); + harness + .tab() + .connect_export_results(move |_, offer| sink.borrow_mut().push(offer)); + + harness.activate_grid("export"); + + let offers = offers.borrow(); + let [offer] = offers.as_slice() else { + panic!("one export, not {}", offers.len()); + }; + assert_eq!(offer.name, "users"); + assert_eq!(offer.origin.shown().row_count(), Some(2)); + assert!(matches!( + &offer.origin, + crate::ui::export::ExportOrigin::Browse { + query: crate::ui::export::ExportQuery::Table(_), + .. + } + )); +} + #[gtk4::test] fn current_page_export_includes_pending_edits() { let mut script = serving_table(); @@ -787,7 +923,7 @@ fn current_page_export_includes_pending_edits() { .tab() .connect_export_results(move |_, offer| sink.borrow_mut().push(offer)); - harness.tab().send(ClipboardInput::Export(None)); + harness.tab().send(GridInput::Export); let offers = offers.borrow(); let [offer] = offers.as_slice() else { @@ -821,66 +957,8 @@ fn current_page_export_includes_pending_edits() { assert!(offer.session.is_some(), "a read of every row has no session"); } -/// The bar's own states are tested with the bar; this is the tab's wiring. -#[gtk4::test] -fn stop_saving_follows_the_save_and_reaches_the_window() { - use gtk4::subclass::prelude::ObjectSubclassIsExt; - - let mut script = serving_table(); - script.query_bound.always(FakeReply::ok(rows(&["Ada"]))); - let harness = Harness::open(script, []); - wait_until(WAIT, || harness.shown().len() == 1).expect("the first page"); - let stops = std::rc::Rc::new(std::cell::Cell::new(0)); - let stop_sink = stops.clone(); - harness - .tab() - .connect_stop_save_requested(move |_| stop_sink.set(stop_sink.get() + 1)); - let stop = harness.tab().imp().pending_bar.imp().stop_button.get(); - harness.tab().send(EditInput::InsertRow); - - harness.tab().send(SaveInput::SaveStarted); - assert!(stop.is_visible(), "a running save offered no Stop Saving"); - - stop.emit_clicked(); - assert_eq!(stops.get(), 1); - - harness.tab().send(SaveInput::SaveFailed("rolled back".to_owned())); - assert!(!stop.is_visible(), "Stop Saving outlived the save"); -} - -#[gtk4::test] -fn an_edit_held_for_the_running_save_says_so_once() { - use gtk4::subclass::prelude::ObjectSubclassIsExt; - - let mut script = serving_table(); - script.query_bound.always(FakeReply::ok(rows(&["Ada"]))); - let harness = Harness::open(script, []); - wait_until(WAIT, || harness.shown().len() == 1).expect("the first page"); - let tracker = harness - .tab() - .imp() - .model - .borrow() - .as_ref() - .map(|model| model.tracker.clone()) - .expect("the tab has a model"); - let toasts = toasts(harness.tab()); - harness.tab().send(EditInput::InsertRow); - harness.tab().send(EditInput::InsertRow); - let _saving = tracker.begin_save().expect("a save").expect("the drafts"); - let drafts: Vec = tracker.drafts().iter().map(|draft| draft.draft_id).collect(); - - harness.tab().send(EditInput::MarkRowsDeleted(RowDeletion { - drafts, - ..RowDeletion::default() - })); - - assert_eq!(tracker.drafts().len(), 2, "a draft the save holds was dropped"); - assert_eq!(toasts.borrow().len(), 1, "{:?}", toasts.borrow()); -} - #[gtk4::test] -fn browse_tab_and_filter_strip_freed_after_drop() { +fn browse_tab_grid_and_filter_strip_freed_after_drop() { let mut script = serving_table(); script.query_bound.always(FakeReply::ok(rows(&["row"]))); let mut harness = Harness::open(script, []); @@ -896,1982 +974,360 @@ fn browse_tab_and_filter_strip_freed_after_drop() { .map(|model| model.filter_strip.downgrade()) .expect("the filter strip") }; + let grid = harness.grid().downgrade(); let tab = harness.tab().downgrade(); harness.tab.take(); drain_main_context(); assert!(strip.upgrade().is_none(), "the filter strip outlived its tab"); + assert!(grid.upgrade().is_none(), "the grid outlived its tab"); assert!(tab.upgrade().is_none(), "the browse tab outlived its last reference"); } +fn named_actions(controller: >k4::ShortcutController) -> Vec { + crate::test_support::shortcut_keys::shortcuts(controller) + .iter() + .filter_map(|shortcut| shortcut.action().and_downcast::()) + .map(|action| action.action_name().to_string()) + .collect() +} + +fn table_actions(owner: crate::application::ShortcutOwner) -> Vec { + use crate::application::{ACCELERATORS, AcceleratorScope}; + ACCELERATORS + .iter() + .filter(|entry| matches!(entry.scope, AcceleratorScope::Local { owner: of, .. } if of == owner)) + .flat_map(|entry| entry.triggers.iter().map(|_| entry.action.to_owned())) + .collect() +} + +/// The grid's keys sit on the grid, so a text field elsewhere in the tab +/// keeps Ctrl+C, Delete and Escape, and the tab holds only its own. #[gtk4::test] -fn duplicate_row_copies_pending_edits_of_persisted_row() { +fn the_tab_holds_only_its_own_keys_and_the_grid_its_own() { + use crate::application::ShortcutOwner; + use crate::test_support::shortcut_keys::{owner_controller, shortcut_controllers}; let mut script = serving_table(); script.query_bound.always(FakeReply::ok(rows(&["Ada", "Grace"]))); let harness = Harness::open(script, []); wait_until(WAIT, || harness.shown().len() == 2).expect("the first page"); - let ada = harness.grid_row(0); - - harness.tab().send(typed(&ada, 1, "Ada Lovelace")); - harness.tab().send(EditInput::DuplicateRows(vec![ada])); - let drafts = harness.tracker.drafts(); - assert_eq!(drafts.len(), 1, "{drafts:?}"); - assert_eq!( - drafts[0].cells, - vec![ - CellInput::Value(Value::Null), - CellInput::Value(Value::Text("Ada Lovelace".to_owned())) - ], - "the duplicate left out the row's pending edit" - ); -} + let tab = owner_controller(harness.tab(), ShortcutOwner::BrowseTab).expect("the tab's shortcuts"); -#[gtk4::test] -fn duplicating_a_draft_leaves_its_defaulted_cells_to_the_server() { - let mut level = col_with_default("integer", "0"); - level.name = "level".to_owned(); - let (harness, _shown) = one_cell(level, Value::Int(3)); - harness.tab().send(EditInput::InsertRow); - let draft = harness.grid_row(0); - assert!(draft.draft_id().is_some(), "the new row goes on top"); - - harness.tab().send(EditInput::DuplicateRows(vec![draft])); - - let drafts = harness.tracker.drafts(); - assert_eq!(drafts.len(), 2, "{drafts:?}"); - assert_eq!( - drafts[1].cells[1], - CellInput::Default, - "the copy wrote NULL where the server fills in" + assert_eq!(named_actions(&tab), table_actions(ShortcutOwner::BrowseTab)); + let on_the_tab: Vec = shortcut_controllers(harness.tab()) + .iter() + .flat_map(named_actions) + .collect(); + assert_eq!(on_the_tab, table_actions(ShortcutOwner::BrowseTab), "{on_the_tab:?}"); + assert!( + owner_controller(&harness.grid(), ShortcutOwner::Grid).is_some(), + "the grid's keys are not on the grid" ); } +/// A bubble key on the tab reaches it only when no class on the way up +/// from the focus binds it first. Text fields keep their own keys, as +/// Ctrl+Z, by design. #[gtk4::test] -fn duplicating_a_selection_is_one_undo_step() { +fn browse_tab_keys_are_not_class_bindings_inside_the_tab() { + use crate::application::{ACCELERATORS, AcceleratorScope, ShortcutOwner}; + use crate::test_support::shortcut_keys::{class_keys_on_focus_paths, keystroke}; let mut script = serving_table(); script.query_bound.always(FakeReply::ok(rows(&["Ada", "Grace"]))); let harness = Harness::open(script, []); wait_until(WAIT, || harness.shown().len() == 2).expect("the first page"); + let _shown = Shown::new(harness.tab()); - harness - .tab() - .send(EditInput::DuplicateRows(vec![harness.grid_row(0), harness.grid_row(1)])); - assert_eq!(draft_rows(&harness.store()), 2); - harness.tab().send(EditInput::Undo); - - assert_eq!(draft_rows(&harness.store()), 0, "one undo left copies behind"); - assert_eq!(harness.tracker.pending_count(), 0); -} - -#[gtk4::test] -fn f004_copy_row_as_insert_uses_row_below_draft() { - let mut script = serving_table(); - script.query_bound.always(FakeReply::ok(rows(&["Ada", "Grace"]))); - let harness = Harness::open(script, []); - wait_until(WAIT, || harness.shown().len() == 2).expect("the first page"); - - harness.tab().send(EditInput::InsertRow); - let below_draft = harness.grid_row(1); - let copied = copied_by(harness.tab(), || { - harness - .tab() - .send(ClipboardInput::GridCopyRowsAsInsert(vec![below_draft])); - }); - - assert_eq!(copied, "INSERT INTO \"users\" (\"id\", \"name\") VALUES (0, 'Ada');"); -} - -/// MySQL reads a backslash in a string as an escape, so the list is -/// written in the server's own literals. -#[gtk4::test] -fn copy_as_in_clause_writes_the_servers_literals() { - let mut script = serving_table(); - script.dialect = Arc::new(tablepro_driver_mysql::MySqlDialect::new( - tablepro_driver_mysql::MySqlFlavour::MySql, - &tablepro_core::ServerInfo::new("MySQL", "8.4.3", 8, 4, 3), - )); - let injected = "\\') OR 1=1 -- "; - script.query_bound.always(FakeReply::ok(rows(&[injected]))); - let harness = Harness::open(script, []); - wait_until(WAIT, || harness.shown().len() == 1).expect("the first page"); - let row = harness.grid_row(0); - let values = crate::ui::clipboard::InClauseRows { - columns: [column("id", true), column("name", false)] - .iter() - .map(ColumnInfo::result_column) - .collect(), - rows: vec![crate::ui::grid::RowSnapshot::new( - row.cells().expect("the row's cells"), - Vec::new(), - )], - column: 1, - }; - - let copied = copied_by(harness.tab(), || { - harness.tab().send(ClipboardInput::GridCopyInClause(values)); + let class_keys = class_keys_on_focus_paths(harness.tab(), |widget| { + widget.is::() || widget.is::() }); - assert_eq!( - copied, - format!( - "(_utf8mb4 X'{}')", - tablepro_core::hex::encode_lower(injected.as_bytes()) - ) + assert!( + class_keys.contains(&keystroke("Page_Up")), + "the grid's own bindings were not found, so the check reads nothing" ); -} - -/// What the clipboard holds once the copy `copy` starts lands there. -fn copied_by(tab: &BrowseTab, copy: impl FnOnce()) -> String { - const BEFORE: &str = "nothing copied yet"; - let clipboard = tab.clipboard(); - clipboard.set_text(BEFORE); - copy(); - let text = || -> Option { clipboard.content()?.value(String::static_type()).ok()?.get().ok() }; - wait_until(WAIT, || text().is_some_and(|text| text != BEFORE)).expect("the copy to land"); - text().unwrap_or_default() -} - -pub(super) fn key(id: i64) -> RowKey { - RowKey::from_pk_values(&[Value::Int(id)]).expect("a key") -} - -/// `names` keyed from `first` on, as the next page of `rows` would be. -pub(super) fn rows_from(first: i64, names: &[&str]) -> QueryResult { - let mut page = rows(names); - for (row, id) in page.rows.iter_mut().zip(first..) { - row[0] = Value::Int(id); - } - page -} - -/// A partitioned PostgreSQL table with no key, whose page carries the -/// partition, the row address and its version after the visible cells. -fn partitioned_table() -> ConnectionScript { - let server = tablepro_core::ServerInfo::new("PostgreSQL", "18.0", 18, 0, 0); - let mut script = ConnectionScript { - dialect: Arc::new(tablepro_driver_postgres::PgDialect::new(&server)), - ..ConnectionScript::default() - }; - script - .fetch_columns - .always(FakeReply::ok(vec![column("id", false), column("name", false)])); - script.row_identity.always(FakeReply::ok(RowIdentity::EngineRowId( - tablepro_core::meta::EngineRowId::PostgresTableoidCtid, - ))); - script.count_rows.always(FakeReply::ok(2)); - script.probe.always(FakeReply::ok(())); - let mut columns: Vec<_> = [column("id", false), column("name", false)] - .iter() - .map(ColumnInfo::result_column) - .collect(); - for hidden in ["__tablepro_row_oid", "__tablepro_row_id", "__tablepro_row_xmin"] { - columns.push(column(hidden, false).result_column()); + for entry in ACCELERATORS { + let AcceleratorScope::Local { + owner: ShortcutOwner::BrowseTab, + phase, + } = entry.scope + else { + continue; + }; + assert_eq!(phase, gtk4::PropagationPhase::Bubble, "{}", entry.action); + for accel in entry.triggers { + assert!( + !class_keys.contains(&keystroke(accel)), + "{accel} of {} never reaches the tab", + entry.action + ); + } } - let rows = [("Ada", "(0,1)"), ("Grace", "(0,1)")] - .iter() - .zip(0..) - .map(|((name, ctid), id)| { - vec![ - Value::Int(id), - Value::Text((*name).to_owned()), - Value::Int(16_401 + id), - Value::Text((*ctid).to_owned()), - Value::Text("812".to_owned()), - ] - }) - .collect(); - script - .query_bound - .always(FakeReply::ok(QueryResult::new(columns, rows))); - script -} - -fn visible_only(values: &[Value]) { - assert_eq!(values.len(), 2, "a row carried hidden cells: {values:?}"); } #[gtk4::test] -fn row_objects_hold_only_visible_cells() { - let harness = Harness::open(partitioned_table(), []); - wait_until(WAIT, || harness.saved_rows().len() == 2).expect("the first page"); - - for row in harness.saved_rows() { - visible_only(&row.cells().expect("a saved row's cells")); - assert_eq!(row.key(), None, "a table with no primary key took edits"); - } +fn browse_actions_follow_the_tab() { use gtk4::subclass::prelude::ObjectSubclassIsExt; - let model = harness.tab().imp().model.borrow(); - let page = model.as_ref().and_then(|model| model.page.clone()).expect("the page"); - assert_eq!(page.result_columns.len(), 2, "the grid was given the hidden columns"); -} - -#[gtk4::test] -fn page_rows_have_single_owner() { - let harness = Harness::open(partitioned_table(), []); - wait_until(WAIT, || harness.saved_rows().len() == 2).expect("the first page"); - - for row in harness.saved_rows() { - let cells = row.shared_cells().expect("a saved row's cells"); - assert_eq!(Arc::strong_count(cells), 1, "the tab kept its own copy of the page"); - } -} - -#[gtk4::test] -fn copy_as_insert_and_duplicate_contain_no_hidden_columns() { - let harness = Harness::open(partitioned_table(), []); - wait_until(WAIT, || harness.saved_rows().len() == 2).expect("the first page"); - let rows = harness.saved_rows(); - - let copied = copied_by(harness.tab(), || { - harness.tab().send(ClipboardInput::GridCopyRowsAsInsert(rows.clone())); - }); - harness.tab().send(EditInput::DuplicateRows(rows)); - - assert_eq!(copied.lines().count(), 2, "{copied}"); - for hidden in ["16401", "(0,1)", "812", "__tablepro"] { - assert!(!copied.contains(hidden), "{hidden} reached the copy: {copied}"); - } - let drafts = harness.tracker.drafts(); - assert_eq!(drafts.len(), 2); - for draft in drafts { - assert_eq!( - draft.cells.len(), - 2, - "a duplicate carried hidden cells: {:?}", - draft.cells - ); - } -} - -#[gtk4::test] -fn copy_as_insert_shares_saved_rows_and_copies_only_drafts() { let mut script = serving_table(); script.query_bound.always(FakeReply::ok(rows(&["Ada", "Grace"]))); let harness = Harness::open(script, []); - wait_until(WAIT, || harness.saved_rows().len() == 2).expect("the first page"); - harness.tab().send(EditInput::InsertRow); - let saved = harness.saved_rows(); - let mut rows = vec![harness.grid_row(0)]; - rows.extend(saved.iter().cloned()); - - use gtk4::subclass::prelude::ObjectSubclassIsExt; - let model = harness.tab().imp().model.borrow(); - let insert_rows = model.as_ref().expect("the model").insert_rows(&rows); - - let [InsertRow::Draft(_), InsertRow::Saved(first), InsertRow::Saved(second)] = insert_rows.as_slice() else { - panic!("{insert_rows:?}"); - }; - for (snapshot, row) in [first, second].into_iter().zip(&saved) { - let cells = row.shared_cells().expect("a saved row's cells"); - assert!(snapshot.shares_cells_with(cells), "a saved row's cells were copied"); - } -} - -#[gtk4::test] -fn page_export_snapshot_contains_no_hidden_columns() { - let harness = Harness::open(partitioned_table(), []); - wait_until(WAIT, || harness.saved_rows().len() == 2).expect("the first page"); - let exported = std::rc::Rc::new(std::cell::RefCell::new(Vec::new())); - let sink = exported.clone(); - harness - .tab() - .connect_export_results(move |_, offer| sink.borrow_mut().push(offer.origin.shown().clone())); - - harness.tab().send(ClipboardInput::Export(None)); - - let exported = exported.borrow(); - let [crate::ui::export::ExportSource::Rows { columns, rows }] = exported.as_slice() else { - panic!("one export, not {}", exported.len()); + wait_until(WAIT, || harness.shown().len() == 2).expect("the first page"); + let activate = |name: &str| { + harness + .tab() + .activate_action(&format!("browse.{name}"), None) + .expect("the browse group"); + drain_main_context(); }; - let names: Vec<&str> = columns.iter().map(|column| column.name.as_str()).collect(); - assert_eq!(names, ["id", "name"]); - assert_eq!(rows.len(), 2); - for row in rows { - visible_only(&row.to_values()); - } -} - -/// A browse page served first, then `next` for every page after it. -pub(super) fn paging(first: QueryResult, next: QueryResult) -> ConnectionScript { - let mut script = serving_table(); - script.count_rows.always(FakeReply::ok(1_000)); - script - .query_bound - .once(FakeReply::ok(first)) - .always(FakeReply::ok(next)); - script -} - -/// Rows enough for a full page, so Next has somewhere to go. -pub(super) fn full_page(first: i64) -> QueryResult { - let names: Vec = (0..100).map(|n| format!("row {n}")).collect(); - let names: Vec<&str> = names.iter().map(String::as_str).collect(); - rows_from(first, &names) -} - -#[gtk4::test] -fn page_load_emits_one_items_changed_with_drafts_prepended() { - let harness = Harness::open(paging(full_page(0), rows_from(100, &["Linus", "Ken"])), []); - wait_until(WAIT, || harness.shown().len() == 100).expect("the first page"); - harness.tab().send(EditInput::InsertRow); - let store = harness.store(); - let changes = std::rc::Rc::new(std::cell::RefCell::new(Vec::new())); - let log = changes.clone(); - store.connect_items_changed(move |_, position, removed, added| { - log.borrow_mut().push((position, removed, added)); - }); - - harness.tab().send(PagingInput::NextPage); - wait_until(WAIT, || harness.shown() == text(&["Linus", "Ken"])).expect("the next page"); - - assert_eq!(*changes.borrow(), [(0, 101, 3)], "the page was not one splice"); - assert!(harness.grid_row(0).draft_id().is_some(), "the draft left the top"); - assert_eq!(harness.grid_row(1).key(), Some(&key(100))); -} - -#[gtk4::test] -fn second_page_load_drops_previous_rows() { - let harness = Harness::open(paging(full_page(0), rows_from(100, &["Linus"])), []); - wait_until(WAIT, || harness.shown().len() == 100).expect("the first page"); - let _shown = Shown::new(harness.tab()); - let _drawn = grid_cell_of(harness.tab(), 0, 1); - let first: Vec> = harness.saved_rows().iter().map(ObjectExt::downgrade).collect(); - - harness.tab().send(PagingInput::NextPage); - wait_until(WAIT, || harness.shown() == text(&["Linus"])).expect("the next page"); - - wait_until(WAIT, || first.iter().all(|row| row.upgrade().is_none())).expect("the first page's rows were let go"); -} + let label = harness.tab().imp().selection_label.get(); -/// The row holding the focus leaves in the splice, which takes the -/// focus from it first: the open edit ends as a focus change ends it. -#[gtk4::test] -fn a_page_that_replaces_the_row_being_edited_keeps_the_edit_and_the_focus() { - let harness = Harness::open(paging(full_page(0), rows_from(100, &["Linus"])), []); - wait_until(WAIT, || harness.shown().len() == 100).expect("the first page"); - let shown = Shown::new(harness.tab()); - let view = column_view(harness.tab()); - let name = crate::ui::grid::data_columns(&view) - .into_iter() - .nth(1) - .expect("the name column"); - view.scroll_to(1, Some(&name), gtk4::ListScrollFlags::FOCUS, None); - view.grab_focus(); - let focused_cell = || gtk4::prelude::GtkWindowExt::focus(&shown.0).and_then(|focus| GridCell::from_focus(&focus)); - wait_until(WAIT, || focused_cell().is_some_and(|cell| cell.position() == Some(1))).expect("a cell takes the focus"); - let cell = focused_cell().expect("the focused cell"); - let _editor = type_into(&cell, "typed"); - // A focus left in a row the splice took out of the window is what a - // window focus-in then trips over. - let stranded = std::rc::Rc::new(std::cell::Cell::new(false)); - let seen = stranded.clone(); - let window = shown.0.downgrade(); - harness.store().connect_items_changed(move |_, _, _, _| { - let focus = window - .upgrade() - .and_then(|window| gtk4::prelude::GtkWindowExt::focus(&window)); - seen.set(seen.get() || focus.is_some_and(|focus| focus.root().is_none())); - }); - - harness.tab().send(PagingInput::NextPage); - wait_until(WAIT, || harness.shown() == text(&["Linus"])).expect("the next page"); - after_next_frame(&shown.0); - - assert!(!stranded.get(), "the focus stayed in a row the page removed"); - - assert!(!cell.is_editing(), "the edit outlived its row"); - let pending = harness.tracker.cell_overlay(&key(1), 1).pending; - assert!( - matches!(&pending, Some(CellInput::Value(Value::Other(typed))) if typed.text == "typed"), - "what was typed was lost, or landed on another row: {pending:?}" - ); - assert_eq!(harness.tracker.pending_count(), 1); - let focus = gtk4::prelude::GtkWindowExt::focus(&shown.0); - assert!( - focus.is_some_and(|focus| focus == *view.upcast_ref::() || focus.is_ancestor(&view)), - "the focus left the grid" - ); -} - -/// With no edit open the focus is on the column view's cell widget, -/// around the grid cell rather than in it, and the row still takes it -/// out of the window when the splice removes it. -#[gtk4::test] -fn a_page_that_replaces_the_focused_row_takes_the_focus_out_first() { - let harness = Harness::open(paging(full_page(0), rows_from(100, &["Linus"])), []); - wait_until(WAIT, || harness.shown().len() == 100).expect("the first page"); - let shown = Shown::new(harness.tab()); - let view = column_view(harness.tab()); - let name = crate::ui::grid::data_columns(&view) - .into_iter() - .nth(1) - .expect("the name column"); - view.scroll_to(1, Some(&name), gtk4::ListScrollFlags::FOCUS, None); - view.grab_focus(); - let focus = || gtk4::prelude::GtkWindowExt::focus(&shown.0); - wait_until(WAIT, || { - focus() - .and_then(|focus| GridCell::from_focus(&focus)) - .is_some_and(|cell| cell.position() == Some(1)) - }) - .expect("a cell takes the focus"); - assert!( - focus().is_some_and(|focus| focus.ancestor(GridCell::static_type()).is_none()), - "the focus is inside the grid cell, not on the cell widget around it" - ); - let stranded = std::rc::Rc::new(std::cell::Cell::new(false)); - let seen = stranded.clone(); - let window = shown.0.downgrade(); - harness.store().connect_items_changed(move |_, _, _, _| { - let focus = window - .upgrade() - .and_then(|window| gtk4::prelude::GtkWindowExt::focus(&window)); - seen.set(seen.get() || focus.is_some_and(|focus| focus.root().is_none())); - }); - - harness.tab().send(PagingInput::NextPage); - wait_until(WAIT, || harness.shown() == text(&["Linus"])).expect("the next page"); - after_next_frame(&shown.0); + assert!(harness.grid_enabled("insert-row"), "a keyed table refused Ctrl+N"); + assert!(!action_enabled(harness.tab(), "save"), "Save with nothing to save"); + assert!(!action_enabled(harness.tab(), "undo")); + assert!(!harness.grid_enabled("copy"), "Ctrl+C with no rows selected"); + assert!(!label.is_visible()); - assert!(!stranded.get(), "the focus stayed in a row the page removed"); + harness.selection().select_all(); + drain_main_context(); + assert!(harness.grid_enabled("copy")); + assert!(harness.grid_enabled("clear-selection")); + assert!(label.is_visible()); + assert_eq!(label.label(), "2 selected · press Delete to remove"); + harness.activate_grid("clear-selection"); assert!( - focus().is_some_and(|focus| focus == *view.upcast_ref::() || focus.is_ancestor(&view)), - "the focus left the grid" + !harness.grid_enabled("clear-selection"), + "the selection was not cleared" ); -} + assert!(!label.is_visible(), "the count outlived the selection"); -/// The cell showing `column` of the saved row keyed `id`, once drawn. -pub(super) fn grid_cell_of(tab: &BrowseTab, id: i64, column: usize) -> GridCell { - let key = key(id); - let mut found = None; - wait_until(WAIT, || { - found = crate::test_support::descendants(tab) - .into_iter() - .filter_map(|widget| widget.downcast::().ok()) - .find(|cell| cell.column() == column && cell.row().is_some_and(|row| row.key() == Some(&key))); - found.is_some() - }) - .expect("the row's cell is drawn"); - found.expect("a cell") -} - -fn cell_text(cell: &GridCell) -> String { - cell.display() - .and_then(|display| display.text()) - .map(|text| text.to_string()) - .unwrap_or_default() -} - -/// What the grid sends once `text` is typed into `column` of a saved -/// row and committed. -fn typed(row: &RowObject, column: usize, text: &str) -> EditInput { - EditInput::GridEdit { - row: row.clone(), - column, - edit: TrackerEdit::Cell { - row_key: row.key().cloned().expect("a saved row"), - col: column, - original: row.cell(column).cloned().unwrap_or(Value::Null), - value: Value::Text(text.to_owned()), - }, - } -} - -/// Open the cell's one-line editor and type `text` over its value. -fn type_into(cell: &GridCell, text: &str) -> gtk4::Text { - crate::ui::grid::begin_edit(cell); - let editor = cell.inline_text().expect("the cell did not open its one-line editor"); - editor.set_text(text); - editor -} - -fn has_class(cell: &GridCell, class: &str) -> bool { - cell.content().is_some_and(|content| content.has_css_class(class)) -} - -fn rename(id: i64, from: &str, to: &str) -> TrackerEdit { - TrackerEdit::Cell { - row_key: key(id), - col: 1, - original: Value::Text(from.to_owned()), - value: Value::Text(to.to_owned()), - } -} - -#[gtk4::test] -fn a_commit_is_tracked_at_once_and_leaves_its_cell_in_the_window() { - let mut script = serving_table(); - script.query_bound.always(FakeReply::ok(rows(&["Ada", "Grace"]))); - let harness = Harness::open(script, []); - wait_until(WAIT, || harness.shown().len() == 2).expect("the first page"); - let _shown = Shown::new(harness.tab()); - let cell = grid_cell_of(harness.tab(), 0, 1); - let editor = type_into(&cell, "Ada Lovelace"); - - editor.emit_activate(); - - assert!( - cell.root().is_some(), - "the commit took its own cell out of the window before Tab could move on" - ); - assert_eq!(harness.tracker.pending_count(), 1, "the edit waited for the main loop"); - assert_eq!(cell_text(&cell), "Ada Lovelace"); - assert!( - has_class(&cell, "tp-cell-modified"), - "the cell does not show the edit as pending" - ); + harness.activate_grid("insert-row"); + assert_eq!(harness.drafts(), 1, "the toolbar's Insert did not reach the grid"); + assert!(action_enabled(harness.tab(), "save")); + assert!(action_enabled(harness.tab(), "undo")); + assert!(!action_enabled(harness.tab(), "redo")); + activate("undo"); + assert_eq!(harness.tracker.pending_count(), 0, "browse.undo did not undo"); + assert_eq!(harness.drafts(), 0, "the undone row stayed in the grid"); + assert!(action_enabled(harness.tab(), "redo")); + assert!(!action_enabled(harness.tab(), "save")); + activate("redo"); + assert_eq!(harness.drafts(), 1, "browse.redo did not bring the row back"); + assert!(harness.grid_row(0).draft_id().is_some(), "the redone row is not on top"); } -#[gtk4::test] -fn tracked_edit_keeps_multi_row_selection() { - let mut script = serving_table(); - script - .query_bound - .always(FakeReply::ok(rows(&["Ada", "Grace", "Linus", "Ken"]))); - let harness = Harness::open(script, []); - wait_until(WAIT, || harness.shown().len() == 4).expect("the first page"); - let _shown = Shown::new(harness.tab()); - let cell = grid_cell_of(harness.tab(), 2, 1); - let selection = harness.selection(); - selection.select_range(1, 3, true); - - let delta = harness.tracker.apply(rename(2, "Linus", "Linus Torvalds")); - harness.apply_delta(&delta); +/// These press real keys on the X display every test shares; nextest runs +/// them with no other test beside them. +mod real_keys { + use super::*; - assert_eq!( - crate::ui::grid::selected_positions(&selection), - vec![1, 2, 3], - "the edited row left the selection" - ); - assert_eq!(cell_text(&cell), "Linus Torvalds"); - assert!(has_class(&cell, "tp-cell-modified")); -} - -#[gtk4::test] -fn tracked_edit_keeps_row_object_identity() { - let mut script = serving_table(); - script - .query_bound - .always(FakeReply::ok(rows(&["Ada", "Grace", "Linus"]))); - let harness = Harness::open(script, []); - wait_until(WAIT, || harness.shown().len() == 3).expect("the first page"); - let store = harness.store(); - let before = store.item(2); - let changes = std::rc::Rc::new(std::cell::Cell::new(0)); - let counter = changes.clone(); - store.connect_items_changed(move |_, _, _, _| counter.set(counter.get() + 1)); - - let delta = harness.tracker.apply(rename(2, "Linus", "Linus Torvalds")); - harness.apply_delta(&delta); - - assert!(before.is_some()); - assert_eq!(store.item(2), before, "the edited row was swapped for another object"); - assert_eq!(changes.get(), 0, "the store announced a change to its rows"); -} - -#[gtk4::test] -fn rejected_edit_restores_cell_display() { - let mut age = col("integer", true); - age.name = "age".to_owned(); - let columns = vec![column("id", true), age]; - let mut script = serving_table(); - script.fetch_columns.always(FakeReply::ok(columns.clone())); - script.query_bound.always(FakeReply::ok(QueryResult::new( - columns.iter().map(ColumnInfo::result_column).collect::>(), - vec![vec![Value::Int(0), Value::Int(36)]], - ))); - let harness = Harness::open(script, []); - wait_until(WAIT, || harness.saved_rows().len() == 1).expect("the first page"); - let toasts = toasts(harness.tab()); - let _shown = Shown::new(harness.tab()); - let cell = grid_cell_of(harness.tab(), 0, 1); - let editor = type_into(&cell, "thirty-six"); - - editor.emit_activate(); - - wait_until(WAIT, || cell_text(&cell) == "36").expect("the cell kept the text that was refused"); - assert_eq!(toasts.borrow().len(), 1, "{:?}", toasts.borrow()); - assert_eq!(harness.tracker.pending_count(), 0); -} - -#[gtk4::test] -fn refused_edit_restores_cell_display() { - let mut script = serving_table(); - script.query_bound.always(FakeReply::ok(rows(&["Ada"]))); - let harness = Harness::open(script, []); - wait_until(WAIT, || harness.shown().len() == 1).expect("the first page"); - let _shown = Shown::new(harness.tab()); - // A saved row's key, which the tab refuses to change in place. - let cell = grid_cell_of(harness.tab(), 0, 0); - let row = cell.row().expect("a bound row"); - cell.display().expect("a text cell").set_text(Some("7")); - - harness.tab().send(EditInput::GridEdit { - row: row.clone(), - column: 0, - edit: TrackerEdit::Cell { - row_key: key(0), - col: 0, - original: Value::Int(0), - value: Value::Int(7), - }, - }); - - assert_eq!(cell_text(&cell), "0", "the cell kept the edit the tab refused"); - assert_eq!(harness.tracker.pending_count(), 0); -} - -#[gtk4::test] -fn undo_under_an_open_editor_leaves_nothing_pending() { - let mut script = serving_table(); - script.query_bound.always(FakeReply::ok(rows(&["Ada", "Grace"]))); - let harness = Harness::open(script, []); - wait_until(WAIT, || harness.shown().len() == 2).expect("the first page"); - let _shown = Shown::new(harness.tab()); - let cell = grid_cell_of(harness.tab(), 0, 1); - harness.apply_delta(&harness.tracker.apply(rename(0, "Ada", "Ada Lovelace"))); - crate::ui::grid::begin_edit(&cell); - assert!(cell.is_editing()); - - harness.tab().send(EditInput::Undo); - cell.cancel_edit(); - - assert_eq!( - harness.tracker.pending_count(), - 0, - "closing the editor sent the undone value back" - ); - assert_eq!(cell_text(&cell), "Ada"); -} - -#[gtk4::test] -fn error_flash_sets_and_clears_on_the_failed_row() { - let mut script = serving_table(); - script.query_bound.always(FakeReply::ok(rows(&["Ada", "Grace"]))); - let harness = Harness::open(script, []); - wait_until(WAIT, || harness.shown().len() == 2).expect("the first page"); - let _shown = Shown::new(harness.tab()); - let cell = grid_cell_of(harness.tab(), 1, 0); - - harness - .tab() - .send(SaveInput::FlashErrorRow(StatementSource::Update { row_key: key(1) })); - - assert!( - has_class(&cell, "tp-row-leftmost-error-flash"), - "the failed row did not flash" - ); - assert!(!has_class( - &grid_cell_of(harness.tab(), 0, 0), - "tp-row-leftmost-error-flash" - )); - wait_until(WAIT, || !has_class(&cell, "tp-row-leftmost-error-flash")).expect("the flash never ended"); -} - -/// The first flash's timer cannot clear a flash that is no longer its -/// own, so the second one has to. -#[gtk4::test] -fn a_second_failed_row_takes_the_mark_off_the_first() { - let mut script = serving_table(); - script.query_bound.always(FakeReply::ok(rows(&["Ada", "Grace"]))); - let harness = Harness::open(script, []); - wait_until(WAIT, || harness.shown().len() == 2).expect("the first page"); - let _shown = Shown::new(harness.tab()); - let first = grid_cell_of(harness.tab(), 1, 0); - let row = first.parent().and_then(|cell| cell.parent()).expect("the row widget"); - let icon = crate::test_support::first_descendant_of_type::(&row).expect("the row's state icon"); - let fail = |id| { - harness - .tab() - .send(SaveInput::FlashErrorRow(StatementSource::Update { row_key: key(id) })); - }; - - fail(1); - assert_eq!(icon.icon_name().as_deref(), Some("dialog-error-symbolic")); - fail(0); - - assert!( - first.row().is_some_and(|row| row.key() == Some(&key(1))), - "the cell moved to another row" - ); - assert!(has_class( - &grid_cell_of(harness.tab(), 0, 0), - "tp-row-leftmost-error-flash" - )); - assert!(!has_class(&first, "tp-row-leftmost-error-flash")); - assert_eq!(icon.icon_name(), None, "the first row still reads as not saved"); - assert!(!gtk4::test_accessible_has_property( - &row, - gtk4::AccessibleProperty::Description - )); -} - -#[gtk4::test] -fn copy_row_as_insert_on_a_draft_leaves_what_the_server_fills_out() { - let mut id = col("integer", false); - id.name = "id".to_owned(); - id.primary_key = true; - id.is_auto_increment = true; - let mut name = col("text", true); - name.name = "name".to_owned(); - let mut created_at = col_with_default("timestamptz", "now()"); - created_at.name = "created_at".to_owned(); - let columns = vec![id, name, created_at]; - let mut script = ConnectionScript::default(); - script.fetch_columns.always(FakeReply::ok(columns.clone())); - script.row_identity.always(FakeReply::ok(RowIdentity::UniqueKey { - columns: vec!["id".to_owned()], - })); - script.count_rows.always(FakeReply::ok(1)); - script.probe.always(FakeReply::ok(())); - script.query_bound.always(FakeReply::ok(QueryResult::new( - columns.iter().map(ColumnInfo::result_column).collect::>(), - vec![vec![Value::Int(1), Value::Text("Ada".into()), Value::Null]], - ))); - let harness = Harness::open(script, []); - wait_until(WAIT, || harness.shown().len() == 1).expect("the first page"); - let toasts = toasts(harness.tab()); - - harness.tab().send(EditInput::InsertRow); - let draft = harness.grid_row(0); - assert!(draft.draft_id().is_some(), "the new row goes on top"); - harness.tab().send(EditInput::GridEdit { - row: draft.clone(), - column: 1, - edit: TrackerEdit::DraftCell { - draft_id: draft.draft_id().expect("a draft"), - col: 1, - input: CellInput::Value(Value::Text("x".into())), - }, - }); - let copied = copied_by(harness.tab(), || { - harness.tab().send(ClipboardInput::GridCopyRowsAsInsert(vec![draft])); - }); - - assert_eq!( - copied, - "INSERT INTO \"users\" (\"name\") VALUES ('x');", - "toasts: {:?}", - toasts.borrow() - ); -} - -fn people(names: &[&str]) -> Vec { - std::iter::once(column("id", true)) - .chain(names.iter().map(|name| column(name, false))) - .collect() -} - -/// One row, id 0, each cell holding its column's name. -fn people_page(columns: &[ColumnInfo]) -> QueryResult { - QueryResult::new( - columns.iter().map(ColumnInfo::result_column).collect::>(), - vec![ - std::iter::once(Value::Int(0)) - .chain(columns[1..].iter().map(|column| Value::Text(column.name.clone()))) - .collect(), - ], - ) -} - -/// A table whose `nickname` another client drops before the tab reads -/// its columns again. `after` is what the page under the new columns -/// answers. -fn losing_nickname(after: FakeReply) -> ConnectionScript { - let before = people(&["nickname", "name", "email"]); - let mut script = serving_table(); - script - .fetch_columns - .once(FakeReply::ok(before.clone())) - .always(FakeReply::ok(people(&["name", "email"]))); - script - .query_bound - .when_sql_contains("nickname", FakeReply::ok(people_page(&before))) - .always(after); - script -} - -fn without_nickname() -> FakeReply { - FakeReply::ok(people_page(&people(&["name", "email"]))) -} - -/// Each assignment of the save, by the name of the column it writes. -fn saved_cells(tracker: &TabTrackerHandle) -> Vec<(String, Option)> { - let batch = tracker.begin_save().expect("a save").expect("changes to save"); - batch - .changes - .changes - .changes - .iter() - .flat_map(|change| match change { - tablepro_core::save::RowChange::Update(update) => update.assignments.clone(), - other => panic!("only a cell was edited: {other:?}"), - }) - .map(|(col, value)| { - ( - batch.columns[col].name.clone(), - tablepro_core::export::value_to_text(&value), - ) - }) - .collect() -} - -fn edit_first_row(harness: &Harness, column: usize, input: &str) { - harness.tab().send(typed(&harness.grid_row(0), column, input)); - assert_eq!(harness.tracker.pending_count(), 1); -} - -fn headers_are(tab: &BrowseTab, expected: &[&str]) -> bool { - header_titles(tab) == expected -} - -#[gtk4::test] -fn refresh_after_a_column_is_dropped_keeps_an_edit_on_its_column() { - let harness = Harness::open(losing_nickname(without_nickname()), []); - wait_until(WAIT, || { - headers_are(harness.tab(), &["id", "nickname", "name", "email"]) - }) - .expect("the first grid"); - edit_first_row(&harness, 2, "Grace"); - - harness.tab().send(PagingInput::Refresh); - wait_until(WAIT, || headers_are(harness.tab(), &["id", "name", "email"])).expect("the grid under the new columns"); - - assert_eq!( - saved_cells(&harness.tracker), - vec![("name".to_owned(), Some("Grace".to_owned()))] - ); -} - -#[gtk4::test] -fn a_structure_save_that_drops_the_edited_column_refuses_the_save() { - let harness = Harness::open(losing_nickname(without_nickname()), []); - wait_until(WAIT, || { - headers_are(harness.tab(), &["id", "nickname", "name", "email"]) - }) - .expect("the first grid"); - edit_first_row(&harness, 1, "countess"); - - harness - .session - .invalidate_table(&tablepro_core::TableRef::unqualified("users")); - harness.tab().send(DataInput::SchemaChanged); - wait_until(WAIT, || headers_are(harness.tab(), &["id", "name", "email"])).expect("the grid under the new columns"); - - let refused = harness - .tracker - .begin_save() - .map(|batch| batch.map(|batch| batch.changes.changes)); - assert!( - refused - .as_ref() - .is_err_and(|rejection| rejection.message().contains("nickname")), - "{refused:?}" - ); - assert_eq!( - harness.tracker.pending_count(), - 1, - "the edit was dropped without a word" - ); -} - -#[gtk4::test] -fn a_grid_built_for_columns_the_table_lost_takes_no_edits() { - let harness = Harness::open(losing_nickname(FakeReply::UntilStopped), []); - wait_until(WAIT, || { - headers_are(harness.tab(), &["id", "nickname", "name", "email"]) - }) - .expect("the first grid"); - let row = harness.grid_row(0); - - harness - .session - .invalidate_table(&tablepro_core::TableRef::unqualified("users")); - harness.tab().send(DataInput::SchemaChanged); - wait_until(WAIT, || harness.tab().columns() == people(&["name", "email"])).expect("the new columns"); - // Column 2 was name in the grid the event came from, and is email now. - harness.tab().send(typed(&row, 2, "Grace")); - - assert_eq!( - harness.tracker.pending_count(), - 0, - "an edit in the old grid landed on another column" - ); - assert_eq!(harness.visible_page().as_deref(), Some("loading")); -} - -pub(super) fn delete_action(tab: &BrowseTab) -> gtk4::gio::SimpleAction { - use gtk4::subclass::prelude::ObjectSubclassIsExt; - tab.imp() - .model - .borrow() - .as_ref() - .map(|model| model.actions.delete_rows.clone()) - .expect("the tab has a model") -} - -fn delete_rows(tab: &BrowseTab) { - tab.activate_action("browse.delete-rows", None) - .expect("the tab has a delete action"); -} - -fn draft_rows(store: >k4::gio::ListStore) -> usize { - (0..store.n_items()) - .filter(|position| row_at(store, *position).draft_id().is_some()) - .count() -} - -fn pending_bar_shown(tab: &BrowseTab) -> bool { - use gtk4::subclass::prelude::ObjectSubclassIsExt; - tab.imp().pending_bar.imp().revealer.reveals_child() -} - -/// Discard empties the footer, which slides away with the focus in it, -/// so the focus goes to the rows. -#[gtk4::test] -fn discard_hands_the_focus_to_the_grid() { - use gtk4::subclass::prelude::ObjectSubclassIsExt; - let mut script = serving_table(); - script.query_bound.always(FakeReply::ok(rows(&["Ada", "Grace"]))); - let harness = Harness::open(script, []); - wait_until(WAIT, || harness.shown().len() == 2).expect("the first page"); - let shown = Shown::new(harness.tab()); - harness.tab().send(EditInput::InsertRow); - wait_until(WAIT, || pending_bar_shown(harness.tab())).expect("the footer"); - let discard = harness.tab().imp().pending_bar.imp().discard_button.get(); - assert!(discard.grab_focus()); - - harness.tab().send(SaveInput::DiscardAll); - - let view = column_view(harness.tab()); - let focus = gtk4::prelude::GtkWindowExt::focus(&shown.0); - assert!( - focus.is_some_and(|focus| focus == *view.upcast_ref::() || focus.is_ancestor(&view)), - "the focus left with the footer" - ); -} - -#[gtk4::test] -fn deleting_a_draft_removes_its_row() { - let mut script = serving_table(); - script.query_bound.always(FakeReply::ok(rows(&["Ada", "Grace"]))); - let harness = Harness::open(script, []); - wait_until(WAIT, || harness.shown().len() == 2).expect("the first page"); - harness.tab().send(EditInput::InsertRow); - let store = harness.store(); - assert_eq!(store.n_items(), 3); - harness.selection().select_item(0, true); - assert!( - delete_action(harness.tab()).is_enabled(), - "a draft could not be deleted" - ); - - delete_rows(harness.tab()); - - assert_eq!(store.n_items(), 2, "the dropped draft stayed in the grid"); - assert_eq!(draft_rows(&store), 0); - assert_eq!(harness.tracker.pending_count(), 0); - assert!( - !pending_bar_shown(harness.tab()), - "the footer still counts the dropped draft" - ); -} - -fn named_actions(controller: >k4::ShortcutController) -> Vec { - crate::test_support::shortcut_keys::shortcuts(controller) - .iter() - .filter_map(|shortcut| shortcut.action().and_downcast::()) - .map(|action| action.action_name().to_string()) - .collect() -} - -fn table_actions(owner: crate::application::ShortcutOwner) -> Vec { - use crate::application::{ACCELERATORS, AcceleratorScope}; - ACCELERATORS - .iter() - .filter(|entry| matches!(entry.scope, AcceleratorScope::Local { owner: of, .. } if of == owner)) - .flat_map(|entry| entry.triggers.iter().map(|_| entry.action.to_owned())) - .collect() -} - -/// The grid's keys sit on the column view, so a text field elsewhere in -/// the tab keeps Ctrl+C, Delete and Escape. -#[gtk4::test] -fn browse_grid_shortcuts_live_on_the_column_view() { - use crate::application::ShortcutOwner; - use crate::test_support::shortcut_keys::{owner_controller, shortcut_controllers}; - let mut script = serving_table(); - script.query_bound.always(FakeReply::ok(rows(&["Ada", "Grace"]))); - let harness = Harness::open(script, []); - wait_until(WAIT, || harness.shown().len() == 2).expect("the first page"); - let view = column_view(harness.tab()); - - let grid = owner_controller(&view, ShortcutOwner::BrowseGrid).expect("the grid's shortcuts"); - let tab = owner_controller(harness.tab(), ShortcutOwner::BrowseTab).expect("the tab's shortcuts"); - - assert_eq!(named_actions(&grid), table_actions(ShortcutOwner::BrowseGrid)); - assert_eq!(named_actions(&tab), table_actions(ShortcutOwner::BrowseTab)); - let on_the_tab: Vec = shortcut_controllers(harness.tab()) - .iter() - .flat_map(named_actions) - .collect(); - assert_eq!(on_the_tab, table_actions(ShortcutOwner::BrowseTab), "{on_the_tab:?}"); -} - -/// A bubble key on the tab reaches it only when no class on the way up -/// from the focus binds it first. Text fields keep their own keys, as -/// Ctrl+Z, by design. -#[gtk4::test] -fn browse_tab_keys_are_not_class_bindings_inside_the_tab() { - use crate::application::{ACCELERATORS, AcceleratorScope, ShortcutOwner}; - use crate::test_support::shortcut_keys::{class_keys_on_focus_paths, keystroke}; - let mut script = serving_table(); - script.query_bound.always(FakeReply::ok(rows(&["Ada", "Grace"]))); - let harness = Harness::open(script, []); - wait_until(WAIT, || harness.shown().len() == 2).expect("the first page"); - let _shown = Shown::new(harness.tab()); - - let class_keys = class_keys_on_focus_paths(harness.tab(), |widget| { - widget.is::() || widget.is::() - }); - - assert!( - class_keys.contains(&keystroke("Page_Up")), - "the grid's own bindings were not found, so the check reads nothing" - ); - for entry in ACCELERATORS { - let AcceleratorScope::Local { - owner: ShortcutOwner::BrowseTab, - phase, - } = entry.scope - else { - continue; - }; - assert_eq!(phase, gtk4::PropagationPhase::Bubble, "{}", entry.action); - for accel in entry.triggers { - assert!( - !class_keys.contains(&keystroke(accel)), - "{accel} of {} never reaches the tab", - entry.action - ); - } - } -} - -#[gtk4::test] -fn browse_actions_follow_the_tab() { - let mut script = serving_table(); - script.query_bound.always(FakeReply::ok(rows(&["Ada", "Grace"]))); - let harness = Harness::open(script, []); - wait_until(WAIT, || harness.shown().len() == 2).expect("the first page"); - let activate = |name: &str| { - harness - .tab() - .activate_action(&format!("browse.{name}"), None) - .expect("the browse group"); - }; - - assert!( - action_enabled(harness.tab(), "insert-row"), - "a keyed table refused Ctrl+N" - ); - assert!(!action_enabled(harness.tab(), "save"), "Save with nothing to save"); - assert!(!action_enabled(harness.tab(), "undo")); - assert!( - !action_enabled(harness.tab(), "copy-rows"), - "Ctrl+C with no rows selected" - ); - - harness.selection().select_all(); - assert!(action_enabled(harness.tab(), "copy-rows")); - assert!(action_enabled(harness.tab(), "clear-selection")); - activate("clear-selection"); - assert!( - !action_enabled(harness.tab(), "clear-selection"), - "the selection was not cleared" - ); - - harness.tab().send(EditInput::InsertRow); - assert!(action_enabled(harness.tab(), "save")); - assert!(action_enabled(harness.tab(), "undo")); - assert!(!action_enabled(harness.tab(), "redo")); - activate("undo"); - assert_eq!(harness.tracker.pending_count(), 0, "browse.undo did not undo"); - assert!(action_enabled(harness.tab(), "redo")); - assert!(!action_enabled(harness.tab(), "save")); -} - -/// These press real keys on the X display every test shares; nextest runs -/// them with no other test beside them. -mod real_keys { - use super::*; - - /// A real key press: the field's own undo runs before the tab's, so - /// Ctrl+Z in a filter field takes back typing, not a row edit. - #[gtk4::test] - fn ctrl_z_in_filter_entry_undoes_text_not_rows() { - use gtk4::gdk::{Key, ModifierType}; - // A key reaches a top bar only once its reveal has ended. - if let Some(settings) = gtk4::Settings::default() { - settings.set_gtk_enable_animations(false); - } - let mut script = serving_table(); - script.query_bound.always(FakeReply::ok(rows(&["Ada", "Grace"]))); - let harness = Harness::open(script, []); - wait_until(WAIT, || harness.shown().len() == 2).expect("the first page"); - let shown = Shown::new(harness.tab()); - crate::test_support::KeyInjector::focus_only(&shown.0); - wait_until(Duration::from_secs(2), || shown.0.is_active()).expect("the window to take the focus"); - harness.tab().send(EditInput::DeleteRows(vec![harness.grid_row(0)])); - assert_eq!(harness.tracker.pending_count(), 1); - let strip = { - use gtk4::subclass::prelude::ObjectSubclassIsExt; - let model = harness.tab().imp().model.borrow(); - model - .as_ref() - .map(|model| model.filter_strip.clone()) - .expect("the filter strip") - }; - - harness.tab().send(PagingInput::ToggleFilterStrip); - let mut field = None; - wait_until(Duration::from_secs(2), || { - field = gtk4::prelude::GtkWindowExt::focus(&shown.0) - .filter(|focus| focus.is_ancestor(&strip)) - .and_then(|focus| focus.downcast::().ok()); - field.is_some() - }) - .expect("the strip's field takes the focus"); - let field = field.expect("the focused field"); - let keys = crate::test_support::KeyInjector::connect().expect("an X11 display"); - - keys.press(Key::x, ModifierType::empty()).expect("the key press"); - wait_until(Duration::from_secs(2), || field.text() == "x").expect("the typing to land"); - keys.press(Key::z, ModifierType::CONTROL_MASK).expect("the key press"); - - wait_until(Duration::from_secs(2), || field.text().is_empty()).expect("Ctrl+Z to take back the typing"); - assert_eq!(harness.tracker.pending_count(), 1, "Ctrl+Z undid the row edit"); - } -} - -#[gtk4::test] -fn refresh_asks_the_window_and_filter_opens_the_strip() { - let mut script = serving_table(); - script.query_bound.always(FakeReply::ok(rows(&["Ada", "Grace"]))); - let harness = Harness::open(script, []); - wait_until(WAIT, || harness.shown().len() == 2).expect("the first page"); - let refreshes = crate::test_support::SignalLog::connect(harness.tab(), "refresh-requested", |_| ()); - let strip_open = || { - use gtk4::subclass::prelude::ObjectSubclassIsExt; - let model = harness.tab().imp().model.borrow(); - model.as_ref().is_some_and(|model| model.filter_strip.revealed()) - }; - - harness - .tab() - .activate_action("browse.refresh", None) - .expect("the browse group"); - harness - .tab() - .activate_action("browse.filter", None) - .expect("the browse group"); - - assert_eq!(refreshes.count(), 1); - assert!(strip_open()); -} - -#[gtk4::test] -fn a_read_only_tab_refuses_insert_row() { - let mut script = serving_table(); - script.query_bound.always(FakeReply::ok(rows(&["Ada"]))); - let harness = Harness::open_read_only(script); - wait_until(WAIT, || harness.shown().len() == 1).expect("the first page"); - - assert!(!action_enabled(harness.tab(), "insert-row")); - harness.tab().activate_action("browse.insert-row", None).ok(); - assert_eq!( - harness.tracker.pending_count(), - 0, - "Ctrl+N added a row to a read-only table" - ); - - harness.tab().send(EditInput::InsertRow); - harness.tab().send(EditInput::DuplicateRows(vec![harness.grid_row(0)])); - assert_eq!(harness.tracker.pending_count(), 0, "a read-only table took a new row"); -} - -#[gtk4::test] -fn browse_buttons_announce_their_keys() { - use gtk4::subclass::prelude::ObjectSubclassIsExt; - let mut script = serving_table(); - script.query_bound.always(FakeReply::ok(rows(&["Ada"]))); - let harness = Harness::open(script, []); - wait_until(WAIT, || harness.shown().len() == 1).expect("the first page"); - let imp = harness.tab().imp(); - - for (name, button) in [ - ("insert row", imp.insert_button.get()), - ("delete rows", imp.delete_button.get()), - ("filter", imp.filter_button.get()), - ("previous page", imp.pager_bar.imp().prev_button.get()), - ("next page", imp.pager_bar.imp().next_button.get()), - ("save", imp.pending_bar.imp().save_button.get()), - ] { - assert!( - gtk4::test_accessible_has_property(&button, gtk4::AccessibleProperty::KeyShortcuts), - "the {name} button announces no keys" - ); - } -} - -/// A page splice leaves the selection model without a selection-changed, -/// so the tab reads the selection again. -#[gtk4::test] -fn a_new_page_turns_off_the_selection_keys() { - use gtk4::subclass::prelude::ObjectSubclassIsExt; - let harness = Harness::open(paging(full_page(0), rows_from(100, &["Linus", "Ken"])), []); - wait_until(WAIT, || harness.shown().len() == 100).expect("the first page"); - harness.selection().select_range(0, 2, true); - assert!(action_enabled(harness.tab(), "copy-rows")); - assert!(action_enabled(harness.tab(), "clear-selection")); - - harness.tab().send(PagingInput::NextPage); - wait_until(WAIT, || harness.shown() == text(&["Linus", "Ken"])).expect("the next page"); - - assert!( - harness.selection().selection().is_empty(), - "the new page kept a selection" - ); - assert!( - !action_enabled(harness.tab(), "copy-rows"), - "Ctrl+C stayed on with nothing selected" - ); - assert!( - !action_enabled(harness.tab(), "clear-selection"), - "Escape stayed on with nothing selected" - ); - assert!( - !harness.tab().imp().selection_label.is_visible(), - "the footer still counts the rows that left" - ); -} - -fn action_enabled(tab: &BrowseTab, name: &str) -> bool { - use gtk4::subclass::prelude::ObjectSubclassIsExt; - let model = tab.imp().model.borrow(); - let actions = &model.as_ref().expect("the tab has a model").actions; - let action = match name { - "save" => &actions.save, - "undo" => &actions.undo, - "redo" => &actions.redo, - "insert-row" => &actions.insert_row, - "delete-rows" => &actions.delete_rows, - "copy-rows" => &actions.copy_rows, - "clear-selection" => &actions.clear_selection, - _ => panic!("browse.{name} does not follow the tab"), - }; - action.is_enabled() -} - -#[gtk4::test] -fn deleting_mixed_selection_removes_drafts_and_marks_persisted() { - let mut script = serving_table(); - script.query_bound.always(FakeReply::ok(rows(&["Ada", "Grace"]))); - let harness = Harness::open(script, []); - wait_until(WAIT, || harness.shown().len() == 2).expect("the first page"); - harness.tab().send(EditInput::InsertRow); - harness.selection().select_range(0, 2, true); - - delete_rows(harness.tab()); - - let store = harness.store(); - assert_eq!(store.n_items(), 2); - assert_eq!(draft_rows(&store), 0); - assert_eq!( - harness.tracker.row_state(&key(0)), - crate::services::change_tracker::RowState::PendingDelete - ); - assert_eq!( - harness.tracker.row_state(&key(1)), - crate::services::change_tracker::RowState::Clean - ); - assert_eq!(harness.tracker.pending_count(), 1); - assert!( - crate::ui::grid::selected_positions(&harness.selection()).is_empty(), - "the delete kept the selection it acted on" - ); -} - -fn menu_gesture(cell: &GridCell) -> gtk4::GestureClick { - let controllers = cell.observe_controllers(); - (0..controllers.n_items()) - .filter_map(|index| controllers.item(index).and_downcast::()) - .find(|gesture| gesture.button() == 3) - .expect("a cell answers a right-click") -} - -pub(super) fn column_view(tab: &BrowseTab) -> gtk4::ColumnView { - crate::test_support::first_descendant_of_type::(tab).expect("the grid") -} - -#[gtk4::test] -fn menu_delete_inside_selection_marks_whole_selection() { - let names: Vec = (0..20).map(|n| format!("user {n}")).collect(); - let names: Vec<&str> = names.iter().map(String::as_str).collect(); - let mut script = serving_table(); - script.query_bound.always(FakeReply::ok(rows(&names))); - let harness = Harness::open(script, []); - wait_until(WAIT, || harness.shown().len() == 20).expect("the first page"); - use libadwaita::prelude::{AdwDialogExt, AdwWindowExt}; - let window = libadwaita::Window::new(); - window.set_default_size(600, 400); - window.set_content(Some(harness.tab())); - window.present(); - harness.selection().select_all(); - let cell = grid_cell_of(harness.tab(), 2, 1); - - menu_gesture(&cell).emit_by_name::<()>("pressed", &[&1i32, &1.0f64, &1.0f64]); - column_view(harness.tab()) - .activate_action("cell.delete-row", None) - .expect("the menu's Delete"); - let mut dialog = None; - wait_until(WAIT, || { - dialog = window.visible_dialog().and_downcast::(); - dialog.is_some() - }) - .expect("a delete of 20 rows was not confirmed first"); - assert_eq!( - harness.tracker.pending_count(), - 0, - "the rows were marked before the answer" - ); - let dialog = dialog.expect("the confirmation"); - dialog.emit_by_name::<()>("response", &[&"delete"]); - - wait_until(WAIT, || harness.tracker.pending_count() == 20).expect("the whole selection was not marked"); - dialog.force_close(); - window.set_content(None::<>k4::Widget>); - window.destroy(); -} - -#[gtk4::test] -fn undo_insert_removes_draft_row() { - let mut script = serving_table(); - script.query_bound.always(FakeReply::ok(rows(&["Ada", "Grace"]))); - let harness = Harness::open(script, []); - wait_until(WAIT, || harness.shown().len() == 2).expect("the first page"); - harness.tab().send(EditInput::InsertRow); - let store = harness.store(); - assert_eq!(store.n_items(), 3); - - harness.tab().send(EditInput::Undo); - - assert_eq!(store.n_items(), 2, "the undone draft stayed in the grid"); - assert_eq!(draft_rows(&store), 0); - assert_eq!(harness.tracker.pending_count(), 0); -} - -#[gtk4::test] -fn redo_insert_restores_draft_row_once() { - let mut script = serving_table(); - script.query_bound.always(FakeReply::ok(rows(&["Ada", "Grace"]))); - let harness = Harness::open(script, []); - wait_until(WAIT, || harness.shown().len() == 2).expect("the first page"); - harness.tab().send(EditInput::InsertRow); - harness.tab().send(EditInput::Undo); - - harness.tab().send(EditInput::Redo); - - let store = harness.store(); - assert_eq!(store.n_items(), 3); - assert_eq!(draft_rows(&store), 1, "the redone draft is in the grid more than once"); - assert!(row_at(&store, 0).draft_id().is_some(), "the redone draft is not on top"); -} - -#[gtk4::test] -fn delete_action_disabled_for_unkeyed_selection() { - let columns = [column("id", false), column("name", false)]; - let mut script = serving_table(); - script.fetch_columns.always(FakeReply::ok(columns.to_vec())); - script.query_bound.always(FakeReply::ok(QueryResult::new( - columns.iter().map(ColumnInfo::result_column).collect::>(), - vec![ - vec![Value::Int(0), Value::Text("Ada".into())], - vec![Value::Int(1), Value::Text("Grace".into())], - ], - ))); - let harness = Harness::open(script, []); - wait_until(WAIT, || harness.shown().len() == 2).expect("the first page"); - let _shown = Shown::new(harness.tab()); - let action = delete_action(harness.tab()); - - harness.selection().select_item(0, true); - - assert!(!action.is_enabled(), "Delete was offered for a row with no key"); - let button = { - use gtk4::subclass::prelude::ObjectSubclassIsExt; - harness.tab().imp().delete_button.get() - }; - assert_eq!( - button.tooltip_text().as_deref(), - Some("Rows without a primary key or unique key can't be deleted here") - ); - let view = column_view(harness.tab()); - let mut cell = None; - wait_until(WAIT, || { - cell = crate::test_support::descendants(&view) - .into_iter() - .filter_map(|widget| widget.downcast::().ok()) - .find(|cell| cell.column() == 1 && cell.position() == Some(0)); - cell.is_some() - }) - .expect("the row's cell is drawn"); - menu_gesture(&cell.expect("a cell")).emit_by_name::<()>("pressed", &[&1i32, &1.0f64, &1.0f64]); - // A menu item is a model button bound to the action's enabled state, - // with the item's label as its text. - let items: Vec = crate::test_support::descendants(&view) - .into_iter() - .filter(|widget| widget.type_().name() == "GtkModelButton") - .filter(|widget| widget.property::>("text").as_deref() == Some("Delete")) - .collect(); - assert!(!items.is_empty(), "the menu has no Delete item"); - wait_until(WAIT, || items.iter().all(|item| !item.is_sensitive())) - .expect("the menu offered Delete for a row with no key"); - view.activate_action("cell.delete-row", None) - .expect("the menu's Delete"); - delete_rows(harness.tab()); - assert_eq!(harness.tracker.pending_count(), 0); -} - -fn named_col(name: &str, data_type: &str, nullable: bool) -> ColumnInfo { - let mut column = col(data_type, nullable); - column.name = name.to_owned(); - column -} - -/// A table of an `id` key and `column`, with one row, id 0, holding -/// `value`, drawn in a window. -fn one_cell(column: ColumnInfo, value: Value) -> (Harness, Shown) { - let columns = vec![self::column("id", true), column]; - let mut script = ConnectionScript::default(); - script.fetch_columns.always(FakeReply::ok(columns.clone())); - script.row_identity.always(FakeReply::ok(RowIdentity::UniqueKey { - columns: vec!["id".to_owned()], - })); - script.count_rows.always(FakeReply::ok(1)); - script.probe.always(FakeReply::ok(())); - script.query_bound.always(FakeReply::ok(QueryResult::new( - columns.iter().map(ColumnInfo::result_column).collect::>(), - vec![vec![Value::Int(0), value]], - ))); - let harness = Harness::open(script, []); - wait_until(WAIT, || harness.saved_rows().len() == 1).expect("the first page"); - let shown = Shown::new(harness.tab()); - (harness, shown) -} - -/// The value the tracker holds for row 0's second cell. -fn pending_value(harness: &Harness, fetched: &Value) -> Value { - harness.tracker.cell_overlay(&key(0), 1).shown(fetched.clone()) -} - -/// Open the cell's one-line editor and paste `text` over its value -/// through the clipboard, as Ctrl+V does. -fn paste_into(cell: &GridCell, text: &str) -> gtk4::Text { - crate::ui::grid::begin_edit(cell); - let editor = cell.inline_text().expect("the cell did not open its one-line editor"); - let opened = editor.text(); - editor.clipboard().set_text(text); - editor.emit_paste_clipboard(); - wait_until(WAIT, || editor.text() != opened).expect("the paste never landed"); - editor -} - -fn calendar_of(cell: &GridCell) -> Option { - crate::test_support::first_descendant_of_type::(cell) -} - -#[gtk4::test] -fn f005_unchanged_40kb_multiline_text_records_no_edit() { - let long = "lorem ipsum ".repeat(3_500); - let (harness, _shown) = one_cell(named_col("note", "text", true), Value::Text(long.clone())); - let cell = grid_cell_of(harness.tab(), 0, 1); - - crate::ui::grid::begin_edit(&cell); - - assert!(cell.inline_text().is_none(), "40 KB of text opened in a one-line field"); - let view = - crate::test_support::first_descendant_of_type::(&cell).expect("the multi-line editor"); - let buffer = view.buffer(); - assert_eq!(buffer.text(&buffer.start_iter(), &buffer.end_iter(), true), long); - crate::test_support::descendants(&cell) - .into_iter() - .filter_map(|widget| widget.downcast::().ok()) - .find(|button| button.has_css_class("suggested-action")) - .expect("the Save button") - .emit_clicked(); - assert_eq!( - harness.tracker.pending_count(), - 0, - "saving the text as it was recorded an edit" - ); - assert!(!cell.is_editing()); -} - -#[gtk4::test] -fn bigint_edit_is_exact() { - let fetched = Value::Int(1_234_567_890_123_456_789); - let (harness, _shown) = one_cell(named_col("n", "bigint", false), fetched.clone()); - let cell = grid_cell_of(harness.tab(), 0, 1); - - let editor = type_into(&cell, "1234567890123456790"); - assert_eq!( - editor.input_purpose(), - gtk4::InputPurpose::Number, - "a signed integer field offers a number keypad" - ); - editor.emit_activate(); - - assert_eq!(pending_value(&harness, &fetched), Value::Int(1_234_567_890_123_456_790)); -} - -#[gtk4::test] -#[expect( - clippy::approx_constant, - reason = "a double with every digit it can hold, which is the point, not pi" -)] -fn f005_pressing_enter_on_float_keeps_full_precision() { - let fetched = Value::Float64(3.141_592_653_589_79); - let (harness, _shown) = one_cell(named_col("x", "double precision", false), fetched); - let cell = grid_cell_of(harness.tab(), 0, 1); - - crate::ui::grid::begin_edit(&cell); - let editor = cell.inline_text().expect("a one-line field"); - assert_eq!(editor.text(), "3.14159265358979"); - editor.emit_activate(); - - assert_eq!( - harness.tracker.pending_count(), - 0, - "Enter rounded the value it opened on" - ); -} - -#[gtk4::test] -fn null_int_opens_empty_with_null_placeholder() { - let (harness, _shown) = one_cell(named_col("n", "integer", true), Value::Null); - let cell = grid_cell_of(harness.tab(), 0, 1); - - crate::ui::grid::begin_edit(&cell); - - let editor = cell.inline_text().expect("a one-line field"); - assert_eq!(editor.text(), "", "NULL opened as the text of its sentinel"); - assert_eq!(editor.placeholder_text().as_deref(), Some("NULL")); -} - -#[gtk4::test] -fn clearing_not_null_int_without_default_shows_required_toast() { - let fetched = Value::Int(5); - let (harness, _shown) = one_cell(named_col("n", "integer", false), fetched.clone()); - let toasts = toasts(harness.tab()); - let cell = grid_cell_of(harness.tab(), 0, 1); - - let editor = type_into(&cell, ""); - assert_eq!(editor.placeholder_text().as_deref(), Some("Required")); - editor.emit_activate(); - - assert_eq!(*toasts.borrow(), ["This column needs a value".to_owned()]); - assert_eq!(harness.tracker.pending_count(), 0); - assert_eq!(cell_text(&cell), "5"); -} - -#[gtk4::test] -fn draft_not_null_with_default_placeholder_is_default() { - let mut level = col_with_default("integer", "0"); - level.name = "level".to_owned(); - let (harness, _shown) = one_cell(level, Value::Int(3)); - harness.tab().send(EditInput::InsertRow); - let mut cell = None; - wait_until(WAIT, || { - cell = crate::test_support::descendants(harness.tab()) - .into_iter() - .filter_map(|widget| widget.downcast::().ok()) - .find(|cell| cell.column() == 1 && cell.row().is_some_and(|row| row.draft_id().is_some())); - cell.is_some() - }) - .expect("the new row's cell is drawn"); - let cell = cell.expect("a cell"); - cell.cancel_edit(); - assert_eq!(cell_text(&cell), "DEFAULT", "a cell left to the server read as NULL"); - - crate::ui::grid::begin_edit(&cell); - - let editor = cell.inline_text().expect("a one-line field"); - assert_eq!(editor.text(), ""); - assert_eq!(editor.placeholder_text().as_deref(), Some("DEFAULT")); -} - -#[gtk4::test] -fn f005_pasted_newlines_in_text_cell_are_kept() { - let fetched = Value::Text("a".into()); - let (harness, _shown) = one_cell(named_col("note", "text", true), fetched.clone()); - let cell = grid_cell_of(harness.tab(), 0, 1); - - let editor = paste_into(&cell, "first line\nsecond line"); - editor.emit_activate(); - - assert_eq!( - pending_value(&harness, &fetched), - Value::Text("first line\nsecond line".into()) - ); -} - -#[gtk4::test] -fn a_line_break_pasted_into_a_number_keeps_the_first_line() { - let fetched = Value::Int(1); - let (harness, _shown) = one_cell(named_col("n", "integer", true), fetched.clone()); - let cell = grid_cell_of(harness.tab(), 0, 1); - - let editor = paste_into(&cell, "42\n43"); - assert_eq!(editor.text(), "42"); - editor.emit_activate(); - - assert_eq!(pending_value(&harness, &fetched), Value::Int(42)); -} - -#[gtk4::test] -fn tinyint1_value_2_renders_inconsistent_with_exact_tooltip() { - let mut flag = named_col("flag", "tinyint(1)", false); - flag.column_type.kind = tablepro_core::column::ColumnKind::Bool(tablepro_core::column::BoolStorage::Integer); - let (harness, _shown) = one_cell(flag, Value::Int(2)); - let cell = grid_cell_of(harness.tab(), 0, 1); - - let check = cell.check_button().expect("a check button"); - - assert!(check.is_inconsistent(), "2 showed as true or false"); - assert_eq!(check.tooltip_text().as_deref(), Some("2")); - check.set_active(true); - assert_eq!(pending_value(&harness, &Value::Int(2)), Value::Int(1)); -} - -fn a_date(year: i32, month: u32, day: u32) -> Value { - Value::Date(tablepro_core::value::Temporal::Finite( - chrono::NaiveDate::from_ymd_opt(year, month, day).expect("a date"), - )) -} - -#[gtk4::test] -fn calendar_preselects_date_value() { - let (harness, _shown) = one_cell(named_col("day", "date", true), a_date(2024, 2, 29)); - let cell = grid_cell_of(harness.tab(), 0, 1); - - crate::ui::grid::begin_edit(&cell); - - let calendar = calendar_of(&cell).expect("a date opened its calendar"); - assert_eq!(calendar.date().ymd(), (2024, 2, 29)); -} - -#[gtk4::test] -fn picking_a_day_commits_it() { - let fetched = a_date(2024, 2, 29); - let (harness, _shown) = one_cell(named_col("day", "date", true), fetched.clone()); - let cell = grid_cell_of(harness.tab(), 0, 1); - crate::ui::grid::begin_edit(&cell); - let calendar = calendar_of(&cell).expect("the calendar"); - - let picked = gtk4::glib::DateTime::from_utc(2024, 3, 1, 0, 0, 0.0).expect("a day"); - calendar.set_date(&picked); - - assert_eq!(pending_value(&harness, &fetched), a_date(2024, 3, 1)); - assert!(!cell.is_editing()); -} - -/// The middle of the first of the calendar's labels that `matches`, -/// once the popover has laid it out. -fn calendar_label(calendar: >k4::Calendar, matches: impl Fn(>k4::Widget) -> bool) -> (f64, f64) { - let mut found = None; - wait_until(WAIT, || { - found = crate::test_support::descendants(calendar) - .into_iter() - .find(|widget| widget.width() > 0 && matches(widget)); - found.is_some() - }) - .expect("the label is drawn"); - let label = found.expect("a label"); - let middle = gtk4::graphene::Point::new(label.width() as f32 / 2.0, label.height() as f32 / 2.0); - let point = label - .compute_point(calendar, &middle) - .expect("the label is inside the calendar"); - (f64::from(point.x()), f64::from(point.y())) -} - -fn selected_day(calendar: >k4::Calendar) -> (f64, f64) { - calendar_label(calendar, |label| { - label.has_css_class("day-number") && label.state_flags().contains(gtk4::StateFlags::SELECTED) - }) -} - -/// What the calendar hears when a primary click at `(x, y)` ends. -fn release_on(calendar: >k4::Calendar, (x, y): (f64, f64)) { - let controllers = calendar.observe_controllers(); - for gesture in - (0..controllers.n_items()).filter_map(|index| controllers.item(index).and_downcast::()) - { - gesture.emit_by_name::<()>("released", &[&1i32, &x, &y]); - } -} - -fn release_space(calendar: >k4::Calendar) { - use gtk4::glib::translate::IntoGlib; - let controllers = calendar.observe_controllers(); - for keys in (0..controllers.n_items()) - .filter_map(|index| controllers.item(index).and_downcast::()) - { - keys.emit_by_name::<()>( - "key-released", - &[ - >k4::gdk::Key::space.into_glib(), - &0u32, - >k4::gdk::ModifierType::empty(), - ], - ); - } -} - -/// Open the cell's calendar until its popover shows. Test processes -/// share one X display, where another test's window can close an -/// autohide popover before it maps. -fn open_calendar(cell: &GridCell) -> gtk4::Calendar { - for _ in 0..5 { - crate::ui::grid::begin_edit(cell); - if let Some(calendar) = calendar_of(cell) - && wait_until(Duration::from_secs(2), || calendar.is_mapped() && calendar.width() > 0).is_ok() - { - return calendar; + /// A real key press: the field's own undo runs before the tab's, so + /// Ctrl+Z in a filter field takes back typing, not a row edit. + #[gtk4::test] + fn ctrl_z_in_filter_entry_undoes_text_not_rows() { + use gtk4::gdk::{Key, ModifierType}; + // A key reaches a top bar only once its reveal has ended. + if let Some(settings) = gtk4::Settings::default() { + settings.set_gtk_enable_animations(false); } - cell.cancel_edit(); - drain_main_context(); - } - panic!("the calendar never showed"); -} - -fn shown_day(calendar: >k4::Calendar) -> Value { - let (year, month, day) = calendar.date().ymd(); - a_date(year, u32::try_from(month).unwrap(), u32::try_from(day).unwrap()) -} - -#[gtk4::test] -fn clicking_today_on_a_null_date_commits_it() { - let (harness, _shown) = one_cell(named_col("day", "date", true), Value::Null); - let cell = grid_cell_of(harness.tab(), 0, 1); - let calendar = open_calendar(&cell); - let today = shown_day(&calendar); - - release_on( - &calendar, - calendar_label(&calendar, |label| label.has_css_class("day-name")), - ); - assert!(cell.is_editing(), "a click on a weekday name picked a day"); - release_on(&calendar, selected_day(&calendar)); - - assert_eq!(pending_value(&harness, &Value::Null), today); - assert!(!cell.is_editing()); -} - -#[gtk4::test] -fn picking_the_same_day_in_another_month_commits_it() { - let fetched = a_date(2024, 3, 15); - let (harness, _shown) = one_cell(named_col("day", "date", true), fetched.clone()); - let cell = grid_cell_of(harness.tab(), 0, 1); - let calendar = open_calendar(&cell); - - // As the next-month arrow does, this moves the date without a - // day-selected. - calendar.set_date(>k4::glib::DateTime::from_utc(2024, 4, 15, 0, 0, 0.0).expect("a day")); - assert!(cell.is_editing(), "a month change picked a day"); - release_on(&calendar, selected_day(&calendar)); - - assert_eq!(pending_value(&harness, &fetched), a_date(2024, 4, 15)); -} + let mut script = serving_table(); + script.query_bound.always(FakeReply::ok(rows(&["Ada", "Grace"]))); + let harness = Harness::open(script, []); + wait_until(WAIT, || harness.shown().len() == 2).expect("the first page"); + let shown = Shown::new(harness.tab()); + crate::test_support::KeyInjector::focus_only(&shown.0); + wait_until(Duration::from_secs(2), || shown.0.is_active()).expect("the window to take the focus"); + harness.apply_delta(&harness.tracker.apply(rename(0, "Ada", "Ada Lovelace"))); + assert_eq!(harness.tracker.pending_count(), 1); + let strip = { + use gtk4::subclass::prelude::ObjectSubclassIsExt; + let model = harness.tab().imp().model.borrow(); + model + .as_ref() + .map(|model| model.filter_strip.clone()) + .expect("the filter strip") + }; -#[gtk4::test] -fn space_on_the_selected_day_commits_it() { - let (harness, _shown) = one_cell(named_col("day", "date", true), Value::Null); - let cell = grid_cell_of(harness.tab(), 0, 1); - let calendar = open_calendar(&cell); - let today = shown_day(&calendar); + harness.tab().send(PagingInput::ToggleFilterStrip); + let mut field = None; + wait_until(Duration::from_secs(2), || { + field = gtk4::prelude::GtkWindowExt::focus(&shown.0) + .filter(|focus| focus.is_ancestor(&strip)) + .and_then(|focus| focus.downcast::().ok()); + field.is_some() + }) + .expect("the strip's field takes the focus"); + let field = field.expect("the focused field"); + let keys = crate::test_support::KeyInjector::connect().expect("an X11 display"); - release_space(&calendar); + keys.press(Key::x, ModifierType::empty()).expect("the key press"); + wait_until(Duration::from_secs(2), || field.text() == "x").expect("the typing to land"); + keys.press(Key::z, ModifierType::CONTROL_MASK).expect("the key press"); - assert_eq!(pending_value(&harness, &Value::Null), today); + wait_until(Duration::from_secs(2), || field.text().is_empty()).expect("Ctrl+Z to take back the typing"); + assert_eq!(harness.tracker.pending_count(), 1, "Ctrl+Z undid the row edit"); + } } #[gtk4::test] -fn opening_calendar_on_existing_date_records_no_edit() { - let (harness, _shown) = one_cell(named_col("day", "date", true), a_date(2024, 2, 29)); - let cell = grid_cell_of(harness.tab(), 0, 1); - crate::ui::grid::begin_edit(&cell); - let popover = crate::test_support::first_descendant_of_type::(&cell).expect("the calendar popover"); +fn refresh_asks_the_window_and_filter_opens_the_strip() { + let mut script = serving_table(); + script.query_bound.always(FakeReply::ok(rows(&["Ada", "Grace"]))); + let harness = Harness::open(script, []); + wait_until(WAIT, || harness.shown().len() == 2).expect("the first page"); + let refreshes = crate::test_support::SignalLog::connect(harness.tab(), "refresh-requested", |_| ()); + let strip_open = || { + use gtk4::subclass::prelude::ObjectSubclassIsExt; + let model = harness.tab().imp().model.borrow(); + model.as_ref().is_some_and(|model| model.filter_strip.revealed()) + }; - popover.popdown(); - wait_until(WAIT, || popover.parent().is_none()).expect("the popover closes"); + harness + .tab() + .activate_action("browse.refresh", None) + .expect("the browse group"); + harness + .tab() + .activate_action("browse.filter", None) + .expect("the browse group"); - assert_eq!( - harness.tracker.pending_count(), - 0, - "opening the calendar recorded its own day" - ); - assert!(!cell.is_editing()); + assert_eq!(refreshes.count(), 1); + assert!(strip_open()); } +/// The grid's Filter by Value reaches the strip, which shows the rule +/// and reads the rows under it. #[gtk4::test] -fn out_of_range_date_opens_inline_editor() { - let (harness, _shown) = one_cell(named_col("day", "date", true), a_date(12_000, 1, 1)); - let cell = grid_cell_of(harness.tab(), 0, 1); +fn filter_by_value_adds_the_rule_to_the_strip_and_reads_under_it() { + use gtk4::subclass::prelude::ObjectSubclassIsExt; + let mut script = serving_table(); + script + .query_bound + .when_sql_contains("IS NOT NULL", FakeReply::ok(rows(&["Ada"]))) + .always(FakeReply::ok(rows(&["Ada", "Grace"]))); + let harness = Harness::open(script, []); + wait_until(WAIT, || harness.shown().len() == 2).expect("the first page"); + let rule = not_null_name().rules.remove(0); - crate::ui::grid::begin_edit(&cell); + harness + .tab() + .send(GridInput::Request(GridRequest::FilterByValue(rule.clone()))); - assert!( - calendar_of(&cell).is_none(), - "the calendar opened on a year it cannot show" - ); - let editor = cell.inline_text().expect("a one-line field"); - assert!(editor.text().contains("12000"), "{}", editor.text()); + wait_until(WAIT, || harness.shown() == text(&["Ada"])).expect("the rows under the rule"); + assert_eq!(harness.tab().applied_filter().rules, [rule]); + let model = harness.tab().imp().model.borrow(); + let strip = &model.as_ref().expect("the tab is open").filter_strip; + assert!(strip.revealed(), "the strip that says why the rows changed is hidden"); } #[gtk4::test] -fn read_only_cells_use_inscription_not_selectable_label() { +fn a_read_only_tab_refuses_insert_row() { + use gtk4::subclass::prelude::ObjectSubclassIsExt; let mut script = serving_table(); script.query_bound.always(FakeReply::ok(rows(&["Ada"]))); - let harness = Harness::open(script, []); + let harness = Harness::open_read_only(script); wait_until(WAIT, || harness.shown().len() == 1).expect("the first page"); - let _shown = Shown::new(harness.tab()); - // A saved row's key, which the row is named by and cannot change. - let cell = grid_cell_of(harness.tab(), 0, 0); - assert!(cell.lock().is_some()); - assert!( - cell.display().is_some(), - "a locked cell does not draw in an Inscription" - ); + assert!(!harness.grid_enabled("insert-row")); assert!( - crate::test_support::descendants(&cell) - .iter() - .all(|widget| !widget.is::()), - "a locked cell carries a selectable label" + !harness.tab().imp().insert_button.is_visible(), + "a read-only tab offers Insert" ); - crate::ui::grid::begin_edit(&cell); - assert!(!cell.is_editing(), "a locked cell opened for editing"); -} - -/// More rows than a delete marks without asking first. -const DOZEN: [&str; 12] = ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l"]; - -/// The confirmation a bulk delete is waiting on, by its ticket. -fn confirming(tab: &BrowseTab) -> Option<(u64, libadwaita::AlertDialog)> { - use gtk4::subclass::prelude::ObjectSubclassIsExt; - tab.imp().model.borrow().as_ref().and_then(|model| { - model - .confirming_delete - .as_ref() - .map(|pending| (pending.ticket, pending.dialog.clone())) - }) -} - -/// Every row selected and Delete pressed, which asks before marking. -fn ask_to_delete_every_row(harness: &Harness) -> libadwaita::AlertDialog { - wait_until(WAIT, || harness.shown().len() == DOZEN.len()).expect("the first page"); - harness.selection().select_all(); - delete_rows(harness.tab()); - let (_, dialog) = confirming(harness.tab()).expect("a bulk delete asks first"); - assert_eq!(harness.tracker.pending_count(), 0); - dialog -} - -fn answer_delete(dialog: &libadwaita::AlertDialog) { - dialog.emit_by_name::<()>("response", &[&"delete"]); + harness.tab().activate_action("grid.insert-row", None).ok(); + harness.select(0..1); + assert!(!harness.grid_enabled("duplicate-rows")); + harness.tab().activate_action("grid.duplicate-rows", None).ok(); drain_main_context(); -} -fn change_schema(harness: &Harness, done: impl Fn(&[ColumnInfo]) -> bool) { - harness - .session - .invalidate_table(&tablepro_core::TableRef::unqualified("users")); - harness.tab().send(DataInput::SchemaChanged); - wait_until(WAIT, || done(&harness.tab().columns())).expect("the new columns"); + assert_eq!(harness.tracker.pending_count(), 0, "a read-only table took a new row"); } -/// Closing the dialog after the answer answers again with its close -/// response, and the rows stay marked. #[gtk4::test] -fn a_confirmed_bulk_delete_marks_every_selected_row() { +fn browse_buttons_announce_their_keys() { + use gtk4::subclass::prelude::ObjectSubclassIsExt; let mut script = serving_table(); - script.query_bound.always(FakeReply::ok(rows(&DOZEN))); + script.query_bound.always(FakeReply::ok(rows(&["Ada"]))); let harness = Harness::open(script, []); - let _shown = Shown::new(harness.tab()); - let dialog = ask_to_delete_every_row(&harness); - - answer_delete(&dialog); - libadwaita::prelude::AdwDialogExt::force_close(&dialog); - drain_main_context(); + wait_until(WAIT, || harness.shown().len() == 1).expect("the first page"); + let imp = harness.tab().imp(); - assert_eq!(harness.tracker.pending_count(), DOZEN.len()); - assert!(confirming(harness.tab()).is_none()); + for (name, button) in [ + ("insert row", imp.insert_button.get().upcast::()), + ("delete rows", imp.delete_button.get().upcast()), + ("row details", imp.details_toggle.get().upcast()), + ("filter", imp.filter_button.get().upcast()), + ("previous page", imp.pager_bar.imp().prev_button.get().upcast()), + ("next page", imp.pager_bar.imp().next_button.get().upcast()), + ("save", imp.pending_bar.imp().save_button.get().upcast()), + ] { + assert!( + gtk4::test_accessible_has_property(&button, gtk4::AccessibleProperty::KeyShortcuts), + "the {name} button announces no keys" + ); + } } +/// The toolbar's Row Details names the grid's action, so it opens the +/// grid's pane and follows it when the pane closes itself. #[gtk4::test] -fn a_bulk_delete_waiting_while_the_primary_key_moves_is_cancelled() { +fn the_row_details_toggle_shows_the_grids_pane() { + use gtk4::subclass::prelude::ObjectSubclassIsExt; let mut script = serving_table(); - script - .fetch_columns - .once(FakeReply::ok(vec![column("id", true), column("name", false)])) - .always(FakeReply::ok(vec![column("id", false), column("name", true)])); - script.query_bound.always(FakeReply::ok(rows(&DOZEN))); + script.query_bound.always(FakeReply::ok(rows(&["Ada"]))); let harness = Harness::open(script, []); + wait_until(WAIT, || harness.shown().len() == 1).expect("the first page"); let _shown = Shown::new(harness.tab()); - let toasts = toasts(harness.tab()); - let dialog = ask_to_delete_every_row(&harness); + let toggle = harness.tab().imp().details_toggle.get(); + let details = || { + harness + .grid() + .actions() + .action_state("show-details") + .and_then(|state| state.get::()) + .unwrap_or(false) + }; + wait_until(WAIT, || toggle.is_sensitive()).expect("the toggle reaches the grid"); - change_schema(&harness, |columns| { - columns.iter().any(|column| column.name == "name" && column.primary_key) - }); - answer_delete(&dialog); + toggle.emit_clicked(); + drain_main_context(); + assert!(details(), "the toggle did not open the pane"); - assert!(confirming(harness.tab()).is_none(), "the confirmation outlived the key"); - assert_eq!( - harness.tracker.pending_count(), - 0, - "rows were marked by the key they had before" - ); - assert_eq!(toasts.borrow().len(), 1, "{:?}", toasts.borrow()); + harness + .grid() + .actions() + .change_action_state("show-details", &false.to_variant()); + drain_main_context(); + assert!(!toggle.is_active(), "the toggle stayed on with the pane closed"); } +/// A page that takes the selected rows away leaves GtkMultiSelection +/// without a selection-changed, so the tab reads the selection again. #[gtk4::test] -fn a_bulk_delete_waiting_while_a_column_is_added_marks_its_rows_under_the_new_columns() { - let widened = [column("id", true), column("name", false), column("email", false)]; - let mut script = serving_table(); - script - .fetch_columns - .once(FakeReply::ok(vec![column("id", true), column("name", false)])) - .always(FakeReply::ok(widened.to_vec())); - script.query_bound.always(FakeReply::ok(rows(&DOZEN))); - let harness = Harness::open(script, []); - let _shown = Shown::new(harness.tab()); - let dialog = ask_to_delete_every_row(&harness); +fn a_new_page_turns_off_the_selection_keys() { + use gtk4::subclass::prelude::ObjectSubclassIsExt; + let harness = Harness::open(paging(full_page(0), rows_from(100, &["Linus", "Ken"])), []); + wait_until(WAIT, || harness.shown().len() == 100).expect("the first page"); + harness.select(0..3); + assert!(harness.grid_enabled("copy")); + assert!(harness.grid_enabled("clear-selection")); + assert!(harness.tab().imp().selection_label.is_visible()); + + harness.tab().send(PagingInput::NextPage); + wait_until(WAIT, || harness.shown() == text(&["Linus", "Ken"])).expect("the next page"); - change_schema(&harness, |columns| columns.len() == widened.len()); assert!( - confirming(harness.tab()).is_some(), - "a change that keeps the key cancelled the delete" + harness.selection().selection().is_empty(), + "the new page kept a selection" ); - answer_delete(&dialog); - - assert_eq!(harness.tracker.pending_count(), DOZEN.len()); - assert_eq!( - harness.tracker.row_state(&key(0)), - crate::services::change_tracker::RowState::PendingDelete + assert!(!harness.grid_enabled("copy"), "Ctrl+C stayed on with nothing selected"); + assert!( + !harness.grid_enabled("clear-selection"), + "Escape stayed on with nothing selected" + ); + assert!( + !harness.tab().imp().selection_label.is_visible(), + "the footer still counts the rows that left" ); } @@ -2893,111 +1349,6 @@ fn a_failed_row_count_logs_its_category_and_not_the_server_message() { assert!(text.contains("category=Some(Server)"), "{text}"); } -#[gtk4::test] -fn a_second_delete_replaces_the_waiting_confirmation() { - let mut script = serving_table(); - script.query_bound.always(FakeReply::ok(rows(&DOZEN))); - let harness = Harness::open(script, []); - let _shown = Shown::new(harness.tab()); - ask_to_delete_every_row(&harness); - let (first, _) = confirming(harness.tab()).expect("the first confirmation"); - - delete_rows(harness.tab()); - drain_main_context(); - let (second, dialog) = confirming(harness.tab()).expect("the closed confirmation cancelled the new one"); - assert_ne!(first, second); - harness.tab().send(EditInput::BulkDeleteAnswered { - ticket: first, - confirmed: true, - }); - drain_main_context(); - - assert_eq!( - harness.tracker.pending_count(), - 0, - "an answer to the replaced confirmation marked the rows" - ); - assert_eq!(confirming(harness.tab()).map(|(ticket, _)| ticket), Some(second)); - answer_delete(&dialog); - assert_eq!(harness.tracker.pending_count(), DOZEN.len()); -} - -#[gtk4::test] -fn a_bulk_delete_with_the_confirmation_off_marks_its_rows_at_once() { - let mut script = serving_table(); - script.query_bound.always(FakeReply::ok(rows(&DOZEN))); - let harness = Harness::open(script, []); - let _shown = Shown::new(harness.tab()); - wait_until(WAIT, || harness.shown().len() == DOZEN.len()).expect("the first page"); - - harness - .stores - .settings - .get() - .set_confirm_bulk_delete(false) - .expect("turn the confirmation off"); - harness.selection().select_all(); - delete_rows(harness.tab()); - drain_main_context(); - - assert!( - confirming(harness.tab()).is_none(), - "the delete asked with the switch off" - ); - assert_eq!(harness.tracker.pending_count(), DOZEN.len()); -} - -#[gtk4::test] -fn turning_the_confirmation_back_on_asks_again_in_an_open_tab() { - let mut script = serving_table(); - script.query_bound.always(FakeReply::ok(rows(&DOZEN))); - let harness = Harness::open(script, []); - let _shown = Shown::new(harness.tab()); - let settings = harness.stores.settings.get(); - settings - .set_confirm_bulk_delete(false) - .expect("turn the confirmation off"); - wait_until(WAIT, || harness.shown().len() == DOZEN.len()).expect("the first page"); - harness.selection().select_all(); - delete_rows(harness.tab()); - drain_main_context(); - assert_eq!(harness.tracker.pending_count(), DOZEN.len()); - harness.tab().send(EditInput::Undo); - assert_eq!(harness.tracker.pending_count(), 0); - - settings - .set_confirm_bulk_delete(true) - .expect("turn the confirmation on"); - let dialog = ask_to_delete_every_row(&harness); - crate::test_support::respond(&dialog, "delete"); - drain_main_context(); - - assert_eq!(harness.tracker.pending_count(), DOZEN.len()); -} - -#[gtk4::test] -fn insert_row_opens_the_new_rows_first_editable_cell() { - let (harness, shown) = one_cell(named_col("name", "text", true), Value::Text("Ada".into())); - grid_cell_of(harness.tab(), 0, 1); - - harness.tab().send(EditInput::InsertRow); - - let mut editing = None; - wait_until(WAIT, || { - editing = crate::test_support::descendants(harness.tab()) - .into_iter() - .filter_map(|widget| widget.downcast::().ok()) - .find(|cell| cell.inline_text().is_some()); - editing.is_some() - }) - .expect("the new row opens for editing"); - let cell = editing.expect("an editing cell"); - assert!(cell.row().is_some_and(|row| row.draft_id().is_some())); - assert_eq!(cell.column(), 0, "a new row sets its own key first"); - let focus = gtk4::prelude::GtkWindowExt::focus(&shown.0).expect("a focused widget"); - assert_eq!(GridCell::from_focus(&focus).as_ref(), Some(&cell)); -} - #[gtk4::test] fn icon_buttons_are_labelled_without_accelerator_hints() { use gtk4::subclass::prelude::ObjectSubclassIsExt; @@ -3014,9 +1365,10 @@ fn icon_buttons_are_labelled_without_accelerator_hints() { Vec::::new() ); for button in [ - imp.insert_button.get(), - imp.pager_bar.imp().next_button.get(), - imp.pending_bar.imp().save_button.get(), + imp.insert_button.get().upcast::(), + imp.details_toggle.get().upcast(), + imp.pager_bar.imp().next_button.get().upcast(), + imp.pending_bar.imp().save_button.get().upcast(), ] { assert!( gtk4::test_accessible_has_property(&button, gtk4::AccessibleProperty::KeyShortcuts), @@ -3049,59 +1401,6 @@ fn filter_button_tooltip_counts_the_active_rules() { .expect("the tooltip still counted rules after they were cleared"); } -/// The pending bar's Discard drops the undo history with the edits, so -/// it asks first, as F5 does. -#[gtk4::test] -fn discard_asks_before_it_drops_the_pending_changes() { - use gtk4::subclass::prelude::ObjectSubclassIsExt; - use libadwaita::prelude::{AdwDialogExt, AdwWindowExt, AlertDialogExt}; - let mut script = serving_table(); - script.query_bound.always(FakeReply::ok(rows(&["Ada", "Grace"]))); - let harness = Harness::open(script, []); - wait_until(WAIT, || harness.shown().len() == 2).expect("the first page"); - let window = crate::test_support::OwnedWindow::presented(libadwaita::Window::new()); - window.set_content(Some(harness.tab())); - harness.tab().send(EditInput::InsertRow); - let discard = harness.tab().imp().pending_bar.imp().discard_button.get(); - let alert = || { - let mut shown = None; - wait_until(WAIT, || { - shown = window.visible_dialog().and_downcast::(); - shown.is_some() - }) - .expect("Discard asks first"); - shown.expect("the alert") - }; - - discard.emit_clicked(); - let first = alert(); - assert_eq!(harness.tracker.pending_count(), 1, "the edit went before the answer"); - assert_eq!(first.close_response(), "cancel"); - first.emit_by_name::<()>("response", &[&"cancel"]); - first.force_close(); - drain_main_context(); - assert_eq!(harness.tracker.pending_count(), 1, "Cancel dropped the edit"); - - discard.emit_clicked(); - let second = alert(); - second.emit_by_name::<()>("response", &[&"discard"]); - second.force_close(); - - wait_until(WAIT, || harness.tracker.pending_count() == 0).expect("the confirmed discard"); - assert_eq!(draft_rows(&harness.store()), 0); - window.set_content(None::<>k4::Widget>); -} - -/// The page queries a tab sent, in order. -fn page_queries(harness: &Harness) -> Vec { - harness - .connection - .observed_sql("query_bound") - .into_iter() - .flatten() - .collect() -} - /// A table with a key pages past the edge row of the page on screen, /// so a page deep into it costs what the first does. The row numbers /// still count from the first page. @@ -3149,10 +1448,7 @@ fn a_sorted_table_pages_by_offset() { script.query_bound.always(FakeReply::ok(full_page(0))); let harness = Harness::open(script, []); wait_until(WAIT, || harness.shown().len() == 100).expect("the first page"); - harness.tab().send(PagingInput::SortChanged { - col_idx: 1, - ascending: true, - }); + harness.grid().sort_by(Some((1, true))); wait_until(WAIT, || { page_queries(&harness).len() == 2 && !harness.tab().is_loading() }) @@ -3164,3 +1460,78 @@ fn a_sorted_table_pages_by_offset() { let next = page_queries(&harness).pop().unwrap_or_default(); assert!(next.ends_with("LIMIT 100 OFFSET 100"), "{next}"); } + +/// A header click asks the server for the page in that order, from the +/// first page, and a sort the tab makes shows on the header. +#[gtk4::test] +fn a_header_click_reads_the_first_page_in_that_order() { + let mut script = serving_table(); + script.count_rows.always(FakeReply::ok(1_000)); + script.query_bound.always(FakeReply::ok(full_page(0))); + let harness = Harness::open(script, []); + wait_until(WAIT, || harness.shown().len() == 100).expect("the first page"); + harness.tab().send(PagingInput::NextPage); + wait_until(WAIT, || { + harness.tab().current_offset() == 100 && !harness.tab().is_loading() + }) + .expect("the second page"); + let grid = harness.grid(); + let name = grid.data_view_column(1).expect("the name column"); + + column_view(harness.tab()).sort_by_column(Some(&name), gtk4::SortType::Descending); + drain_main_context(); + + assert_eq!(harness.tab().current_sort(), Some((1, false))); + wait_until(WAIT, || { + page_queries(&harness).len() == 3 && !harness.tab().is_loading() + }) + .expect("the sorted page"); + let sorted = page_queries(&harness).pop().unwrap_or_default(); + assert!(sorted.contains("ORDER BY \"users\".\"name\" DESC"), "{sorted}"); + assert_eq!(harness.tab().current_offset(), 0, "a new order kept the page it was on"); + + harness.grid().sort_by(None); + let sorter = column_view(harness.tab()) + .sorter() + .and_downcast::() + .expect("the view's sorter"); + assert_eq!(sorter.primary_sort_column(), None, "the header kept the cleared sort"); +} + +/// A document's field whose name has a dot cannot be named by a sort or +/// an update (ADR 0026), so its header takes no sort and its cells no +/// edit, while a plain field takes both. +#[gtk4::test] +fn a_dotted_field_of_a_document_neither_sorts_nor_takes_an_edit() { + let columns = [column("_id", true), column("a.b", false)]; + let mut script = serving_table(); + script.dialect = Arc::new(tablepro_driver_mongodb::MongoDialect); + script.fetch_columns = Default::default(); + script.fetch_columns.always(FakeReply::ok(columns.to_vec())); + script.row_identity = Default::default(); + script.row_identity.always(FakeReply::ok(RowIdentity::UniqueKey { + columns: vec!["_id".to_owned()], + })); + script.query_bound.always(FakeReply::ok(QueryResult::new( + columns.iter().map(ColumnInfo::result_column).collect::>(), + vec![vec![Value::Int(0), Value::Text("nested".to_owned())]], + ))); + let harness = Harness::open(script, []); + wait_until(WAIT, || harness.shown() == text(&["nested"])).expect("the first page"); + let _shown = Shown::new(harness.tab()); + let grid = harness.grid(); + + assert!(!grid.grid_columns()[1].addressable); + assert!( + grid.data_view_column(1).is_some_and(|column| column.sorter().is_none()), + "the dotted field's header sorts" + ); + assert!( + grid.data_view_column(0).is_some_and(|column| column.sorter().is_some()), + "a plain field lost its sort" + ); + let cell = grid_cell_of(harness.tab(), 0, 1); + assert_eq!(cell.lock(), Some(crate::services::grid::CellLock::Unaddressable)); + assert!(!grid.edit().edit_cell(&cell), "the dotted field opened for editing"); + assert_eq!(harness.tracker.pending_count(), 0); +} diff --git a/linux/crates/app/src/ui/browse_tab/tests/paste.rs b/linux/crates/app/src/ui/browse_tab/tests/paste.rs deleted file mode 100644 index d41380ea6c..0000000000 --- a/linux/crates/app/src/ui/browse_tab/tests/paste.rs +++ /dev/null @@ -1,449 +0,0 @@ -use std::cell::RefCell; -use std::rc::Rc; - -use gtk4::glib; -use gtk4::prelude::*; -use gtk4::subclass::prelude::ObjectSubclassIsExt; -use tablepro_core::edit::CellInput; -use tablepro_core::meta::RowIdentity; -use tablepro_core::{ColumnInfo, QueryResult, Value}; -use tablepro_test_support::{CallCounters, ConnectionScript, FakeReply}; - -use super::{ - Harness, Shown, WAIT, column, column_view, copied_by, draft_rows, grid_cell_of, key, menu_gesture, named_col, rows, - serving_table, toasts, -}; -use crate::test_support::{drain_main_context, wait_until}; -use crate::ui::browse_tab::paste_at::PasteAt; -use crate::ui::browse_tab::paste_handlers::PASTE_CONFIRM_ROWS; -use crate::ui::browse_tab::{BrowseTab, ClipboardInput, EditInput, PagingInput}; -use crate::ui::clipboard::{CopyKind, CopyRequest}; -use crate::ui::grid::GridCell; -use crate::ui::grid::RowSnapshot; - -/// An `id` key, a name and an age, with Ada and Grace saved. -fn people() -> Vec { - let mut id = named_col("id", "integer", false); - id.primary_key = true; - vec![id, named_col("name", "text", true), named_col("age", "integer", true)] -} - -fn people_table() -> Harness { - people_holding(vec![ - vec![Value::Int(0), Value::Text("Ada".into()), Value::Int(36)], - vec![Value::Int(1), Value::Text("Grace".into()), Value::Int(85)], - ]) -} - -fn people_holding(saved: Vec>) -> Harness { - let columns = people(); - let mut script = ConnectionScript::default(); - script.fetch_columns.always(FakeReply::ok(columns.clone())); - script.row_identity.always(FakeReply::ok(RowIdentity::UniqueKey { - columns: vec!["id".to_owned()], - })); - script.count_rows.always(FakeReply::ok(2)); - script.probe.always(FakeReply::ok(())); - script.fetch_outbound_foreign_keys.always(FakeReply::ok(Vec::new())); - script.fetch_inbound_foreign_keys.always(FakeReply::ok(Vec::new())); - let count = saved.len(); - script.query_bound.always(FakeReply::ok(QueryResult::new( - columns.iter().map(ColumnInfo::result_column).collect::>(), - saved, - ))); - let harness = Harness::open(script, []); - wait_until(WAIT, || harness.saved_rows().len() == count).expect("the first page"); - harness -} - -/// Put `text` on the tab's clipboard and paste it at `at`. The read -/// comes back later, so each test waits for what it should do. -fn paste(harness: &Harness, text: &str, at: Option) { - harness.tab().clipboard().set_text(text); - harness.tab().send(ClipboardInput::Paste(at)); -} - -fn alerts(tab: &BrowseTab) -> Rc>> { - let alerts = Rc::new(RefCell::new(Vec::new())); - let sink = alerts.clone(); - tab.connect_alert(move |_, title, body| sink.borrow_mut().push(format!("{title}: {body}"))); - alerts -} - -fn confirming_paste(tab: &BrowseTab) -> Option { - tab.imp() - .model - .borrow() - .as_ref() - .and_then(|model| model.confirming_paste.as_ref().map(|pending| pending.dialog.clone())) -} - -fn draft_cells(harness: &Harness, position: u32) -> Vec { - let draft_id = harness.grid_row(position).draft_id().expect("a new row"); - harness.tracker.draft_inputs(draft_id).expect("the draft's cells") -} - -fn int(value: i64) -> CellInput { - CellInput::Value(Value::Int(value)) -} - -fn name(value: &str) -> CellInput { - CellInput::Value(Value::Text(value.to_owned())) -} - -fn null() -> CellInput { - CellInput::Value(Value::Null) -} - -#[gtk4::test] -fn pasted_rows_are_new_rows_in_the_order_they_were_copied() { - let harness = people_table(); - let toasts = toasts(harness.tab()); - - paste(&harness, "7\tLinus\t54\n8\tKen\t\n", None); - - wait_until(WAIT, || draft_rows(&harness.store()) == 2).expect("the rows were pasted"); - assert_eq!(draft_cells(&harness, 0), vec![int(7), name("Linus"), int(54)]); - assert_eq!(draft_cells(&harness, 1), vec![int(8), name("Ken"), null()]); - assert_eq!( - harness.grid_row(2).key(), - Some(&key(0)), - "the saved rows moved above the new ones" - ); - assert_eq!(toasts.borrow().as_slice(), ["Pasted 2 rows"]); -} - -/// The copy offers the cells beside its text, which says the same of a -/// NULL and the text `NULL`, and quotes what it then cannot unquote. -#[gtk4::test] -fn rows_copied_from_the_grid_paste_back_exactly() { - let harness = people_holding(vec![ - vec![Value::Int(0), Value::Text("NULL".into()), Value::Null], - vec![Value::Int(1), Value::Text("\"two\nlines\"".into()), Value::Int(3)], - ]); - harness.selection().select_all(); - copied_by(harness.tab(), || { - harness.tab().send(ClipboardInput::CopySelectedRowsAsTsv) - }); - - harness.tab().send(ClipboardInput::Paste(None)); - - wait_until(WAIT, || draft_rows(&harness.store()) == 2).expect("the rows were pasted"); - assert_eq!(draft_cells(&harness, 0), vec![int(0), name("NULL"), null()]); - assert_eq!(draft_cells(&harness, 1), vec![int(1), name("\"two\nlines\""), int(3)]); -} - -#[gtk4::test] -fn a_copied_cell_pastes_into_another_cell_as_it_was() { - let harness = people_holding(vec![ - vec![Value::Int(0), Value::Text("\"two\nlines\"".into()), Value::Null], - vec![Value::Int(1), Value::Text("Grace".into()), Value::Int(85)], - ]); - let request = CopyRequest::Rows { - columns: people().iter().map(ColumnInfo::result_column).collect(), - rows: vec![RowSnapshot::new( - harness.grid_row(0).cells().expect("the row's cells"), - Vec::new(), - )], - kind: CopyKind::Value(1), - }; - copied_by(harness.tab(), || harness.tab().send(ClipboardInput::GridCopy(request))); - - harness.tab().send(ClipboardInput::Paste(Some(PasteAt { - row: harness.grid_row(1), - column: 1, - }))); - - wait_until(WAIT, || harness.tracker.pending_count() == 1).expect("the cell was edited"); - assert_eq!( - harness.tracker.pending_values(&key(1), 3), - vec![(1, Value::Text("\"two\nlines\"".into()))] - ); -} - -#[gtk4::test] -fn one_undo_takes_back_the_whole_paste() { - let harness = people_table(); - paste(&harness, "7\tLinus\t54\n8\tKen\t1\n9\tDennis\t70\n", None); - wait_until(WAIT, || draft_rows(&harness.store()) == 3).expect("the rows were pasted"); - - harness.tab().send(EditInput::Undo); - - assert_eq!(draft_rows(&harness.store()), 0); - assert_eq!(harness.tracker.pending_count(), 0); - harness.tab().send(EditInput::Redo); - assert_eq!(draft_rows(&harness.store()), 3); - assert_eq!( - draft_cells(&harness, 0)[1], - name("Linus"), - "the redone rows came back in another order" - ); -} - -#[gtk4::test] -fn rows_fill_the_columns_from_the_one_the_paste_starts_at() { - let harness = people_table(); - let toasts = toasts(harness.tab()); - let at = PasteAt { - row: harness.grid_row(0), - column: 1, - }; - - paste(&harness, "Linus\t54\textra\n", Some(at)); - - wait_until(WAIT, || draft_rows(&harness.store()) == 1).expect("the row was pasted"); - assert_eq!(draft_cells(&harness, 0), vec![null(), name("Linus"), int(54)]); - assert_eq!( - toasts.borrow().as_slice(), - ["Pasted 1 row", "Left out 1 value no column could take"] - ); -} - -#[gtk4::test] -fn values_fill_the_columns_in_the_order_the_grid_shows_them() { - let harness = people_table(); - let view = column_view(harness.tab()); - let shown = crate::ui::grid::data_columns(&view); - let (name_column, age_column) = (shown[1].clone(), shown[2].clone()); - let columns = view.columns(); - let name_at = (0..columns.n_items()) - .find(|position| columns.item(*position).as_ref() == Some(name_column.upcast_ref::())) - .expect("the name column"); - view.insert_column(name_at, &age_column); - - paste( - &harness, - "7\t54\tLinus\n", - Some(PasteAt { - row: harness.grid_row(0), - column: 0, - }), - ); - - wait_until(WAIT, || draft_rows(&harness.store()) == 1).expect("the row was pasted"); - assert_eq!(draft_cells(&harness, 0), vec![int(7), name("Linus"), int(54)]); -} - -#[gtk4::test] -fn a_header_line_maps_the_values_by_name() { - let harness = people_table(); - - paste(&harness, "age\tname\tid\n54\tLinus\t7\n", None); - - wait_until(WAIT, || draft_rows(&harness.store()) == 1).expect("the row was pasted"); - assert_eq!(draft_cells(&harness, 0), vec![int(7), name("Linus"), int(54)]); -} - -#[gtk4::test] -fn one_value_goes_into_the_cell_the_paste_starts_at() { - let harness = people_table(); - let ada = harness.grid_row(0); - - paste( - &harness, - "Lovelace", - Some(PasteAt { - row: ada.clone(), - column: 1, - }), - ); - - wait_until(WAIT, || harness.tracker.pending_count() == 1).expect("the cell was edited"); - assert_eq!(draft_rows(&harness.store()), 0, "one value made a new row"); - assert_eq!( - harness.tracker.pending_values(&key(0), 3), - vec![(1, Value::Text("Lovelace".into()))] - ); -} - -#[gtk4::test] -fn one_value_a_cell_cannot_take_says_why() { - let harness = people_table(); - let toasts = toasts(harness.tab()); - let ada = harness.grid_row(0); - - paste( - &harness, - "5", - Some(PasteAt { - row: ada.clone(), - column: 0, - }), - ); - wait_until(WAIT, || toasts.borrow().len() == 1).expect("the key said why"); - paste(&harness, "old", Some(PasteAt { row: ada, column: 2 })); - wait_until(WAIT, || toasts.borrow().len() == 2).expect("the number said why"); - - assert_eq!(harness.tracker.pending_count(), 0); - assert_eq!( - toasts.borrow().as_slice(), - ["A saved row's key can't be changed here", "Enter a whole number"] - ); -} - -#[gtk4::test] -fn ctrl_v_pastes_at_the_focused_cell() { - let harness = people_table(); - let shown = Shown::new(harness.tab()); - let view = column_view(harness.tab()); - let age = crate::ui::grid::data_columns(&view) - .into_iter() - .nth(2) - .expect("the age column"); - view.scroll_to(1, Some(&age), gtk4::ListScrollFlags::FOCUS, None); - view.grab_focus(); - wait_until(WAIT, || { - gtk4::prelude::GtkWindowExt::focus(&shown.0) - .and_then(|focus| GridCell::from_focus(&focus)) - .is_some_and(|cell| cell.position() == Some(1) && cell.column() == 2) - }) - .expect("Grace's age takes the focus"); - harness.tab().clipboard().set_text("90"); - - harness - .tab() - .activate_action("browse.paste-rows", None) - .expect("the tab's Paste"); - - wait_until(WAIT, || harness.tracker.pending_count() == 1).expect("the focused cell was edited"); - assert_eq!(harness.tracker.pending_values(&key(1), 3), vec![(2, Value::Int(90))]); -} - -#[gtk4::test] -fn the_cell_menu_pastes_rows_from_its_column() { - let harness = people_table(); - let _shown = Shown::new(harness.tab()); - let cell = grid_cell_of(harness.tab(), 1, 1); - harness.tab().clipboard().set_text("Linus\t54\nKen\t81\n"); - - menu_gesture(&cell).emit_by_name::<()>("pressed", &[&1i32, &1.0f64, &1.0f64]); - column_view(harness.tab()) - .activate_action("cell.paste", None) - .expect("the menu's Paste"); - - wait_until(WAIT, || draft_rows(&harness.store()) == 2).expect("the rows were pasted"); - assert_eq!(draft_cells(&harness, 1), vec![null(), name("Ken"), int(81)]); - wait_until(WAIT, || { - crate::ui::grid::selected_positions(&harness.selection()) == vec![0, 1] - }) - .expect("the pasted rows are the selection"); -} - -#[gtk4::test] -fn a_value_the_column_refuses_stops_the_paste_and_names_it() { - let harness = people_table(); - let alerts = alerts(harness.tab()); - - paste(&harness, "7\tLinus\t54\n8\tKen\tforty\n", None); - - wait_until(WAIT, || !alerts.borrow().is_empty()).expect("the paste said why it stopped"); - assert_eq!(draft_rows(&harness.store()), 0, "part of the paste landed"); - assert_eq!( - alerts.borrow().as_slice(), - ["Couldn't Paste: Line 2, column age: Enter a whole number (the value was “forty”)"] - ); -} - -#[gtk4::test] -fn an_empty_clipboard_says_there_is_nothing_to_paste() { - let harness = people_table(); - let toasts = toasts(harness.tab()); - - paste(&harness, "\n\n", None); - - wait_until(WAIT, || !toasts.borrow().is_empty()).expect("the paste said why it stopped"); - assert_eq!(toasts.borrow().as_slice(), ["The clipboard has no rows to paste."]); -} - -#[gtk4::test] -fn a_read_only_connection_refuses_to_paste() { - let mut script = serving_table(); - script.query_bound.always(FakeReply::ok(rows(&["Ada"]))); - let harness = Harness::open_read_only(script); - wait_until(WAIT, || harness.shown().len() == 1).expect("the first page"); - let toasts = toasts(harness.tab()); - - paste(&harness, "7\tLinus\n", None); - - assert_eq!(harness.tracker.pending_count(), 0); - assert_eq!(toasts.borrow().as_slice(), ["This connection is read-only."]); -} - -/// A view has no key either: rows pasted there could not be named once -/// saved, as the toolbar's Insert says. -#[gtk4::test] -fn a_table_without_a_key_refuses_to_paste() { - let columns = [column("id", false), column("name", false)]; - let mut script = serving_table(); - script.fetch_columns.always(FakeReply::ok(columns.to_vec())); - script.query_bound.always(FakeReply::ok(QueryResult::new( - columns.iter().map(ColumnInfo::result_column).collect::>(), - vec![vec![Value::Int(0), Value::Text("Ada".into())]], - ))); - let harness = Harness::open(script, []); - wait_until(WAIT, || harness.shown().len() == 1).expect("the first page"); - let toasts = toasts(harness.tab()); - - paste(&harness, "7\tLinus\n", None); - - assert_eq!(harness.tracker.pending_count(), 0); - assert_eq!( - toasts.borrow().as_slice(), - ["Rows can only be pasted into a table with a primary key."] - ); -} - -#[gtk4::test] -fn a_large_paste_asks_first() { - let harness = people_table(); - let text: String = (0..=PASTE_CONFIRM_ROWS) - .map(|n| format!("{}\tuser {n}\t\n", n + 10)) - .collect(); - - paste(&harness, &text, None); - - wait_until(WAIT, || confirming_paste(harness.tab()).is_some()).expect("a paste this large asks first"); - let dialog = confirming_paste(harness.tab()).expect("the question"); - assert_eq!(harness.tracker.pending_count(), 0, "the rows landed before the answer"); - dialog.emit_by_name::<()>("response", &[&"paste"]); - drain_main_context(); - - assert_eq!(draft_rows(&harness.store()), PASTE_CONFIRM_ROWS + 1); - assert_eq!(draft_cells(&harness, 0)[1], name("user 0")); -} - -#[gtk4::test] -fn more_lines_than_a_paste_takes_point_to_import_csv() { - let harness = people_table(); - let toasts = toasts(harness.tab()); - let text = "x\n".repeat(tablepro_core::import::MAX_PASTED_ROWS + 1); - - paste(&harness, &text, None); - - wait_until(WAIT, || !toasts.borrow().is_empty()).expect("the paste said why it stopped"); - assert_eq!( - toasts.borrow().as_slice(), - ["The clipboard holds too much to paste. Import CSV takes large amounts of data."] - ); - assert_eq!(harness.tracker.pending_count(), 0); -} - -#[gtk4::test] -fn duplicated_rows_keep_their_order_on_screen_and_after_a_refresh() { - let harness = people_table(); - let (ada, grace) = (harness.grid_row(0), harness.grid_row(1)); - - harness.tab().send(EditInput::DuplicateRows(vec![ada, grace])); - - let names = |harness: &Harness| (draft_cells(harness, 0)[1].clone(), draft_cells(harness, 1)[1].clone()); - assert_eq!(names(&harness), (name("Ada"), name("Grace"))); - let reads = || CallCounters::get(&harness.connection.counters().query_bound); - let before = reads(); - harness.tab().send(PagingInput::Refresh); - wait_until(WAIT, || reads() > before && harness.saved_rows().len() == 2).expect("the page read again"); - assert_eq!( - names(&harness), - (name("Ada"), name("Grace")), - "a refresh turned them around" - ); -} diff --git a/linux/crates/app/src/ui/browse_tab/tests/references.rs b/linux/crates/app/src/ui/browse_tab/tests/references.rs index d7764ab991..f4969445c7 100644 --- a/linux/crates/app/src/ui/browse_tab/tests/references.rs +++ b/linux/crates/app/src/ui/browse_tab/tests/references.rs @@ -13,8 +13,8 @@ use tablepro_test_support::{CallCounters, FakeReply}; use super::{Harness, TableFilter, WAIT, column, rows, serving_table, text}; use crate::test_support::{drain_main_context, wait_until}; -use crate::ui::browse_tab::BrowseTabInput; -use crate::ui::grid::{GridMsg, TableKeys}; +use crate::ui::browse_tab::GridInput; +use crate::ui::data_grid::{GridRequest, TableKeys}; fn name_key() -> OutboundForeignKey { OutboundForeignKey { @@ -148,7 +148,7 @@ fn a_reference_the_grid_follows_goes_to_the_window() { harness .tab() - .send(BrowseTabInput::from(GridMsg::FollowReference(reference.clone()))); + .send(GridInput::Request(GridRequest::FollowReference(reference.clone()))); assert_eq!(*asked.borrow(), [reference]); } diff --git a/linux/crates/app/src/ui/browse_tab/tests/rows.rs b/linux/crates/app/src/ui/browse_tab/tests/rows.rs new file mode 100644 index 0000000000..6ada13ef99 --- /dev/null +++ b/linux/crates/app/src/ui/browse_tab/tests/rows.rs @@ -0,0 +1,606 @@ +use std::sync::Arc; + +use gtk4::prelude::*; +use gtk4::subclass::prelude::ObjectSubclassIsExt; +use tablepro_core::edit::CellInput; +use tablepro_core::meta::RowIdentity; +use tablepro_core::{ColumnInfo, QueryResult, Value}; +use tablepro_test_support::{ConnectionScript, FakeReply}; + +use super::super::{DataInput, GridInput, PagingInput}; +use super::{ + Harness, Shown, WAIT, col_with_default, column, full_page, grid_cell_of, headers_are, key, paging, + pending_bar_shown, rename, rows, rows_from, serving_table, text, toasts, type_into, +}; +use crate::services::change_tracker::RowState; +use crate::services::tab_tracker_handle::TabTrackerHandle; +use crate::services::tracker_edit::TrackerEdit; +use crate::test_support::{drain_main_context, respond, wait_until}; +use crate::ui::browse_tab::BrowseTab; + +/// A table of an `id` key and `column`, with one row, id 0, holding +/// `value`. +fn one_row(column: ColumnInfo, value: Value) -> Harness { + let columns = vec![self::column("id", true), column]; + let mut script = ConnectionScript::default(); + script.fetch_columns.always(FakeReply::ok(columns.clone())); + script.row_identity.always(FakeReply::ok(RowIdentity::UniqueKey { + columns: vec!["id".to_owned()], + })); + script.count_rows.always(FakeReply::ok(1)); + script.probe.always(FakeReply::ok(())); + script.fetch_outbound_foreign_keys.always(FakeReply::ok(Vec::new())); + script.fetch_inbound_foreign_keys.always(FakeReply::ok(Vec::new())); + script.query_bound.always(FakeReply::ok(QueryResult::new( + columns.iter().map(ColumnInfo::result_column).collect::>(), + vec![vec![Value::Int(0), value]], + ))); + let harness = Harness::open(script, []); + wait_until(WAIT, || harness.saved_rows().len() == 1).expect("the first page"); + harness +} + +fn two_rows() -> Harness { + let mut script = serving_table(); + script.query_bound.always(FakeReply::ok(rows(&["Ada", "Grace"]))); + let harness = Harness::open(script, []); + wait_until(WAIT, || harness.shown().len() == 2).expect("the first page"); + harness +} + +/// What the clipboard holds once the copy `copy` starts lands there. +fn copied_by(tab: &BrowseTab, copy: impl FnOnce()) -> String { + const BEFORE: &str = "nothing copied yet"; + let clipboard = tab.clipboard(); + clipboard.set_text(BEFORE); + copy(); + let text = || -> Option { clipboard.content()?.value(String::static_type()).ok()?.get().ok() }; + wait_until(WAIT, || text().is_some_and(|text| text != BEFORE)).expect("the copy to land"); + text().unwrap_or_default() +} + +/// A partitioned PostgreSQL table with no key, whose page carries the +/// partition, the row address and its version after the visible cells. +fn partitioned_table() -> ConnectionScript { + let server = tablepro_core::ServerInfo::new("PostgreSQL", "18.0", 18, 0, 0); + let mut script = ConnectionScript { + dialect: Arc::new(tablepro_driver_postgres::PgDialect::new(&server)), + ..ConnectionScript::default() + }; + script + .fetch_columns + .always(FakeReply::ok(vec![column("id", false), column("name", false)])); + script.row_identity.always(FakeReply::ok(RowIdentity::EngineRowId( + tablepro_core::meta::EngineRowId::PostgresTableoidCtid, + ))); + script.count_rows.always(FakeReply::ok(2)); + script.probe.always(FakeReply::ok(())); + let mut columns: Vec<_> = [column("id", false), column("name", false)] + .iter() + .map(ColumnInfo::result_column) + .collect(); + for hidden in ["__tablepro_row_oid", "__tablepro_row_id", "__tablepro_row_xmin"] { + columns.push(column(hidden, false).result_column()); + } + let rows = [("Ada", "(0,1)"), ("Grace", "(0,1)")] + .iter() + .zip(0..) + .map(|((name, ctid), id)| { + vec![ + Value::Int(id), + Value::Text((*name).to_owned()), + Value::Int(16_401 + id), + Value::Text((*ctid).to_owned()), + Value::Text("812".to_owned()), + ] + }) + .collect(); + script + .query_bound + .always(FakeReply::ok(QueryResult::new(columns, rows))); + script +} + +fn visible_only(values: &[Value]) { + assert_eq!(values.len(), 2, "a row carried hidden cells: {values:?}"); +} + +#[gtk4::test] +fn page_rows_hold_only_visible_cells() { + let harness = Harness::open(partitioned_table(), []); + wait_until(WAIT, || harness.saved_rows().len() == 2).expect("the first page"); + + for row in harness.saved_rows() { + visible_only(&row.cells().expect("a saved row's cells")); + assert_eq!(row.key(), None, "a table with no primary key took edits"); + } + assert_eq!( + harness.grid().grid_columns().len(), + 2, + "the grid was given the hidden columns" + ); +} + +#[gtk4::test] +fn page_rows_have_single_owner() { + let harness = Harness::open(partitioned_table(), []); + wait_until(WAIT, || harness.saved_rows().len() == 2).expect("the first page"); + + for row in harness.saved_rows() { + let cells = row.cells().expect("a saved row's cells"); + // This handle and the grid's slot. + assert_eq!(Arc::strong_count(&cells), 2, "the tab kept its own copy of the page"); + } +} + +/// D1: a table with no key takes no new row, a duplicate included. +#[gtk4::test] +fn copy_as_insert_contains_no_hidden_columns_and_a_keyless_table_takes_no_duplicate() { + let harness = Harness::open(partitioned_table(), []); + wait_until(WAIT, || harness.saved_rows().len() == 2).expect("the first page"); + harness.select(0..2); + + let copied = copied_by(harness.tab(), || harness.activate_grid("copy-insert")); + + assert_eq!(copied.lines().count(), 2, "{copied}"); + for hidden in ["16401", "(0,1)", "812", "__tablepro"] { + assert!(!copied.contains(hidden), "{hidden} reached the copy: {copied}"); + } + assert!(!harness.grid_enabled("duplicate-rows")); + assert!(!harness.grid_enabled("insert-row")); +} + +#[gtk4::test] +fn page_export_contains_no_hidden_columns() { + let harness = Harness::open(partitioned_table(), []); + wait_until(WAIT, || harness.saved_rows().len() == 2).expect("the first page"); + let exported = std::rc::Rc::new(std::cell::RefCell::new(Vec::new())); + let sink = exported.clone(); + harness + .tab() + .connect_export_results(move |_, offer| sink.borrow_mut().push(offer.origin.shown().clone())); + + harness.tab().send(GridInput::Export); + + let exported = exported.borrow(); + let [crate::ui::export::ExportSource::Rows { columns, rows }] = exported.as_slice() else { + panic!("one export, not {}", exported.len()); + }; + let names: Vec<&str> = columns.iter().map(|column| column.name.as_str()).collect(); + assert_eq!(names, ["id", "name"]); + assert_eq!(rows.len(), 2); + for row in rows { + visible_only(&row.to_values()); + } +} + +/// The page writes into the rows the grid has and drops the ones past +/// its end, under the drafts. +#[gtk4::test] +fn a_page_turn_keeps_the_drafts_on_top_and_changes_only_the_tail() { + let harness = Harness::open(paging(full_page(0), rows_from(100, &["Linus", "Ken"])), []); + wait_until(WAIT, || harness.shown().len() == 100).expect("the first page"); + harness.activate_grid("insert-row"); + let store = harness.store(); + let changes = std::rc::Rc::new(std::cell::RefCell::new(Vec::new())); + let log = changes.clone(); + store.connect_items_changed(move |_, position, removed, added| { + log.borrow_mut().push((position, removed, added)); + }); + + harness.tab().send(PagingInput::NextPage); + wait_until(WAIT, || harness.shown() == text(&["Linus", "Ken"])).expect("the next page"); + + assert_eq!(*changes.borrow(), [(3, 98, 0)], "the page built rows again"); + assert!(harness.grid_row(0).draft_id().is_some(), "the draft left the top"); + assert_eq!(harness.grid_row(1).key(), Some(key(100))); +} + +/// The grid keeps its row slots across pages, and lets go of the cells +/// they held. +#[gtk4::test] +fn a_new_page_lets_go_of_the_cells_it_replaced() { + let harness = Harness::open(paging(full_page(0), rows_from(100, &["Linus"])), []); + wait_until(WAIT, || harness.shown().len() == 100).expect("the first page"); + let first: Vec> = harness + .saved_rows() + .iter() + .filter_map(|row| row.cells()) + .map(|cells| Arc::downgrade(&cells)) + .collect(); + + harness.tab().send(PagingInput::NextPage); + wait_until(WAIT, || harness.shown() == text(&["Linus"])).expect("the next page"); + + wait_until(WAIT, || first.iter().all(|cells| cells.upgrade().is_none())) + .expect("the first page's cells were let go"); +} + +#[gtk4::test] +fn duplicate_row_copies_pending_edits_of_persisted_row() { + let harness = two_rows(); + harness.apply_delta(&harness.tracker.apply(rename(0, "Ada", "Ada Lovelace"))); + harness.select(0..1); + + harness.activate_grid("duplicate-rows"); + + let drafts = harness.tracker.drafts(); + assert_eq!(drafts.len(), 1, "{drafts:?}"); + assert_eq!( + drafts[0].cells, + vec![ + CellInput::Value(Value::Null), + CellInput::Value(Value::Text("Ada Lovelace".to_owned())) + ], + "the duplicate left out the row's pending edit" + ); +} + +#[gtk4::test] +fn duplicating_a_draft_leaves_its_defaulted_cells_to_the_server() { + let mut level = col_with_default("integer", "0"); + level.name = "level".to_owned(); + let harness = one_row(level, Value::Int(3)); + harness.activate_grid("insert-row"); + assert!(harness.grid_row(0).draft_id().is_some(), "the new row goes on top"); + harness.select(0..1); + + harness.activate_grid("duplicate-rows"); + + let drafts = harness.tracker.drafts(); + assert_eq!(drafts.len(), 2, "{drafts:?}"); + assert_eq!( + drafts[1].cells[1], + CellInput::Default, + "the copy wrote NULL where the server fills in" + ); +} + +#[gtk4::test] +fn duplicating_a_selection_is_one_undo_step() { + let harness = two_rows(); + harness.select(0..2); + + harness.activate_grid("duplicate-rows"); + assert_eq!(harness.drafts(), 2); + harness.tab().send(GridInput::Undo); + + assert_eq!(harness.drafts(), 0, "one undo left copies behind"); + assert_eq!(harness.tracker.pending_count(), 0); +} + +/// F004: the row under a new one is the one copied, not the new one. +#[gtk4::test] +fn f004_copy_row_as_insert_uses_row_below_draft() { + let harness = two_rows(); + harness.activate_grid("insert-row"); + harness.select(1..2); + + let copied = copied_by(harness.tab(), || harness.activate_grid("copy-insert")); + + assert_eq!(copied, "INSERT INTO \"users\" (\"id\", \"name\") VALUES (0, 'Ada');"); +} + +#[gtk4::test] +fn an_edit_held_for_the_running_save_says_so_once() { + let harness = two_rows(); + let toasts = toasts(harness.tab()); + harness.activate_grid("insert-row"); + harness.activate_grid("insert-row"); + let _saving = harness.tracker.begin_save().expect("a save").expect("the drafts"); + harness.select(0..2); + + harness.activate_grid("delete-rows"); + + assert_eq!(harness.tracker.drafts().len(), 2, "a draft the save holds was dropped"); + assert_eq!(toasts.borrow().len(), 1, "{:?}", toasts.borrow()); +} + +#[gtk4::test] +fn deleting_a_draft_from_the_toolbar_empties_the_footer() { + let harness = two_rows(); + harness.activate_grid("insert-row"); + assert_eq!(harness.store().n_items(), 3); + harness.select(0..1); + assert!(harness.grid_enabled("delete-rows"), "a draft could not be deleted"); + + harness.activate_grid("delete-rows"); + + assert_eq!(harness.store().n_items(), 2, "the dropped draft stayed in the grid"); + assert_eq!(harness.drafts(), 0); + assert_eq!(harness.tracker.pending_count(), 0); + assert!( + !pending_bar_shown(harness.tab()), + "the footer still counts the dropped draft" + ); +} + +/// The toolbar says why Delete is off, and the count names Delete only +/// where it acts (D12). +#[gtk4::test] +fn delete_is_off_for_rows_without_a_key_and_the_toolbar_says_why() { + let columns = [column("id", false), column("name", false)]; + let mut script = serving_table(); + script.fetch_columns = Default::default(); + script.fetch_columns.always(FakeReply::ok(columns.to_vec())); + script.query_bound.always(FakeReply::ok(QueryResult::new( + columns.iter().map(ColumnInfo::result_column).collect::>(), + vec![ + vec![Value::Int(0), Value::Text("Ada".into())], + vec![Value::Int(1), Value::Text("Grace".into())], + ], + ))); + let harness = Harness::open(script, []); + wait_until(WAIT, || harness.shown().len() == 2).expect("the first page"); + let imp = harness.tab().imp(); + + harness.select(0..1); + assert!( + !harness.grid_enabled("delete-rows"), + "Delete was offered for a row with no key" + ); + assert_eq!( + imp.delete_button.tooltip_text().as_deref(), + Some("Rows without a primary key or unique key can't be deleted here") + ); + harness.select(0..2); + assert_eq!(imp.selection_label.label(), "2 selected"); + harness.tab().activate_action("grid.delete-rows", None).ok(); + drain_main_context(); + + assert_eq!(harness.tracker.pending_count(), 0); +} + +/// More rows than a delete marks without asking first. +const DOZEN: [&str; 12] = ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l"]; +const DOZEN_ROWS: std::ops::Range = 0..12; + +fn dozen_rows(script: ConnectionScript) -> Harness { + let mut script = script; + script.query_bound.always(FakeReply::ok(rows(&DOZEN))); + let harness = Harness::open(script, []); + wait_until(WAIT, || harness.shown().len() == DOZEN.len()).expect("the first page"); + harness +} + +/// Every row selected and Delete clicked, which asks before marking. +fn ask_to_delete_every_row(harness: &Harness) -> libadwaita::AlertDialog { + harness.select(DOZEN_ROWS); + harness.activate_grid("delete-rows"); + let dialog = harness.grid().edit().question().expect("a bulk delete asks first"); + assert_eq!(harness.tracker.pending_count(), 0); + dialog +} + +fn change_schema(harness: &Harness, done: impl Fn(&[ColumnInfo]) -> bool) { + harness + .session + .invalidate_table(&tablepro_core::TableRef::unqualified("users")); + harness.tab().send(DataInput::SchemaChanged); + wait_until(WAIT, || done(&harness.tab().columns())).expect("the new columns"); +} + +#[gtk4::test] +fn a_bulk_delete_waiting_while_the_primary_key_moves_is_cancelled() { + let mut script = serving_table(); + script.fetch_columns = Default::default(); + script + .fetch_columns + .once(FakeReply::ok(vec![column("id", true), column("name", false)])) + .always(FakeReply::ok(vec![column("id", false), column("name", true)])); + let harness = dozen_rows(script); + let _shown = Shown::new(harness.tab()); + let toasts = toasts(harness.tab()); + let dialog = ask_to_delete_every_row(&harness); + + change_schema(&harness, |columns| { + columns.iter().any(|column| column.name == "name" && column.primary_key) + }); + respond(&dialog, "delete"); + + wait_until(WAIT, || !toasts.borrow().is_empty()).expect("the cancelled delete says why"); + assert_eq!( + harness.tracker.pending_count(), + 0, + "rows were marked by the key they had before" + ); + assert_eq!(toasts.borrow().len(), 1, "{:?}", toasts.borrow()); +} + +#[gtk4::test] +fn a_bulk_delete_waiting_while_a_column_is_added_marks_its_rows_under_the_new_columns() { + let widened = [column("id", true), column("name", false), column("email", false)]; + let mut script = serving_table(); + script.fetch_columns = Default::default(); + script + .fetch_columns + .once(FakeReply::ok(vec![column("id", true), column("name", false)])) + .always(FakeReply::ok(widened.to_vec())); + let harness = dozen_rows(script); + let _shown = Shown::new(harness.tab()); + let dialog = ask_to_delete_every_row(&harness); + + change_schema(&harness, |columns| columns.len() == widened.len()); + respond(&dialog, "delete"); + + wait_until(WAIT, || harness.tracker.pending_count() == DOZEN.len()).expect("the rows were marked"); + assert_eq!(harness.tracker.row_state(&key(0)), RowState::PendingDelete); +} + +/// The tab hands the grid the settings it reads at each delete, so a +/// change in Preferences reaches an open tab. +#[gtk4::test] +fn the_delete_confirmation_follows_preferences_in_an_open_tab() { + let harness = dozen_rows(serving_table()); + let _shown = Shown::new(harness.tab()); + let settings = harness.stores.settings.get(); + settings + .set_confirm_bulk_delete(false) + .expect("turn the confirmation off"); + harness.select(DOZEN_ROWS); + + harness.activate_grid("delete-rows"); + assert!( + harness.grid().edit().question().is_none(), + "the delete asked with the switch off" + ); + assert_eq!(harness.tracker.pending_count(), DOZEN.len()); + harness.tab().send(GridInput::Undo); + assert_eq!(harness.tracker.pending_count(), 0); + + settings + .set_confirm_bulk_delete(true) + .expect("turn the confirmation on"); + let dialog = ask_to_delete_every_row(&harness); + respond(&dialog, "delete"); + + wait_until(WAIT, || harness.tracker.pending_count() == DOZEN.len()).expect("the confirmed delete"); +} + +fn people(names: &[&str]) -> Vec { + std::iter::once(column("id", true)) + .chain(names.iter().map(|name| column(name, false))) + .collect() +} + +/// One row, id 0, each cell holding its column's name. +fn people_page(columns: &[ColumnInfo]) -> QueryResult { + QueryResult::new( + columns.iter().map(ColumnInfo::result_column).collect::>(), + vec![ + std::iter::once(Value::Int(0)) + .chain(columns[1..].iter().map(|column| Value::Text(column.name.clone()))) + .collect(), + ], + ) +} + +/// A table whose `nickname` another client drops before the tab reads +/// its columns again. `after` is what the page under the new columns +/// answers. +fn losing_nickname(after: FakeReply) -> ConnectionScript { + let before = people(&["nickname", "name", "email"]); + let mut script = serving_table(); + script.fetch_columns = Default::default(); + script + .fetch_columns + .once(FakeReply::ok(before.clone())) + .always(FakeReply::ok(people(&["name", "email"]))); + script + .query_bound + .when_sql_contains("nickname", FakeReply::ok(people_page(&before))) + .always(after); + script +} + +fn without_nickname() -> FakeReply { + FakeReply::ok(people_page(&people(&["name", "email"]))) +} + +/// Each assignment of the save, by the name of the column it writes. +fn saved_cells(tracker: &TabTrackerHandle) -> Vec<(String, Option)> { + let batch = tracker.begin_save().expect("a save").expect("changes to save"); + batch + .changes + .changes + .changes + .iter() + .flat_map(|change| match change { + tablepro_core::save::RowChange::Update(update) => update.assignments.clone(), + other => panic!("only a cell was edited: {other:?}"), + }) + .map(|(col, value)| { + ( + batch.columns[col].name.clone(), + tablepro_core::export::value_to_text(&value), + ) + }) + .collect() +} + +/// Write `input` into `column` of the first row, as a commit in the +/// grid does. +fn edit_first_row(harness: &Harness, column: usize, input: &str) { + let original = harness + .grid_row(0) + .cells() + .and_then(|cells| cells.get(column).cloned()) + .unwrap_or(Value::Null); + harness.apply_delta(&harness.tracker.apply(TrackerEdit::Cell { + row_key: key(0), + col: column, + original, + value: Value::Text(input.to_owned()), + })); + assert_eq!(harness.tracker.pending_count(), 1); +} + +fn first_grid(harness: &Harness) { + wait_until(WAIT, || { + headers_are(harness.tab(), &["id", "nickname", "name", "email"]) && harness.saved_rows().len() == 1 + }) + .expect("the first grid"); +} + +#[gtk4::test] +fn refresh_after_a_column_is_dropped_keeps_an_edit_on_its_column() { + let harness = Harness::open(losing_nickname(without_nickname()), []); + first_grid(&harness); + edit_first_row(&harness, 2, "Grace"); + + harness.tab().send(PagingInput::Refresh); + wait_until(WAIT, || headers_are(harness.tab(), &["id", "name", "email"])).expect("the grid under the new columns"); + + assert_eq!( + saved_cells(&harness.tracker), + vec![("name".to_owned(), Some("Grace".to_owned()))] + ); +} + +#[gtk4::test] +fn a_structure_save_that_drops_the_edited_column_refuses_the_save() { + let harness = Harness::open(losing_nickname(without_nickname()), []); + first_grid(&harness); + edit_first_row(&harness, 1, "countess"); + + change_schema(&harness, |columns| columns == people(&["name", "email"])); + wait_until(WAIT, || headers_are(harness.tab(), &["id", "name", "email"])).expect("the grid under the new columns"); + + let refused = harness + .tracker + .begin_save() + .map(|batch| batch.map(|batch| batch.changes.changes)); + assert!( + refused + .as_ref() + .is_err_and(|rejection| rejection.message().contains("nickname")), + "{refused:?}" + ); + assert_eq!( + harness.tracker.pending_count(), + 1, + "the edit was dropped without a word" + ); +} + +/// The grid takes the new columns before the tracker does, so what was +/// typed is filed under the column it was typed in, wherever that column +/// has moved. +#[gtk4::test] +fn an_edit_open_while_the_columns_change_stays_on_its_column() { + let harness = Harness::open(losing_nickname(FakeReply::UntilStopped), []); + first_grid(&harness); + let _shown = Shown::new(harness.tab()); + let cell = grid_cell_of(harness.tab(), 0, 2); + type_into(&harness, &cell, "Grace"); + + change_schema(&harness, |columns| columns == people(&["name", "email"])); + + assert_eq!( + saved_cells(&harness.tracker), + vec![("name".to_owned(), Some("Grace".to_owned()))], + "what was typed was dropped, or landed on another column" + ); + assert_eq!(harness.visible_page().as_deref(), Some("loading")); + assert_eq!(harness.grid().row_count(), 0, "rows read under the old columns stayed"); +} diff --git a/linux/crates/app/src/ui/browse_tab/tests/save.rs b/linux/crates/app/src/ui/browse_tab/tests/save.rs new file mode 100644 index 0000000000..a7f4a5d48d --- /dev/null +++ b/linux/crates/app/src/ui/browse_tab/tests/save.rs @@ -0,0 +1,246 @@ +use gtk4::prelude::*; +use gtk4::subclass::prelude::ObjectSubclassIsExt; +use tablepro_test_support::FakeReply; + +use super::super::SaveInput; +use super::{ + Harness, Shown, WAIT, action_enabled, column_view, grid_cell_of, key, pending_bar_shown, rename, rows, + serving_table, type_into, +}; +use crate::services::change_tracker::{RowKey, StatementSource}; +use crate::test_support::{drain_main_context, wait_until}; + +fn two_rows() -> Harness { + let mut script = serving_table(); + script.query_bound.always(FakeReply::ok(rows(&["Ada", "Grace"]))); + let harness = Harness::open(script, []); + wait_until(WAIT, || harness.shown().len() == 2).expect("the first page"); + harness +} + +/// What the gutter says of the row keyed `key`, `None` when it has no +/// pending change. +fn row_state(harness: &Harness, key: &RowKey) -> Option { + let grid = harness.grid(); + let row = grid.store().row_for(key).expect("the row is on the page"); + grid.tracked().row_state(&row).text() +} + +fn selected_keys(harness: &Harness) -> Vec> { + harness.grid().selected_rows().iter().map(|row| row.key()).collect() +} + +fn fail_at(harness: &Harness, id: i64) { + harness + .tab() + .send(SaveInput::MarkErrorRow(StatementSource::Update { row_key: key(id) })); + drain_main_context(); +} + +/// D5: Ctrl+S with a value still in its editor saves that value. +#[gtk4::test] +fn save_commits_an_open_edit_first_and_stays_on_while_it_is_open() { + let harness = two_rows(); + let _shown = Shown::new(harness.tab()); + let saved = std::rc::Rc::new(std::cell::Cell::new(0)); + let sink = saved.clone(); + harness.tab().connect_save_requested(move |_| sink.set(sink.get() + 1)); + let cell = grid_cell_of(harness.tab(), 0, 1); + + type_into(&harness, &cell, "Ada Lovelace"); + assert_eq!(harness.tracker.pending_count(), 0); + wait_until(WAIT, || action_enabled(harness.tab(), "save")).expect("Save is off while a value is typed"); + + harness + .tab() + .activate_action("browse.save", None) + .expect("the browse group"); + + assert_eq!( + harness.tracker.pending_count(), + 1, + "the save went without what was typed" + ); + assert_eq!(saved.get(), 1); + assert!(!cell.is_editing(), "the editor outlived the save"); +} + +/// F5 asks the window first when the tab has unsaved changes, and what is +/// typed in an open editor is one of them. +#[gtk4::test] +fn a_refresh_counts_what_is_typed_in_an_open_editor() { + let harness = two_rows(); + let _shown = Shown::new(harness.tab()); + let refreshes = crate::test_support::SignalLog::connect(harness.tab(), "refresh-requested", |_| ()); + let cell = grid_cell_of(harness.tab(), 0, 1); + type_into(&harness, &cell, "Ada Lovelace"); + + harness + .tab() + .activate_action("browse.refresh", None) + .expect("the browse group"); + + assert_eq!(refreshes.count(), 1); + assert_eq!( + harness.tracker.pending_count(), + 1, + "the window asked without the typed value" + ); +} + +/// The bar's own states are tested with the bar; this is the tab's wiring. +#[gtk4::test] +fn stop_saving_follows_the_save_and_reaches_the_window() { + let harness = two_rows(); + let stops = std::rc::Rc::new(std::cell::Cell::new(0)); + let stop_sink = stops.clone(); + harness + .tab() + .connect_stop_save_requested(move |_| stop_sink.set(stop_sink.get() + 1)); + let stop = harness.tab().imp().pending_bar.imp().stop_button.get(); + harness.activate_grid("insert-row"); + + harness.tab().send(SaveInput::SaveStarted); + assert!(stop.is_visible(), "a running save offered no Stop Saving"); + + stop.emit_clicked(); + assert_eq!(stops.get(), 1); + + harness.tab().send(SaveInput::SaveFailed("rolled back".to_owned())); + assert!(!stop.is_visible(), "Stop Saving outlived the save"); +} + +/// A failed save brings its row into view and marks it, with no timer: +/// the mark stays until the row is changed. +#[gtk4::test] +fn a_failed_save_marks_its_row_until_it_is_changed() { + let harness = two_rows(); + let _shown = Shown::new(harness.tab()); + grid_cell_of(harness.tab(), 1, 1); + + fail_at(&harness, 1); + + assert_eq!( + selected_keys(&harness), + [Some(key(1))], + "the failed row was not brought into view" + ); + assert_eq!(row_state(&harness, &key(1)).as_deref(), Some("Not saved")); + assert_eq!(row_state(&harness, &key(0)), None); + std::thread::sleep(std::time::Duration::from_millis(500)); + drain_main_context(); + assert!(harness.tracker.is_error_row(&key(1)), "the mark went on its own"); + + harness.apply_delta(&harness.tracker.apply(rename(1, "Grace", "Grace Hopper"))); + + assert!(!harness.tracker.is_error_row(&key(1))); + assert_eq!(row_state(&harness, &key(1)).as_deref(), Some("Modified")); +} + +#[gtk4::test] +fn a_second_failed_row_takes_the_mark_off_the_first() { + let harness = two_rows(); + let _shown = Shown::new(harness.tab()); + grid_cell_of(harness.tab(), 1, 1); + + fail_at(&harness, 1); + fail_at(&harness, 0); + + assert_eq!(row_state(&harness, &key(0)).as_deref(), Some("Not saved")); + assert_eq!( + row_state(&harness, &key(1)), + None, + "the first row still reads as not saved" + ); + assert_eq!(selected_keys(&harness), [Some(key(0))]); +} + +#[gtk4::test] +fn the_next_save_takes_the_mark_off() { + let harness = two_rows(); + let _shown = Shown::new(harness.tab()); + harness.apply_delta(&harness.tracker.apply(rename(1, "Grace", "Grace Hopper"))); + fail_at(&harness, 1); + assert_eq!(row_state(&harness, &key(1)).as_deref(), Some("Not saved")); + + let _saving = harness.tracker.begin_save().expect("a save").expect("the edit"); + harness.tab().send(SaveInput::SaveStarted); + + assert_eq!(row_state(&harness, &key(1)).as_deref(), Some("Modified")); +} + +/// Discard empties the footer, which slides away with the focus in it, +/// so the focus goes to the rows. +#[gtk4::test] +fn discard_hands_the_focus_to_the_grid() { + let harness = two_rows(); + let shown = Shown::new(harness.tab()); + harness.activate_grid("insert-row"); + wait_until(WAIT, || pending_bar_shown(harness.tab())).expect("the footer"); + let discard = harness.tab().imp().pending_bar.imp().discard_button.get(); + assert!(discard.grab_focus()); + + harness.tab().send(SaveInput::DiscardAll); + + let view = column_view(harness.tab()); + let focus = gtk4::prelude::GtkWindowExt::focus(&shown.0); + assert!( + focus.is_some_and(|focus| focus == *view.upcast_ref::() || focus.is_ancestor(&view)), + "the focus left with the footer" + ); +} + +/// The close prompts discard through the tracker and keep no delta, so +/// the grid rebuilds its drafts from the tracker. +#[gtk4::test] +fn a_discard_the_window_made_takes_the_new_rows_off_the_grid() { + let harness = two_rows(); + harness.activate_grid("insert-row"); + assert_eq!(harness.drafts(), 1); + let _ = harness.tracker.discard(); + + harness.tab().send(SaveInput::DiscardAll); + + assert_eq!(harness.drafts(), 0, "the discarded row stayed in the grid"); + assert_eq!(harness.store().n_items(), 2); + assert!(!pending_bar_shown(harness.tab())); +} + +/// The pending bar's Discard drops the undo history with the edits, so +/// it asks first, as F5 does. +#[gtk4::test] +fn discard_asks_before_it_drops_the_pending_changes() { + use libadwaita::prelude::{AdwDialogExt, AdwWindowExt, AlertDialogExt}; + let harness = two_rows(); + let window = crate::test_support::OwnedWindow::presented(libadwaita::Window::new()); + window.set_content(Some(harness.tab())); + harness.activate_grid("insert-row"); + let discard = harness.tab().imp().pending_bar.imp().discard_button.get(); + let alert = || { + let mut shown = None; + wait_until(WAIT, || { + shown = window.visible_dialog().and_downcast::(); + shown.is_some() + }) + .expect("Discard asks first"); + shown.expect("the alert") + }; + + discard.emit_clicked(); + let first = alert(); + assert_eq!(harness.tracker.pending_count(), 1, "the edit went before the answer"); + assert_eq!(first.close_response(), "cancel"); + first.emit_by_name::<()>("response", &[&"cancel"]); + first.force_close(); + drain_main_context(); + assert_eq!(harness.tracker.pending_count(), 1, "Cancel dropped the edit"); + + discard.emit_clicked(); + let second = alert(); + second.emit_by_name::<()>("response", &[&"discard"]); + second.force_close(); + + wait_until(WAIT, || harness.tracker.pending_count() == 0).expect("the confirmed discard"); + assert_eq!(harness.drafts(), 0); + window.set_content(None::<>k4::Widget>); +} diff --git a/linux/crates/app/src/ui/clipboard/copy_renderer.rs b/linux/crates/app/src/ui/clipboard/copy_renderer.rs index 3d502f042f..5b9f132f2f 100644 --- a/linux/crates/app/src/ui/clipboard/copy_renderer.rs +++ b/linux/crates/app/src/ui/clipboard/copy_renderer.rs @@ -14,7 +14,7 @@ use thiserror::Error; use super::clipboard_writer::holds_nul; use super::{ClipboardFormat, ClipboardPayload}; -use crate::ui::grid::RowSnapshot; +use crate::services::grid::RowSnapshot; /// What a copy holds, taken on the GTK thread and rendered off it. #[derive(Debug)] diff --git a/linux/crates/app/src/ui/clipboard/tests.rs b/linux/crates/app/src/ui/clipboard/tests.rs index c04ea83bcb..39f926fe76 100644 --- a/linux/crates/app/src/ui/clipboard/tests.rs +++ b/linux/crates/app/src/ui/clipboard/tests.rs @@ -14,8 +14,8 @@ use tablepro_core::meta::TableRef; use tablepro_test_support::FakeDialect; use super::*; +use crate::services::grid::RowSnapshot; use crate::test_support::{finish_on_main, test_runtime, wait_until}; -use crate::ui::grid::RowSnapshot; const WAIT: Duration = Duration::from_secs(30); diff --git a/linux/crates/app/src/ui/data_grid/client_sorter.rs b/linux/crates/app/src/ui/data_grid/client_sorter.rs new file mode 100644 index 0000000000..75991550de --- /dev/null +++ b/linux/crates/app/src/ui/data_grid/client_sorter.rs @@ -0,0 +1,57 @@ +use std::cmp::Ordering; + +use gtk4::prelude::*; +use gtk4::{self as gtk, glib}; +use tablepro_core::Value; +use tablepro_core::column::ColumnKind; + +use super::GridRow; +use crate::services::grid::GridColumn; +use crate::services::grid::value_order::{compare, sort_text, storage_class_order}; + +/// What a header click sorts a grid's rows by. +/// +/// Text is collated in the user's locale by `GtkStringSorter`, which +/// builds a collation key once per row rather than once per comparison. +/// The sorter ahead of it places every value that is not text, so the +/// pair serves any column that can hold text, including a SQLite column +/// whose rows choose their own storage. +pub fn column_sorter(column: &GridColumn) -> gtk::Sorter { + let index = column.index; + let column_type = &column.column_type; + if column_type.dynamic_storage || holds_text(&column_type.kind) { + let sorter = gtk::MultiSorter::new(); + sorter.append(cells_by(index, storage_class_order)); + sorter.append(text_sorter(index)); + return sorter.upcast(); + } + cells_by(index, compare).upcast() +} + +fn holds_text(kind: &ColumnKind) -> bool { + kind.is_textual() || matches!(kind, ColumnKind::Json { .. } | ColumnKind::Network(_)) +} + +fn text_sorter(index: usize) -> gtk::StringSorter { + let text = gtk::ClosureExpression::with_callback(&[] as &[gtk::Expression], move |values: &[glib::Value]| { + let row = values.first()?.get::().ok()?; + let cells = row.cells()?; + cells.get(index).and_then(sort_text).map(str::to_owned) + }); + let sorter = gtk::StringSorter::new(Some(text)); + sorter.set_collation(gtk::Collation::Unicode); + sorter +} + +fn cells_by(index: usize, order: fn(&Value, &Value) -> Ordering) -> gtk::CustomSorter { + gtk::CustomSorter::new(move |left, right| { + let (Some(left), Some(right)) = (left.downcast_ref::(), right.downcast_ref::()) else { + return gtk::Ordering::Equal; + }; + let (left, right) = (left.cells(), right.cells()); + let null = Value::Null; + let left = left.as_deref().and_then(|cells| cells.get(index)).unwrap_or(&null); + let right = right.as_deref().and_then(|cells| cells.get(index)).unwrap_or(&null); + order(left, right).into() + }) +} diff --git a/linux/crates/app/src/ui/data_grid/column_chooser.rs b/linux/crates/app/src/ui/data_grid/column_chooser.rs new file mode 100644 index 0000000000..bc5786e50c --- /dev/null +++ b/linux/crates/app/src/ui/data_grid/column_chooser.rs @@ -0,0 +1,583 @@ +use std::cell::{Cell, OnceCell, RefCell}; +use std::collections::HashMap; + +use gtk4::prelude::*; +use gtk4::subclass::prelude::*; +use gtk4::{self as gtk, gio, glib, pango}; +use libadwaita as adw; +use libadwaita::prelude::*; +use libadwaita::subclass::prelude::*; + +use super::DataGrid; + +/// A signal the dialog keeps connected on the grid while it is open. +type Watch = (glib::WeakRef, glib::SignalHandlerId); + +mod imp { + use super::*; + + #[derive(Default, gtk::CompositeTemplate)] + #[template(resource = "/app/tablepro/TablePro/ui/column-chooser.ui")] + pub struct ColumnChooser { + #[template_child] + pub search_entry: TemplateChild, + #[template_child] + pub stack: TemplateChild, + #[template_child] + pub list_view: TemplateChild, + #[template_child] + pub reset_button: TemplateChild, + #[template_child] + pub show_all_button: TemplateChild, + + pub grid: glib::WeakRef, + /// The grid's data columns, in the order on screen. + pub columns: OnceCell, + pub filter: OnceCell, + /// Each drawn row's check follows its column's visibility. + pub bindings: RefCell>, + pub grid_watch: RefCell>, + pub visibility_watches: RefCell>, + pub refill_queued: Cell, + } + + #[glib::object_subclass] + impl ObjectSubclass for ColumnChooser { + const NAME: &'static str = "TableProColumnChooser"; + type Type = super::ColumnChooser; + type ParentType = adw::Dialog; + + fn class_init(klass: &mut Self::Class) { + klass.bind_template(); + } + + fn instance_init(object: &glib::subclass::InitializingObject) { + object.init_template(); + } + } + + impl ObjectImpl for ColumnChooser { + fn dispose(&self) { + self.obj().stop_watching(); + for (_, binding) in self.bindings.take() { + binding.unbind(); + } + } + } + + impl WidgetImpl for ColumnChooser {} + impl AdwDialogImpl for ColumnChooser {} +} + +glib::wrapper! { + /// Which of a grid's columns show, and the way back to the layout the + /// result came in. + pub struct ColumnChooser(ObjectSubclass) + @extends adw::Dialog, gtk::Widget, + @implements gtk::Accessible, gtk::Buildable, gtk::ConstraintTarget; +} + +pub fn present(grid: &DataGrid) { + open(grid); +} + +fn open(grid: &DataGrid) -> ColumnChooser { + let chooser: ColumnChooser = glib::Object::new(); + chooser.setup(grid); + chooser.present(Some(grid)); + chooser +} + +impl ColumnChooser { + fn setup(&self, grid: &DataGrid) { + let imp = self.imp(); + imp.grid.set(Some(grid)); + let columns = gio::ListStore::new::(); + let filter = gtk::StringFilter::new(Some(gtk::PropertyExpression::new( + gtk::ColumnViewColumn::static_type(), + None::, + "title", + ))); + filter.set_match_mode(gtk::StringFilterMatchMode::Substring); + filter.set_ignore_case(true); + let filtered = gtk::FilterListModel::new(Some(columns.clone()), Some(filter.clone())); + filtered.connect_items_changed(glib::clone!( + #[weak(rename_to = chooser)] + self, + move |model, _, _, _| chooser.show_list_or_empty(model.n_items()) + )); + imp.list_view.set_model(Some(>k::NoSelection::new(Some(filtered)))); + imp.list_view.set_factory(Some(&self.row_factory())); + imp.list_view.connect_activate(glib::clone!( + #[weak(rename_to = chooser)] + self, + move |_, position| chooser.toggle_at(position) + )); + let _ = imp.columns.set(columns); + let _ = imp.filter.set(filter); + self.wire_controls(); + self.watch_grid(grid); + self.connect_closed(|chooser| chooser.stop_watching()); + self.refill(); + } + + fn wire_controls(&self) { + let imp = self.imp(); + imp.search_entry.set_key_capture_widget(Some(self)); + imp.search_entry.connect_search_changed(glib::clone!( + #[weak(rename_to = chooser)] + self, + move |entry| chooser.filter_by(&entry.text()) + )); + // Escape clears the search first, then closes, which the entry + // would otherwise keep from the dialog. + imp.search_entry.connect_stop_search(glib::clone!( + #[weak(rename_to = chooser)] + self, + move |entry| match entry.text().is_empty() { + true => { + chooser.close(); + } + false => entry.set_text(""), + } + )); + imp.reset_button.connect_clicked(glib::clone!( + #[weak(rename_to = chooser)] + self, + move |_| chooser.reset() + )); + imp.show_all_button.connect_clicked(glib::clone!( + #[weak(rename_to = chooser)] + self, + move |_| chooser.show_all() + )); + } + + fn grid(&self) -> Option { + self.imp().grid.upgrade() + } + + fn row_factory(&self) -> gtk::SignalListItemFactory { + let factory = gtk::SignalListItemFactory::new(); + let weak = self.downgrade(); + factory.connect_setup(move |_, item| { + if let Some(item) = item.downcast_ref::() { + setup_row(item, weak.clone()); + } + }); + let weak = self.downgrade(); + factory.connect_bind(move |_, item| { + if let (Some(item), Some(chooser)) = (item.downcast_ref::(), weak.upgrade()) { + chooser.bind_row(item); + } + }); + let weak = self.downgrade(); + factory.connect_unbind(move |_, item| { + if let Some(chooser) = weak.upgrade() + && let Some(binding) = chooser.imp().bindings.borrow_mut().remove(item) + { + binding.unbind(); + } + }); + factory + } + + fn bind_row(&self, item: >k::ListItem) { + let (Some(view_column), Some(check), Some(grid)) = ( + item.item().and_downcast::(), + item.child().and_downcast::(), + self.grid(), + ) else { + return; + }; + let Some(column) = grid + .column_index(&view_column) + .and_then(|index| grid.grid_columns().get(index).cloned()) + else { + return; + }; + let type_name = column.value_type().name.clone(); + let labels = check.child(); + let label = |widget: Option| widget.and_downcast::(); + if let Some(name) = label(labels.as_ref().and_then(|labels| labels.first_child())) { + name.set_label(&column.name); + } + if let Some(kind) = label(labels.as_ref().and_then(|labels| labels.last_child())) { + kind.set_label(&type_name); + kind.set_visible(!type_name.is_empty()); + } + check.update_property(&[gtk::accessible::Property::Label(&column.name)]); + match type_name.is_empty() { + true => check.reset_property(gtk::AccessibleProperty::Description), + false => check.update_property(&[gtk::accessible::Property::Description(&type_name)]), + } + let binding = view_column + .bind_property("visible", &check, "active") + .sync_create() + .build(); + if let Some(old) = self.imp().bindings.borrow_mut().insert(item.clone().upcast(), binding) { + old.unbind(); + } + } + + /// The user checked or cleared a row. + fn set_shown(&self, view_column: >k::ColumnViewColumn, check: >k::CheckButton) { + let Some(grid) = self.grid() else { + return; + }; + let Some(index) = grid.column_index(view_column) else { + return; + }; + grid.set_column_visible(index, check.is_active()); + // The grid keeps its last column on screen, so the check goes back. + if check.is_active() != view_column.is_visible() { + check.set_active(view_column.is_visible()); + } + } + + fn toggle_at(&self, position: u32) { + let Some(view_column) = self + .imp() + .list_view + .model() + .and_then(|model| model.item(position)) + .and_downcast::() + else { + return; + }; + if let Some(grid) = self.grid() + && let Some(index) = grid.column_index(&view_column) + { + grid.set_column_visible(index, !view_column.is_visible()); + } + } + + /// List the grid's data columns as they are on screen now. + fn refill(&self) { + let (Some(grid), Some(columns)) = (self.grid(), self.imp().columns.get()) else { + return; + }; + let view_columns = grid.column_view().columns(); + let shown: Vec = (0..view_columns.n_items()) + .filter_map(|position| view_columns.item(position).and_downcast::()) + .filter(|column| grid.column_index(column).is_some()) + .collect(); + columns.splice(0, columns.n_items(), &shown); + self.watch_visibility(&shown); + self.refresh_show_all(); + } + + /// The grid builds its columns again for a reset or a new result, and + /// the list follows it once the build is done. + fn watch_grid(&self, grid: &DataGrid) { + let view_columns = grid.column_view().columns(); + let handler = view_columns.connect_items_changed(glib::clone!( + #[weak(rename_to = chooser)] + self, + move |_, _, _, _| chooser.queue_refill() + )); + self.imp() + .grid_watch + .replace(Some((view_columns.upcast_ref::().downgrade(), handler))); + } + + fn queue_refill(&self) { + if self.imp().refill_queued.replace(true) { + return; + } + let chooser = self.downgrade(); + glib::idle_add_local_once(move || { + if let Some(chooser) = chooser.upgrade() { + chooser.imp().refill_queued.set(false); + chooser.refill(); + } + }); + } + + fn watch_visibility(&self, columns: &[gtk::ColumnViewColumn]) { + disconnect(self.imp().visibility_watches.take()); + let watches = columns + .iter() + .map(|column| { + let handler = column.connect_visible_notify(glib::clone!( + #[weak(rename_to = chooser)] + self, + move |_| chooser.refresh_show_all() + )); + (column.upcast_ref::().downgrade(), handler) + }) + .collect(); + self.imp().visibility_watches.replace(watches); + } + + fn stop_watching(&self) { + let imp = self.imp(); + disconnect(imp.grid_watch.take()); + disconnect(imp.visibility_watches.take()); + } + + fn refresh_show_all(&self) { + let imp = self.imp(); + let hidden = imp.columns.get().is_some_and(|columns| { + columns + .iter::() + .flatten() + .any(|column| !column.is_visible()) + }); + imp.show_all_button.set_sensitive(hidden); + } + + fn filter_by(&self, text: &str) { + if let Some(filter) = self.imp().filter.get() { + filter.set_search(Some(text).filter(|text| !text.is_empty())); + } + } + + fn show_list_or_empty(&self, listed: u32) { + let imp = self.imp(); + let page = match listed == 0 && !imp.search_entry.text().is_empty() { + true => "empty", + false => "list", + }; + imp.stack.set_visible_child_name(page); + } + + fn reset(&self) { + if let Some(grid) = self.grid() { + grid.reset_layout(); + self.refill(); + } + } + + fn show_all(&self) { + if let Some(grid) = self.grid() { + for index in 0..grid.grid_columns().len() { + grid.set_column_visible(index, true); + } + } + } +} + +/// A row is one check: the column's name over its type. The check, not +/// the row, takes the focus, so Space toggles it. +fn setup_row(item: >k::ListItem, chooser: glib::WeakRef) { + item.set_focusable(false); + let name = gtk::Label::builder() + .xalign(0.0) + .ellipsize(pango::EllipsizeMode::End) + .build(); + let kind = gtk::Label::builder() + .xalign(0.0) + .ellipsize(pango::EllipsizeMode::End) + .build(); + kind.add_css_class("caption"); + kind.add_css_class("dimmed"); + let labels = gtk::Box::new(gtk::Orientation::Vertical, 2); + labels.append(&name); + labels.append(&kind); + let check = gtk::CheckButton::builder().child(&labels).hexpand(true).build(); + let item_ref = item.downgrade(); + check.connect_toggled(move |check| { + if let (Some(item), Some(chooser)) = (item_ref.upgrade(), chooser.upgrade()) + && let Some(view_column) = item.item().and_downcast::() + { + chooser.set_shown(&view_column, check); + } + }); + item.set_child(Some(&check)); +} + +fn disconnect(watches: impl IntoIterator) { + for (object, handler) in watches { + if let Some(object) = object.upgrade() { + object.disconnect(handler); + } + } +} + +#[cfg(test)] +mod tests { + use std::cell::Cell; + use std::rc::Rc; + + use gtk4::prelude::*; + use gtk4::subclass::prelude::*; + use libadwaita as adw; + use libadwaita::prelude::*; + use tablepro_core::{ColumnInfo, Value}; + use uuid::Uuid; + + use super::super::LayoutBinding; + use super::super::test_grid::{Grid, GridSpec, WAIT, named, persisted}; + use super::{ColumnChooser, open}; + use crate::persistence::ColumnLayoutStore; + use crate::services::grid::ColumnLayout; + use crate::test_support::{assert_labelled, descendants, drain_main_context, wait_until}; + + fn columns() -> Vec { + vec![ + named("id", "integer", true), + named("name", "text", false), + named("email", "varchar(80)", false), + ] + } + + fn rows() -> Vec> { + vec![ + vec![ + Value::Int(1), + Value::Text("Ada".into()), + Value::Text("ada@example.com".into()), + ], + vec![ + Value::Int(2), + Value::Text("Grace".into()), + Value::Text("grace@example.com".into()), + ], + ] + } + + fn row_labels(check: >k4::CheckButton) -> Option<(String, String)> { + let labels = check.child()?; + let name = labels.first_child().and_downcast::()?; + let kind = labels.last_child().and_downcast::()?; + Some((name.label().to_string(), kind.label().to_string())) + } + + /// The drawn check of the column named `name`. + fn check(chooser: &ColumnChooser, name: &str) -> gtk4::CheckButton { + descendants(chooser) + .into_iter() + .filter_map(|widget| widget.downcast::().ok()) + .find(|check| row_labels(check).is_some_and(|(shown, _)| shown == name)) + .unwrap_or_else(|| panic!("no check for {name}")) + } + + /// The columns the chooser lists, by data column, in its order. + fn listed(chooser: &ColumnChooser, grid: &Grid) -> Vec { + let model = chooser.imp().list_view.model().expect("a model"); + (0..model.n_items()) + .filter_map(|position| model.item(position).and_downcast::()) + .filter_map(|column| grid.grid.column_index(&column)) + .collect() + } + + fn opened(grid: &Grid) -> ColumnChooser { + let chooser = open(&grid.grid); + wait_until(WAIT, || { + descendants(&chooser) + .into_iter() + .filter(|widget| widget.is::()) + .count() + == 3 + }) + .expect("a check per column"); + chooser + } + + #[gtk4::test] + fn a_check_hides_and_shows_its_column_and_the_last_one_stays() { + adw::init().expect("libadwaita initialises under the test backend"); + let grid = Grid::open(columns(), rows()); + let chooser = opened(&grid); + let email = check(&chooser, "email"); + assert_eq!(row_labels(&email).map(|(_, kind)| kind).as_deref(), Some("varchar(80)")); + assert_eq!(assert_labelled(&email), Ok(())); + assert!(gtk4::test_accessible_has_property( + &email, + gtk4::AccessibleProperty::Description + )); + + check(&chooser, "name").set_active(false); + assert_eq!(grid.grid.shown_columns(), [0, 2]); + assert!(chooser.imp().show_all_button.is_sensitive()); + check(&chooser, "name").set_active(true); + assert_eq!(grid.grid.shown_columns(), [0, 1, 2]); + assert!(!chooser.imp().show_all_button.is_sensitive()); + + check(&chooser, "id").set_active(false); + check(&chooser, "email").set_active(false); + check(&chooser, "name").set_active(false); + assert_eq!(grid.grid.shown_columns(), [1], "the last column stays on screen"); + assert!(check(&chooser, "name").is_active(), "its check goes back"); + + chooser.imp().show_all_button.emit_clicked(); + assert_eq!(grid.grid.shown_columns(), [0, 1, 2]); + assert!(check(&chooser, "id").is_active() && check(&chooser, "email").is_active()); + chooser.close(); + drain_main_context(); + } + + #[gtk4::test] + fn reset_puts_every_column_back_and_forgets_the_saved_layout() { + adw::init().expect("libadwaita initialises under the test backend"); + let root = tempfile::tempdir().expect("tempdir"); + let runtime = crate::test_support::test_runtime(); + let store = ColumnLayoutStore::load(root.path().join("column_widths.json"), &runtime.tasks()); + let connection_id = Uuid::new_v4(); + let saved = || store.layout(connection_id, Some("public"), "users"); + let spec = GridSpec { + layout: Some(LayoutBinding { + store: store.clone(), + connection_id, + schema: Some("public".to_owned()), + table: "users".to_owned(), + }), + ..GridSpec::table(columns()) + }; + let grid = Grid::build(spec, persisted(&rows())); + let email = grid.grid.data_view_column(2).expect("the email column"); + grid.grid.column_view().insert_column(1, &email); + grid.grid + .data_view_column(1) + .expect("the name column") + .set_fixed_width(321); + grid.grid.set_column_visible(0, false); + drain_main_context(); + assert_eq!(grid.grid.shown_columns(), [2, 1]); + assert!( + saved().hidden.contains("id") && saved().widths.get("name") == Some(&321), + "{:?}", + saved() + ); + let chooser = opened(&grid); + assert_eq!(listed(&chooser, &grid), [2, 0, 1], "listed as on screen"); + + chooser.imp().reset_button.emit_clicked(); + drain_main_context(); + + assert_eq!(grid.grid.shown_columns(), [0, 1, 2]); + assert_ne!( + grid.grid.data_view_column(1).map(|column| column.fixed_width()), + Some(321) + ); + assert_eq!(saved(), ColumnLayout::default()); + assert_eq!(listed(&chooser, &grid), [0, 1, 2]); + wait_until(WAIT, || check(&chooser, "id").is_active()).expect("the id column is checked again"); + chooser.close(); + drain_main_context(); + } + + #[gtk4::test] + fn the_search_narrows_the_list_and_escape_clears_it_before_closing() { + adw::init().expect("libadwaita initialises under the test backend"); + let grid = Grid::open(columns(), rows()); + let chooser = opened(&grid); + let closed = Rc::new(Cell::new(false)); + let flag = closed.clone(); + chooser.connect_closed(move |_| flag.set(true)); + let imp = chooser.imp(); + + imp.search_entry.set_text("MAI"); + wait_until(WAIT, || listed(&chooser, &grid) == [2]).expect("only email is listed"); + imp.search_entry.set_text("zzz"); + wait_until(WAIT, || imp.stack.visible_child_name().as_deref() == Some("empty")).expect("No Matches"); + + imp.search_entry.emit_by_name::<()>("stop-search", &[]); + assert!(imp.search_entry.text().is_empty() && !closed.get()); + assert_eq!(imp.stack.visible_child_name().as_deref(), Some("list")); + assert_eq!(listed(&chooser, &grid), [0, 1, 2]); + imp.search_entry.emit_by_name::<()>("stop-search", &[]); + wait_until(WAIT, || closed.get()).expect("a second Escape closes the dialog"); + } +} diff --git a/linux/crates/app/src/ui/data_grid/columns.rs b/linux/crates/app/src/ui/data_grid/columns.rs new file mode 100644 index 0000000000..22409e08f2 --- /dev/null +++ b/linux/crates/app/src/ui/data_grid/columns.rs @@ -0,0 +1,631 @@ +use std::cell::Cell; +use std::rc::Rc; +use std::sync::Arc; + +use gtk4::prelude::*; +use gtk4::subclass::prelude::*; +use gtk4::{self as gtk, glib, pango}; +use tablepro_core::Value; +use tablepro_core::column::ColumnKind; + +use super::grid_cell::{GridCell, TextAttributes}; +use super::server_sorter::ServerSorter; +use super::{DataGrid, GridRow, GridSorting, TrackedValues, client_sorter, grid_menu, row_number_column}; +use crate::services::grid::{ + CellDisplay, ColumnLayout, GridColumn, LayoutSlot, Placement, default_width_chars, merge_order, number_width_chars, + widest_text, +}; + +/// Rows sampled for a column's opening width. +const WIDTH_SAMPLE: usize = 200; +/// Cell padding Adwaita gives a data table's cell, both sides, and the +/// column separator. +const CELL_CHROME_PX: i32 = 13; +/// A resize step, in digits of the grid's font, so a step is the same +/// number of characters at any text size. +const STEP_DIGITS: i32 = 2; + +/// What a column's cells read to draw a row. The column makes one and +/// every cell it builds shares it. +#[derive(Debug)] +pub struct ColumnPaint { + pub column: GridColumn, + pub tracked: TrackedValues, + pub attributes: Rc, + /// The column lines its values up on the last digit. An untyped column + /// does once every value it holds is a number. + pub numbers: Cell, +} + +/// The grid's columns: what each one is and the view column that shows it. +#[derive(Debug, Default)] +pub struct ColumnSet { + columns: Arc<[GridColumn]>, + view_columns: Vec, + paints: Vec>, + row_numbers: Option, + sorting: Option, + /// The order, visibility and widths as the user left them. + layout: ColumnLayout, + /// The widths the grid gave columns itself, which the layout does not + /// keep: a better guess from other rows should still reach them. + auto_widths: std::collections::HashMap, + handlers: Vec<(glib::WeakRef, glib::SignalHandlerId)>, +} + +impl ColumnSet { + pub fn columns(&self) -> Arc<[GridColumn]> { + self.columns.clone() + } + + pub fn view_column(&self, index: usize) -> Option<>k::ColumnViewColumn> { + self.view_columns.get(index) + } + + pub fn paint(&self, index: usize) -> Option> { + self.paints.get(index).cloned() + } + + pub fn index_of(&self, view_column: >k::ColumnViewColumn) -> Option { + self.view_columns.iter().position(|column| column == view_column) + } + + pub fn row_numbers(&self) -> Option<>k::ColumnViewColumn> { + self.row_numbers.as_ref() + } + + pub fn layout(&self) -> &ColumnLayout { + &self.layout + } + + pub fn sorting(&self) -> GridSorting { + self.sorting.unwrap_or(GridSorting::None) + } + + pub(super) fn release(&mut self) { + for (object, handler) in self.handlers.drain(..) { + if let Some(object) = object.upgrade() { + object.disconnect(handler); + } + } + // GTK 4.22 leaks a reference to the column beside the focus column + // when the view drops the focus column, so a column can outlive its + // view; its factory must not keep the grid's cells alive with it. + for column in self.view_columns.drain(..).chain(self.row_numbers.take()) { + column.set_factory(None::<>k::ListItemFactory>); + } + self.paints.clear(); + } +} + +impl DataGrid { + pub(super) fn rebuild_columns(&self, columns: Arc<[GridColumn]>, sorting: GridSorting) { + let view = self.column_view(); + let old_layout = self.imp().columns.borrow().layout.clone(); + let mut set = self.imp().columns.take(); + set.release(); + while let Some(column) = view.columns().item(0).and_downcast::() { + view.remove_column(&column); + } + let layout = match self.config().and_then(|config| config.layout.as_ref()) { + Some(binding) => binding.load(), + None if same_names(&old_layout, &columns) => old_layout, + None => ColumnLayout::default(), + }; + let tracked = self.tracked(); + let attributes = Rc::new(TextAttributes::new()); + let mut built = ColumnSet { + columns: columns.clone(), + sorting: Some(sorting), + layout, + ..ColumnSet::default() + }; + for column in columns.iter() { + let paint = Rc::new(ColumnPaint { + column: column.clone(), + tracked: tracked.clone(), + attributes: attributes.clone(), + numbers: Cell::new(column.display() == CellDisplay::Number), + }); + built.view_columns.push(self.build_column(&paint, sorting)); + built.paints.push(paint); + } + built.row_numbers = Some(row_number_column::build(self)); + let names: Vec<&str> = columns.iter().map(|column| column.name.as_str()).collect(); + for placement in built.layout.arrange(&names) { + let column = match placement { + Placement::RowNumbers => built.row_numbers.clone(), + Placement::Column { index, visible, width } => { + built.view_columns.get(index).cloned().inspect(|column| { + column.set_visible(visible); + if let Some(width) = width { + column.set_fixed_width(width); + } + }) + } + }; + if let Some(column) = column { + view.append_column(&column); + } + } + self.imp().columns.replace(built); + self.size_unsized_columns(); + self.watch_layout_changes(); + self.wire_sorting(sorting); + } + + fn build_column(&self, paint: &Rc, sorting: GridSorting) -> gtk::ColumnViewColumn { + let factory = gtk::SignalListItemFactory::new(); + let setup_paint = paint.clone(); + factory.connect_setup(move |_, item| { + let Some(cell) = item.downcast_ref::() else { + return; + }; + // Set through the property: under GTK 4.12 and later gtk-rs maps + // `set_focusable` to the GtkListItem field the cell never reads. + cell.set_property("focusable", true); + let grid_cell = GridCell::new(setup_paint.clone()); + grid_cell.attach(cell); + cell.set_child(Some(&grid_cell)); + }); + factory.connect_bind(|_, item| { + let Some(cell) = item.downcast_ref::() else { + return; + }; + if let (Some(row), Some(grid_cell)) = ( + cell.item().and_downcast::(), + cell.child().and_downcast::(), + ) { + grid_cell.bind(&row); + } + }); + factory.connect_unbind(|_, item| { + if let Some(grid_cell) = item + .downcast_ref::() + .and_then(|cell| cell.child()) + .and_downcast::() + { + grid_cell.unbind(); + } + }); + let column = &paint.column; + let view_column = gtk::ColumnViewColumn::builder() + .title(&column.name) + .factory(&factory) + .resizable(true) + .build(); + view_column.set_id(Some(&format!("column-{}", column.index))); + view_column.set_sorter(self.sorter_for(column, sorting).as_ref()); + view_column.set_header_menu(Some(&grid_menu::header_menu(column, sorting))); + view_column + } + + fn sorter_for(&self, column: &GridColumn, sorting: GridSorting) -> Option { + if !column.sortable() { + return None; + } + match sorting { + GridSorting::Server { .. } => Some(ServerSorter::new().upcast()), + GridSorting::Client => Some(client_sorter::column_sorter(column)), + GridSorting::None => None, + } + } + + /// A column the layout gives no width opens at one from its type and + /// the first rows, and keeps it while rows scroll by, since an + /// automatic width measures every row GTK builds and shifts. + fn size_unsized_columns(&self) { + let widths = self.opening_widths(); + for (index, view_column, width) in widths { + self.imp().columns.borrow_mut().auto_widths.insert(index, width); + view_column.set_fixed_width(width); + } + } + + fn opening_widths(&self) -> Vec<(usize, gtk::ColumnViewColumn, i32)> { + let set = self.imp().columns.borrow(); + let sample: Vec>> = self.store().rows().take(WIDTH_SAMPLE).map(|row| row.cells()).collect(); + let metrics = self.column_view().pango_context().metrics(None, None); + let char_px = pango::units_to_double(metrics.approximate_char_width()); + let mut widths = Vec::new(); + for (paint, view_column) in set.paints.iter().zip(&set.view_columns) { + let index = paint.column.index; + let auto = set.auto_widths.get(&index).copied(); + if view_column.fixed_width() > 0 && auto != Some(view_column.fixed_width()) { + continue; + } + let values: Vec<&Value> = sample + .iter() + .filter_map(|cells| cells.as_deref().and_then(|cells| cells.get(index))) + .collect(); + let chars = match paint.numbers.get() { + true => number_width_chars(&paint.column, values.iter().copied()), + false => default_width_chars(&paint.column, values.iter().copied()), + }; + let estimate = (f64::from(chars) * char_px).ceil() as i32; + // A number is never cut, and its digits in tabular figures run + // wider than an average character. + let text = match paint.numbers.get() { + true => estimate.max(tabular_width(&self.column_view(), chars)), + false => widest_text(&paint.column, values.iter().copied()).map_or(estimate, |widest| { + estimate.max(drawn_width(&self.column_view(), &widest)) + }), + }; + let width = text + CELL_CHROME_PX; + if auto.is_none_or(|auto| width > auto) { + widths.push((index, view_column.clone(), width)); + } + } + widths + } + + /// Rows came in: an untyped column may have turned out not to hold + /// only numbers, and the row numbers may have grown a digit. + pub(super) fn columns_took_rows(&self) { + self.detect_untyped_numbers(); + self.size_unsized_columns(); + row_number_column::fit(self); + } + + /// PostgreSQL results carry no type (ADR 0016), so such a column lines + /// up by what it holds, as a spreadsheet does. + fn detect_untyped_numbers(&self) { + let set = self.imp().columns.borrow(); + let mut changed = false; + for paint in &set.paints { + if !matches!(paint.column.column_type.kind, ColumnKind::Other | ColumnKind::Variant) { + continue; + } + let index = paint.column.index; + let mut held = false; + let all_numbers = + self.store() + .rows() + .all(|row| match row.cells().and_then(|cells| cells.get(index).cloned()) { + Some(Value::Null) | None => true, + Some(value) => { + held = true; + is_number(&value) + } + }); + let numbers = held && all_numbers; + changed |= paint.numbers.replace(numbers) != numbers; + } + drop(set); + if changed { + self.redraw(); + } + } + + /// Save a drag in the header, a width and a hidden column the moment + /// the user makes them. + fn watch_layout_changes(&self) { + let view = self.column_view(); + let mut handlers = Vec::new(); + let order = view.columns().connect_items_changed(glib::clone!( + #[weak(rename_to = grid)] + self, + move |_, _, _, _| grid.columns_moved() + )); + handlers.push((view.columns().upcast::().downgrade(), order)); + let set = self.imp().columns.borrow(); + for column in &set.view_columns { + let width = column.connect_notify_local( + Some("fixed-width"), + glib::clone!( + #[weak(rename_to = grid)] + self, + move |_, _| grid.record_layout() + ), + ); + let visible = column.connect_notify_local( + Some("visible"), + glib::clone!( + #[weak(rename_to = grid)] + self, + move |_, _| grid.columns_moved() + ), + ); + for handler in [width, visible] { + handlers.push((column.upcast_ref::().downgrade(), handler)); + } + } + drop(set); + self.imp().columns.borrow_mut().handlers.extend(handlers); + } + + /// A column moved, went or came back: the layout keeps it, and a + /// search counts only the columns on screen, in their order. + fn columns_moved(&self) { + self.record_layout(); + self.find_rows_changed(); + } + + /// Fold what the view shows into the stored layout. A column the rows + /// lack this time keeps its place, width and hidden flag for when it + /// is back. + fn record_layout(&self) { + let Ok(mut set) = self.imp().columns.try_borrow_mut() else { + return; + }; + let view = self.column_view(); + let mut layout = set.layout.clone(); + let mut shown_order = Vec::new(); + // A double click on a header's edge gives a column its automatic + // width back, which the grid replaces with its own guess. + let mut reset = Vec::new(); + for position in 0..view.columns().n_items() { + let Some(column) = view.columns().item(position).and_downcast::() else { + continue; + }; + if set.row_numbers.as_ref() == Some(&column) { + shown_order.push(LayoutSlot::RowNumbers); + continue; + } + let Some(index) = set.index_of(&column) else { + continue; + }; + let name = set.columns[index].name.clone(); + match column.is_visible() { + true => layout.hidden.remove(&name), + false => layout.hidden.insert(name.clone()), + }; + let width = column.fixed_width(); + match width > 0 && set.auto_widths.get(&index) != Some(&width) { + true => layout.widths.insert(name.clone(), width), + false => layout.widths.remove(&name), + }; + if width <= 0 { + reset.push(index); + } + shown_order.push(LayoutSlot::Column(name)); + } + layout.order = merge_order(&set.layout.order, shown_order); + for index in &reset { + set.auto_widths.remove(index); + } + let before = std::mem::replace(&mut set.layout, layout.clone()); + drop(set); + if !reset.is_empty() { + glib::idle_add_local_once(glib::clone!( + #[weak(rename_to = grid)] + self, + move || grid.size_unsized_columns() + )); + } + if layout != before + && let Some(binding) = self.config().and_then(|config| config.layout.as_ref()) + { + binding.save(&layout.rebased(&before, &binding.load())); + } + } + + /// The user gave the column a width, or took it back with `None`. + pub(super) fn set_column_width(&self, index: usize, width: Option) { + if let Some(column) = self.data_view_column(index) { + column.set_fixed_width(width.unwrap_or(-1)); + } + } + + pub(super) fn step_column(&self, index: usize, wider: bool) { + let column = self.imp().columns.borrow().view_column(index).cloned(); + let Some(column) = column else { + return; + }; + let metrics = self.column_view().pango_context().metrics(None, None); + let step = (pango::units_to_double(metrics.approximate_digit_width()) * f64::from(STEP_DIGITS)).ceil() as i32; + let width = column.fixed_width(); + let width = match wider { + true => width.max(0).saturating_add(step), + // Narrow stops at one step, so the column the focus is in stays + // on screen to widen again. + false if width > step => (width - step).max(step), + false => return, + }; + self.set_column_width(index, Some(width)); + } + + /// The data columns on screen, in the order the user arranged them. + pub(crate) fn shown_columns(&self) -> Vec { + let set = self.imp().columns.borrow(); + let view = self.column_view(); + (0..view.columns().n_items()) + .filter_map(|position| view.columns().item(position).and_downcast::()) + .filter(|column| column.is_visible()) + .filter_map(|column| set.index_of(&column)) + .collect() + } + + pub(crate) fn data_view_column(&self, index: usize) -> Option { + self.imp().columns.borrow().view_column(index).cloned() + } + + pub(crate) fn column_index(&self, view_column: >k::ColumnViewColumn) -> Option { + self.imp().columns.borrow().index_of(view_column) + } + + pub(crate) fn row_numbers_column(&self) -> Option { + self.imp().columns.borrow().row_numbers().cloned() + } + + pub(crate) fn set_column_visible(&self, index: usize, visible: bool) { + // The last column on screen stays: a grid of no columns has nothing + // to focus and no header to bring one back from. + if !visible && self.shown_columns() == [index] { + self.column_view().error_bell(); + return; + } + if let Some(column) = self.data_view_column(index) { + column.set_visible(visible); + } + } + + /// Every column back where the result has it, at its opening width, + /// under the sort the headers show now. + pub(crate) fn reset_layout(&self) { + let sort = self.current_sort(); + let (columns, sorting) = { + let mut set = self.imp().columns.borrow_mut(); + set.layout = ColumnLayout::default(); + (set.columns.clone(), set.sorting()) + }; + if let Some(binding) = self.config().and_then(|config| config.layout.as_ref()) { + binding.save(&ColumnLayout::default()); + } + let rows: Vec<_> = self.store().rows().map(|row| row.data()).collect(); + self.rebuild_columns(columns, sorting); + self.apply_sort_quietly(sort); + self.store().replace(rows); + self.columns_took_rows(); + } + + fn wire_sorting(&self, sorting: GridSorting) { + match sorting { + GridSorting::Server { initial } => self.apply_sort_quietly(initial), + GridSorting::Client => {} + GridSorting::None => return, + } + let Some(sorter) = self.column_view().sorter().and_downcast::() else { + return; + }; + let handler = sorter.connect_changed(glib::clone!( + #[weak(rename_to = grid)] + self, + move |sorter, _| grid.sort_changed(sorter) + )); + self.imp() + .columns + .borrow_mut() + .handlers + .push((sorter.upcast_ref::().downgrade(), handler)); + } + + /// A header click or the menu changed the sort: a server grid asks the + /// host for its rows in that order. + fn sort_changed(&self, sorter: >k::ColumnViewSorter) { + self.refresh_actions(); + if self.imp().quiet_sort.get() { + return; + } + let sort = self.sort_of(sorter); + self.announce_sort(sort); + if matches!(self.imp().columns.borrow().sorting(), GridSorting::Server { .. }) { + self.request(super::GridRequest::Sort(sort)); + } + } + + pub(super) fn apply_sort_quietly(&self, sort: Option<(usize, bool)>) { + let quiet = &self.imp().quiet_sort; + quiet.set(true); + self.sort_by(sort); + quiet.set(false); + } + + /// Sort as a header click does: the server or the grid sorts, and the + /// header shows it. + pub(crate) fn sort_by(&self, sort: Option<(usize, bool)>) { + let view = self.column_view(); + match sort.and_then(|(index, ascending)| Some((self.data_view_column(index)?, ascending))) { + Some((column, ascending)) => { + let order = match ascending { + true => gtk::SortType::Ascending, + false => gtk::SortType::Descending, + }; + view.sort_by_column(Some(&column), order); + } + None => view.sort_by_column(None::<>k::ColumnViewColumn>, gtk::SortType::Ascending), + } + } + + /// The data column the headers sort by and whether ascending. + fn current_sort(&self) -> Option<(usize, bool)> { + let sorter = self.column_view().sorter().and_downcast::()?; + self.sort_of(&sorter) + } + + fn sort_of(&self, sorter: >k::ColumnViewSorter) -> Option<(usize, bool)> { + let column = sorter.primary_sort_column()?; + let index = self.column_index(&column)?; + Some((index, sorter.primary_sort_order() == gtk::SortType::Ascending)) + } + + pub(crate) fn is_sorted(&self) -> bool { + self.column_view() + .sorter() + .and_downcast::() + .is_some_and(|sorter| sorter.primary_sort_column().is_some()) + } + + /// The header's arrow says nothing to a screen reader on Linux. + fn announce_sort(&self, sort: Option<(usize, bool)>) { + let text = match sort { + Some((index, ascending)) => { + let Some(name) = self.grid_columns().get(index).map(|column| column.name.clone()) else { + return; + }; + match ascending { + true => crate::i18n::gettext_f("Sorted by {column}, ascending", &[("column", &name)]), + false => crate::i18n::gettext_f("Sorted by {column}, descending", &[("column", &name)]), + } + } + None => crate::i18n::gettext("Sort cleared"), + }; + crate::ui::a11y::announce(&self.column_view(), &text, gtk::AccessibleAnnouncementPriority::Medium); + } + + pub(crate) fn shown_rows(&self) -> Vec { + let model = self.selection(); + (0..model.n_items()) + .filter_map(|position| model.item(position).and_downcast::()) + .collect() + } + + pub(crate) fn view_position(&self, row: &GridRow) -> Option { + match self.imp().sort_model.get() { + Some(sorted) => { + (0..sorted.n_items()).find(|position| sorted.item(*position).as_ref() == Some(row.upcast_ref())) + } + None => self.store().position_of(row), + } + } +} + +fn same_names(layout: &ColumnLayout, columns: &[GridColumn]) -> bool { + let names: Vec<&str> = layout + .order + .iter() + .filter_map(|slot| match slot { + LayoutSlot::Column(name) => Some(name.as_str()), + LayoutSlot::RowNumbers => None, + }) + .collect(); + names.len() == columns.len() && names.iter().zip(columns).all(|(name, column)| *name == column.name) +} + +/// How wide `chars` digits are in the tabular figures libadwaita's +/// `numeric` class draws. +fn tabular_width(widget: &impl IsA, chars: u32) -> i32 { + let layout = widget.create_pango_layout(Some(&"0".repeat(usize::try_from(chars).unwrap_or_default()))); + let tabular = pango::AttrList::new(); + tabular.insert(pango::AttrFontFeatures::new("tnum")); + layout.set_attributes(Some(&tabular)); + layout.pixel_size().0 +} + +fn drawn_width(widget: &impl IsA, text: &str) -> i32 { + widget.create_pango_layout(Some(text)).pixel_size().0 +} + +fn is_number(value: &Value) -> bool { + match value { + Value::Int(_) + | Value::UInt(_) + | Value::WideInt(_) + | Value::Float32(_) + | Value::Float64(_) + | Value::Decimal(_) => true, + Value::Text(text) => text.parse::().is_ok(), + Value::Other(other) => other.text.parse::().is_ok(), + _ => false, + } +} diff --git a/linux/crates/app/src/ui/data_grid/copy/mod.rs b/linux/crates/app/src/ui/data_grid/copy/mod.rs new file mode 100644 index 0000000000..7b6fb7c6b0 --- /dev/null +++ b/linux/crates/app/src/ui/data_grid/copy/mod.rs @@ -0,0 +1,346 @@ +use std::sync::Arc; + +use gtk4::prelude::*; +use gtk4::{self as gtk, glib}; +use tablepro_core::column::{ColumnInfo, ResultColumn}; +use tablepro_core::dialect::SqlDialect; +use tablepro_core::edit::CellInput; +use tablepro_core::{FilterRule, Value}; + +use super::grid_actions::{add, add_for_column, enable}; +use super::selection::Resolved; +use super::{DataGrid, GridRequest, GridRow, RowJsonRequest, present_row_json}; +use crate::i18n::gettext; +use crate::services::grid::{CopyScope, GridColumn, GridRowId, RowSnapshot, cell_filter_rule, copy_scope}; +use crate::ui::clipboard::{CopyKind, CopyRequest, InClauseRows, InsertCopy, InsertRow}; +use crate::ui::export::ExportSource; +use crate::ui::filter_strip::FilterVocabulary; + +#[cfg(test)] +mod tests; + +/// Copy As, one action per format. +const ROW_COPIES: [(&str, CopyKind); 6] = [ + ("copy-rows", CopyKind::Rows), + ("copy-rows-headers", CopyKind::RowsWithHeaders), + ("copy-json", CopyKind::Json), + ("copy-csv", CopyKind::Csv), + ("copy-csv-headers", CopyKind::CsvWithHeaders), + ("copy-markdown", CopyKind::Markdown), +]; + +pub fn install_actions(grid: &DataGrid) { + add(grid, "copy", copy); + for (name, kind) in ROW_COPIES { + add(grid, name, move |grid| { + if let Some(target) = grid.target() { + copy_rows(grid, &target.rows, kind); + } + }); + } + add(grid, "copy-in-clause", copy_in_clause); + add(grid, "copy-insert", copy_insert); + add(grid, "copy-column-name", |grid| { + if let Some(index) = grid.target_column() { + copy_column_name(grid, index); + } + }); + add_for_column(grid, "copy-column-name-at", copy_column_name); + add(grid, "show-row-json", show_row_json); + add(grid, "export", |grid| { + grid.request(GridRequest::Export(export_source(grid))) + }); + add(grid, "filter-by-value", |grid| filter_by_value(grid, false)); + add(grid, "exclude-value", |grid| filter_by_value(grid, true)); +} + +pub fn refresh_actions(grid: &DataGrid, target: Option<&Resolved>) { + let cell = target.and_then(|target| target.cell.as_ref()); + let rows = target.is_some_and(|target| !target.rows.is_empty()); + enable(grid, "copy", cell.is_some() || rows); + for (name, _) in ROW_COPIES { + enable(grid, name, rows); + } + enable(grid, "copy-in-clause", cell.is_some() && sql_dialect(grid).is_some()); + enable(grid, "copy-insert", rows && copies_inserts(grid)); + enable(grid, "copy-column-name", cell.is_some()); + enable(grid, "show-row-json", target.and_then(json_row).is_some()); + enable(grid, "export", grid.row_count() > 0); + for (name, exclude) in [("filter-by-value", false), ("exclude-value", true)] { + let rule = cell.and_then(|(row, column)| value_rule(grid, row, *column, exclude)); + enable(grid, name, rule.is_some()); + } +} + +/// The rows the grid shows, pending edits included, under the columns +/// on screen in their order. +pub fn export_source(grid: &DataGrid) -> ExportSource { + let shown = ShownColumns::of(grid); + let tracked = grid.tracked(); + ExportSource::Rows { + columns: shown.columns.clone(), + rows: grid + .shown_rows() + .iter() + .map(|row| shown.project(tracked.snapshot(row))) + .collect(), + } +} + +/// The columns on screen in the order the user arranged them, which a +/// copy or an export of rows takes. +struct ShownColumns { + indices: Vec, + columns: Arc<[ResultColumn]>, + /// Every column shown where the result has it. + in_place: bool, +} + +impl ShownColumns { + fn of(grid: &DataGrid) -> Self { + let all = grid.grid_columns(); + let indices: Vec = grid + .shown_columns() + .into_iter() + .filter(|index| *index < all.len()) + .collect(); + let columns = indices + .iter() + .filter_map(|index| all.get(*index)) + .map(GridColumn::result_column) + .collect(); + let in_place = indices.iter().copied().eq(0..all.len()); + Self { + indices, + columns, + in_place, + } + } + + /// A row whose columns are all in place keeps sharing its cells. + fn project(&self, row: RowSnapshot) -> RowSnapshot { + if self.in_place && row.len() == self.indices.len() { + return row; + } + let cells: Vec = self.indices.iter().map(|index| row.value(*index).clone()).collect(); + RowSnapshot::new(cells.into(), Vec::new()) + } + + fn project_inputs(&self, inputs: &[CellInput]) -> Vec { + self.indices + .iter() + .map(|index| inputs.get(*index).cloned().unwrap_or(CellInput::Default)) + .collect() + } +} + +/// Copy: the target cell's value when one row is in play, else the rows. +fn copy(grid: &DataGrid) { + let Some(target) = grid.target() else { + return; + }; + let column = target.cell.as_ref().map(|(_, column)| *column); + match (copy_scope(target.rows.len(), column), &target.cell) { + (CopyScope::Value(column), Some((row, _))) => copy_value(grid, row, column), + _ => copy_rows(grid, &target.rows, CopyKind::Rows), + } +} + +/// The cell's whole value, never the text the cell draws, which stands in +/// for NULL and cuts long values. +fn copy_value(grid: &DataGrid, row: &GridRow, column: usize) { + render( + grid, + CopyRequest::Rows { + columns: result_columns(grid), + rows: vec![grid.tracked().snapshot(row)], + kind: CopyKind::Value(column), + }, + ); +} + +fn copy_rows(grid: &DataGrid, rows: &[GridRow], kind: CopyKind) { + if rows.is_empty() { + return; + } + let shown = ShownColumns::of(grid); + let tracked = grid.tracked(); + render( + grid, + CopyRequest::Rows { + columns: shown.columns.clone(), + rows: rows.iter().map(|row| shown.project(tracked.snapshot(row))).collect(), + kind, + }, + ); +} + +fn copy_in_clause(grid: &DataGrid) { + let Some(target) = grid.target() else { + return; + }; + let Some((_, column)) = target.cell else { + return; + }; + let Some(dialect) = sql_dialect(grid) else { + grid.toast(gettext("Connect to a database first.")); + return; + }; + let tracked = grid.tracked(); + render( + grid, + CopyRequest::InClause { + dialect, + values: InClauseRows { + columns: result_columns(grid), + rows: target.rows.iter().map(|row| tracked.snapshot(row)).collect(), + column, + }, + }, + ); +} + +/// Each row as the INSERT its save would run on the server the rows come +/// from, under the columns on screen. A draft's leaves out what the +/// server fills. +fn copy_insert(grid: &DataGrid) { + let (Some(target), Some(config)) = (grid.target(), grid.config()) else { + return; + }; + let (Some(tracker), Some(table), Some(catalog)) = ( + config.tracker.as_ref(), + config.table.clone(), + catalog_columns(&grid.grid_columns()), + ) else { + return; + }; + if target.rows.is_empty() { + return; + } + let Some(dialect) = config.dialect.as_ref().and_then(|dialect| dialect()) else { + grid.toast(gettext("Connect to a database first.")); + return; + }; + let shown = ShownColumns::of(grid); + let tracked = grid.tracked(); + let rows = target + .rows + .iter() + .map(|row| match row.id() { + GridRowId::Draft(draft_id) => { + InsertRow::Draft(shown.project_inputs(&tracker.draft_cells(draft_id, &catalog).unwrap_or_default())) + } + _ => InsertRow::Saved(shown.project(tracked.snapshot(row))), + }) + .collect(); + render( + grid, + CopyRequest::Insert(InsertCopy { + dialect, + table, + columns: shown + .indices + .iter() + .filter_map(|index| catalog.get(*index).cloned()) + .collect(), + rows, + }), + ); +} + +fn copy_column_name(grid: &DataGrid, index: usize) { + let Some(name) = grid.grid_columns().get(index).map(|column| column.name.clone()) else { + return; + }; + crate::ui::clipboard::copy_text(grid, name, |message| grid.toast(message)); +} + +fn show_row_json(grid: &DataGrid) { + let (Some(target), Some(config)) = (grid.target(), grid.config()) else { + return; + }; + let Some(row) = json_row(&target) else { + return; + }; + let shown = ShownColumns::of(grid); + let request = RowJsonRequest { + columns: shown.columns.clone(), + row: shown.project(grid.tracked().snapshot(row)), + }; + let parent = grid.clone().upcast::(); + let tasks = config.tasks.clone(); + glib::spawn_future_local(async move { + present_row_json(parent, tasks, request).await; + }); +} + +/// The row Show Row as JSON shows: the target cell's, else the one row +/// a row's menu was opened on. +fn json_row(target: &Resolved) -> Option<&GridRow> { + match (&target.cell, target.rows.as_slice()) { + (Some((row, _)), _) | (None, [row]) => Some(row), + _ => None, + } +} + +fn filter_by_value(grid: &DataGrid, exclude: bool) { + let Some((row, column)) = grid.target().and_then(|target| target.cell) else { + return; + }; + if let Some(rule) = value_rule(grid, &row, column, exclude) { + grid.request(GridRequest::FilterByValue(rule)); + } +} + +/// The rule from the value the cell shows. A draft's cell left to the +/// server has no value to name, and an operator the engine does not +/// evaluate is one the filter strip would refuse. +fn value_rule(grid: &DataGrid, row: &GridRow, column: usize, exclude: bool) -> Option { + let config = grid.config().filter(|config| config.filter_by_value)?; + let columns = grid.grid_columns(); + let grid_column = columns.get(column)?; + let rule = grid.tracked().with_value(row, column, |value, default| match default { + true => None, + false => cell_filter_rule(grid_column, value, exclude), + })?; + let dialect = config.dialect.as_ref().and_then(|dialect| dialect()); + FilterVocabulary::of(dialect) + .operators(grid_column.catalog.as_ref()?) + .contains(&rule.op) + .then_some(rule) +} + +/// The connection's dialect, where the rows have an SQL spelling. +fn sql_dialect(grid: &DataGrid) -> Option> { + let dialect = (grid.config()?.dialect.as_ref()?)()?; + dialect.grammar().is_sql().then_some(dialect) +} + +/// An editing grid of a table, every column of it read from the +/// catalogue, which an INSERT needs. +fn copies_inserts(grid: &DataGrid) -> bool { + grid.config() + .is_some_and(|config| config.tracker.is_some() && config.table.is_some() && config.dialect.is_some()) + && grid.grid_columns().iter().all(|column| column.catalog.is_some()) +} + +fn catalog_columns(columns: &[GridColumn]) -> Option> { + columns.iter().map(|column| column.catalog.clone()).collect() +} + +fn result_columns(grid: &DataGrid) -> Arc<[ResultColumn]> { + grid.grid_columns().iter().map(GridColumn::result_column).collect() +} + +/// Render off the GTK thread and say through the host how it went. +fn render(grid: &DataGrid, request: CopyRequest) { + let Some(config) = grid.config() else { + return; + }; + let weak = grid.downgrade(); + crate::ui::clipboard::copy_rows(grid, &config.tasks, request, move |text| { + if let Some(grid) = weak.upgrade() { + grid.toast(text); + } + }); +} diff --git a/linux/crates/app/src/ui/data_grid/copy/tests.rs b/linux/crates/app/src/ui/data_grid/copy/tests.rs new file mode 100644 index 0000000000..ab3fe4cfa8 --- /dev/null +++ b/linux/crates/app/src/ui/data_grid/copy/tests.rs @@ -0,0 +1,645 @@ +use std::rc::Rc; +use std::sync::Arc; + +use gtk4 as gtk; +use gtk4::prelude::*; +use gtk4::{gdk, gio}; +use libadwaita as adw; +use libadwaita::prelude::*; +use tablepro_core::dialect::SqlDialect; +use tablepro_core::edit::CellInput; +use tablepro_core::meta::TableRef; +use tablepro_core::{ColumnInfo, FilterOp, FilterRule, FilterValue, Value}; +use tablepro_test_support::FakeDialect; + +use crate::application::ShortcutOwner; +use crate::services::tab_tracker_handle::TabTrackerHandle; +use crate::services::tracker_edit::TrackerEdit; +use crate::test_support::shortcut_keys::{keystroke, owner_controller, shortcuts, trigger_keys}; +use crate::test_support::{descendants, drain_main_context, wait_until}; +use crate::ui::data_grid::test_grid::{ + Grid, GridSpec, WAIT, key, named, page, persisted, results, two_text_columns, two_text_values, +}; +use crate::ui::data_grid::{DataGrid, GridCell, GridRequest}; +use crate::ui::export::ExportSource; + +const BEFORE: &str = "nothing copied yet"; + +/// What a copy test drives: a grid and the requests it made. +trait Copies { + fn data_grid(&self) -> &DataGrid; + fn requests(&self) -> Vec; +} + +impl Copies for Grid { + fn data_grid(&self) -> &DataGrid { + &self.grid + } + + fn requests(&self) -> Vec { + self.take_requests() + } +} + +fn local_text(clipboard: &gdk::Clipboard) -> Option { + clipboard.content()?.value(String::static_type()).ok()?.get().ok() +} + +fn toasts(requests: Vec) -> Vec { + requests + .into_iter() + .filter_map(|request| match request { + GridRequest::Toast(text) => Some(text), + _ => None, + }) + .collect() +} + +/// What the clipboard holds once the copy `act` starts has landed, which +/// its toast says. +fn copied(grid: &impl Copies, act: impl FnOnce()) -> String { + let clipboard = grid.data_grid().clipboard(); + clipboard.set_text(BEFORE); + act(); + let mut said = Vec::new(); + wait_until(WAIT, || { + said.extend(toasts(grid.requests())); + !said.is_empty() + }) + .expect("the copy to land"); + local_text(&clipboard).unwrap_or_else(|| panic!("nothing on the clipboard; toasts: {said:?}")) +} + +fn activate(grid: &DataGrid, action: &str) { + grid.activate_action(&format!("grid.{action}"), None) + .unwrap_or_else(|_| panic!("the grid has no {action}")); + drain_main_context(); +} + +/// Copy from the cell menu opened on `cell`, then close it. +fn from_menu(grid: &DataGrid, cell: &GridCell, action: &str) { + assert!(grid.open_cell_menu(cell, None), "the menu opens"); + activate(grid, action); + if let Some(menus) = grid.menus() { + menus.cell.popdown(); + } + drain_main_context(); +} + +/// Run what `accel` does on the focused cell, as a key press reaching the +/// grid's controller would. `true` when the grid took the key. +fn press(grid: &DataGrid, owner: ShortcutOwner, accel: &str) -> bool { + let widget: gtk::Widget = match owner { + ShortcutOwner::GridCell => grid.column_view().upcast(), + _ => grid.clone().upcast(), + }; + let controller = owner_controller(&widget, owner).expect("the grid's keys"); + let action = shortcuts(&controller) + .into_iter() + .find(|shortcut| { + shortcut + .trigger() + .is_some_and(|trigger| trigger_keys(&trigger).contains(&keystroke(accel))) + }) + .and_then(|shortcut| shortcut.action()) + .expect("the grid binds the key"); + action.activate(gtk::ShortcutActionFlags::empty(), &widget, None) +} + +fn no_focus(grid: &Grid) { + gtk::prelude::GtkWindowExt::set_focus(&grid.window, None::<>k::Widget>); + drain_main_context(); +} + +fn people() -> Vec { + vec![ + named("id", "integer", true), + named("name", "text", false), + named("age", "integer", false), + ] +} + +fn people_rows() -> Vec> { + vec![ + vec![Value::Int(1), Value::Text("Ada".into()), Value::Int(36)], + vec![Value::Int(2), Value::Text("Grace".into()), Value::Int(85)], + ] +} + +fn rename(tracker: &TabTrackerHandle, id: i64, from: &str, to: &str) -> crate::services::tracker_delta::TrackerDelta { + tracker.apply(TrackerEdit::Cell { + row_key: key(id), + col: 1, + original: Value::Text(from.into()), + value: Value::Text(to.into()), + }) +} + +fn accel_of(menu: &gio::MenuModel, action: &str) -> Option> { + for position in 0..menu.n_items() { + let named = menu + .item_attribute_value(position, "action", None) + .and_then(|value| value.get::()); + if named.as_deref() == Some(action) { + return Some( + menu.item_attribute_value(position, "accel", None) + .and_then(|value| value.get::()), + ); + } + for link in ["section", "submenu"] { + if let Some(found) = menu + .item_link(position, link) + .and_then(|inner| accel_of(&inner, action)) + { + return Some(found); + } + } + } + None +} + +/// D2: every grid binds Ctrl+C, not only the table tab's. +#[gtk4::test] +fn ctrl_c_in_a_read_only_results_grid_copies() { + let grid = Grid::build(GridSpec::results(two_text_columns()), results(&two_text_values())); + grid.focus(1, 1); + + let text = copied(&grid, || { + assert!(press(&grid.grid, ShortcutOwner::Grid, "c"), "Ctrl+C went by"); + }); + + assert_eq!(text, "B"); +} + +/// D3: the key and the menu item are one action, so they copy the same +/// thing: the cell for one row, the rows for several. +#[gtk4::test] +fn ctrl_c_and_the_menus_copy_copy_the_same() { + let grid = Grid::open(two_text_columns(), two_text_values()); + let cell = grid.focus(0, 1); + let by_key = copied(&grid, || { + press(&grid.grid, ShortcutOwner::Grid, "c"); + }); + let by_menu = copied(&grid, || from_menu(&grid.grid, &cell, "copy")); + grid.grid.selection().select_all(); + let cell = grid.focus(0, 1); + let rows_by_key = copied(&grid, || { + press(&grid.grid, ShortcutOwner::Grid, "c"); + }); + let rows_by_menu = copied(&grid, || from_menu(&grid.grid, &cell, "copy")); + + assert_eq!((by_key.as_str(), by_menu.as_str()), ("A", "A")); + assert_eq!(rows_by_key, "1\tA\n2\tB\n"); + assert_eq!(rows_by_menu, rows_by_key); + let menu = grid + .grid + .menus() + .and_then(|menus| menus.cell.menu_model()) + .expect("the cell menu"); + assert_eq!(accel_of(&menu, "grid.copy"), Some(Some("c".to_owned()))); +} + +#[gtk4::test] +fn copy_is_off_once_a_page_takes_the_selection_away() { + let grid = Grid::build(GridSpec::table(two_text_columns()), page(0..100)); + no_focus(&grid); + + grid.grid.selection().select_range(0, 2, true); + let with_rows = (grid.is_enabled("copy"), grid.is_enabled("copy-rows")); + grid.grid.replace_rows(page(100..102), 100); + drain_main_context(); + + assert_eq!(with_rows, (true, true)); + assert!(grid.grid.selection().selection().is_empty()); + assert!(!grid.is_enabled("copy"), "Ctrl+C stayed on with nothing selected"); + assert!(!grid.is_enabled("copy-rows")); +} + +/// The rows leave as the user arranged them: a dragged column where it +/// was dropped, a hidden one left out, pending edits in. +#[gtk4::test] +fn copies_and_exports_follow_a_moved_and_a_hidden_column() { + let grid = Grid::open(people(), people_rows()); + let name = grid.grid.data_view_column(1).expect("the name column"); + let row_numbers = grid.grid.row_numbers_column().expect("the row numbers"); + let view = grid.grid.column_view(); + let after_numbers = (0..view.columns().n_items()) + .find(|position| view.columns().item(*position).as_ref() == Some(row_numbers.upcast_ref())) + .map_or(0, |position| position + 1); + view.insert_column(after_numbers, &name); + grid.grid.set_column_visible(2, false); + grid.grid + .apply_delta(&rename(&grid.tracker, 2, "Grace", "Grace Hopper")); + drain_main_context(); + grid.grid.selection().select_all(); + no_focus(&grid); + + let rows = copied(&grid, || activate(&grid.grid, "copy-rows-headers")); + activate(&grid.grid, "export"); + let exported = grid.take_requests().into_iter().find_map(|request| match request { + GridRequest::Export(ExportSource::Rows { columns, rows }) => Some((columns, rows)), + _ => None, + }); + + assert_eq!(grid.grid.shown_columns(), [1, 0]); + assert_eq!(rows, "name\tid\nAda\t1\nGrace Hopper\t2\n"); + let (columns, rows) = exported.expect("an export of the rows"); + let names: Vec<&str> = columns.iter().map(|column| column.name.as_str()).collect(); + assert_eq!(names, ["name", "id"]); + let values: Vec> = rows.iter().map(|row| row.to_values()).collect(); + assert_eq!( + values, + [ + vec![Value::Text("Ada".into()), Value::Int(1)], + vec![Value::Text("Grace Hopper".into()), Value::Int(2)], + ] + ); +} + +#[gtk4::test] +fn an_export_of_every_column_in_place_shares_the_rows_cells() { + let grid = Grid::open(people(), people_rows()); + + activate(&grid.grid, "export"); + let exported = grid + .take_requests() + .into_iter() + .find_map(|request| match request { + GridRequest::Export(ExportSource::Rows { rows, .. }) => Some(rows), + _ => None, + }) + .expect("an export of the rows"); + + let held: Vec<_> = grid.grid.store().rows().filter_map(|row| row.data().cells).collect(); + assert_eq!(exported.len(), held.len()); + for (snapshot, cells) in exported.iter().zip(&held) { + assert!(snapshot.shares_cells_with(cells), "a saved row's cells were copied"); + } +} + +#[gtk4::test] +fn copy_after_a_sort_takes_the_row_on_screen() { + let rows = vec![ + vec![Value::Int(3), Value::Text("c".into())], + vec![Value::Int(1), Value::Text("a".into())], + vec![Value::Int(2), Value::Text("b".into())], + ]; + let grid = Grid::build(GridSpec::results(two_text_columns()), results(&rows)); + grid.grid.sort_by(Some((0, true))); + let first = || { + grid.cells() + .into_iter() + .find(|cell| cell.position() == Some(0) && cell.column() == 1) + }; + wait_until(WAIT, || first().is_some_and(|cell| Grid::display_text(&cell) == "a")) + .expect("the sorted rows are drawn"); + + let text = copied(&grid, || { + from_menu(&grid.grid, &first().expect("the first row's cell"), "copy") + }); + + assert_eq!(text, "a"); +} + +/// The rule names the value the cell holds, never the text it draws: a +/// NULL is a NULL test and a long value goes whole. +#[gtk4::test] +fn filter_by_value_builds_eq_and_is_null_rules_from_the_value() { + let long = "x".repeat(1_500); + let grid = Grid::open( + two_text_columns(), + vec![ + vec![Value::Int(1), Value::Text("Ada".into())], + vec![Value::Int(2), Value::Null], + vec![Value::Int(3), Value::Text(long.clone())], + ], + ); + let rule = |op: FilterOp, value: Option<&str>| FilterRule { + column: "name".to_owned(), + op, + value: value.map(|text| FilterValue::Single(text.to_owned())), + }; + + for (id, action) in [ + (1, "filter-by-value"), + (1, "exclude-value"), + (2, "filter-by-value"), + (2, "exclude-value"), + (3, "filter-by-value"), + ] { + from_menu(&grid.grid, &grid.cell_of(id, 1), action); + } + let rules: Vec = grid + .take_requests() + .into_iter() + .filter_map(|request| match request { + GridRequest::FilterByValue(rule) => Some(rule), + _ => None, + }) + .collect(); + + assert_eq!( + rules, + [ + rule(FilterOp::Eq, Some("Ada")), + rule(FilterOp::NotEq, Some("Ada")), + rule(FilterOp::IsNull, None), + rule(FilterOp::IsNotNull, None), + rule(FilterOp::Eq, Some(&long)), + ] + ); +} + +#[gtk4::test] +fn a_results_grid_offers_no_filter_by_value() { + let grid = Grid::build(GridSpec::results(two_text_columns()), results(&two_text_values())); + + grid.grid.open_cell_menu(&grid.cell_at(0, 1), None); + let offered = (grid.is_enabled("filter-by-value"), grid.is_enabled("exclude-value")); + if let Some(menus) = grid.grid.menus() { + menus.cell.popdown(); + } + + assert_eq!(offered, (false, false)); +} + +/// CQL matches a text column by equality but has no `!=` and no NULL test. +#[gtk4::test] +fn filter_by_value_offers_only_what_the_engine_filters() { + let cassandra = + tablepro_driver_cassandra::CqlDialect::new(&tablepro_core::ServerInfo::new("Cassandra", "5.0.9", 5, 0, 9)); + let grid = sql_grid( + two_text_columns(), + vec![ + vec![Value::Int(1), Value::Text("Ada".into())], + vec![Value::Int(2), Value::Null], + ], + Arc::new(cassandra), + ); + let offered = |id: i64| { + grid.grid.open_cell_menu(&grid.cell_of(id, 1), None); + let offered = (grid.is_enabled("filter-by-value"), grid.is_enabled("exclude-value")); + if let Some(menus) = grid.grid.menus() { + menus.cell.popdown(); + } + offered + }; + + assert_eq!(offered(1), (true, false)); + assert_eq!(offered(2), (false, false)); +} + +#[gtk4::test] +fn a_page_that_lands_under_an_open_menu_leaves_its_items_acting_on_nothing() { + let grid = Grid::open(two_text_columns(), two_text_values()); + let clipboard = grid.grid.clipboard(); + clipboard.set_text(BEFORE); + let actions = [ + "copy", + "copy-rows", + "copy-column-name", + "show-row-json", + "filter-by-value", + ]; + grid.grid.open_cell_menu(&grid.cell_of(2, 1), None); + let before: Vec = actions.iter().map(|action| grid.is_enabled(action)).collect(); + + grid.grid.replace_rows(page(3..5), 0); + drain_main_context(); + let after: Vec = actions.iter().map(|action| grid.is_enabled(action)).collect(); + for action in actions { + activate(&grid.grid, action); + } + if let Some(menus) = grid.grid.menus() { + menus.cell.popdown(); + } + drain_main_context(); + + assert_eq!(before, [true; 5]); + assert_eq!(after, [false; 5]); + let acted = grid + .take_requests() + .into_iter() + .any(|request| matches!(request, GridRequest::Toast(_) | GridRequest::FilterByValue(_))); + assert!(!acted, "the menu acted on a row of the new page"); + assert_eq!(local_text(&clipboard).as_deref(), Some(BEFORE)); +} + +/// The list hands its cell widgets on to other rows as it scrolls, all but +/// the focused one, which the right click moved to the clicked cell. +#[gtk4::test] +fn a_scroll_under_an_open_menu_leaves_it_on_its_row() { + let grid = Grid::build(GridSpec::table(two_text_columns()), page(0..300)); + let clicked = grid.cell_of(6, 1); + let below = grid.cell_of(7, 1); + grid.grid.open_cell_menu(&clicked, None); + + grid.grid + .column_view() + .scroll_to(299, None, gtk::ListScrollFlags::NONE, None); + wait_until(WAIT, || below.row().is_some_and(|row| row.key() != Some(key(7)))) + .expect("the scroll did not rebind the cells it left"); + let text = copied(&grid, || activate(&grid.grid, "copy")); + if let Some(menus) = grid.grid.menus() { + menus.cell.popdown(); + } + + assert_eq!(clicked.row().and_then(|row| row.key()), Some(key(6))); + assert_eq!(text, "row 6"); +} + +#[gtk4::test] +fn copy_column_name_takes_the_menus_column_or_the_headers() { + let grid = Grid::open(two_text_columns(), two_text_values()); + + let from_cell = copied(&grid, || from_menu(&grid.grid, &grid.cell_of(1, 1), "copy-column-name")); + let from_header = copied(&grid, || { + grid.grid + .activate_action("grid.copy-column-name-at", Some(&0u32.to_variant())) + .expect("the header's Copy Column Name"); + }); + + assert_eq!((from_cell.as_str(), from_header.as_str()), ("name", "id")); +} + +#[gtk4::test] +fn show_row_json_shows_the_row_under_the_columns_on_screen() { + adw::init().expect("libadwaita initialises under the test backend"); + let grid = Grid::open(people(), people_rows()); + grid.grid.set_column_visible(2, false); + grid.grid.apply_delta(&rename(&grid.tracker, 1, "Ada", "Ada Lovelace")); + + from_menu(&grid.grid, &grid.cell_of(1, 0), "show-row-json"); + let text = |view: &sourceview5::View| { + let buffer = view.buffer(); + buffer.text(&buffer.start_iter(), &buffer.end_iter(), false).to_string() + }; + let mut shown = None; + wait_until(WAIT, || { + shown = gtk::Window::list_toplevels() + .into_iter() + .flat_map(|window| descendants(&window)) + .filter_map(|widget| widget.downcast::().ok()) + .find(|view| text(view).contains("Ada Lovelace")); + shown.is_some() + }) + .expect("the row's JSON is shown"); + let view = shown.expect("the JSON view"); + let json = text(&view); + if let Some(dialog) = view.ancestor(adw::Dialog::static_type()).and_downcast::() { + dialog.force_close(); + } + + assert!(json.contains("\"Ada Lovelace\""), "{json}"); + assert!(json.contains("\"id\": 1"), "{json}"); + assert!(!json.contains("age"), "a hidden column was shown: {json}"); +} + +/// A table grid that writes SQL: Copy As INSERT and IN Clause need the +/// connection's dialect and the table. +fn sql_grid(columns: Vec, rows: Vec>, dialect: Arc) -> Grid { + Grid::build( + GridSpec { + dialect: Some(Rc::new(move || Some(dialect.clone()))), + table: Some(TableRef::new(None, "users")), + ..GridSpec::table(columns) + }, + persisted(&rows), + ) +} + +fn select_all(grid: &Grid) { + gtk::prelude::GtkWindowExt::set_focus(&grid.window, None::<>k::Widget>); + grid.grid.selection().select_all(); + drain_main_context(); +} + +fn mysql() -> Arc { + Arc::new(tablepro_driver_mysql::MySqlDialect::new( + tablepro_driver_mysql::MySqlFlavour::MySql, + &tablepro_core::ServerInfo::new("MySQL", "8.4.3", 8, 4, 3), + )) +} + +#[gtk4::test] +fn copy_as_insert_writes_the_columns_on_screen_with_pending_edits() { + let grid = sql_grid(people(), people_rows(), Arc::new(FakeDialect)); + grid.grid.set_column_visible(2, false); + grid.grid + .apply_delta(&rename(&grid.tracker, 2, "Grace", "Grace Hopper")); + select_all(&grid); + + let text = copied(&grid, || activate(&grid.grid, "copy-insert")); + + assert_eq!( + text, + "INSERT INTO \"users\" (\"id\", \"name\") VALUES (1, 'Ada');\n\ + INSERT INTO \"users\" (\"id\", \"name\") VALUES (2, 'Grace Hopper');" + ); +} + +#[gtk4::test] +fn copy_as_insert_on_a_draft_leaves_what_the_server_fills_out() { + let mut id = named("id", "integer", true); + id.is_auto_increment = true; + let columns = vec![ + id, + named("name", "text", false), + named("created_at", "timestamptz", false), + ]; + let grid = sql_grid( + columns, + vec![vec![Value::Int(1), Value::Text("Ada".into()), Value::Null]], + Arc::new(FakeDialect), + ); + let delta = grid.tracker.apply(TrackerEdit::InsertDrafts { + rows: vec![vec![ + CellInput::Default, + CellInput::Value(Value::Text("x".into())), + CellInput::Default, + ]], + }); + grid.grid.apply_delta(&delta); + let mut draft = None; + wait_until(WAIT, || { + draft = grid + .find_cell(0, 1) + .filter(|cell| cell.row().is_some_and(|row| row.draft_id().is_some())); + draft.is_some() + }) + .expect("the draft goes on top"); + let draft = draft.expect("the draft's cell"); + + let text = copied(&grid, || from_menu(&grid.grid, &draft, "copy-insert")); + + assert_eq!(text, "INSERT INTO \"users\" (\"name\") VALUES ('x');"); +} + +#[gtk4::test] +fn copy_as_insert_is_off_in_a_grid_with_no_table_to_write_into() { + let table = Grid::open(two_text_columns(), two_text_values()); + let read_only = Grid::build(GridSpec::results(two_text_columns()), results(&two_text_values())); + + for grid in [&table, &read_only] { + grid.grid.selection().select_all(); + } + + assert!(!table.is_enabled("copy-insert"), "a grid with no dialect or table"); + assert!(!read_only.is_enabled("copy-insert")); +} + +/// MySQL reads a backslash in a string as an escape, so the list is +/// written in the server's own literals. +#[gtk4::test] +fn copy_as_in_clause_writes_the_servers_literals() { + let injected = "\\') OR 1=1 -- "; + let grid = sql_grid( + two_text_columns(), + vec![ + vec![Value::Int(1), Value::Text(injected.into())], + vec![Value::Int(2), Value::Null], + ], + mysql(), + ); + select_all(&grid); + let cell = grid.cell_at(0, 1); + + let text = copied(&grid, || from_menu(&grid.grid, &cell, "copy-in-clause")); + + assert_eq!( + text, + format!( + "(_utf8mb4 X'{}')", + tablepro_core::hex::encode_lower(injected.as_bytes()) + ) + ); +} + +#[gtk4::test] +fn in_clause_is_offered_only_for_a_dialect_that_speaks_sql() { + let mongo = sql_grid( + two_text_columns(), + two_text_values(), + Arc::new(tablepro_driver_mongodb::MongoDialect), + ); + let none = Grid::build(GridSpec::results(two_text_columns()), results(&two_text_values())); + let sql = sql_grid(two_text_columns(), two_text_values(), Arc::new(FakeDialect)); + let offered = |grid: &DataGrid, cell: &GridCell| { + grid.open_cell_menu(cell, None); + let enabled = grid + .actions() + .lookup_action("copy-in-clause") + .is_some_and(|action| action.is_enabled()); + if let Some(menus) = grid.menus() { + menus.cell.popdown(); + } + drain_main_context(); + enabled + }; + + let in_mongo = offered(&mongo.grid, &mongo.cell_at(0, 1)); + let in_results = offered(&none.grid, &none.cell_at(0, 1)); + let in_sql = offered(&sql.grid, &sql.cell_at(0, 1)); + + assert_eq!((in_mongo, in_results, in_sql), (false, false, true)); +} diff --git a/linux/crates/app/src/ui/data_grid/edit/actions.rs b/linux/crates/app/src/ui/data_grid/edit/actions.rs new file mode 100644 index 0000000000..5571d2f97c --- /dev/null +++ b/linux/crates/app/src/ui/data_grid/edit/actions.rs @@ -0,0 +1,97 @@ +use super::{EditController, cell_widget}; +use crate::services::grid::{ + CellEditorKind, CellPreset, CellPresets, DeleteAvailability, GridRowId, cell_presets, delete_availability, +}; +use crate::ui::data_grid::grid_actions::{add, enable}; +use crate::ui::data_grid::selection::Resolved; +use crate::ui::data_grid::{DataGrid, GridRow}; + +/// Installed on every grid, so a key that names one finds it; a grid +/// that edits nothing keeps them off. +pub(super) fn install(grid: &DataGrid) { + add(grid, "edit", |grid| { + if let Some((row, column)) = grid.target().and_then(|target| target.cell) + && let Some(cell) = cell_widget(grid, &row, column) + { + grid.edit().edit_cell(&cell); + } + }); + add(grid, "toggle", |grid| grid.edit().toggle_target(grid)); + add(grid, "set-empty", |grid| { + grid.edit().set_preset(grid, CellPreset::Empty) + }); + add(grid, "set-null", |grid| grid.edit().set_preset(grid, CellPreset::Null)); + add(grid, "set-default", |grid| { + grid.edit().set_preset(grid, CellPreset::Default) + }); + add(grid, "insert-row", |grid| grid.edit().insert_row(grid)); + add(grid, "duplicate-rows", |grid| grid.edit().duplicate_rows(grid)); + add(grid, "delete-rows", |grid| grid.edit().delete_rows(grid)); + add(grid, "paste", |grid| grid.edit().paste(grid)); +} + +const ACTIONS: [&str; 9] = [ + "edit", + "toggle", + "set-empty", + "set-null", + "set-default", + "insert-row", + "duplicate-rows", + "delete-rows", + "paste", +]; + +pub(super) fn refresh(controller: &EditController, grid: &DataGrid, target: Option<&Resolved>) { + let Some(policy) = controller.policy() else { + for name in ACTIONS { + enable(grid, name, false); + } + return; + }; + let cell = match policy.cells { + Ok(()) => target.and_then(|target| target.cell.as_ref()), + Err(_) => None, + }; + let (kind, presets) = cell.map_or((None, CellPresets::default()), |(row, column)| { + cell_state(controller, grid, row, *column) + }); + // The capture-phase keys a focused editor would lose to the grid. + let typing = controller.editor_has_focus(); + let edits = !typing + && matches!( + kind, + Some(CellEditorKind::Inline { .. } | CellEditorKind::Toggle | CellEditorKind::Details(_)) + ); + enable(grid, "edit", edits); + enable(grid, "toggle", !typing && kind == Some(CellEditorKind::Toggle)); + enable(grid, "set-empty", !typing && presets.empty); + enable(grid, "set-null", !typing && presets.null); + enable(grid, "set-default", !typing && presets.default); + let rows: &[GridRow] = target.map_or(&[], |target| target.rows.as_slice()); + enable(grid, "insert-row", policy.insert.is_ok()); + enable(grid, "duplicate-rows", policy.duplicate.is_ok() && !rows.is_empty()); + let ids: Vec = rows.iter().map(GridRow::id).collect(); + let deletes = policy.delete.is_ok() && delete_availability(&ids, false) == DeleteAvailability::Available; + enable(grid, "delete-rows", deletes); + enable(grid, "paste", policy.paste.is_ok()); +} + +/// The cell's editor and the presets it takes, from its lock now. +fn cell_state( + controller: &EditController, + grid: &DataGrid, + row: &GridRow, + column: usize, +) -> (Option, CellPresets) { + let Some(grid_column) = grid.grid_columns().get(column).cloned() else { + return (None, CellPresets::default()); + }; + let lock = controller.tracked().lock(&grid_column, row); + let kind = CellEditorKind::for_cell(&grid_column, lock.map_or(Ok(()), Err)); + let presets = match lock { + None => cell_presets(&grid_column, &row.id()), + Some(_) => CellPresets::default(), + }; + (Some(kind), presets) +} diff --git a/linux/crates/app/src/ui/data_grid/edit/cell_editor.rs b/linux/crates/app/src/ui/data_grid/edit/cell_editor.rs new file mode 100644 index 0000000000..e96b245adf --- /dev/null +++ b/linux/crates/app/src/ui/data_grid/edit/cell_editor.rs @@ -0,0 +1,627 @@ +use std::cell::{Cell, RefCell}; +use std::rc::Rc; + +use gtk4::prelude::*; +use gtk4::{self as gtk, gdk, glib}; +use tablepro_core::edit::{CellInput, EditParseError, EditText, edit_text}; + +use super::{EditController, edit_start, ticket_after}; +use crate::application::component_matches; +use crate::i18n::{gettext, gettext_f}; +use crate::services::grid::{ + CellEditorKind, CommitResult, EDIT_MAX_BYTES, EditDirection, EditStart, GridColumn, TextPurpose, commit_edit, + empty_placeholder, next_edit_target, +}; +use crate::ui::data_grid::grid_cell::holds_window_focus; +use crate::ui::data_grid::{DataGrid, GridCell, GridRow, PinnedRow}; +use crate::ui::error_text::edit_parse_message; + +/// The inline editor open in a cell, and the edit it holds. The edit +/// names its row by a pin, so it commits to the row it opened on +/// whatever the cell shows by then. +#[derive(Debug)] +pub(super) struct OpenEdit { + id: u64, + cell: glib::WeakRef, + row: PinnedRow, + column: usize, + start: EditStart, + text: gtk::Text, + /// An input method is composing in the field. + composing: Cell, + /// The focus left mid-composition, so the commit waits for the end. + commit_when_composed: Cell, +} + +/// What editing a cell comes to. +pub(super) enum Plan { + Inline(Box), + Toggle, + /// The value is edited in the detail pane. + Details, + /// Nothing opens, for this reason. + Refused(String), + Locked, +} + +pub(super) struct InlineStart { + column: GridColumn, + row: GridRow, + start: EditStart, + text: String, + accepts_line_breaks: bool, + purpose: TextPurpose, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Opened { + /// The whole value, selected, so the first key replaces it. + ToEdit, + /// Empty, for the key that opened it to land in. + ByTyping, +} + +/// How a commit treats a value the column refuses. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Refusal { + /// The editor stays open with the text (D7). + Keep, + /// Before a reload, a save or a close: the value was never pending, + /// so it goes. + Drop, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum CloseWhen { + Now, + /// Hidden now, out of the cell once the main loop is idle. GTK 4.22 + /// ends a focus change by walking up from the widget that lost the + /// focus and loops forever when that widget has no parent by then. + AfterFocusChange, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Ended { + Closed, + Kept, + Nothing, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum EditorKey { + Cancel, + /// Commit, then edit the next editable cell that way. + Move(EditDirection), +} + +impl EditController { + /// Edit `cell` the way its column and value call for. `false` for a + /// locked cell, whose value the caller shows in the detail pane. + pub fn edit_cell(&self, cell: &GridCell) -> bool { + let (Some(grid), Some(row)) = (self.grid.upgrade(), cell.row()) else { + return false; + }; + if let Some(edit) = self.open_in(cell) { + edit.text.grab_focus(); + return true; + } + match self.plan(&grid, &row, cell.column()) { + Plan::Locked => false, + Plan::Refused(why) => { + grid.toast(why); + true + } + Plan::Toggle => { + self.toggle(&grid, &row, cell.column()); + true + } + Plan::Details => { + grid.show_details_for(cell.column()); + true + } + Plan::Inline(start) => { + self.open_inline(&grid, cell, start, Opened::ToEdit); + true + } + } + } + + /// Open an empty editor on the focused cell for a typed key, when + /// the cell edits inline. + pub fn open_for_typing(&self) -> Option { + let grid = self.grid.upgrade()?; + let cell = grid.focused_cell()?; + if self.open_in(&cell).is_some() { + return None; + } + match self.plan(&grid, &cell.row()?, cell.column()) { + Plan::Inline(start) => Some(self.open_inline(&grid, &cell, start, Opened::ByTyping)), + Plan::Refused(why) => { + grid.toast(why); + None + } + Plan::Toggle | Plan::Details | Plan::Locked => None, + } + } + + /// Commit the open edit, as Return does, before the grid writes its + /// slots or a save reads the tracker. + pub fn commit_open_edit(&self) { + let held = self.editor_has_focus(); + let cell = self.open_cell(); + if self.commit_open(Refusal::Drop, CloseWhen::Now) == Ended::Closed && held { + self.return_focus(cell.as_ref()); + } + } + + pub fn cancel_open_edit(&self) { + let held = self.editor_has_focus(); + let cell = self.open_cell(); + if self.cancel_open() && held { + self.return_focus(cell.as_ref()); + } + } + + /// A cell given another row while its editor is open commits the + /// edit to the row it opened on. + pub fn cell_unbinding(&self, cell: &GridCell) { + if self.open_in(cell).is_some() { + self.commit_open(Refusal::Drop, CloseWhen::Now); + } + } + + /// GTK detaches a removed row before it unbinds it, and moves a focus + /// left inside only after the next frame; a window focus-in between + /// the two crashes GTK 4.22 on the detached row. So the focus goes + /// before the edit ends, and the grid takes it back after the frame. + pub fn cell_detaching(&self, cell: &GridCell) { + let window = cell.root().and_downcast::(); + let held = window + .as_ref() + .and_then(gtk::prelude::GtkWindowExt::focus) + .is_some_and(|focus| row_holds(cell, &focus)); + let editing = self.open_in(cell).is_some(); + if !held && !editing { + return; + } + // Out of the slot first, so the focus leaving finds no edit to + // commit on its own. + let edit = editing.then(|| self.open.take()).flatten(); + if let Some(window) = window.as_ref().filter(|_| held) { + gtk::prelude::GtkWindowExt::set_focus(window, None::<>k::Widget>); + } + if let Some(edit) = edit { + self.open.replace(Some(edit)); + // Not out of a cell GTK is unrooting: the field goes once + // the unroot is over. + self.commit_open(Refusal::Drop, CloseWhen::AfterFocusChange); + } + if let (Some(window), true, Some(grid)) = (window, held, self.grid.upgrade()) { + grab_after_paint(&window, &grid.column_view()); + } + } + + pub(super) fn plan(&self, grid: &DataGrid, row: &GridRow, index: usize) -> Plan { + let (Some(column), Some(policy)) = (grid.grid_columns().get(index).cloned(), self.policy()) else { + return Plan::Locked; + }; + if policy.cells.is_err() { + return Plan::Locked; + } + let tracked = self.tracked(); + let kind = CellEditorKind::for_cell(&column, tracked.lock(&column, row).map_or(Ok(()), Err)); + let (accepts_line_breaks, purpose) = match kind { + CellEditorKind::ReadOnly(_) => return Plan::Locked, + CellEditorKind::Toggle => return Plan::Toggle, + CellEditorKind::Details(_) => return Plan::Details, + CellEditorKind::Inline { + accepts_line_breaks, + purpose, + } => (accepts_line_breaks, purpose), + }; + let start = edit_start(&tracked, row, index); + // From the whole value with any pending edit, never from the text + // the cell draws, which is cut short or stands in for the value. + let text = match &start.original { + CellInput::Value(value) => edit_text(value, column.value_type()), + CellInput::Default => EditText::Editable(String::new()), + }; + match &text { + EditText::ReadOnly(reason) => { + return Plan::Refused(edit_parse_message(&EditParseError::NotEditable(*reason))); + } + // A GTK editor holds text only up to a NUL, and committing it + // would cut the value there. + EditText::Editable(editable) if editable.contains('\0') => { + return Plan::Refused(gettext("Values with a NUL character can't be edited here")); + } + EditText::Editable(editable) if editable.len() > EDIT_MAX_BYTES => { + return Plan::Refused(too_large_to_edit()); + } + EditText::Editable(_) => {} + } + if !matches!(kind.for_text(&text), CellEditorKind::Inline { .. }) { + return Plan::Details; + } + let EditText::Editable(text) = text else { + return Plan::Locked; + }; + Plan::Inline(Box::new(InlineStart { + column, + row: row.clone(), + start, + text, + accepts_line_breaks, + purpose, + })) + } + + fn open_inline(&self, grid: &DataGrid, cell: &GridCell, plan: Box, opened: Opened) -> gtk::Text { + self.commit_open(Refusal::Drop, CloseWhen::Now); + let text = gtk::Text::new(); + text.set_hexpand(true); + text.set_truncate_multiline(!plan.accepts_line_breaks); + text.set_input_purpose(input_purpose(plan.purpose)); + text.set_placeholder_text(empty_placeholder(&plan.column, &plan.start.row).as_deref()); + if opened == Opened::ToEdit { + text.set_text(&plan.text); + } + // The header that names the column takes no focus, and Tab moves + // an edit across columns. + text.update_property(&[gtk::accessible::Property::Label(&plan.column.name)]); + cell.open_editor(&text); + let id = ticket_after(&self.edits_opened); + self.open.replace(Some(Rc::new(OpenEdit { + id, + cell: cell.downgrade(), + row: plan.row.pin(), + column: plan.column.index, + start: plan.start, + text: text.clone(), + composing: Cell::new(false), + commit_when_composed: Cell::new(false), + }))); + connect(grid, &text, id); + text.grab_focus(); + if opened == Opened::ToEdit { + text.select_region(0, -1); + } + grid.set_editing(true); + text + } + + /// Commit the open edit. `Kept` when the column refused the value and + /// the editor stays open with it. + fn commit_open(&self, refusal: Refusal, close: CloseWhen) -> Ended { + let Some(edit) = self.open.take() else { + return Ended::Nothing; + }; + let Some(grid) = self.grid.upgrade() else { + return Ended::Nothing; + }; + let typed = edit.text.text(); + let result = self.evaluate(&grid, &edit.row, edit.column, &edit.start, typed.as_str()); + if let Some(CommitResult::Rejected(error)) = &result + && refusal == Refusal::Keep + { + edit.text.add_css_class("error"); + self.open.replace(Some(edit)); + grid.toast(edit_parse_message(error)); + return Ended::Kept; + } + self.close(&grid, &edit, close); + match result { + Some(CommitResult::Edit(change)) => self.apply_edit(&grid, change), + Some(CommitResult::Rejected(error)) => grid.toast(edit_parse_message(&error)), + Some(CommitResult::Unchanged) | None => {} + } + Ended::Closed + } + + fn cancel_open(&self) -> bool { + let Some(edit) = self.open.take() else { + return false; + }; + if let Some(grid) = self.grid.upgrade() { + self.close(&grid, &edit, CloseWhen::Now); + } + true + } + + fn close(&self, grid: &DataGrid, edit: &OpenEdit, when: CloseWhen) { + edit.text.remove_css_class("error"); + if let Some(cell) = edit.cell.upgrade() { + match when { + CloseWhen::Now => cell.close_editor(), + CloseWhen::AfterFocusChange => { + edit.text.set_visible(false); + if let Some(display) = cell.display() { + display.set_visible(true); + } + close_when_idle(grid, &cell); + } + } + } + grid.set_editing(false); + } + + /// The lock is read again: a key or a late event can reach a cell + /// locked since its edit began. + pub(super) fn evaluate( + &self, + grid: &DataGrid, + row: &PinnedRow, + column: usize, + start: &EditStart, + typed: &str, + ) -> Option { + let row = row.get()?; + let column = grid.grid_columns().get(column).cloned()?; + let locked = self.tracked().lock(&column, row).is_some() || self.policy()?.cells.is_err(); + if locked { + tracing::debug!(column = column.index, "refused an edit to a locked cell"); + return None; + } + Some(commit_edit(&column, start, typed)) + } + + fn current(&self, id: u64) -> Option> { + self.open.borrow().as_ref().filter(|edit| edit.id == id).cloned() + } + + fn open_in(&self, cell: &GridCell) -> Option> { + self.open + .borrow() + .as_ref() + .filter(|edit| edit.cell.upgrade().as_ref() == Some(cell)) + .cloned() + } + + fn open_cell(&self) -> Option { + self.open.borrow().as_ref().and_then(|edit| edit.cell.upgrade()) + } + + /// The keys a focused cell takes in the capture phase stay off while + /// the editor has the focus, so the editor gets them. + pub(crate) fn editor_has_focus(&self) -> bool { + self.open + .borrow() + .as_ref() + .is_some_and(|edit| holds_window_focus(edit.text.upcast_ref())) + } + + fn return_focus(&self, cell: Option<&GridCell>) { + if let (Some(grid), Some(cell)) = (self.grid.upgrade(), cell) { + grid.return_focus(cell); + } + } + + fn on_activate(&self, id: u64) { + let Some(edit) = self.current(id) else { + return; + }; + if self.commit_open(Refusal::Keep, CloseWhen::Now) == Ended::Closed { + self.return_focus(edit.cell.upgrade().as_ref()); + } + } + + fn on_key(&self, id: u64, keyval: gdk::Key, state: gdk::ModifierType) -> glib::Propagation { + let Some(edit) = self.current(id) else { + return glib::Propagation::Proceed; + }; + // While a preedit is open, both keys are the input method's. + if edit.composing.get() { + return glib::Propagation::Proceed; + } + match editor_key(keyval, state) { + Some(EditorKey::Cancel) => { + self.cancel_open(); + self.return_focus(edit.cell.upgrade().as_ref()); + } + Some(EditorKey::Move(direction)) => { + if self.commit_open(Refusal::Keep, CloseWhen::Now) == Ended::Closed { + self.edit_next(&edit, direction); + } + } + None => return glib::Propagation::Proceed, + } + glib::Propagation::Stop + } + + /// Focus moving to an input method's popover ends the edit mid + /// composition, and committing then would send the text from before + /// it, so the commit waits for the preedit to clear. + fn on_preedit(&self, id: u64, preedit: &str) { + let Some(edit) = self.current(id) else { + return; + }; + edit.composing.set(!preedit.is_empty()); + if preedit.is_empty() && edit.commit_when_composed.replace(false) { + self.commit_open(Refusal::Keep, CloseWhen::Now); + } + } + + fn on_focus_leave(&self, id: u64, field: >k::Widget) { + let Some(edit) = self.current(id) else { + return; + }; + // Another window taking the focus leaves it in the field, so the + // edit stays open for when the window is back. A focus moving + // within the window is unset while the leave runs. + if holds_window_focus(field) { + return; + } + if edit.composing.get() { + edit.commit_when_composed.set(true); + return; + } + self.commit_open(Refusal::Keep, CloseWhen::AfterFocusChange); + } + + /// Edit the cell Tab or Shift+Tab goes to. With none left on the + /// page, the focus stays where the edit was. + fn edit_next(&self, edit: &OpenEdit, direction: EditDirection) { + let (Some(grid), Some(row)) = (self.grid.upgrade(), edit.row.get().cloned()) else { + return; + }; + let Some(position) = grid.view_position(&row) else { + return; + }; + let shown = grid.shown_columns(); + let Some(from) = shown.iter().position(|column| *column == edit.column) else { + return; + }; + let selection = grid.selection(); + let opens_inline = |position: u32, at: usize| { + shown + .get(at) + .zip(selection.item(position).and_downcast::()) + .is_some_and(|(column, row)| matches!(self.plan(&grid, &row, *column), Plan::Inline(_))) + }; + let target = next_edit_target( + opens_inline, + shown.len(), + from, + position, + selection.n_items(), + direction, + ) + .and_then(|(position, at)| Some((position, *shown.get(at)?))); + match target { + Some((position, column)) => { + if let Some(cell) = grid.focus_cell(position, column) { + self.edit_cell(&cell); + } + } + None => { + grid.focus_cell(position, edit.column); + } + } + } + + #[cfg(test)] + pub(crate) fn open_text(&self) -> Option { + self.open.borrow().as_ref().map(|edit| edit.text.clone()) + } + + #[cfg(test)] + pub(crate) fn editing_cell(&self) -> Option { + self.open_cell() + } +} + +/// The field is the cell's child, so its handlers hold the grid weakly +/// and find the edit by its number. +fn connect(grid: &DataGrid, text: >k::Text, id: u64) { + let weak = grid.downgrade(); + text.connect_activate(move |_| { + if let Some(grid) = weak.upgrade() { + grid.edit().on_activate(id); + } + }); + text.connect_changed(|text| text.remove_css_class("error")); + // Capture phase, so Escape cancels before the GtkText takes it to + // clear its own buffer, and Tab commits before the window moves the + // focus. + let keys = gtk::EventControllerKey::new(); + keys.set_propagation_phase(gtk::PropagationPhase::Capture); + let weak = grid.downgrade(); + keys.connect_key_pressed(move |_, keyval, _, state| match weak.upgrade() { + Some(grid) => grid.edit().on_key(id, keyval, state), + None => glib::Propagation::Proceed, + }); + text.add_controller(keys); + let weak = grid.downgrade(); + text.connect_preedit_changed(move |_, preedit| { + if let Some(grid) = weak.upgrade() { + grid.edit().on_preedit(id, preedit); + } + }); + let focus = gtk::EventControllerFocus::new(); + let weak = grid.downgrade(); + focus.connect_leave(move |focus| { + if let (Some(grid), Some(field)) = (weak.upgrade(), focus.widget()) { + grid.edit().on_focus_leave(id, &field); + } + }); + text.add_controller(focus); +} + +/// The keys an open editor reads itself, to leave them to an open +/// preedit. GDK sends Shift+Tab as ISO_Left_Tab, which the table spells +/// as Tab with Shift. +pub(super) fn editor_key(keyval: gdk::Key, state: gdk::ModifierType) -> Option { + let (keyval, state) = match keyval { + gdk::Key::ISO_Left_Tab => (gdk::Key::Tab, state | gdk::ModifierType::SHIFT_MASK), + gdk::Key::KP_Tab => (gdk::Key::Tab, state), + other => (other, state), + }; + if component_matches("cell-editor.cancel", keyval, state) { + return Some(EditorKey::Cancel); + } + if component_matches("cell-editor.next", keyval, state) { + return Some(EditorKey::Move(EditDirection::Forward)); + } + if component_matches("cell-editor.previous", keyval, state) { + return Some(EditorKey::Move(EditDirection::Backward)); + } + None +} + +fn input_purpose(purpose: TextPurpose) -> gtk::InputPurpose { + match purpose { + TextPurpose::FreeForm => gtk::InputPurpose::FreeForm, + TextPurpose::Number => gtk::InputPurpose::Number, + TextPurpose::Digits => gtk::InputPurpose::Digits, + } +} + +fn too_large_to_edit() -> String { + let size = glib::format_size(EDIT_MAX_BYTES as u64); + gettext_f("Values over {size} can't be edited here", &[("size", size.as_str())]) +} + +/// A field that closed from its focus-leave leaves the cell once the +/// focus change is over, unless an edit opened in the cell since. +fn close_when_idle(grid: &DataGrid, cell: &GridCell) { + let (grid, cell) = (grid.downgrade(), cell.downgrade()); + glib::idle_add_local_once(move || { + if let (Some(grid), Some(cell)) = (grid.upgrade(), cell.upgrade()) + && grid.edit().open_in(&cell).is_none() + && cell.is_editing() + { + cell.close_editor(); + } + }); +} + +/// Whether `focus` is in `cell`'s row: in the cell or its editor, or on +/// the list's own cell or row widget above it, where the keyboard sits +/// while nothing is being edited. +fn row_holds(cell: &GridCell, focus: >k::Widget) -> bool { + if focus == cell.upcast_ref::() || focus.is_ancestor(cell) { + return true; + } + cell.ancestor(gtk::ListView::static_type()) + .is_some_and(|list| cell.is_ancestor(focus) && focus.is_ancestor(&list)) +} + +fn grab_after_paint(window: >k::Window, view: >k::ColumnView) { + let Some(clock) = window.frame_clock() else { + return; + }; + let view = view.downgrade(); + let handler: Rc>> = Rc::default(); + let slot = handler.clone(); + let id = clock.connect_after_paint(move |clock| { + if let Some(id) = slot.borrow_mut().take() { + clock.disconnect(id); + } + if let Some(view) = view.upgrade() + && view.root().is_some() + { + view.grab_focus(); + } + }); + handler.replace(Some(id)); +} diff --git a/linux/crates/app/src/ui/data_grid/edit/deletion.rs b/linux/crates/app/src/ui/data_grid/edit/deletion.rs new file mode 100644 index 0000000000..dece7a4f7a --- /dev/null +++ b/linux/crates/app/src/ui/data_grid/edit/deletion.rs @@ -0,0 +1,153 @@ +use std::sync::Arc; + +use libadwaita as adw; +use libadwaita::prelude::*; +use tablepro_core::{ColumnInfo, Value}; + +use super::{EditController, catalog_columns, on_answer}; +use crate::i18n::{gettext, ngettext_f}; +use crate::services::change_tracker::RowKey; +use crate::services::column_relayout::ColumnRelayout; +use crate::services::grid::{DeleteAvailability, GridRowId, delete_availability}; +use crate::services::tracker_edit::TrackerEdit; +use crate::ui::data_grid::{DataGrid, GridRow}; + +/// A Delete of this many rows asks first, while Preferences say so. +pub(crate) const BULK_DELETE_CONFIRM_THRESHOLD: usize = 10; + +/// The rows a Delete takes, each saved row with its cells as read. +#[derive(Debug, Clone, Default, PartialEq)] +pub(super) struct RowDeletion { + pub(super) persisted: Vec<(RowKey, Arc<[Value]>)>, + pub(super) drafts: Vec, +} + +impl RowDeletion { + /// `None` when a row has no key a DELETE could name it by. + pub(super) fn plan(rows: &[GridRow]) -> Option { + let mut deletion = Self::default(); + for row in rows { + match row.id() { + GridRowId::Draft(draft_id) => deletion.drafts.push(draft_id), + GridRowId::Persisted(key) => deletion + .persisted + .push((key, row.cells().unwrap_or_else(|| Arc::from([])))), + GridRowId::Unkeyed | GridRowId::Result => return None, + } + } + Some(deletion) + } + + pub(super) fn len(&self) -> usize { + self.persisted.len() + self.drafts.len() + } + + /// A draft goes at once with its undo history, so a set that holds + /// one cannot be described as waiting for Save. + pub(super) fn confirmation_body(&self) -> String { + match (self.persisted.is_empty(), self.drafts.is_empty()) { + (_, true) => gettext("These rows will be marked for deletion. They aren't removed until you click Save."), + (true, false) => { + gettext("These rows were never saved. They're removed now, and Undo won't bring them back.") + } + (false, false) => gettext( + "Saved rows will be marked for deletion and aren't removed until you click Save. Rows never saved are removed now, and Undo won't bring them back.", + ), + } + } +} + +impl EditController { + /// Mark saved rows for deletion and drop drafts. Nothing leaves the + /// table until Save. + pub(super) fn delete_rows(&self, grid: &DataGrid) { + if self.policy().is_none_or(|policy| policy.delete.is_err()) { + return; + } + let Some(target) = grid.target() else { + return; + }; + let ids: Vec = target.rows.iter().map(GridRow::id).collect(); + if delete_availability(&ids, false) != DeleteAvailability::Available { + return; + } + let Some(deletion) = RowDeletion::plan(&target.rows) else { + return; + }; + let asks = grid + .config() + .is_some_and(|config| config.settings.confirm_bulk_delete()); + match deletion.len() >= BULK_DELETE_CONFIRM_THRESHOLD && asks { + true => self.confirm_delete(grid, deletion), + false => self.mark_deleted(grid, deletion), + } + } + + fn mark_deleted(&self, grid: &DataGrid, deletion: RowDeletion) { + grid.commit_editors(); + self.apply_edit( + grid, + TrackerEdit::DeleteRows { + persisted: deletion.persisted, + drafts: deletion.drafts, + }, + ); + // A bulk action ends the selection it acted on. + grid.selection().unselect_all(); + } + + fn confirm_delete(&self, grid: &DataGrid, deletion: RowDeletion) { + let count = deletion.len(); + let title = ngettext_f( + "Delete {n} Row?", + "Delete {n} Rows?", + u32::try_from(count).unwrap_or(u32::MAX), + &[("n", &count.to_string())], + ); + let dialog = adw::AlertDialog::new(Some(&title), Some(&deletion.confirmation_body())); + dialog.add_response("cancel", &gettext("Cancel")); + dialog.add_response("delete", &gettext("Delete")); + dialog.set_response_appearance("delete", adw::ResponseAppearance::Destructive); + dialog.set_default_response(Some("cancel")); + dialog.set_close_response("cancel"); + let asked_under = catalog_columns(grid); + let ticket = self.ask(&dialog); + on_answer(grid, dialog, ticket, move |grid, response| { + if response == "delete" { + grid.edit().delete_confirmed(grid, deletion, asked_under.as_deref()); + } + }); + } + + /// The rows are keyed by the primary key of the columns they were + /// read under, so columns read while the question was open move them, + /// or cancel the delete when the key changed. + fn delete_confirmed(&self, grid: &DataGrid, mut deletion: RowDeletion, asked_under: Option<&[ColumnInfo]>) { + let current = catalog_columns(grid); + if let (Some(asked), Some(current)) = (asked_under, current.as_deref()) + && asked != current + { + let layout = ColumnRelayout::new(asked, current); + let moved: Option)>> = deletion + .persisted + .iter() + .map(|(key, values)| { + layout + .key(key) + .ok() + .map(|()| (key.clone(), layout.fetched(values).into())) + }) + .collect(); + match moved { + Some(rows) => deletion.persisted = rows, + None => { + grid.toast(gettext( + "The table's primary key changed, so no rows were marked for deletion. Select them again.", + )); + return; + } + } + } + self.mark_deleted(grid, deletion); + } +} diff --git a/linux/crates/app/src/ui/data_grid/edit/mod.rs b/linux/crates/app/src/ui/data_grid/edit/mod.rs new file mode 100644 index 0000000000..42fe0ad852 --- /dev/null +++ b/linux/crates/app/src/ui/data_grid/edit/mod.rs @@ -0,0 +1,280 @@ +//! Every change a grid makes to its rows: the inline editor, toggles, +//! presets, new and duplicated rows, deletes, pastes, undo and redo. +//! Each goes through the tracker, and each delta reaches the store here. + +mod actions; +mod cell_editor; +mod deletion; +mod paste; +mod row_edits; +#[cfg(test)] +mod tests; + +use std::cell::{Cell, RefCell}; +use std::rc::Rc; + +use gtk4 as gtk; +use gtk4::glib; +use gtk4::prelude::*; +use libadwaita as adw; +use libadwaita::prelude::*; +use tablepro_core::{ColumnInfo, Value}; + +use self::cell_editor::OpenEdit; +pub(crate) use self::deletion::BULK_DELETE_CONFIRM_THRESHOLD; +use super::selection::Resolved; +use super::{DataGrid, EditRules, GridCell, GridRequest, GridRow, PinnedRow, RowData, TrackedValues}; +use crate::i18n::gettext; +use crate::services::grid::{CommitResult, EditPolicy, EditRefusal, EditStart}; +use crate::services::tracker_delta::TrackerDelta; +use crate::services::tracker_edit::TrackerEdit; + +#[derive(Debug, Default)] +pub struct EditController { + grid: glib::WeakRef, + tracked: RefCell, + policy: Cell>, + open: RefCell>>, + edits_opened: Cell, + /// Only the latest paste lands: another program's clipboard can take + /// any time to answer, or never. + paste_ticket: Cell, + question: RefCell>, + questions_asked: Cell, + #[cfg(test)] + alerts: RefCell>, +} + +/// A bulk delete or a large paste waiting for the user's answer. +#[derive(Debug)] +struct Question { + ticket: u64, + dialog: adw::AlertDialog, +} + +impl EditController { + pub fn attach(&self, grid: &DataGrid) { + self.grid.set(Some(grid)); + } + + /// A grid edits only with a tracker and the rules of its column set. + pub fn set_rules(&self, grid: &DataGrid, rules: Option) { + let tracker = grid.config().and_then(|config| config.tracker.clone()); + let (tracked, policy) = match (tracker, rules) { + (Some(tracker), Some(rules)) => { + let read_only = rules.policy.cells == Err(EditRefusal::ReadOnly); + let tracked = TrackedValues::tracked(tracker, rules.identity).on_read_only_connection(read_only); + (tracked, Some(rules.policy)) + } + _ => (TrackedValues::untracked(), None), + }; + self.tracked.replace(tracked); + self.policy.set(policy); + } + + pub fn tracked(&self) -> TrackedValues { + self.tracked.borrow().clone() + } + + /// The tracker keeps drafts bottom up, and the newest shows on top. + pub fn draft_rows(&self) -> Vec { + self.tracked() + .tracker() + .map(|tracker| { + tracker + .drafts() + .iter() + .rev() + .map(|draft| RowData::draft(draft.draft_id)) + .collect() + }) + .unwrap_or_default() + } + + pub fn install_actions(&self, grid: &DataGrid) { + actions::install(grid); + } + + pub fn refresh_actions(&self, grid: &DataGrid, target: Option<&Resolved>) { + actions::refresh(self, grid, target); + } + + /// Bring the store in line with a tracker change: drafts come or go, + /// and each changed row draws again in its own slot. + pub fn apply_delta(&self, delta: &TrackerDelta) { + let Some(grid) = self.grid.upgrade() else { + return; + }; + if !delta.inserted_drafts.is_empty() || !delta.removed_drafts.is_empty() { + grid.commit_editors(); + } + let store = grid.store(); + store.remove_drafts(&delta.removed_drafts); + store.insert_drafts(&delta.inserted_drafts); + for key in &delta.changed { + if let Some(row) = store.row_for(key) { + row.bump_revision(); + } + } + grid.show_rows_or_empty(); + grid.refresh_actions(); + grid.request(GridRequest::PendingChanged); + } + + pub fn undo(&self) { + self.step(|tracked| tracked.tracker().and_then(|tracker| tracker.undo())); + } + + pub fn redo(&self) { + self.step(|tracked| tracked.tracker().and_then(|tracker| tracker.redo())); + } + + pub fn discard(&self) { + self.step(|tracked| tracked.tracker().map(|tracker| tracker.discard())); + } + + /// What was typed counts first, so an undo takes back the latest + /// change, typed text included. + fn step(&self, change: impl FnOnce(&TrackedValues) -> Option) { + let Some(grid) = self.grid.upgrade() else { + return; + }; + grid.commit_editors(); + if let Some(delta) = change(&self.tracked()) { + self.apply_delta(&delta); + } + } + + /// Commit `typed` as an edit of the cell `row` and `column` name, as + /// the detail pane's editor does. `None` when nothing was committed: + /// the row left its slot, or the cell is locked now. + pub fn commit_value(&self, row: &PinnedRow, column: usize, start: &EditStart, typed: &str) -> Option { + let grid = self.grid.upgrade()?; + let result = self.evaluate(&grid, row, column, start, typed)?; + if let CommitResult::Edit(edit) = &result { + self.apply_edit(&grid, edit.clone()); + } + Some(result) + } + + fn policy(&self) -> Option { + self.policy.get() + } + + /// Every tracker write but undo, redo and discard goes through here. + fn apply_edit(&self, grid: &DataGrid, edit: TrackerEdit) { + let Some(tracker) = self.tracked().tracker().cloned() else { + return; + }; + if tracker.refuses(&edit) { + grid.toast(gettext( + "This row is being saved. Change it again once the save finishes.", + )); + } + let delta = tracker.apply(edit); + self.apply_delta(&delta); + } + + /// Ask `dialog`, replacing a question still open. + fn ask(&self, dialog: &adw::AlertDialog) -> u64 { + let ticket = ticket_after(&self.questions_asked); + let previous = self.question.replace(Some(Question { + ticket, + dialog: dialog.clone(), + })); + if let Some(previous) = previous { + previous.dialog.force_close(); + } + ticket + } + + /// Whether `ticket` is still the open question, which it then no + /// longer is. + fn answered(&self, ticket: u64) -> bool { + let current = self + .question + .borrow() + .as_ref() + .is_some_and(|question| question.ticket == ticket); + if current { + self.question.take(); + } + current + } + + /// A dismiss-only alert, for what a toast is too short to say. + fn alert(&self, grid: &DataGrid, title: &str, body: &str) { + #[cfg(test)] + self.alerts.borrow_mut().push(format!("{title}: {body}")); + let dialog = adw::AlertDialog::new(Some(title), Some(body)); + dialog.add_response("close", &gettext("Close")); + dialog.set_default_response(Some("close")); + dialog.set_close_response("close"); + dialog.present(Some(grid)); + } + + #[cfg(test)] + pub(crate) fn question(&self) -> Option { + self.question.borrow().as_ref().map(|question| question.dialog.clone()) + } + + #[cfg(test)] + pub(crate) fn take_alerts(&self) -> Vec { + self.alerts.take() + } +} + +/// Each column's catalogue entry, in row order. `None` when one is +/// missing: a paste or a delete check needs every column. +fn catalog_columns(grid: &DataGrid) -> Option> { + grid.grid_columns() + .iter() + .map(|column| column.catalog.clone()) + .collect() +} + +/// The cell as it stands now, for an edit that begins now. +fn edit_start(tracked: &TrackedValues, row: &GridRow, column: usize) -> EditStart { + EditStart { + row: row.id(), + read: row + .cells() + .and_then(|cells| cells.get(column).cloned()) + .unwrap_or(Value::Null), + original: tracked.edit_input(row, column), + } +} + +/// The widget a cell action works on: the focused cell when it shows +/// `row` and `column`, else that cell, focused. +fn cell_widget(grid: &DataGrid, row: &GridRow, column: usize) -> Option { + if let Some(cell) = grid.focused_cell() + && cell.row().as_ref() == Some(row) + && cell.column() == column + { + return Some(cell); + } + grid.focus_cell(grid.view_position(row)?, column) +} + +fn ticket_after(counter: &Cell) -> u64 { + let ticket = counter.get().wrapping_add(1); + counter.set(ticket); + ticket +} + +/// Present `dialog` on the grid and hand its answer to `answer` once the +/// grid still holds the question. +fn on_answer(grid: &DataGrid, dialog: adw::AlertDialog, ticket: u64, answer: impl FnOnce(&DataGrid, &str) + 'static) { + let parent: gtk::Widget = grid.clone().upcast(); + let weak = grid.downgrade(); + glib::spawn_future_local(async move { + let response = crate::workspace::respond(&dialog, &parent).await; + drop(parent); + if let Some(grid) = weak.upgrade() + && grid.edit().answered(ticket) + { + answer(&grid, &response); + } + }); +} diff --git a/linux/crates/app/src/ui/data_grid/edit/paste.rs b/linux/crates/app/src/ui/data_grid/edit/paste.rs new file mode 100644 index 0000000000..a18803be4f --- /dev/null +++ b/linux/crates/app/src/ui/data_grid/edit/paste.rs @@ -0,0 +1,309 @@ +use gtk4::prelude::*; +use gtk4::{gdk, glib}; +use libadwaita as adw; +use libadwaita::prelude::*; +use tablepro_core::edit::{CellInput, EditParseError, parse_edit_text}; +use tablepro_core::import::{CsvRowError, ImportError, PastedRows, PastedTable}; +use tablepro_core::{ColumnInfo, Value}; + +use super::{EditController, catalog_columns, edit_start, on_answer, ticket_after}; +use crate::i18n::{gettext, gettext_f, ngettext_f}; +use crate::services::grid::{cell_presets, edit_context}; +use crate::ui::data_grid::{DataGrid, PinnedRow}; +use crate::ui::error_text::{edit_parse_message, row_error_message}; +use crate::ui::format::{format_thousands, plural_count}; + +/// A paste of more rows asks first: each becomes a pending row, and +/// Import CSV is the tool for a lot of data. +pub(crate) const PASTE_CONFIRM_ROWS: usize = 1_000; + +struct PasteRequest { + ticket: u64, + /// The cell the paste starts at: one value goes into it, and rows + /// fill the columns from its column on. + at: Option<(PinnedRow, usize)>, + columns: Vec, + /// The columns values fill one after another, by their place in a + /// row: as the grid shows them, from the one the paste starts at. + in_order: Vec, +} + +#[derive(Debug)] +enum PasteOutcome { + /// No text, or text with no rows in it. + Nothing, + /// One value alone, for the cell the paste started at. `None` is a + /// NULL. + Value(Option), + Rows(PastedRows), + Unreadable(ImportError), + Refused(CsvRowError), + /// Reading failed in a way the user can only retry. + Failed, + /// More than a paste reads at once. + TooLarge, +} + +/// The cells a grid copy offers beside its text, or text alone. +#[derive(Debug)] +enum PasteSource { + Cells(Vec), + Text(String), +} + +impl EditController { + pub(super) fn paste(&self, grid: &DataGrid) { + if self.policy().is_none_or(|policy| policy.paste.is_err()) { + return; + } + let (Some(columns), Some(config)) = (catalog_columns(grid), grid.config()) else { + tracing::debug!("a paste reached a grid with a column the catalogue does not know"); + return; + }; + if columns.is_empty() { + return; + } + let at = grid + .target() + .and_then(|target| target.cell) + .map(|(row, column)| (row.pin(), column)); + let in_order = columns_from(grid, at.as_ref().map(|(_, column)| *column)); + let request = PasteRequest { + ticket: ticket_after(&self.paste_ticket), + at, + columns, + in_order, + }; + let tasks = config.tasks.clone(); + let clipboard = grid.clipboard(); + let weak = grid.downgrade(); + glib::spawn_future_local(async move { + let outcome = match read_clipboard(&clipboard).await { + Ok(source) => { + let (columns, in_order, at_cell) = + (request.columns.clone(), request.in_order.clone(), request.at.is_some()); + tasks + .spawn_blocking_task(move || read_pasted(&source, &columns, &in_order, at_cell)) + .await + .unwrap_or_else(|failure| { + tracing::warn!(%failure, "pasted text could not be read"); + PasteOutcome::Failed + }) + } + Err(outcome) => outcome, + }; + if let Some(grid) = weak.upgrade() { + grid.edit().paste_read(&grid, request, outcome); + } + }); + } + + fn paste_read(&self, grid: &DataGrid, request: PasteRequest, outcome: PasteOutcome) { + if request.ticket != self.paste_ticket.get() { + return; + } + if catalog_columns(grid).as_ref() != Some(&request.columns) { + grid.toast(changed_while_pasting()); + return; + } + match outcome { + PasteOutcome::Nothing => grid.toast(gettext("The clipboard has no rows to paste.")), + PasteOutcome::Failed => grid.toast(gettext("Couldn't read the clipboard.")), + PasteOutcome::TooLarge => grid.toast(gettext( + "The clipboard holds too much to paste. Import CSV takes large amounts of data.", + )), + PasteOutcome::Unreadable(error) => self.alert(grid, &gettext("Couldn't Paste"), &unreadable_text(&error)), + PasteOutcome::Refused(error) => self.alert(grid, &gettext("Couldn't Paste"), &row_error_message(&error)), + PasteOutcome::Value(text) => match request.at { + Some(at) => self.paste_value(grid, at, text.as_deref()), + None => tracing::error!("a single value came back for a paste with no cell"), + }, + PasteOutcome::Rows(rows) if rows.rows.len() > PASTE_CONFIRM_ROWS => { + self.confirm_paste(grid, rows, request.columns); + } + PasteOutcome::Rows(rows) => self.insert_pasted(grid, rows), + } + } + + /// One value goes into the cell as if typed there, and a NULL as Set + /// to NULL puts it. + fn paste_value(&self, grid: &DataGrid, at: (PinnedRow, usize), text: Option<&str>) { + let (pinned, column) = at; + let Some(row) = pinned.get().cloned() else { + grid.toast(changed_while_pasting()); + return; + }; + let Some(grid_column) = grid.grid_columns().get(column).cloned() else { + return; + }; + if let Some(lock) = self.tracked().lock(&grid_column, &row) { + grid.toast(lock.explanation(edit_parse_message)); + return; + } + let Some(catalog) = grid_column.catalog.as_ref() else { + return; + }; + let input = match text { + None if cell_presets(&grid_column, &row.id()).null => Ok(CellInput::Value(Value::Null)), + None => Err(EditParseError::Required), + Some(text) => parse_edit_text(text, catalog, edit_context(&row.id())), + }; + match input { + Ok(input) => { + grid.commit_editors(); + let start = edit_start(&self.tracked(), &row, column); + self.commit_input(grid, &grid_column, &start, input); + } + Err(error) => grid.toast(edit_parse_message(&error)), + } + } + + fn insert_pasted(&self, grid: &DataGrid, pasted: PastedRows) { + let PastedRows { rows, skipped } = pasted; + let count = rows.len(); + if count == 0 { + grid.toast(gettext("The clipboard has no rows to paste.")); + return; + } + if self.insert_drafts(grid, rows).is_none() { + return; + } + self.select_top_rows_soon(grid, u32::try_from(count).unwrap_or(u32::MAX)); + let n = count as u64; + grid.toast(ngettext_f( + "Pasted {n} row", + "Pasted {n} rows", + plural_count(n), + &[("n", &format_thousands(n))], + )); + if skipped > 0 { + let n = skipped as u64; + grid.toast(ngettext_f( + "Left out {n} value no column could take", + "Left out {n} values no column could take", + plural_count(n), + &[("n", &format_thousands(n))], + )); + } + } + + fn confirm_paste(&self, grid: &DataGrid, rows: PastedRows, columns: Vec) { + let count = rows.rows.len() as u64; + let title = ngettext_f( + "Paste {n} Row?", + "Paste {n} Rows?", + plural_count(count), + &[("n", &format_thousands(count))], + ); + let body = gettext( + "Each one is added as a new row that waits until you save. Import CSV, in the table's menu in the sidebar, writes large amounts of data straight to the table.", + ); + let dialog = adw::AlertDialog::new(Some(&title), Some(&body)); + dialog.add_response("cancel", &gettext("Cancel")); + dialog.add_response("paste", &gettext("Paste")); + dialog.set_response_appearance("paste", adw::ResponseAppearance::Suggested); + dialog.set_default_response(Some("paste")); + dialog.set_close_response("cancel"); + let ticket = self.ask(&dialog); + on_answer(grid, dialog, ticket, move |grid, response| { + if response != "paste" { + return; + } + if catalog_columns(grid).as_ref() != Some(&columns) { + grid.toast(changed_while_pasting()); + return; + } + grid.edit().insert_pasted(grid, rows); + }); + } +} + +/// The columns as the grid shows them, left to right after any drag of +/// a header, from `start` on. +fn columns_from(grid: &DataGrid, start: Option) -> Vec { + let shown = grid.shown_columns(); + let first = start + .and_then(|start| shown.iter().position(|index| *index == start)) + .unwrap_or_default(); + shown.get(first..).unwrap_or_default().to_vec() +} + +/// What the clipboard holds: the cells a grid copy offers, which keep +/// each value and NULL as they were, or else its text. Either is read +/// only up to the import's limit, since another program's clipboard can +/// hold anything. +async fn read_clipboard(clipboard: &gdk::Clipboard) -> Result { + use crate::ui::clipboard::CELLS_MIME_TYPE; + use crate::ui::file_read::{FileReadError, read_capped}; + + let cells = clipboard.formats().contain_mime_type(CELLS_MIME_TYPE); + let wanted: &[&str] = match cells { + true => &[CELLS_MIME_TYPE], + false => &["text/plain;charset=utf-8", "text/plain"], + }; + let stream = match clipboard.read_future(wanted, glib::Priority::DEFAULT).await { + Ok((stream, _)) => stream, + Err(error) => { + tracing::debug!(%error, "the clipboard holds nothing to paste"); + return Err(PasteOutcome::Nothing); + } + }; + let bytes = match read_capped(&stream, tablepro_core::import::MAX_CSV_FILE_BYTES).await { + Ok(bytes) => bytes, + Err(FileReadError::TooLarge) => return Err(PasteOutcome::TooLarge), + Err(error) => { + tracing::warn!(?error, "the clipboard could not be read"); + return Err(PasteOutcome::Failed); + } + }; + match cells { + true => Ok(PasteSource::Cells(bytes)), + false => String::from_utf8(bytes).map(PasteSource::Text).map_err(|error| { + PasteOutcome::Unreadable(ImportError::NotUtf8 { + offset: error.utf8_error().valid_up_to(), + }) + }), + } +} + +/// Read what the clipboard held against `columns`. With a cell to start +/// at, one value alone is that cell's. +fn read_pasted(source: &PasteSource, columns: &[ColumnInfo], in_order: &[usize], at_cell: bool) -> PasteOutcome { + let read = match source { + PasteSource::Cells(json) => PastedTable::from_cells(json), + PasteSource::Text(text) => PastedTable::read(text), + }; + let table = match read { + Ok(table) => table, + Err(error) => return PasteOutcome::Unreadable(error), + }; + if table.is_empty() { + return PasteOutcome::Nothing; + } + if table.too_large_for(columns.len()) { + return PasteOutcome::TooLarge; + } + if at_cell && let Some(value) = table.single_value() { + return PasteOutcome::Value(value.map(str::to_owned)); + } + match table.rows(columns, in_order) { + Ok(rows) => PasteOutcome::Rows(rows), + Err(error) => PasteOutcome::Refused(error), + } +} + +fn changed_while_pasting() -> String { + gettext("The table changed while pasting. Paste again.") +} + +fn unreadable_text(error: &ImportError) -> String { + match error { + ImportError::TooManyFields { line, limit } => gettext_f( + "Line {line} of the pasted text has more than {limit} values, more than any table holds.", + &[("line", &line.to_string()), ("limit", &limit.to_string())], + ), + ImportError::NotUtf8 { .. } | ImportError::Malformed { .. } | ImportError::NoColumns => { + gettext("The pasted text couldn't be read as rows.") + } + } +} diff --git a/linux/crates/app/src/ui/data_grid/edit/row_edits.rs b/linux/crates/app/src/ui/data_grid/edit/row_edits.rs new file mode 100644 index 0000000000..a08ded7b72 --- /dev/null +++ b/linux/crates/app/src/ui/data_grid/edit/row_edits.rs @@ -0,0 +1,241 @@ +use gtk4::prelude::*; +use gtk4::{self as gtk, glib}; +use tablepro_core::Value; +use tablepro_core::column::{BoolStorage, ColumnKind}; +use tablepro_core::edit::{CellInput, EditParseError, new_draft_cell, parse_edit_text}; + +use super::cell_editor::Plan; +use super::{EditController, edit_start}; +use crate::i18n::gettext; +use crate::services::change_tracker::RowKey; +use crate::services::grid::{ + CellPreset, CommitResult, EditStart, GridColumn, cell_presets, commit_input, edit_context, +}; +use crate::services::tracker_edit::TrackerEdit; +use crate::ui::data_grid::{DataGrid, GridRow}; +use crate::ui::error_text::edit_parse_message; + +impl EditController { + /// Write the column's own spelling of the opposite value. + pub(super) fn toggle(&self, grid: &DataGrid, row: &GridRow, column: usize) { + let Some(grid_column) = self.unlocked_column(grid, row, column) else { + return; + }; + let ColumnKind::Bool(storage) = grid_column.value_type().kind else { + return; + }; + grid.commit_editors(); + let tracked = self.tracked(); + let next = tracked.with_value(row, column, |value, default| toggled(value, default, storage)); + let start = edit_start(&tracked, row, column); + self.commit_input(grid, &grid_column, &start, CellInput::Value(next)); + } + + pub(super) fn toggle_target(&self, grid: &DataGrid) { + if let Some((row, column)) = grid.target().and_then(|target| target.cell) { + self.toggle(grid, &row, column); + } + } + + /// Set to Empty, NULL or DEFAULT on the cell an action names. The + /// menu offers only what the cell takes; a column that refuses the + /// value says why, as committing an empty cell would. + pub(super) fn set_preset(&self, grid: &DataGrid, preset: CellPreset) { + let Some((row, column)) = grid.target().and_then(|target| target.cell) else { + return; + }; + let Some(grid_column) = self.unlocked_column(grid, &row, column) else { + return; + }; + let Some(catalog) = grid_column.catalog.as_ref() else { + return; + }; + let presets = cell_presets(&grid_column, &row.id()); + let input = match preset { + CellPreset::Empty => { + parse_edit_text("", catalog, edit_context(&row.id())).map_err(|error| edit_parse_message(&error)) + } + CellPreset::Null if presets.null => Ok(CellInput::Value(Value::Null)), + CellPreset::Null => Err(edit_parse_message(&EditParseError::Required)), + CellPreset::Default if presets.default => Ok(CellInput::Default), + CellPreset::Default => Err(gettext("This cell can't take the column's default")), + }; + match input { + Ok(input) => { + grid.commit_editors(); + let start = edit_start(&self.tracked(), &row, column); + self.commit_input(grid, &grid_column, &start, input); + } + Err(message) => grid.toast(message), + } + } + + /// Only a table whose rows can be saved takes a new one (D1). + pub(super) fn insert_row(&self, grid: &DataGrid) { + if self.policy().is_none_or(|policy| policy.insert.is_err()) { + return; + } + let columns = grid.grid_columns(); + if columns.is_empty() { + return; + } + let cells = columns + .iter() + .map(|column| { + column + .catalog + .as_ref() + .map_or(CellInput::Value(Value::Null), new_draft_cell) + }) + .collect(); + if let Some(draft_id) = self.insert_drafts(grid, vec![cells]) { + self.edit_new_row_soon(grid, draft_id); + } + } + + /// Copies of the rows as the user sees them, pending edits included. + /// What the database owns (key, identity, generated) is left to it. + pub(super) fn duplicate_rows(&self, grid: &DataGrid) { + if self.policy().is_none_or(|policy| policy.duplicate.is_err()) { + return; + } + let Some(target) = grid.target().filter(|target| !target.rows.is_empty()) else { + return; + }; + let columns = grid.grid_columns(); + let tracked = self.tracked(); + let copies = target + .rows + .iter() + .map(|row| { + let source = tracked.inputs(row); + columns + .iter() + .map(|column| match &column.catalog { + Some(catalog) if catalog.primary_key || catalog.is_auto_increment || catalog.is_generated => { + new_draft_cell(catalog) + } + Some(_) | None => source + .get(column.index) + .cloned() + .unwrap_or(CellInput::Value(Value::Null)), + }) + .collect() + }) + .collect(); + if let Some(draft_id) = self.insert_drafts(grid, copies) { + self.edit_new_row_soon(grid, draft_id); + } + } + + /// New drafts, one undo step. Returns the one shown on top. + pub(super) fn insert_drafts(&self, grid: &DataGrid, rows: Vec>) -> Option { + let tracker = self.tracked().tracker().cloned()?; + grid.commit_editors(); + let delta = tracker.apply(TrackerEdit::InsertDrafts { rows }); + let top = delta.inserted_drafts.last().copied(); + self.apply_delta(&delta); + top + } + + /// In an idle callback: GTK aborts updating the list's adjustments + /// when it scrolls in the tick a first row appears in an empty grid. + fn edit_new_row_soon(&self, grid: &DataGrid, draft_id: u64) { + let weak = grid.downgrade(); + glib::idle_add_local_once(move || { + if let Some(grid) = weak.upgrade() + && grid.is_mapped() + { + grid.edit().edit_row(&grid, &RowKey::Draft(draft_id)); + } + }); + } + + /// Select the row and edit its first cell that edits inline. A row + /// with none keeps the focus on the cell the row gave it. + pub(super) fn edit_row(&self, grid: &DataGrid, key: &RowKey) { + let Some(row) = grid.store().row_for(key) else { + return; + }; + let Some(position) = grid.view_position(&row) else { + return; + }; + let view = grid.column_view(); + view.scroll_to( + position, + None, + gtk::ListScrollFlags::FOCUS | gtk::ListScrollFlags::SELECT, + None, + ); + // `scroll_to` only marks the row while the focus is outside the + // list; the grab then takes it there. + view.grab_focus(); + let column = grid + .shown_columns() + .into_iter() + .find(|column| matches!(self.plan(grid, &row, *column), Plan::Inline(_))); + if let Some(cell) = column.and_then(|column| grid.focus_cell(position, column)) { + self.edit_cell(&cell); + } + } + + /// Select the first `count` rows, where new rows land, and focus the + /// first, once the grid has drawn them. + pub(super) fn select_top_rows_soon(&self, grid: &DataGrid, count: u32) { + let weak = grid.downgrade(); + glib::idle_add_local_once(move || { + let Some(grid) = weak.upgrade().filter(|grid| grid.is_mapped()) else { + return; + }; + let selection = grid.selection(); + let count = count.min(selection.n_items()); + if count == 0 { + return; + } + selection.select_range(0, count, true); + let view = grid.column_view(); + view.scroll_to(0, None, gtk::ListScrollFlags::FOCUS, None); + view.grab_focus(); + }); + } + + pub(super) fn commit_input(&self, grid: &DataGrid, column: &GridColumn, start: &EditStart, input: CellInput) { + match commit_input(column, start, input) { + CommitResult::Edit(edit) => self.apply_edit(grid, edit), + CommitResult::Unchanged => {} + CommitResult::Rejected(error) => grid.toast(edit_parse_message(&error)), + } + } + + /// The column, while the cell takes a value: checked again here, as + /// a key or a late event can reach a cell locked since. + pub(super) fn unlocked_column(&self, grid: &DataGrid, row: &GridRow, column: usize) -> Option { + let policy = self.policy()?; + let grid_column = grid.grid_columns().get(column).cloned()?; + match (policy.cells, self.tracked().lock(&grid_column, row)) { + (Ok(()), None) => Some(grid_column), + (_, lock) => { + tracing::debug!(?lock, column, "refused a change to a locked cell"); + None + } + } + } +} + +/// NULL, DEFAULT and a number other than 0 and 1 are neither true nor +/// false, and toggle to true. +pub(super) fn toggled(value: &Value, default: bool, storage: BoolStorage) -> Value { + let truth = match (default, value) { + (true, _) => None, + (false, Value::Bool(flag)) => Some(*flag), + (false, Value::Int(0) | Value::UInt(0)) => Some(false), + (false, Value::Int(1) | Value::UInt(1)) => Some(true), + (false, _) => None, + }; + let next = truth.is_none_or(|truth| !truth); + match storage { + BoolStorage::Native => Value::Bool(next), + // `tinyint(1)` keeps any integer, so a toggle writes 0 or 1. + BoolStorage::Integer => Value::Int(i64::from(next)), + } +} diff --git a/linux/crates/app/src/ui/data_grid/edit/tests/mod.rs b/linux/crates/app/src/ui/data_grid/edit/tests/mod.rs new file mode 100644 index 0000000000..5ee5354047 --- /dev/null +++ b/linux/crates/app/src/ui/data_grid/edit/tests/mod.rs @@ -0,0 +1,1220 @@ +mod paste; +mod rows; + +use gtk4::prelude::*; +use gtk4::{self as gtk, gdk, glib}; +use tablepro_core::Value; +use tablepro_core::column::BoolStorage; +use tablepro_core::edit::CellInput; + +use super::cell_editor::{EditorKey, editor_key}; +use super::row_edits::toggled; +use crate::application::ShortcutOwner; +use crate::services::change_tracker::RowState; +use crate::services::grid::display_text::editable_null_sentinel; +use crate::services::grid::{CommitResult, EDIT_MAX_BYTES, EditDirection, EditStart}; +use crate::services::tracker_edit::TrackerEdit; +use crate::test_support::shortcut_keys::{keystroke, owner_controller, shortcuts, trigger_keys}; +use crate::test_support::{FocusThief, after_next_frame, assert_labelled, drain_main_context, wait_until}; +use crate::ui::data_grid::test_grid::{ + Grid, WAIT, focus_in_grid, key, named, page, text, two_text_columns, two_text_values, +}; +use crate::ui::data_grid::{GridCell, GridRequest, GridRow}; + +pub(super) fn none() -> gdk::ModifierType { + gdk::ModifierType::empty() +} + +pub(super) fn two_text_rows() -> Grid { + Grid::open(two_text_columns(), two_text_values()) +} + +pub(super) fn editor(grid: &Grid) -> Option { + grid.grid.edit().open_text() +} + +pub(super) fn editing_cell(grid: &Grid) -> Option { + grid.grid.edit().editing_cell() +} + +/// Open `cell`'s editor and type `value` over what it opened with. +pub(super) fn type_into(grid: &Grid, cell: &GridCell, value: &str) -> gtk::Text { + assert!(grid.grid.edit().edit_cell(cell), "the cell took no edit"); + let editor = editor(grid).expect("the cell did not open for editing"); + editor.set_text(value); + editor +} + +/// What the tracker holds for the saved row `id`'s cell, `None` while it +/// shows what was read. +pub(super) fn pending(grid: &Grid, id: i64, column: usize) -> Option { + grid.tracker.cell_overlay(&key(id), column).pending +} + +pub(super) fn toasts(grid: &Grid) -> Vec { + grid.take_requests() + .into_iter() + .filter_map(|request| match request { + GridRequest::Toast(text) => Some(text), + _ => None, + }) + .collect() +} + +/// Run what `key` does on the focused cell, as a key press reaching the +/// grid's controllers would. `true` when the grid took the key. +pub(super) fn press(grid: &Grid, key: gdk::Key, modifiers: gdk::ModifierType) -> bool { + let view: gtk::Widget = grid.grid.column_view().upcast(); + let on_grid: gtk::Widget = grid.grid.clone().upcast(); + let wanted = (key.to_lower(), modifiers); + [(ShortcutOwner::GridCell, view), (ShortcutOwner::Grid, on_grid)] + .into_iter() + .filter_map(|(owner, widget)| Some((owner_controller(&widget, owner)?, widget))) + .flat_map(|(controller, widget)| { + shortcuts(&controller) + .into_iter() + .map(move |shortcut| (shortcut, widget.clone())) + }) + .find(|(shortcut, _)| { + shortcut + .trigger() + .is_some_and(|trigger| trigger_keys(&trigger).contains(&wanted)) + }) + .and_then(|(shortcut, widget)| Some((shortcut.action()?, widget))) + .map(|(action, widget)| action.activate(gtk::ShortcutActionFlags::empty(), &widget, None)) + .expect("the grid binds the key") +} + +/// Press `key` in the open editor, through the handler a real key press +/// reaches first. +pub(super) fn press_in_editor(editor: >k::Text, key: gdk::Key, modifiers: gdk::ModifierType) -> bool { + let controllers = editor.observe_controllers(); + let keys = (0..controllers.n_items()) + .filter_map(|position| controllers.item(position).and_downcast::()) + .find(|controller| controller.propagation_phase() == gtk::PropagationPhase::Capture) + .expect("the editor's key handler"); + keys.emit_by_name::("key-pressed", &[&key, &0u32, &modifiers]) +} + +/// The cell being edited once the focus is in its editor. +pub(super) fn editing(grid: &Grid) -> GridCell { + let mut cell = None; + wait_until(WAIT, || { + cell = editing_cell(grid).filter(|_| grid.grid.edit().editor_has_focus()); + cell.is_some() + }) + .expect("a cell is open for editing with the focus"); + cell.expect("an editing cell") +} + +pub(super) fn details_shown(grid: &Grid) -> bool { + grid.grid + .actions() + .action_state("show-details") + .and_then(|state| state.get::()) + .unwrap_or(false) +} + +fn at(cell: &GridCell) -> (Option, usize) { + (cell.position(), cell.column()) +} + +/// A key, a text, a boolean and a text: the key is locked on the saved +/// rows, and a boolean toggles rather than opening an editor. +fn mixed_rows() -> Grid { + Grid::open( + vec![ + named("id", "integer", true), + named("name", "text", false), + named("flag", "boolean", false), + named("note", "text", false), + ], + vec![ + vec![ + Value::Int(1), + Value::Text("a".into()), + Value::Bool(false), + Value::Text("x".into()), + ], + vec![ + Value::Int(2), + Value::Text("b".into()), + Value::Bool(true), + Value::Text("y".into()), + ], + ], + ) +} + +fn name_rows(first: Value) -> Grid { + Grid::open( + vec![named("id", "integer", true), named("name", "text", false)], + vec![vec![Value::Int(1), first], vec![Value::Int(2), Value::Text("B".into())]], + ) +} + +fn number_row() -> Grid { + let mut number = named("n", "integer", false); + number.nullable = false; + Grid::open( + vec![named("id", "integer", true), number], + vec![vec![Value::Int(1), Value::Int(5)]], + ) +} + +#[gtk4::test] +fn tab_moves_escape_cancels_and_window_keys_pass() { + let shift = gdk::ModifierType::SHIFT_MASK; + + assert_eq!(editor_key(gdk::Key::Escape, none()), Some(EditorKey::Cancel)); + assert_eq!( + editor_key(gdk::Key::Tab, none()), + Some(EditorKey::Move(EditDirection::Forward)) + ); + assert_eq!( + editor_key(gdk::Key::ISO_Left_Tab, shift), + Some(EditorKey::Move(EditDirection::Backward)) + ); + assert_eq!( + editor_key(gdk::Key::ISO_Left_Tab, none()), + Some(EditorKey::Move(EditDirection::Backward)) + ); + assert_eq!( + editor_key(gdk::Key::Tab, shift), + Some(EditorKey::Move(EditDirection::Backward)) + ); + assert_eq!(editor_key(gdk::Key::Tab, gdk::ModifierType::CONTROL_MASK), None); + assert_eq!(editor_key(gdk::Key::Tab, gdk::ModifierType::ALT_MASK), None); + assert_eq!( + editor_key(gdk::Key::Return, none()), + None, + "Return is the field's activate" + ); +} + +/// The editor reads keys itself, to leave them to an open preedit, so +/// the table's entries are checked against what it does. +#[gtk4::test] +fn edit_keys_match_cell_editor_entries() { + let expected = [ + ("cell-editor.cancel", EditorKey::Cancel), + ("cell-editor.next", EditorKey::Move(EditDirection::Forward)), + ("cell-editor.previous", EditorKey::Move(EditDirection::Backward)), + ]; + for (action, outcome) in expected { + let triggers = crate::application::triggers_for(action); + assert!(!triggers.is_empty(), "{action} is not in the table"); + for accel in triggers { + let (key, modifiers) = keystroke(accel); + assert_eq!(editor_key(key, modifiers), Some(outcome), "{accel}"); + } + } +} + +#[test] +fn a_toggle_writes_the_columns_own_form() { + assert_eq!( + toggled(&Value::Bool(false), false, BoolStorage::Native), + Value::Bool(true) + ); + assert_eq!( + toggled(&Value::Bool(true), false, BoolStorage::Native), + Value::Bool(false) + ); + assert_eq!(toggled(&Value::Int(0), false, BoolStorage::Integer), Value::Int(1)); + assert_eq!(toggled(&Value::Int(1), false, BoolStorage::Integer), Value::Int(0)); +} + +#[test] +fn null_default_and_other_numbers_toggle_to_true() { + assert_eq!(toggled(&Value::Null, false, BoolStorage::Native), Value::Bool(true)); + assert_eq!(toggled(&Value::Null, true, BoolStorage::Integer), Value::Int(1)); + assert_eq!(toggled(&Value::Int(2), false, BoolStorage::Integer), Value::Int(1)); +} + +#[gtk4::test] +fn f2_starts_editing_focused_text_cell() { + let grid = two_text_rows(); + grid.focus(1, 1); + + assert!(press(&grid, gdk::Key::F2, none())); + + let editor = editor(&grid).expect("F2 opened no editor"); + assert_eq!(editor.text(), "B"); + assert_eq!( + editor.selection_bounds(), + Some((0, 1)), + "the first key would not replace the value" + ); +} + +#[gtk4::test] +fn kp_enter_starts_editing_focused_int_cell() { + let grid = Grid::open( + vec![named("id", "integer", true), named("n", "integer", false)], + vec![vec![Value::Int(1), Value::Int(42)]], + ); + grid.focus(0, 1); + + assert!(press(&grid, gdk::Key::KP_Enter, none())); + + let editor = editor(&grid).expect("KP_Enter opened no editor"); + assert_eq!(editor.text(), "42"); + assert_eq!(editor.input_purpose(), gtk::InputPurpose::Number); +} + +#[gtk4::test] +fn return_commits_and_returns_focus_to_cell() { + let grid = two_text_rows(); + let cell = grid.focus(1, 1); + assert!(press(&grid, gdk::Key::Return, none())); + let editor = editor(&grid).expect("Return opened no editor"); + editor.set_text("renamed"); + + editor.emit_activate(); + + assert_eq!(pending(&grid, 2, 1), Some(text("renamed"))); + assert!(editing_cell(&grid).is_none()); + assert_eq!(grid.focused_cell().as_ref(), Some(&cell), "the focus did not come back"); + assert!( + grid.take_requests() + .iter() + .any(|request| matches!(request, GridRequest::PendingChanged)) + ); +} + +#[gtk4::test] +fn grid_shortcuts_proceed_while_editing() { + let grid = two_text_rows(); + let cell = grid.focus(0, 1); + assert!(grid.grid.edit().edit_cell(&cell)); + editing(&grid); + + assert!(!press(&grid, gdk::Key::Return, none())); + assert!(!press(&grid, gdk::Key::space, none())); + assert!(!press(&grid, gdk::Key::BackSpace, gdk::ModifierType::CONTROL_MASK)); + assert!(!press(&grid, gdk::Key::Menu, none())); + + assert!(editor(&grid).is_some(), "a grid key closed the open editor"); + assert_eq!(grid.tracker.pending_count(), 0); +} + +#[gtk4::test] +fn grid_keys_proceed_with_no_focused_cell() { + let grid = two_text_rows(); + gtk::prelude::GtkWindowExt::set_focus(&grid.window, None::<>k::Widget>); + drain_main_context(); + + assert!(!press(&grid, gdk::Key::F2, none())); + assert!(!press(&grid, gdk::Key::BackSpace, gdk::ModifierType::CONTROL_MASK)); + assert_eq!(grid.tracker.pending_count(), 0); +} + +/// Return on a locked cell is the list's own activation, which shows +/// the whole value in the detail pane. +#[gtk4::test] +fn focused_read_only_cell_resolves_grid_cell() { + let grid = two_text_rows(); + + let cell = grid.focus(0, 0); + + assert!(cell.lock().is_some(), "a saved row's key was editable"); + assert_eq!(cell.row().and_then(|row| row.key()), Some(key(1))); + assert!(!press(&grid, gdk::Key::F2, none()), "F2 took a locked cell"); + assert!(!grid.grid.edit().edit_cell(&cell)); + assert!(editing_cell(&grid).is_none()); + grid.grid.column_view().emit_by_name::<()>("activate", &[&0u32]); + drain_main_context(); + assert!(details_shown(&grid), "Return on a locked cell showed nothing"); +} + +/// A page that lands between a double click and its editor gives the +/// slot another row, which the click never meant. +#[gtk4::test] +fn a_page_landing_after_a_double_click_opens_no_editor() { + let grid = two_text_rows(); + grid.focus(0, 1); + + grid.grid.column_view().emit_by_name::<()>("activate", &[&0u32]); + grid.grid.replace_rows(page(3..5), 0); + drain_main_context(); + + assert!( + editing_cell(&grid).is_none(), + "the editor opened on the row the page put there" + ); +} + +#[gtk4::test] +fn return_toggles_a_focused_boolean_cell() { + let grid = Grid::open( + vec![named("id", "integer", true), named("flag", "boolean", false)], + vec![vec![Value::Int(1), Value::Bool(false)]], + ); + grid.focus(0, 1); + + assert!(press(&grid, gdk::Key::Return, none())); + + assert_eq!(pending(&grid, 1, 1), Some(CellInput::Value(Value::Bool(true)))); + assert!(editing_cell(&grid).is_none()); +} + +/// A boolean draws as text (D9), and Space flips it. +#[gtk4::test] +fn space_toggles_a_focused_boolean_and_selects_from_any_other_cell() { + let grid = mixed_rows(); + + grid.focus(0, 2); + assert!(press(&grid, gdk::Key::space, none())); + grid.focus(0, 1); + assert!( + !press(&grid, gdk::Key::space, none()), + "Space on a text cell did not go on to select the row" + ); + + assert_eq!(pending(&grid, 1, 2), Some(CellInput::Value(Value::Bool(true)))); + assert_eq!(grid.tracker.pending_count(), 1); +} + +#[gtk4::test] +fn ctrl_backspace_sets_focused_nullable_cell_null() { + let mut required = named("code", "text", false); + required.nullable = false; + let grid = Grid::open( + vec![named("id", "integer", true), named("name", "text", false), required], + vec![vec![Value::Int(1), Value::Text("a".into()), Value::Text("b".into())]], + ); + let ctrl = gdk::ModifierType::CONTROL_MASK; + + grid.focus(0, 1); + assert!(press(&grid, gdk::Key::BackSpace, ctrl)); + grid.focus(0, 2); + assert!(!press(&grid, gdk::Key::BackSpace, ctrl), "a NOT NULL column took NULL"); + grid.focus(0, 0); + assert!(!press(&grid, gdk::Key::BackSpace, ctrl), "a saved row's key took NULL"); + + assert_eq!(pending(&grid, 1, 1), Some(CellInput::Value(Value::Null))); + assert_eq!(grid.tracker.pending_count(), 1); +} + +#[gtk4::test] +fn tab_in_edit_mode_moves_to_next_editable_column() { + let grid = mixed_rows(); + let name = grid.focus(0, 1); + type_into(&grid, &name, "a2"); + + assert!(press_in_editor( + &editor(&grid).expect("an editor"), + gdk::Key::Tab, + none() + )); + let note = editing(&grid); + assert_eq!(at(¬e), (Some(0), 3), "Tab did not skip the boolean"); + assert_eq!( + editor(&grid).map(|editor| editor.text().to_string()).as_deref(), + Some("x") + ); + + assert!(press_in_editor( + &editor(&grid).expect("an editor"), + gdk::Key::Tab, + none() + )); + let next_row = editing(&grid); + assert_eq!(at(&next_row), (Some(1), 1), "Tab did not skip the saved row's key"); + + assert!(press_in_editor( + &editor(&grid).expect("an editor"), + gdk::Key::ISO_Left_Tab, + gdk::ModifierType::SHIFT_MASK + )); + let back = editing(&grid); + assert_eq!(back, note, "Shift+Tab did not go back a row"); + + assert_eq!(pending(&grid, 1, 1), Some(text("a2"))); + assert_eq!(grid.tracker.pending_count(), 1); +} + +#[gtk4::test] +fn tab_past_the_last_editable_cell_keeps_the_focus_there() { + let grid = mixed_rows(); + let note = grid.focus(1, 3); + assert!(grid.grid.edit().edit_cell(¬e)); + + assert!(press_in_editor( + &editor(&grid).expect("an editor"), + gdk::Key::Tab, + none() + )); + + assert!(editing_cell(&grid).is_none()); + assert_eq!(grid.focused_cell().as_ref(), Some(¬e)); +} + +#[gtk4::test] +fn escape_cancels_and_returns_focus_to_cell() { + let grid = mixed_rows(); + let name = grid.focus(1, 1); + let editor = type_into(&grid, &name, "typed"); + + assert!(press_in_editor(&editor, gdk::Key::Escape, none())); + + assert!(editing_cell(&grid).is_none()); + assert_eq!(grid.focused_cell().as_ref(), Some(&name), "the focus did not come back"); + assert_eq!(grid.tracker.pending_count(), 0, "Escape sent the typed text"); +} + +/// GTK ends the focus change by walking up from the field the edit was +/// in. GTK 4.22 logs a critical forever and never returns when the field +/// has left its cell by then. +#[gtk4::test] +fn the_focus_moving_to_another_cell_commits_the_edit() { + let grid = mixed_rows(); + let name = grid.focus(0, 1); + type_into(&grid, &name, "a2"); + + let previous = glib::log_set_always_fatal(glib::LogLevels::LEVEL_CRITICAL); + let note = grid.focus(1, 3); + drain_main_context(); + glib::log_set_always_fatal(previous); + + assert!(editing_cell(&grid).is_none(), "the edit outlived the focus"); + assert!(!name.is_editing(), "the field stayed in the cell"); + assert_eq!(pending(&grid, 1, 1), Some(text("a2"))); + assert_eq!(grid.focused_cell().as_ref(), Some(¬e)); + assert!( + !name.state_flags().contains(gtk::StateFlags::FOCUS_WITHIN), + "the cell the edit left still shows the focus" + ); +} + +/// Another window taking the focus is no focus change in this one, so +/// the edit stays open, and typing goes on there once it is back. +#[gtk4::test] +fn an_edit_stays_open_while_another_window_has_the_focus() { + let grid = mixed_rows(); + let name = grid.focus(1, 1); + let editor = type_into(&grid, &name, "typed"); + + let thief = FocusThief::take_from(&grid.window).expect("another window takes the focus"); + after_next_frame(&grid.window); + assert!( + editing_cell(&grid).is_some(), + "another window taking the focus ended the edit" + ); + thief.give_back(&grid.window); + drain_main_context(); + after_next_frame(&grid.window); + + assert!( + editing_cell(&grid).is_some(), + "the edit ended when the window came back" + ); + assert_eq!(grid.tracker.pending_count(), 0, "the half-typed value was sent"); + assert_eq!( + gtk::prelude::GtkWindowExt::focus(&grid.window).as_ref(), + Some(editor.upcast_ref::()), + "the focus left the field" + ); +} + +#[gtk4::test] +fn ctrl_tab_in_an_editor_is_left_to_the_window() { + let grid = mixed_rows(); + let name = grid.focus(1, 1); + assert!(grid.grid.edit().edit_cell(&name)); + + assert!(!press_in_editor( + &editor(&grid).expect("an editor"), + gdk::Key::Tab, + gdk::ModifierType::CONTROL_MASK + )); + assert!(editing_cell(&grid).is_some()); +} + +#[gtk4::test] +fn new_draft_enters_edit_mode_on_first_editable_cell() { + let mut serial = named("id", "integer", true); + serial.is_auto_increment = true; + let grid = Grid::open( + vec![serial, named("name", "text", false), named("note", "text", false)], + vec![vec![Value::Int(1), Value::Text("a".into()), Value::Text("x".into())]], + ); + + grid.activate("insert-row"); + + let cell = editing(&grid); + assert!(cell.row().is_some_and(|row| row.draft_id().is_some())); + assert_eq!(cell.column(), 1, "the edit did not skip the auto-increment key"); + let selection = grid.grid.selection(); + assert!(selection.is_selected(0) && !selection.is_selected(1)); +} + +/// Tab moves an edit across the columns, and no header is focused on +/// the way, so the editor names the column it edits. +#[gtk4::test] +fn the_cell_editor_is_named_by_its_column() { + let grid = two_text_rows(); + + let editor = type_into(&grid, &grid.cell_of(2, 1), "B"); + + assert_eq!(assert_labelled(&editor), Ok(())); +} + +#[gtk4::test] +fn a_value_too_large_to_lay_out_does_not_open_and_says_why() { + let large = "x".repeat(EDIT_MAX_BYTES + 1); + let grid = Grid::open( + vec![named("id", "integer", true), named("body", "text", false)], + vec![vec![Value::Int(1), Value::Text(large)]], + ); + let cell = grid.cell_of(1, 1); + + assert!(grid.grid.edit().edit_cell(&cell)); + + assert!(editing_cell(&grid).is_none(), "an editor opened on the whole value"); + assert!( + !details_shown(&grid), + "the detail pane took a value too large to lay out" + ); + // GLib puts a no-break space between the number and the unit. + assert_eq!(toasts(&grid), ["Values over 1.0\u{a0}MB can't be edited here"]); +} + +#[gtk4::test] +fn a_text_with_a_nul_draws_and_refuses_an_editor() { + let grid = Grid::open( + vec![named("id", "integer", true), named("name", "text", false)], + vec![vec![Value::Int(1), Value::Text("a\0b".into())]], + ); + let cell = grid.cell_of(1, 1); + assert_eq!(Grid::display_text(&cell), "a\u{2400}b"); + + grid.grid.edit().edit_cell(&cell); + + assert!( + editing_cell(&grid).is_none(), + "an editor opened on a value it cannot hold" + ); + assert_eq!( + toasts(&grid), + ["Values with a NUL character can't be edited here"], + "the refusal was not explained" + ); +} + +#[gtk4::test] +fn a_saved_rows_key_is_locked_and_a_drafts_is_not() { + let grid = two_text_rows(); + grid.insert_draft(); + let draft_key_cell = grid + .cells() + .into_iter() + .find(|cell| cell.column() == 0 && cell.row().is_some_and(|row| row.draft_id().is_some())) + .expect("the draft's key cell"); + + assert!( + grid.cell_of(1, 0).lock().is_some(), + "a saved row's key was offered for editing" + ); + assert!(grid.cell_of(1, 1).lock().is_none()); + assert!(draft_key_cell.lock().is_none(), "a draft could not set its own key"); + + assert!(!grid.grid.edit().edit_cell(&grid.cell_of(1, 0))); + assert!(editing_cell(&grid).is_none()); + assert!(grid.grid.edit().edit_cell(&draft_key_cell)); + assert_eq!(editing_cell(&grid), Some(draft_key_cell)); +} + +#[gtk4::test] +fn f004_edit_after_draft_insert_targets_edited_row() { + let grid = two_text_rows(); + grid.insert_draft(); + + let cell = grid.focus(2, 1); + assert_eq!(cell.row().and_then(|row| row.key()), Some(key(2))); + let editor = type_into(&grid, &cell, "renamed"); + editor.emit_activate(); + + assert_eq!(pending(&grid, 2, 1), Some(text("renamed"))); +} + +#[gtk4::test] +fn f004_menu_set_null_after_draft_removal_targets_clicked_row() { + let grid = two_text_rows(); + let draft_id = grid.insert_draft(); + let cell = grid.cell_of(2, 1); + assert!(grid.grid.open_cell_menu(&cell, None)); + + let removed = grid.tracker.apply(TrackerEdit::DeleteRows { + persisted: Vec::new(), + drafts: vec![draft_id], + }); + grid.grid.apply_delta(&removed); + drain_main_context(); + grid.grid + .activate_action("grid.set-null", None) + .expect("the menu's Set to NULL"); + + assert_eq!(pending(&grid, 2, 1), Some(CellInput::Value(Value::Null))); + assert_eq!(pending(&grid, 1, 1), None, "the menu acted on another row"); + if let Some(menus) = grid.grid.menus() { + menus.cell.popdown(); + } +} + +#[gtk4::test] +fn edit_started_before_rows_shift_commits_to_original_row() { + let grid = two_text_rows(); + let cell = grid.cell_of(2, 1); + let editor = type_into(&grid, &cell, "renamed"); + + grid.insert_draft(); + editor.emit_activate(); + + assert_eq!(pending(&grid, 2, 1), Some(text("renamed"))); +} + +#[gtk4::test] +fn toggle_after_draft_insert_targets_toggled_row() { + let grid = Grid::open( + vec![named("id", "integer", true), named("flag", "boolean", false)], + vec![ + vec![Value::Int(1), Value::Bool(true)], + vec![Value::Int(2), Value::Bool(false)], + ], + ); + grid.insert_draft(); + let cell = grid.focus(2, 1); + assert_eq!(cell.row().and_then(|row| row.key()), Some(key(2))); + + grid.activate("toggle"); + + assert_eq!(pending(&grid, 2, 1), Some(CellInput::Value(Value::Bool(true)))); + assert_eq!(pending(&grid, 1, 1), None); +} + +#[gtk4::test] +fn an_edit_in_progress_keeps_its_text_when_the_row_is_drawn_again() { + let grid = two_text_rows(); + let cell = grid.cell_of(2, 1); + let editor = type_into(&grid, &cell, "typed"); + let row = cell.row().expect("a bound row"); + + row.bump_revision(); + + assert_eq!(editor.text(), "typed", "the redraw overwrote what was typed"); + grid.grid.edit().cancel_open_edit(); + assert_eq!( + Grid::display_text(&cell), + "B", + "Escape did not go back to the row's value" + ); + assert!(editing_cell(&grid).is_none()); + assert_eq!(grid.tracker.pending_count(), 0); +} + +/// Row 1's name edited from `from` to `to`, its editor opened on `to`, +/// then the edit undone under the open editor. +fn undo_under_open_editor(from: Value, to: Value) -> (Grid, GridCell) { + let grid = name_rows(from.clone()); + let cell = grid.cell_of(1, 1); + grid.grid.apply_delta(&grid.tracker.apply(TrackerEdit::Cell { + row_key: key(1), + col: 1, + original: from, + value: to, + })); + assert!(grid.grid.edit().edit_cell(&cell)); + assert!(editor(&grid).is_some(), "the cell did not open for editing"); + + grid.grid.undo(); + + (grid, cell) +} + +#[gtk4::test] +fn escape_after_an_undo_under_the_editor_sends_nothing() { + let (grid, cell) = undo_under_open_editor(Value::Text("A".into()), Value::Text("X".into())); + assert!( + editing_cell(&grid).is_none(), + "the editor stayed open on a value the row no longer has" + ); + + grid.grid.edit().cancel_open_edit(); + + assert_eq!(Grid::display_text(&cell), "A"); + assert_eq!(grid.tracker.pending_count(), 0); +} + +#[gtk4::test] +fn escape_after_undoing_an_edit_of_null_sends_nothing() { + let (grid, cell) = undo_under_open_editor(Value::Null, Value::Text("hello".into())); + + grid.grid.edit().cancel_open_edit(); + + assert_eq!(grid.tracker.pending_count(), 0); + assert_eq!(Grid::display_text(&cell), editable_null_sentinel()); +} + +#[gtk4::test] +fn escape_on_a_null_cell_drawn_again_under_its_editor_sends_nothing() { + let grid = name_rows(Value::Null); + let cell = grid.cell_of(1, 1); + assert!(grid.grid.edit().edit_cell(&cell)); + + cell.row().expect("a bound row").bump_revision(); + assert!( + editing_cell(&grid).is_some(), + "a redraw of the same value closed the editor" + ); + grid.grid.edit().cancel_open_edit(); + + assert_eq!(grid.tracker.pending_count(), 0); +} + +#[gtk4::test] +fn a_null_text_cell_left_as_it_opened_stays_null() { + let grid = name_rows(Value::Null); + let cell = grid.cell_of(1, 1); + + let editor = type_into(&grid, &cell, ""); + + assert_eq!( + editor.placeholder_text().unwrap_or_default(), + "", + "a text column keeps the empty string, so empty means nothing more" + ); + editor.emit_activate(); + assert_eq!(grid.tracker.pending_count(), 0); +} + +#[gtk4::test] +fn a_text_that_reads_as_the_null_sentinel_opens_as_itself() { + let grid = name_rows(Value::Text(editable_null_sentinel())); + + assert!(grid.grid.edit().edit_cell(&grid.cell_of(1, 1))); + + assert_eq!( + editor(&grid).map(|editor| editor.text().to_string()), + Some(editable_null_sentinel()) + ); +} + +#[gtk4::test] +fn a_blob_in_a_text_column_does_not_open_on_its_placeholder() { + let grid = name_rows(Value::Bytes(vec![1, 2, 3])); + let cell = grid.cell_of(1, 1); + + grid.grid.edit().edit_cell(&cell); + + assert!( + editing_cell(&grid).is_none(), + "the editor opened on the size placeholder" + ); + assert_eq!(toasts(&grid).len(), 1, "the cell did not say why it would not open"); +} + +/// Long and multi-line text is edited in the detail pane's editor now, +/// which shows all of it. +#[gtk4::test] +fn a_long_or_multi_line_text_opens_in_the_detail_pane() { + let long = format!("tpyo{}", "x".repeat(60_000)); + let grid = Grid::open( + vec![named("id", "integer", true), named("name", "text", false)], + vec![ + vec![Value::Int(1), Value::Text(long)], + vec![Value::Int(2), Value::Text("one\ntwo".into())], + ], + ); + + for id in [1, 2] { + grid.grid + .actions() + .change_action_state("show-details", &false.to_variant()); + let cell = grid.focus(u32::try_from(id - 1).expect("a position"), 1); + + assert!(grid.grid.edit().edit_cell(&cell)); + + assert!(editing_cell(&grid).is_none(), "row {id} opened in a one-line field"); + assert!(details_shown(&grid), "row {id} did not open the detail pane"); + } +} + +/// JSON, XML and dates open the detail pane's value editor rather than a +/// popover in the cell. +#[gtk4::test] +fn documents_and_dates_open_in_the_detail_pane() { + let grid = Grid::open( + vec![ + named("id", "integer", true), + named("doc", "jsonb", false), + named("day", "date", false), + ], + vec![vec![ + Value::Int(1), + Value::Json(tablepro_core::value::JsonText::parse("[1]".to_owned()).expect("json")), + Value::Date(tablepro_core::value::Temporal::Finite( + chrono::NaiveDate::from_ymd_opt(2026, 9, 15).expect("a date"), + )), + ]], + ); + + for column in [1, 2] { + grid.grid + .actions() + .change_action_state("show-details", &false.to_variant()); + grid.focus(0, column); + + assert!(press(&grid, gdk::Key::Return, none())); + + assert!(editing_cell(&grid).is_none()); + assert!(details_shown(&grid), "column {column} did not open the detail pane"); + } + assert_eq!(grid.tracker.pending_count(), 0); +} + +/// A closed editor must leave nothing holding its cell. +#[gtk4::test] +fn a_closed_editor_frees_its_cell() { + let grid = two_text_rows(); + type_into(&grid, &grid.focus(1, 1), "typed"); + grid.grid.edit().cancel_open_edit(); + type_into(&grid, &grid.cell_of(1, 1), "again"); + grid.grid.edit().commit_open_edit(); + let freed: Vec> = grid.cells().iter().map(ObjectExt::downgrade).collect(); + + drop(grid); + + assert!( + freed.iter().all(|cell| cell.upgrade().is_none()), + "an editor kept its cell alive" + ); +} + +/// D7: what was typed stays, marked, with the reason, and Escape goes +/// back to the value. +#[gtk4::test] +fn a_refused_value_stays_in_the_editor_with_its_reason() { + let grid = number_row(); + let cell = grid.focus(0, 1); + let editor = type_into(&grid, &cell, "12,5"); + + editor.emit_activate(); + + assert_eq!( + editor_text(&grid).as_deref(), + Some("12,5"), + "the typed text was thrown away" + ); + assert!(editor.has_css_class("error")); + assert_eq!(toasts(&grid), ["Enter a whole number"]); + assert_eq!(grid.tracker.pending_count(), 0); + editor.set_text("12"); + assert!(!editor.has_css_class("error"), "the mark outlived the change"); + editor.set_text("12,5"); + editor.emit_activate(); + + assert!(press_in_editor(&editor, gdk::Key::Escape, none())); + + assert!(editing_cell(&grid).is_none()); + assert_eq!(Grid::display_text(&cell), "5"); + assert_eq!(grid.focused_cell().as_ref(), Some(&cell)); + assert_eq!(grid.tracker.pending_count(), 0); +} + +fn editor_text(grid: &Grid) -> Option { + editor(grid).map(|editor| editor.text().to_string()) +} + +/// A value never pending goes when a save, a reload or a close commits. +#[gtk4::test] +fn committing_before_a_reload_drops_a_refused_value() { + let grid = number_row(); + let cell = grid.focus(0, 1); + type_into(&grid, &cell, "thirty").emit_activate(); + assert!(editing_cell(&grid).is_some()); + + grid.grid.commit_edit(); + + assert!(editing_cell(&grid).is_none()); + assert!(!grid.grid.is_editing()); + assert_eq!(grid.tracker.pending_count(), 0); + assert_eq!(toasts(&grid), ["Enter a whole number", "Enter a whole number"]); +} + +#[gtk4::test] +fn a_page_that_replaces_the_row_commits_the_open_edit_to_it() { + let grid = two_text_rows(); + let cell = grid.focus(1, 1); + type_into(&grid, &cell, "renamed"); + + grid.grid.replace_rows(page(5..7), 0); + + assert!(editing_cell(&grid).is_none()); + assert_eq!(pending(&grid, 2, 1), Some(text("renamed"))); + assert_eq!( + pending(&grid, 6, 1), + None, + "the edit went to the row that took the slot" + ); +} + +/// D8: a row marked for deletion takes no edit, which would count as +/// pending and never save. +#[gtk4::test] +fn a_row_marked_for_deletion_opens_no_editor() { + let grid = two_text_rows(); + grid.focus(0, 1); + grid.activate("delete-rows"); + assert_eq!(grid.tracker.row_state(&key(1)), RowState::PendingDelete); + + let cell = grid.focus(0, 1); + + assert!(!grid.is_enabled("edit")); + assert!(!grid.is_enabled("set-null")); + assert!(!grid.grid.edit().edit_cell(&cell)); + assert!(grid.grid.edit().open_for_typing().is_none()); + assert!(editing_cell(&grid).is_none()); + assert_eq!(grid.tracker.pending_count(), 1); +} + +#[gtk4::test] +fn typing_on_a_cell_opens_an_empty_editor() { + let grid = two_text_rows(); + grid.focus(1, 1); + + let editor = grid.grid.edit().open_for_typing().expect("typing opened no editor"); + + assert_eq!(editor.text(), ""); + assert_eq!(editor.selection_bounds(), None); + editor.set_text("C"); + editor.emit_activate(); + assert_eq!(pending(&grid, 2, 1), Some(text("C"))); + grid.focus(0, 0); + assert!( + grid.grid.edit().open_for_typing().is_none(), + "typing opened a locked key" + ); +} + +/// The detail pane commits through `commit_value`, against the row it +/// pinned. +#[gtk4::test] +fn commit_value_commits_nothing_once_the_row_left_its_slot() { + let grid = two_text_rows(); + let row = grid.grid.store().row(0).expect("a row"); + let pinned = row.pin(); + let start = EditStart { + row: row.id(), + read: Value::Text("A".into()), + original: CellInput::Value(Value::Text("A".into())), + }; + + let committed = grid.grid.edit().commit_value(&pinned, 1, &start, "Ada"); + assert!(matches!(committed, Some(CommitResult::Edit(_))), "{committed:?}"); + assert_eq!(pending(&grid, 1, 1), Some(text("Ada"))); + assert_eq!( + grid.grid.edit().commit_value(&pinned, 0, &start, "9"), + None, + "a saved row's key took a value" + ); + + grid.grid.replace_rows(page(5..7), 0); + + assert_eq!(grid.grid.edit().commit_value(&pinned, 1, &start, "again"), None); + assert_eq!(pending(&grid, 5, 1), None); +} + +#[gtk4::test] +fn presets_follow_the_cell_and_default_is_for_new_rows() { + let mut stamped = named("made", "integer", false); + stamped.nullable = false; + stamped.default = tablepro_core::column::ColumnDefault::Literal(Value::Int(0)); + let grid = Grid::open( + vec![named("id", "integer", true), named("name", "text", false), stamped], + vec![vec![Value::Int(1), Value::Text("a".into()), Value::Int(3)]], + ); + + grid.focus(0, 1); + assert!(grid.is_enabled("set-empty") && grid.is_enabled("set-null")); + assert!(!grid.is_enabled("set-default"), "a saved row offered DEFAULT"); + grid.activate("set-empty"); + assert_eq!(pending(&grid, 1, 1), Some(text(""))); + grid.focus(0, 2); + assert!(!grid.is_enabled("set-null") && !grid.is_enabled("set-empty")); + + let draft_id = grid.insert_draft(); + grid.focus(0, 2); + assert!(grid.is_enabled("set-default")); + grid.activate("set-default"); + assert_eq!(grid.tracker.draft_input(draft_id, 2), Some(CellInput::Default)); +} + +/// The rows a store holds after an edit are the same objects, so the +/// selection and the cells' widgets stay. +#[gtk4::test] +fn an_edit_redraws_the_row_in_its_own_slot() { + let grid = two_text_rows(); + let row = grid.grid.store().row(1).expect("a row"); + let revision = row.revision(); + let cell = grid.focus(1, 1); + + type_into(&grid, &cell, "renamed").emit_activate(); + + assert_eq!(grid.grid.store().row(1), Some(row.clone())); + assert!(row.revision() > revision); + assert_eq!(Grid::display_text(&grid.cell_of(2, 1)), "renamed"); +} + +/// Rows taken out of the list without the grid's own replace, as GTK +/// sees a page that drops the rows it had. +fn drop_every_row(grid: &Grid, ids: std::ops::Range) { + let store = grid.grid.store(); + let rows: Vec = page(ids).into_iter().map(GridRow::new).collect(); + store.list().splice(0, store.len(), &rows); +} + +/// The keyboard sits on the list's own cell widget, above the cell. +/// GTK moves a focus left in a removed row only after the next frame, +/// and a window focus-in before then crashed GTK 4.22 on the row. +#[gtk4::test] +fn a_focus_in_after_a_page_took_the_focused_row_keeps_the_focus_in_the_grid() { + let grid = two_text_rows(); + grid.focus(0, 1); + let thief = FocusThief::take_from(&grid.window).expect("another window takes the focus"); + + drop_every_row(&grid, 3..5); + thief.give_back(&grid.window); + drain_main_context(); + after_next_frame(&grid.window); + + assert!(focus_in_grid(&grid), "the focus left the grid"); +} + +/// A field whose window lost the focus to another hears no focus +/// leaving when its row goes, and its edit is committed all the same. +#[gtk4::test] +fn a_page_that_takes_an_edited_row_from_an_inactive_window_commits_the_edit() { + let grid = two_text_rows(); + let cell = grid.focus(0, 1); + type_into(&grid, &cell, "typed"); + let _thief = FocusThief::take_from(&grid.window).expect("another window takes the focus"); + + drop_every_row(&grid, 3..5); + drain_main_context(); + + assert!(editing_cell(&grid).is_none(), "the edit outlived its row"); + assert!(!grid.grid.is_editing()); + assert_eq!(pending(&grid, 1, 1), Some(text("typed"))); +} + +/// Paste `text` into `cell`'s editor through the clipboard, as Ctrl+V in +/// the field does. +fn paste_into(grid: &Grid, cell: &GridCell, text: &str) -> gtk::Text { + assert!(grid.grid.edit().edit_cell(cell), "the cell took no edit"); + let editor = editor(grid).expect("the cell did not open its one-line editor"); + let opened = editor.text(); + editor.clipboard().set_text(text); + editor.emit_paste_clipboard(); + wait_until(WAIT, || editor.text() != opened).expect("the paste never landed"); + editor +} + +#[gtk4::test] +fn bigint_edit_is_exact() { + let grid = Grid::open( + vec![named("id", "integer", true), named("n", "bigint", false)], + vec![vec![Value::Int(1), Value::Int(1_234_567_890_123_456_789)]], + ); + let cell = grid.cell_of(1, 1); + + let editor = type_into(&grid, &cell, "1234567890123456790"); + assert_eq!( + editor.input_purpose(), + gtk::InputPurpose::Number, + "a signed integer field offers a number keypad" + ); + editor.emit_activate(); + + assert_eq!( + pending(&grid, 1, 1), + Some(CellInput::Value(Value::Int(1_234_567_890_123_456_790))) + ); +} + +#[gtk4::test] +fn f005_pasted_newlines_in_text_cell_are_kept() { + let grid = name_rows(Value::Text("a".into())); + let cell = grid.cell_of(1, 1); + + let editor = paste_into(&grid, &cell, "first line\nsecond line"); + editor.emit_activate(); + + assert_eq!(pending(&grid, 1, 1), Some(text("first line\nsecond line"))); +} + +#[gtk4::test] +fn a_line_break_pasted_into_a_number_keeps_the_first_line() { + let grid = number_row(); + let cell = grid.cell_of(1, 1); + + let editor = paste_into(&grid, &cell, "42\n43"); + assert_eq!(editor.text(), "42"); + editor.emit_activate(); + + assert_eq!(pending(&grid, 1, 1), Some(CellInput::Value(Value::Int(42)))); +} + +#[gtk4::test] +#[expect( + clippy::approx_constant, + reason = "a double with every digit it can hold, which is the point, not pi" +)] +fn f005_pressing_enter_on_float_keeps_full_precision() { + let grid = Grid::open( + vec![named("id", "integer", true), named("x", "double precision", false)], + vec![vec![Value::Int(1), Value::Float64(3.141_592_653_589_79)]], + ); + let cell = grid.cell_of(1, 1); + + assert!(grid.grid.edit().edit_cell(&cell)); + let editor = editor(&grid).expect("a one-line field"); + assert_eq!(editor.text(), "3.14159265358979"); + editor.emit_activate(); + + assert_eq!(grid.tracker.pending_count(), 0, "Enter rounded the value it opened on"); +} + +/// An editor left open across a save would commit against the value read +/// before it, so a cell the save writes takes no editor until it settles. +#[gtk4::test] +fn a_cell_the_running_save_writes_opens_no_editor() { + let grid = two_text_rows(); + let _ = grid.tracker.set_target(crate::services::save_batch::SaveTarget { + table: tablepro_core::meta::TableRef::unqualified("t"), + columns: two_text_columns(), + }); + type_into(&grid, &grid.cell_of(1, 1), "renamed").emit_activate(); + let batch = grid.tracker.begin_save().expect("a save").expect("changes to save"); + grid.grid.redraw(); + + assert!( + !grid.grid.edit().edit_cell(&grid.cell_of(1, 1)), + "an editor opened on a cell being saved" + ); + assert!( + grid.grid.edit().edit_cell(&grid.cell_of(2, 1)), + "the save locked a cell it does not write" + ); + grid.grid.edit().cancel_open_edit(); + let _ = grid.tracker.settle_save( + &crate::workspace::SaveOutcome::Committed { rows: Some(1) }, + batch.snapshot, + ); + grid.grid.redraw(); + assert!( + grid.grid.edit().edit_cell(&grid.cell_of(1, 1)), + "the cell stayed locked after the save" + ); +} diff --git a/linux/crates/app/src/ui/data_grid/edit/tests/paste.rs b/linux/crates/app/src/ui/data_grid/edit/tests/paste.rs new file mode 100644 index 0000000000..cf7eedc2c3 --- /dev/null +++ b/linux/crates/app/src/ui/data_grid/edit/tests/paste.rs @@ -0,0 +1,393 @@ +use gtk4::prelude::*; +use gtk4::{self as gtk, gdk, glib}; +use tablepro_core::edit::CellInput; +use tablepro_core::{ColumnInfo, Value}; + +use super::{pending, press, toasts}; +use crate::services::grid::{RowSnapshot, edit_policy}; +use crate::test_support::{drain_main_context, respond, wait_until}; +use crate::ui::clipboard::{CopyKind, CopyRequest, copy_rows}; +use crate::ui::data_grid::edit::paste::PASTE_CONFIRM_ROWS; +use crate::ui::data_grid::test_grid::{Grid, GridSpec, WAIT, named, persisted}; + +/// An `id` key, a name and an age, with Ada and Grace saved. +fn people() -> Vec { + let mut id = named("id", "integer", true); + id.nullable = false; + vec![id, named("name", "text", false), named("age", "integer", false)] +} + +fn people_holding(saved: Vec>) -> Grid { + Grid::open(people(), saved) +} + +fn people_table() -> Grid { + people_holding(vec![ + vec![Value::Int(0), Value::Text("Ada".into()), Value::Int(36)], + vec![Value::Int(1), Value::Text("Grace".into()), Value::Int(85)], + ]) +} + +/// Paste `text` with no cell to start at, so it lands as new rows. +fn paste_rows(grid: &Grid, text: &str) { + gtk::prelude::GtkWindowExt::set_focus(&grid.window, None::<>k::Widget>); + grid.grid.clipboard().set_text(text); + grid.activate("paste"); +} + +/// Paste `text` at the cell of data column `column` at `position`. +fn paste_at(grid: &Grid, text: &str, position: u32, column: usize) { + grid.focus(position, column); + grid.grid.clipboard().set_text(text); + grid.activate("paste"); +} + +fn drafts(grid: &Grid) -> usize { + grid.grid.store().draft_count() as usize +} + +fn draft_cells(grid: &Grid, position: u32) -> Vec { + let draft_id = grid + .grid + .store() + .row(position) + .and_then(|row| row.draft_id()) + .expect("a new row"); + grid.tracker.draft_inputs(draft_id).expect("the draft's cells") +} + +fn int(value: i64) -> CellInput { + CellInput::Value(Value::Int(value)) +} + +fn name(value: &str) -> CellInput { + CellInput::Value(Value::Text(value.to_owned())) +} + +fn null() -> CellInput { + CellInput::Value(Value::Null) +} + +/// Copy `rows` of the grid as the copy action does, and wait for the +/// copy to reach the clipboard. +fn copy(grid: &Grid, rows: Vec, kind: CopyKind) { + const BEFORE: &str = "nothing copied yet"; + let clipboard = grid.grid.clipboard(); + clipboard.set_text(BEFORE); + let tasks = grid.grid.config().expect("a configured grid").tasks.clone(); + let request = CopyRequest::Rows { + columns: grid.columns.iter().map(|column| column.result_column()).collect(), + rows, + kind, + }; + copy_rows(&grid.grid, &tasks, request, |_| {}); + let text = || -> Option { clipboard.content()?.value(String::static_type()).ok()?.get().ok() }; + wait_until(WAIT, || text().is_some_and(|text| text != BEFORE)).expect("the copy to land"); +} + +fn saved_snapshot(grid: &Grid, position: u32) -> RowSnapshot { + let cells = grid + .grid + .store() + .row(position) + .and_then(|row| row.cells()) + .expect("the row's cells"); + RowSnapshot::new(cells, Vec::new()) +} + +#[gtk4::test] +fn pasted_rows_are_new_rows_in_the_order_they_were_copied() { + let grid = people_table(); + + paste_rows(&grid, "7\tLinus\t54\n8\tKen\t\n"); + + wait_until(WAIT, || drafts(&grid) == 2).expect("the rows were pasted"); + assert_eq!(draft_cells(&grid, 0), vec![int(7), name("Linus"), int(54)]); + assert_eq!(draft_cells(&grid, 1), vec![int(8), name("Ken"), null()]); + assert_eq!( + grid.grid.store().row(2).and_then(|row| row.key()), + Some(crate::ui::data_grid::test_grid::key(0)), + "the saved rows moved above the new ones" + ); + assert_eq!(toasts(&grid), ["Pasted 2 rows"]); +} + +/// The copy offers the cells beside its text, which says the same of a +/// NULL and the text `NULL`, and quotes what it then cannot unquote. +#[gtk4::test] +fn rows_copied_from_the_grid_paste_back_exactly() { + let grid = people_holding(vec![ + vec![Value::Int(0), Value::Text("NULL".into()), Value::Null], + vec![Value::Int(1), Value::Text("\"two\nlines\"".into()), Value::Int(3)], + ]); + copy( + &grid, + vec![saved_snapshot(&grid, 0), saved_snapshot(&grid, 1)], + CopyKind::Rows, + ); + + gtk::prelude::GtkWindowExt::set_focus(&grid.window, None::<>k::Widget>); + grid.activate("paste"); + + wait_until(WAIT, || drafts(&grid) == 2).expect("the rows were pasted"); + assert_eq!(draft_cells(&grid, 0), vec![int(0), name("NULL"), null()]); + assert_eq!(draft_cells(&grid, 1), vec![int(1), name("\"two\nlines\""), int(3)]); +} + +#[gtk4::test] +fn a_copied_cell_pastes_into_another_cell_as_it_was() { + let grid = people_holding(vec![ + vec![Value::Int(0), Value::Text("\"two\nlines\"".into()), Value::Null], + vec![Value::Int(1), Value::Text("Grace".into()), Value::Int(85)], + ]); + copy(&grid, vec![saved_snapshot(&grid, 0)], CopyKind::Value(1)); + + grid.focus(1, 1); + grid.activate("paste"); + + wait_until(WAIT, || grid.tracker.pending_count() == 1).expect("the cell was edited"); + assert_eq!(pending(&grid, 1, 1), Some(name("\"two\nlines\""))); +} + +#[gtk4::test] +fn one_undo_takes_back_the_whole_paste() { + let grid = people_table(); + paste_rows(&grid, "7\tLinus\t54\n8\tKen\t1\n9\tDennis\t70\n"); + wait_until(WAIT, || drafts(&grid) == 3).expect("the rows were pasted"); + + grid.grid.undo(); + + assert_eq!(drafts(&grid), 0); + assert_eq!(grid.tracker.pending_count(), 0); + grid.grid.redo(); + assert_eq!(drafts(&grid), 3); + assert_eq!( + draft_cells(&grid, 0)[1], + name("Linus"), + "the redone rows came back in another order" + ); +} + +#[gtk4::test] +fn rows_fill_the_columns_from_the_one_the_paste_starts_at() { + let grid = people_table(); + + paste_at(&grid, "Linus\t54\textra\n", 0, 1); + + wait_until(WAIT, || drafts(&grid) == 1).expect("the row was pasted"); + assert_eq!(draft_cells(&grid, 0), vec![null(), name("Linus"), int(54)]); + assert_eq!(toasts(&grid), ["Pasted 1 row", "Left out 1 value no column could take"]); +} + +#[gtk4::test] +fn values_fill_the_columns_in_the_order_the_grid_shows_them() { + let grid = people_table(); + let view = grid.grid.column_view(); + let (name_column, age_column) = ( + grid.grid.data_view_column(1).expect("the name column"), + grid.grid.data_view_column(2).expect("the age column"), + ); + let columns = view.columns(); + let name_at = (0..columns.n_items()) + .find(|position| columns.item(*position).as_ref() == Some(name_column.upcast_ref::())) + .expect("the name column"); + view.insert_column(name_at, &age_column); + assert_eq!(grid.grid.shown_columns(), [0, 2, 1]); + + paste_at(&grid, "7\t54\tLinus\n", 0, 0); + + wait_until(WAIT, || drafts(&grid) == 1).expect("the row was pasted"); + assert_eq!(draft_cells(&grid, 0), vec![int(7), name("Linus"), int(54)]); +} + +#[gtk4::test] +fn a_header_line_maps_the_values_by_name() { + let grid = people_table(); + + paste_rows(&grid, "age\tname\tid\n54\tLinus\t7\n"); + + wait_until(WAIT, || drafts(&grid) == 1).expect("the row was pasted"); + assert_eq!(draft_cells(&grid, 0), vec![int(7), name("Linus"), int(54)]); +} + +#[gtk4::test] +fn one_value_goes_into_the_cell_the_paste_starts_at() { + let grid = people_table(); + + paste_at(&grid, "Lovelace", 0, 1); + + wait_until(WAIT, || grid.tracker.pending_count() == 1).expect("the cell was edited"); + assert_eq!(drafts(&grid), 0, "one value made a new row"); + assert_eq!(pending(&grid, 0, 1), Some(name("Lovelace"))); +} + +#[gtk4::test] +fn one_value_a_cell_cannot_take_says_why() { + let grid = people_table(); + + paste_at(&grid, "5", 0, 0); + let mut said = Vec::new(); + wait_until(WAIT, || { + said.extend(toasts(&grid)); + !said.is_empty() + }) + .expect("the key said why"); + paste_at(&grid, "old", 0, 2); + wait_until(WAIT, || { + said.extend(toasts(&grid)); + said.len() == 2 + }) + .expect("the number said why"); + + assert_eq!(grid.tracker.pending_count(), 0); + assert_eq!( + said, + ["A saved row's key can't be changed here", "Enter a whole number"] + ); +} + +#[gtk4::test] +fn ctrl_v_pastes_at_the_focused_cell() { + let grid = people_table(); + grid.focus(1, 2); + grid.grid.clipboard().set_text("90"); + + assert!(press(&grid, gdk::Key::v, gdk::ModifierType::CONTROL_MASK)); + + wait_until(WAIT, || grid.tracker.pending_count() == 1).expect("the focused cell was edited"); + assert_eq!(pending(&grid, 1, 2), Some(int(90))); +} + +#[gtk4::test] +fn the_cell_menu_pastes_rows_from_its_column() { + let grid = people_table(); + let cell = grid.cell_of(1, 1); + grid.grid.clipboard().set_text("Linus\t54\nKen\t81\n"); + + assert!(grid.grid.open_cell_menu(&cell, None)); + grid.activate("paste"); + if let Some(menus) = grid.grid.menus() { + menus.cell.popdown(); + } + + wait_until(WAIT, || drafts(&grid) == 2).expect("the rows were pasted"); + assert_eq!(draft_cells(&grid, 1), vec![null(), name("Ken"), int(81)]); + wait_until(WAIT, || { + let selection = grid.grid.selection(); + selection.is_selected(0) && selection.is_selected(1) && grid.grid.selected_count() == 2 + }) + .expect("the pasted rows are the selection"); +} + +#[gtk4::test] +fn a_value_the_column_refuses_stops_the_paste_and_names_it() { + let grid = people_table(); + + paste_rows(&grid, "7\tLinus\t54\n8\tKen\tforty\n"); + + let mut alerts = Vec::new(); + wait_until(WAIT, || { + alerts.extend(grid.grid.edit().take_alerts()); + !alerts.is_empty() + }) + .expect("the paste said why it stopped"); + assert_eq!(drafts(&grid), 0, "part of the paste landed"); + assert_eq!( + alerts, + ["Couldn't Paste: Line 2, column age: Enter a whole number (the value was “forty”)"] + ); +} + +#[gtk4::test] +fn an_empty_clipboard_says_there_is_nothing_to_paste() { + let grid = people_table(); + + paste_rows(&grid, "\n\n"); + + let mut said = Vec::new(); + wait_until(WAIT, || { + said.extend(toasts(&grid)); + !said.is_empty() + }) + .expect("the paste said why it stopped"); + assert_eq!(said, ["The clipboard has no rows to paste."]); +} + +/// Paste follows the grid's policy like every other change: off, with +/// nothing to read, where no pasted row could be saved. +#[gtk4::test] +fn paste_is_off_on_a_read_only_connection_and_without_a_key() { + for policy in [edit_policy(true, true, true), edit_policy(false, false, true)] { + let mut spec = GridSpec::table(people()); + spec.policy = Some(policy); + let grid = Grid::build( + spec, + persisted(&[vec![Value::Int(0), Value::Text("Ada".into()), Value::Null]]), + ); + + assert!(!grid.is_enabled("paste"), "{policy:?}"); + grid.grid.clipboard().set_text("7\tLinus\n"); + grid.grid.actions().activate_action("paste", None); + drain_main_context(); + + assert_eq!(grid.tracker.pending_count(), 0); + assert_eq!(drafts(&grid), 0); + } +} + +#[gtk4::test] +fn a_large_paste_asks_first() { + let grid = people_table(); + let text: String = (0..=PASTE_CONFIRM_ROWS) + .map(|n| format!("{}\tuser {n}\t\n", n + 10)) + .collect(); + + paste_rows(&grid, &text); + + wait_until(WAIT, || grid.grid.edit().question().is_some()).expect("a paste this large asks first"); + // The question is presented by a task the ask spawned. + drain_main_context(); + let question = grid.grid.edit().question().expect("the question"); + assert_eq!(grid.tracker.pending_count(), 0, "the rows landed before the answer"); + respond(&question, "paste"); + + wait_until(WAIT, || drafts(&grid) == PASTE_CONFIRM_ROWS + 1).expect("the rows were pasted"); + assert_eq!(draft_cells(&grid, 0)[1], name("user 0")); +} + +#[gtk4::test] +fn more_lines_than_a_paste_takes_point_to_import_csv() { + let grid = people_table(); + let text = "x\n".repeat(tablepro_core::import::MAX_PASTED_ROWS + 1); + + paste_rows(&grid, &text); + + let mut said = Vec::new(); + wait_until(WAIT, || { + said.extend(toasts(&grid)); + !said.is_empty() + }) + .expect("the paste said why it stopped"); + assert_eq!( + said, + ["The clipboard holds too much to paste. Import CSV takes large amounts of data."] + ); + assert_eq!(grid.tracker.pending_count(), 0); +} + +/// The clipboard of another program can take any time to answer, so a +/// paste made while another reads is the only one that lands. +#[gtk4::test] +fn only_the_latest_paste_lands() { + let grid = people_table(); + gtk::prelude::GtkWindowExt::set_focus(&grid.window, None::<>k::Widget>); + grid.grid.clipboard().set_text("7\tLinus\t54\n"); + + for _ in 0..2 { + grid.grid.activate_action("grid.paste", None).expect("the grid's Paste"); + } + + wait_until(WAIT, || drafts(&grid) > 0).expect("a paste landed"); + drain_main_context(); + assert_eq!(drafts(&grid), 1, "both pastes landed"); +} diff --git a/linux/crates/app/src/ui/data_grid/edit/tests/rows.rs b/linux/crates/app/src/ui/data_grid/edit/tests/rows.rs new file mode 100644 index 0000000000..eb57aefbbf --- /dev/null +++ b/linux/crates/app/src/ui/data_grid/edit/tests/rows.rs @@ -0,0 +1,271 @@ +use std::sync::Arc; + +use gtk4::gdk; +use gtk4::prelude::*; +use libadwaita::prelude::*; +use tablepro_core::Value; +use tablepro_core::edit::CellInput; + +use super::{editing, none, press, two_text_rows}; +use crate::services::change_tracker::RowState; +use crate::services::grid::edit_policy; +use crate::test_support::{drain_main_context, respond, wait_until}; +use crate::ui::data_grid::test_grid::{Grid, GridSpec, WAIT, key, named, page, persisted, two_text_columns}; +use crate::ui::data_grid::{GridRequest, RowData}; + +fn draft_cells(grid: &Grid, position: u32) -> Vec { + let draft_id = grid + .grid + .store() + .row(position) + .and_then(|row| row.draft_id()) + .expect("a new row"); + grid.tracker.draft_inputs(draft_id).expect("the draft's cells") +} + +fn name(value: &str) -> CellInput { + CellInput::Value(Value::Text(value.to_owned())) +} + +fn select_all_from(grid: &Grid, position: u32, column: usize) { + grid.focus(position, column); + grid.grid.selection().select_all(); + drain_main_context(); +} + +fn set_confirm_bulk_delete(grid: &Grid, confirm: bool) { + grid.grid + .config() + .expect("a configured grid") + .settings + .set_confirm_bulk_delete(confirm) + .expect("the setting is written"); +} + +#[gtk4::test] +fn insert_row_puts_a_draft_on_top_and_edits_it() { + let grid = two_text_rows(); + grid.focus(1, 1); + + assert!(press(&grid, gdk::Key::n, gdk::ModifierType::CONTROL_MASK)); + drain_main_context(); + + assert_eq!(grid.grid.store().draft_count(), 1); + assert_eq!( + draft_cells(&grid, 0), + [CellInput::Value(Value::Null), CellInput::Value(Value::Null)] + ); + let cell = editing(&grid); + assert_eq!( + (cell.position(), cell.column()), + (Some(0), 0), + "a draft sets its own key" + ); + assert!( + grid.take_requests() + .iter() + .any(|request| matches!(request, GridRequest::PendingChanged)) + ); +} + +/// D1: the menu inserted a row the toolbar refused, on a table whose +/// rows cannot be saved. +#[gtk4::test] +fn insert_and_duplicate_are_off_without_a_primary_key() { + let grid = Grid::open( + vec![named("id", "integer", false), named("name", "text", false)], + vec![vec![Value::Int(1), Value::Text("A".into())]], + ); + let cell = grid.focus(0, 1); + assert!(grid.grid.open_cell_menu(&cell, None)); + + for action in [ + "insert-row", + "duplicate-rows", + "delete-rows", + "paste", + "edit", + "set-null", + ] { + assert!(!grid.is_enabled(action), "{action} was on"); + } + grid.grid.actions().activate_action("insert-row", None); + grid.grid.actions().activate_action("duplicate-rows", None); + if let Some(menus) = grid.grid.menus() { + menus.cell.popdown(); + } + drain_main_context(); + assert!(!press(&grid, gdk::Key::n, gdk::ModifierType::CONTROL_MASK)); + drain_main_context(); + + assert_eq!(grid.grid.store().draft_count(), 0); + assert_eq!(grid.tracker.pending_count(), 0); +} + +#[gtk4::test] +fn a_grid_that_only_shows_rows_offers_no_change() { + let grid = Grid::build( + GridSpec::results(two_text_columns()), + crate::ui::data_grid::test_grid::results(&[vec![Value::Int(1), Value::Text("A".into())]]), + ); + grid.focus(0, 1); + + for action in [ + "edit", + "toggle", + "set-null", + "insert-row", + "duplicate-rows", + "delete-rows", + "paste", + ] { + assert!(!grid.is_enabled(action), "{action} was on"); + } + assert!(grid.grid.edit().draft_rows().is_empty()); +} + +#[gtk4::test] +fn duplicated_rows_keep_their_order_on_screen_and_after_a_refresh() { + let rows = vec![ + vec![Value::Int(0), Value::Text("Ada".into())], + vec![Value::Int(1), Value::Text("Grace".into())], + ]; + let grid = Grid::open(two_text_columns(), rows.clone()); + select_all_from(&grid, 0, 1); + + grid.activate("duplicate-rows"); + + let names = |grid: &Grid| (draft_cells(grid, 0)[1].clone(), draft_cells(grid, 1)[1].clone()); + assert_eq!(names(&grid), (name("Ada"), name("Grace"))); + assert_eq!( + draft_cells(&grid, 0)[0], + CellInput::Value(Value::Null), + "the copy took the key the database owns" + ); + grid.grid.replace_rows(persisted(&rows), 0); + assert_eq!( + names(&grid), + (name("Ada"), name("Grace")), + "a refresh turned them around" + ); + assert_eq!(grid.grid.store().draft_count(), 2); +} + +#[gtk4::test] +fn delete_marks_saved_rows_drops_drafts_and_ends_the_selection() { + let grid = two_text_rows(); + grid.insert_draft(); + select_all_from(&grid, 0, 1); + + assert!(press(&grid, gdk::Key::Delete, none())); + + assert_eq!(grid.grid.store().draft_count(), 0); + assert_eq!(grid.tracker.row_state(&key(1)), RowState::PendingDelete); + assert_eq!(grid.tracker.row_state(&key(2)), RowState::PendingDelete); + assert_eq!(grid.grid.selected_count(), 0); +} + +#[gtk4::test] +fn delete_is_off_for_rows_without_a_key() { + let mut spec = GridSpec::table(two_text_columns()); + spec.policy = Some(edit_policy(false, true, true)); + let grid = Grid::build( + spec, + vec![RowData::persisted( + None, + Arc::from([Value::Int(1), Value::Text("A".into())]), + )], + ); + grid.focus(0, 1); + + assert!(!grid.is_enabled("delete-rows")); + assert!(!press(&grid, gdk::Key::Delete, none())); + assert_eq!(grid.tracker.pending_count(), 0); +} + +#[gtk4::test] +fn a_bulk_delete_asks_first() { + let grid = Grid::build(GridSpec::table(two_text_columns()), page(0..12)); + select_all_from(&grid, 0, 1); + + grid.activate("delete-rows"); + let question = grid.grid.edit().question().expect("twelve rows ask first"); + assert_eq!(question.heading().as_deref(), Some("Delete 12 Rows?")); + assert_eq!(grid.tracker.pending_count(), 0, "the rows went before the answer"); + respond(&question, "cancel"); + drain_main_context(); + assert_eq!(grid.tracker.pending_count(), 0); + assert!(grid.grid.edit().question().is_none()); + + grid.activate("delete-rows"); + respond(&grid.grid.edit().question().expect("asked again"), "delete"); + + wait_until(WAIT, || grid.tracker.pending_count() == 12).expect("every row is marked"); + assert_eq!(grid.grid.selected_count(), 0); +} + +#[gtk4::test] +fn a_bulk_delete_goes_at_once_when_preferences_say_not_to_ask() { + let grid = Grid::build(GridSpec::table(two_text_columns()), page(0..12)); + set_confirm_bulk_delete(&grid, false); + select_all_from(&grid, 0, 1); + + grid.activate("delete-rows"); + + assert!(grid.grid.edit().question().is_none()); + assert_eq!(grid.tracker.pending_count(), 12); +} + +/// A second Delete while one waits replaces the question, and the first +/// answer, when it comes, does nothing. +#[gtk4::test] +fn a_second_bulk_delete_replaces_the_question() { + let grid = Grid::build(GridSpec::table(two_text_columns()), page(0..12)); + select_all_from(&grid, 0, 1); + grid.activate("delete-rows"); + let first = grid.grid.edit().question().expect("the first question"); + + grid.activate("delete-rows"); + let second = grid.grid.edit().question().expect("the second question"); + + assert_ne!(first, second); + drain_main_context(); + assert_eq!(grid.tracker.pending_count(), 0, "the closed question deleted"); + respond(&second, "delete"); + wait_until(WAIT, || grid.tracker.pending_count() == 12).expect("the rows are marked"); +} + +#[gtk4::test] +fn undo_and_redo_take_a_new_row_away_and_back() { + let grid = two_text_rows(); + grid.insert_draft(); + grid.take_requests(); + + grid.grid.undo(); + assert_eq!(grid.grid.store().draft_count(), 0); + assert_eq!(grid.tracker.pending_count(), 0); + grid.grid.redo(); + assert_eq!(grid.grid.store().draft_count(), 1); + + let changed = grid + .take_requests() + .iter() + .filter(|request| matches!(request, GridRequest::PendingChanged)) + .count(); + assert_eq!(changed, 2); +} + +#[gtk4::test] +fn discard_drops_every_pending_change() { + let grid = two_text_rows(); + grid.insert_draft(); + let cell = grid.focus(1, 1); + let editor = super::type_into(&grid, &cell, "typed"); + assert!(editor.is_visible()); + + grid.grid.discard(); + + assert_eq!(grid.grid.store().draft_count(), 0); + assert_eq!(grid.tracker.pending_count(), 0); + assert!(super::editing_cell(&grid).is_none(), "the editor outlived the discard"); +} diff --git a/linux/crates/app/src/ui/data_grid/find_bar.rs b/linux/crates/app/src/ui/data_grid/find_bar.rs new file mode 100644 index 0000000000..13f985eea0 --- /dev/null +++ b/linux/crates/app/src/ui/data_grid/find_bar.rs @@ -0,0 +1,636 @@ +use std::cell::{Cell, RefCell}; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; + +use gtk4::prelude::*; +use gtk4::subclass::prelude::*; +use gtk4::{self as gtk, gdk, glib}; + +use super::{DataGrid, GridRow, PinnedRow, grid_actions}; +use crate::i18n::{gettext, gettext_f}; +use crate::services::grid::{FindMatch, RowSnapshot, find_matches}; + +/// The find bar's search over the rows the grid holds. +#[derive(Debug, Default)] +pub struct FindState { + found: RefCell, + /// Set to stop the search that is running once a newer one starts. + running: RefCell>>, + /// Counts searches, so an answer to an older one is dropped. + searches: Cell, + /// The search running was typed, so its answer moves to a match. + moving: Cell, + /// Where the search stands: the cell focused when the bar opened, then + /// each match it moved to. Escape puts the focus there. + cursor: RefCell)>>, + rerun_queued: Cell, +} + +#[derive(Debug, Default)] +struct Found { + /// The rows searched, in the order the grid showed them. + rows: Vec, + matches: Vec, + current: Option, + /// A search for the entry's text has answered. + answered: bool, +} + +impl Found { + fn label(&self) -> String { + if !self.answered { + return String::new(); + } + match self.matches.len() { + 0 => gettext("No results"), + count => gettext_f( + "{position} of {count}", + &[ + ("position", &self.current.map_or(0, |index| index + 1).to_string()), + ("count", &count.to_string()), + ], + ), + } + } + + /// The cursor's row among the rows searched, and its column. + fn locate(&self, cursor: Option<&(PinnedRow, Option)>) -> Option<(usize, Option)> { + let (pin, column) = cursor?; + let row = pin.get()?; + let at = self.rows.iter().position(|searched| searched.get() == Some(row))?; + Some((at, *column)) + } +} + +/// The first match in `row` or below it, wrapping to the first of all. +fn first_from(matches: &[FindMatch], row: usize) -> usize { + matches.iter().position(|found| found.row >= row).unwrap_or(0) +} + +pub fn install(grid: &DataGrid) { + let imp = grid.imp(); + let finds = grid.config().is_some_and(|config| config.find); + // The table tab keeps Ctrl+F for its filter, so its grid has no bar. + imp.search_bar.set_visible(finds); + if !finds { + return; + } + let entry = imp.search_entry.get(); + imp.search_bar.connect_entry(&entry); + for button in [imp.find_previous.get(), imp.find_next.get()] { + if let Some(action) = button.action_name() { + crate::ui::a11y::announce_key_shortcuts(&button, &action); + } + } + entry.connect_search_changed(glib::clone!( + #[weak] + grid, + move |entry| grid.start_search(entry.text().to_string(), true) + )); + entry.connect_activate(glib::clone!( + #[weak] + grid, + move |_| grid.find_step(true) + )); + entry.connect_next_match(glib::clone!( + #[weak] + grid, + move |_| grid.find_step(true) + )); + entry.connect_previous_match(glib::clone!( + #[weak] + grid, + move |_| grid.find_step(false) + )); + entry.add_controller(shift_enter(grid)); + imp.search_bar.connect_search_mode_enabled_notify(glib::clone!( + #[weak] + grid, + move |bar| { + if !bar.is_search_mode() { + grid.find_closed(); + } + } + )); + // A client sort reorders the rows without writing them. + grid.selection().connect_items_changed(glib::clone!( + #[weak] + grid, + move |_, _, _, _| grid.find_rows_changed() + )); +} + +pub fn install_actions(grid: &DataGrid) { + grid_actions::add(grid, "find", DataGrid::open_find); + grid_actions::add(grid, "find-next", |grid| grid.find_step(true)); + grid_actions::add(grid, "find-previous", |grid| grid.find_step(false)); +} + +pub fn refresh_actions(grid: &DataGrid) { + let finds = grid.config().is_some_and(|config| config.find); + let matches = finds && !grid.imp().find.found.borrow().matches.is_empty(); + grid_actions::enable(grid, "find", finds); + grid_actions::enable(grid, "find-next", matches); + grid_actions::enable(grid, "find-previous", matches); +} + +/// Shift+Enter goes back, as Shift+Ctrl+G does; GtkText binds Enter alone. +fn shift_enter(grid: &DataGrid) -> gtk::EventControllerKey { + let keys = gtk::EventControllerKey::new(); + keys.set_propagation_phase(gtk::PropagationPhase::Capture); + let weak = grid.downgrade(); + keys.connect_key_pressed(move |_, keyval, _, state| { + let enter = matches!(keyval, gdk::Key::Return | gdk::Key::KP_Enter | gdk::Key::ISO_Enter); + let shift_only = state & gtk::accelerator_get_default_mod_mask() == gdk::ModifierType::SHIFT_MASK; + match (enter && shift_only, weak.upgrade()) { + (true, Some(grid)) => { + grid.find_step(false); + glib::Propagation::Stop + } + _ => glib::Propagation::Proceed, + } + }); + keys +} + +impl DataGrid { + fn open_find(&self) { + let imp = self.imp(); + if !imp.search_bar.is_search_mode() { + imp.find.cursor.replace(self.find_start()); + imp.search_bar.set_search_mode(true); + } + imp.search_entry.grab_focus(); + imp.search_entry.select_region(0, -1); + } + + /// Where a new search counts from: the focused cell, else the first + /// selected row. + fn find_start(&self) -> Option<(PinnedRow, Option)> { + if let Some(cell) = self.focused_cell() + && let Some(row) = cell.row() + { + return Some((row.pin(), Some(cell.column()))); + } + self.selected_rows().first().map(|row| (row.pin(), None)) + } + + /// Search the rows as they are shown now, off the GTK thread. A newer + /// search stops this one; `moving` goes to its first match once it + /// answers. + fn start_search(&self, query: String, moving: bool) { + let state = &self.imp().find; + if let Some(running) = state.running.take() { + running.store(true, Ordering::Relaxed); + } + let search = state.searches.get().wrapping_add(1); + state.searches.set(search); + state.moving.set(moving); + let Some(tasks) = self + .config() + .map(|config| config.tasks.clone()) + .filter(|_| !query.is_empty()) + else { + state.found.replace(Found::default()); + self.show_find_status(); + refresh_actions(self); + return; + }; + let shown = self.shown_rows(); + let tracked = self.tracked(); + let snapshots: Vec = shown.iter().map(|row| tracked.snapshot(row)).collect(); + let rows: Vec = shown.iter().map(GridRow::pin).collect(); + let columns = self.grid_columns(); + let order = self.shown_columns(); + let cancelled = Arc::new(AtomicBool::new(false)); + state.running.replace(Some(cancelled.clone())); + let grid = self.downgrade(); + glib::spawn_future_local(async move { + let answer = tasks + .spawn_blocking_task(move || { + find_matches(&query, &snapshots, &columns, &order, &|| { + cancelled.load(Ordering::Relaxed) + }) + }) + .await; + let Some(grid) = grid.upgrade() else { + return; + }; + let state = &grid.imp().find; + if state.searches.get() != search { + return; + } + state.running.take(); + let moving = state.moving.replace(false); + match answer { + Ok(matches) => grid.take_matches(rows, matches, moving), + Err(failure) => tracing::warn!(%failure, "the rows could not be searched"), + } + }); + } + + /// A typed search goes to the first match from the cursor's row down; + /// a search again over changed rows stays on the match it was on. + fn take_matches(&self, rows: Vec, matches: Vec, moving: bool) { + let state = &self.imp().find; + let mut found = Found { + rows, + matches, + current: None, + answered: true, + }; + let cursor = found.locate(state.cursor.borrow().as_ref()); + found.current = match moving { + true => (!found.matches.is_empty()).then(|| first_from(&found.matches, cursor.map_or(0, |(row, _)| row))), + false => cursor.and_then(|(row, column)| { + found + .matches + .iter() + .position(|hit| hit.row == row && Some(hit.column) == column) + }), + }; + let current = found.current; + state.found.replace(found); + if moving && let Some(index) = current { + self.visit_match(index); + } + self.show_find_status(); + refresh_actions(self); + } + + fn find_step(&self, forward: bool) { + let next = { + let state = &self.imp().find; + let found = state.found.borrow(); + let count = found.matches.len(); + if count == 0 { + return; + } + let from = match found.current { + Some(index) => index, + // Off every match: the first one from the cursor's row down + // is the next, the one before it the previous. + None => { + let row = found.locate(state.cursor.borrow().as_ref()).map_or(0, |(row, _)| row); + let first = first_from(&found.matches, row); + match forward { + true => (first + count - 1) % count, + false => first, + } + } + }; + match forward { + true => (from + 1) % count, + false => (from + count - 1) % count, + } + }; + self.visit_match(next); + self.show_find_status(); + } + + /// Select the match's row and mark its cell as the one the grid + /// focuses next, leaving the focus where it is. + fn visit_match(&self, index: usize) { + let state = &self.imp().find; + let target = { + let found = state.found.borrow(); + found + .matches + .get(index) + .and_then(|hit| Some((found.rows.get(hit.row)?.clone(), hit.row, hit.column))) + }; + let Some((pin, searched_at, column)) = target else { + return; + }; + // A slot that holds another row now has a search again on its way. + let Some(row) = pin.get().cloned() else { + return; + }; + let model = self.selection(); + let position = u32::try_from(searched_at) + .ok() + .filter(|at| model.item(*at).as_ref() == Some(row.upcast_ref())) + .or_else(|| self.view_position(&row)); + let Some(position) = position else { + return; + }; + state.found.borrow_mut().current = Some(index); + state.cursor.replace(Some((pin, Some(column)))); + let view_column = self.data_view_column(column).filter(|column| column.is_visible()); + self.column_view().scroll_to( + position, + view_column.as_ref(), + gtk::ListScrollFlags::FOCUS | gtk::ListScrollFlags::SELECT, + None, + ); + } + + fn show_find_status(&self) { + let imp = self.imp(); + let text = imp.find.found.borrow().label(); + crate::ui::a11y::show_status(&imp.match_label, &text); + } + + /// Only Escape and the bar's close button hide it, both from inside + /// the bar, so the focus goes back to the rows, on the match. + fn find_closed(&self) { + let cursor = self.imp().find.cursor.take(); + if !self.is_mapped() { + return; + } + let at = cursor.and_then(|(pin, column)| { + let row = pin.get()?.clone(); + Some((self.view_position(&row)?, column)) + }); + match at { + Some((position, Some(column))) if self.focus_cell(position, column).is_some() => {} + Some((position, _)) => { + self.column_view() + .scroll_to(position, None, gtk::ListScrollFlags::FOCUS, None); + self.focus_rows(); + } + None => { + self.focus_rows(); + } + } + } + + /// Search the rows again for the text in the bar, once the rows the + /// last search read are no longer the ones shown. + pub(crate) fn find_rows_changed(&self) { + let imp = self.imp(); + if !imp.search_bar.is_search_mode() || imp.search_entry.text().is_empty() || imp.find.rerun_queued.replace(true) + { + return; + } + let grid = self.downgrade(); + glib::idle_add_local_once(move || { + let Some(grid) = grid.upgrade() else { + return; + }; + let imp = grid.imp(); + imp.find.rerun_queued.set(false); + grid.start_search(imp.search_entry.text().to_string(), imp.find.moving.get()); + }); + } +} + +#[cfg(test)] +mod tests { + use std::cell::Cell; + use std::rc::Rc; + + use gtk4::prelude::*; + use gtk4::subclass::prelude::*; + use gtk4::{self as gtk, gdk}; + use tablepro_core::Value; + + use super::super::test_grid::{Grid, GridSpec, WAIT, focus_in_grid, named, results}; + use crate::test_support::{KeyInjector, wait_until}; + + fn text(value: &str) -> Value { + Value::Text(value.to_owned()) + } + + fn row(id: i64, fruit: &str, note: &str) -> Vec { + vec![Value::Int(id), text(fruit), text(note)] + } + + fn spec(find: bool) -> GridSpec { + GridSpec { + find, + ..GridSpec::results(vec![ + named("id", "integer", false), + named("fruit", "text", false), + named("note", "text", false), + ]) + } + } + + /// "apple" is in row 1's fruit, row 2's note, and both of row 3's. + fn orchard() -> Grid { + Grid::build( + spec(true), + results(&[ + row(1, "apple", "-"), + row(2, "pear", "apple tart"), + row(3, "apple pie", "green apple"), + ]), + ) + } + + fn label(grid: &Grid) -> String { + grid.grid.imp().match_label.label().to_string() + } + + fn wait_for_label(grid: &Grid, expected: &str) { + wait_until(WAIT, || label(grid) == expected) + .unwrap_or_else(|_| panic!("the count reads {expected:?}, not {:?}", label(grid))); + } + + fn id_of(cells: &[Value]) -> Option { + match cells.first()? { + Value::Int(id) => Some(*id), + _ => None, + } + } + + /// The row id and the column of the match the grid is on. + fn on_match(grid: &Grid) -> Option<(i64, usize)> { + let cursor = grid.grid.imp().find.cursor.borrow(); + let (pin, column) = cursor.as_ref()?; + Some((id_of(&pin.get()?.cells()?)?, (*column)?)) + } + + fn selected_ids(grid: &Grid) -> Vec { + grid.grid + .selected_rows() + .iter() + .filter_map(|row| id_of(&row.cells()?)) + .collect() + } + + fn search(grid: &Grid, query: &str) { + grid.activate("find"); + grid.grid.imp().search_entry.set_text(query); + } + + #[gtk4::test] + fn hiding_a_column_takes_its_matches_out_of_the_count() { + let grid = orchard(); + search(&grid, "apple"); + wait_for_label(&grid, "1 of 4"); + + grid.grid.set_column_visible(2, false); + + wait_for_label(&grid, "1 of 2"); + } + + #[gtk4::test] + fn matches_go_along_the_columns_on_screen_and_next_and_previous_wrap() { + let grid = orchard(); + let note = grid.grid.data_view_column(2).expect("the note column"); + grid.grid.column_view().insert_column(1, ¬e); + + search(&grid, "apple"); + + wait_for_label(&grid, "1 of 4"); + assert_eq!(on_match(&grid), Some((1, 1))); + assert_eq!(selected_ids(&grid), [1], "the match's row is selected"); + assert!( + crate::ui::a11y::holds_focus(&grid.grid.imp().search_entry.get()), + "the entry keeps the focus" + ); + let mut visited = Vec::new(); + for _ in 0..4 { + grid.activate("find-next"); + visited.push((on_match(&grid), label(&grid))); + } + assert_eq!( + visited, + [ + (Some((2, 2)), "2 of 4".to_owned()), + (Some((3, 2)), "3 of 4".to_owned()), + (Some((3, 1)), "4 of 4".to_owned()), + (Some((1, 1)), "1 of 4".to_owned()), + ], + "the note column comes first on screen, and Next wraps" + ); + grid.activate("find-previous"); + assert_eq!((on_match(&grid), label(&grid)), (Some((3, 1)), "4 of 4".to_owned())); + assert_eq!(selected_ids(&grid), [3]); + } + + #[gtk4::test] + fn the_entrys_keys_move_between_matches() { + let grid = orchard(); + search(&grid, "apple"); + wait_for_label(&grid, "1 of 4"); + let entry = grid.grid.imp().search_entry.get(); + + entry.emit_by_name::<()>("activate", &[]); + assert_eq!(on_match(&grid), Some((2, 2)), "Enter goes to the next match"); + entry.emit_by_name::<()>("next-match", &[]); + assert_eq!(on_match(&grid), Some((3, 1)), "Ctrl+G goes to the next match"); + entry.emit_by_name::<()>("previous-match", &[]); + assert_eq!(on_match(&grid), Some((2, 2)), "Shift+Ctrl+G goes back"); + } + + #[gtk4::test] + fn the_first_match_is_at_or_below_the_focused_row() { + let grid = orchard(); + grid.focus(1, 1); + + search(&grid, "apple"); + + wait_for_label(&grid, "2 of 4"); + assert_eq!(on_match(&grid), Some((2, 2))); + grid.grid.imp().search_entry.set_text("nothing like it"); + wait_for_label(&grid, "No results"); + assert!(!grid.is_enabled("find-next") && !grid.is_enabled("find-previous")); + } + + #[gtk4::test] + fn a_new_query_stops_the_search_still_running() { + let rows: Vec> = (0..5_000).map(|id| row(id, &format!("row {id}"), "")).collect(); + let grid = orchard(); + grid.grid.replace_rows(results(&rows), 0); + grid.activate("find"); + + grid.grid.start_search("row".to_owned(), true); + let first = grid + .grid + .imp() + .find + .running + .borrow() + .clone() + .expect("the first search runs"); + grid.grid.start_search("row 42".to_owned(), true); + + assert!( + first.load(std::sync::atomic::Ordering::Relaxed), + "the older search was told to stop" + ); + // row 42, rows 420 to 429 and rows 4200 to 4299. + wait_for_label(&grid, "1 of 111"); + assert_eq!(grid.grid.imp().find.found.borrow().matches.len(), 111); + assert_eq!(on_match(&grid), Some((42, 1))); + } + + #[gtk4::test] + fn rows_that_change_are_searched_again() { + let grid = orchard(); + search(&grid, "apple"); + wait_for_label(&grid, "1 of 4"); + + grid.grid.append_rows(results(&[row(4, "crab apple", "-")])); + + wait_for_label(&grid, "1 of 5"); + assert_eq!(on_match(&grid), Some((1, 1)), "the search stays on its match"); + grid.grid + .replace_rows(results(&[row(5, "apple", "-"), row(6, "apple", "-")]), 0); + wait_for_label(&grid, "0 of 2"); + grid.activate("find-next"); + assert_eq!((on_match(&grid), label(&grid)), (Some((5, 1)), "1 of 2".to_owned())); + } + + #[gtk4::test] + fn without_find_the_bar_never_shows_and_ctrl_f_goes_on() { + let grid = Grid::build(spec(false), results(&[row(1, "apple", "-")])); + let heard = Rc::new(Cell::new(false)); + let outer = gtk::ShortcutController::new(); + let flag = heard.clone(); + outer.add_shortcut(gtk::Shortcut::new( + gtk::ShortcutTrigger::parse_string("f"), + Some(gtk::CallbackAction::new(move |_, _| { + flag.set(true); + gtk::glib::Propagation::Stop + })), + )); + grid.window.add_controller(outer); + KeyInjector::focus_only(&grid.window); + grid.focus(0, 1); + let keys = KeyInjector::connect().expect("an X11 display"); + + keys.press(gdk::Key::f, gdk::ModifierType::CONTROL_MASK) + .expect("the key press"); + + wait_until(WAIT, || heard.get()).expect("Ctrl+F reaches the host"); + let bar = grid.grid.imp().search_bar.get(); + assert!(!bar.is_visible() && !bar.is_search_mode()); + assert!(!grid.is_enabled("find") && !grid.is_enabled("find-next") && !grid.is_enabled("find-previous")); + } + + #[gtk4::test] + fn the_keys_search_and_escape_returns_the_focus_to_the_match() { + let grid = orchard(); + KeyInjector::focus_only(&grid.window); + grid.focus(0, 1); + let keys = KeyInjector::connect().expect("an X11 display"); + let entry = grid.grid.imp().search_entry.get(); + + keys.press(gdk::Key::f, gdk::ModifierType::CONTROL_MASK) + .expect("the key press"); + wait_until(WAIT, || crate::ui::a11y::holds_focus(&entry)).expect("Ctrl+F focuses the entry"); + entry.set_text("apple"); + wait_for_label(&grid, "1 of 4"); + keys.press(gdk::Key::Return, gdk::ModifierType::empty()) + .expect("the key press"); + wait_for_label(&grid, "2 of 4"); + keys.press(gdk::Key::Return, gdk::ModifierType::SHIFT_MASK) + .expect("the key press"); + wait_for_label(&grid, "1 of 4"); + keys.press(gdk::Key::g, gdk::ModifierType::CONTROL_MASK) + .expect("the key press"); + wait_for_label(&grid, "2 of 4"); + + keys.press(gdk::Key::Escape, gdk::ModifierType::empty()) + .expect("the key press"); + + wait_until(WAIT, || !grid.grid.imp().search_bar.is_search_mode()).expect("Escape hides the bar"); + wait_until(WAIT, || focus_in_grid(&grid)).expect("the focus is back in the rows"); + let focused = grid.focused_cell().expect("a focused cell"); + assert_eq!((focused.position(), focused.column()), (Some(1), 2), "on row 2's note"); + } +} diff --git a/linux/crates/app/src/ui/data_grid/grid_actions.rs b/linux/crates/app/src/ui/data_grid/grid_actions.rs new file mode 100644 index 0000000000..0381cc2889 --- /dev/null +++ b/linux/crates/app/src/ui/data_grid/grid_actions.rs @@ -0,0 +1,180 @@ +use gtk4::prelude::*; +use gtk4::subclass::prelude::*; +use gtk4::{gio, glib}; + +use super::{DataGrid, GridSorting, column_chooser, copy, find_bar, references}; + +/// Add `grid.`, run on the grid. The action holds the grid weakly: +/// the grid owns its action group. +pub(crate) fn add(grid: &DataGrid, name: &str, act: impl Fn(&DataGrid) + 'static) { + let action = gio::SimpleAction::new(name, None); + let weak = grid.downgrade(); + action.connect_activate(move |_, _| { + if let Some(grid) = weak.upgrade() { + act(&grid); + } + }); + grid.imp().actions.add_action(&action); +} + +/// Add `grid.` taking a data column's index, for a header's menu, +/// which GTK opens without telling the grid which column it is on. +pub(crate) fn add_for_column(grid: &DataGrid, name: &str, act: impl Fn(&DataGrid, usize) + 'static) { + let action = gio::SimpleAction::new(name, Some(glib::VariantTy::UINT32)); + let weak = grid.downgrade(); + action.connect_activate(move |_, parameter| { + let (Some(grid), Some(index)) = (weak.upgrade(), parameter.and_then(glib::Variant::get::)) else { + return; + }; + act(&grid, index as usize); + }); + grid.imp().actions.add_action(&action); +} + +pub(crate) fn enable(grid: &DataGrid, name: &str, enabled: bool) { + if let Some(action) = grid + .imp() + .actions + .lookup_action(name) + .and_downcast::() + { + action.set_enabled(enabled); + } +} + +pub fn install(grid: &DataGrid) { + install_column_actions(grid); + add(grid, "menu", |grid| { + if let Some(cell) = grid.focused_cell() { + grid.open_cell_menu(&cell, None); + } + }); + add(grid, "clear-selection", |grid| { + grid.selection().unselect_all(); + }); + let details = gio::SimpleAction::new_stateful("show-details", None, &false.to_variant()); + let weak = grid.downgrade(); + details.connect_change_state(move |action, state| { + let (Some(grid), Some(shown)) = (weak.upgrade(), state.and_then(glib::Variant::get::)) else { + return; + }; + action.set_state(&shown.to_variant()); + grid.imp().split_view.set_show_sidebar(shown); + }); + grid.imp().actions.add_action(&details); + let weak = details.downgrade(); + // The pane closes itself too: its own button, or a click outside it + // while it overlaps the rows. + grid.imp().split_view.connect_show_sidebar_notify(move |split_view| { + if let Some(details) = weak.upgrade() { + details.set_state(&split_view.shows_sidebar().to_variant()); + } + }); + add(grid, "columns", column_chooser::present); + grid.edit().install_actions(grid); + copy::install_actions(grid); + references::install_actions(grid); + find_bar::install_actions(grid); +} + +fn install_column_actions(grid: &DataGrid) { + let on_target_column = |act: fn(&DataGrid, usize)| { + move |grid: &DataGrid| { + if let Some(index) = grid.target_column() { + act(grid, index); + } + } + }; + add( + grid, + "sort-ascending", + on_target_column(|grid, index| grid.sort_by(Some((index, true)))), + ); + add( + grid, + "sort-descending", + on_target_column(|grid, index| grid.sort_by(Some((index, false)))), + ); + add_for_column(grid, "sort-column-ascending", |grid, index| { + grid.sort_by(Some((index, true))) + }); + add_for_column(grid, "sort-column-descending", |grid, index| { + grid.sort_by(Some((index, false))) + }); + add(grid, "clear-sort", |grid| grid.sort_by(None)); + add( + grid, + "hide-column", + on_target_column(|grid, index| grid.set_column_visible(index, false)), + ); + add_for_column(grid, "hide-column-at", |grid, index| { + grid.set_column_visible(index, false) + }); + add( + grid, + "size-to-fit", + on_target_column(|grid, index| grid.set_column_width(index, None)), + ); + add_for_column(grid, "size-column-to-fit", |grid, index| { + grid.set_column_width(index, None) + }); + add( + grid, + "widen-column", + on_target_column(|grid, index| grid.step_column(index, true)), + ); + add( + grid, + "narrow-column", + on_target_column(|grid, index| grid.step_column(index, false)), + ); +} + +impl DataGrid { + /// The data column an action on "the column" means: the cell a menu + /// opened on, else the focused cell's. + pub(crate) fn target_column(&self) -> Option { + self.target()?.cell.map(|(_, column)| column) + } + + /// Open the detail pane on the focused row, with `column`'s field. + pub(crate) fn show_details_for(&self, column: usize) { + self.imp().detail_pane.select_field(column); + if let Some(details) = self.imp().actions.lookup_action("show-details") { + details.change_state(&true.to_variant()); + } + self.imp().detail_pane.focus_value(); + } + + /// Set every action's enabled state for what the grid now shows and + /// the target an action would act on. The keys of the actions held off + /// while the inline editor has the focus belong to the editor. + pub(crate) fn refresh_actions(&self) { + let target = self.target(); + let editing = self.edit().editor_has_focus(); + let column = target + .as_ref() + .and_then(|target| target.cell.as_ref().map(|(_, column)| *column)); + let sorting = self.imp().columns.borrow().sorting(); + let sortable = column + .and_then(|index| self.grid_columns().get(index).map(|column| column.sortable())) + .unwrap_or(false); + let sorts = sorting != GridSorting::None && sortable && !editing; + enable( + self, + "menu", + !editing && target.as_ref().is_some_and(|target| target.cell.is_some()), + ); + enable(self, "sort-ascending", sorts); + enable(self, "sort-descending", sorts); + enable(self, "clear-sort", sorting != GridSorting::None && self.is_sorted()); + for name in ["hide-column", "size-to-fit", "widen-column", "narrow-column"] { + enable(self, name, !editing && column.is_some()); + } + enable(self, "clear-selection", !editing && self.selected_count() > 1); + self.edit().refresh_actions(self, target.as_ref()); + copy::refresh_actions(self, target.as_ref()); + references::refresh_actions(self, target.as_ref()); + find_bar::refresh_actions(self); + } +} diff --git a/linux/crates/app/src/ui/data_grid/grid_cell.rs b/linux/crates/app/src/ui/data_grid/grid_cell.rs new file mode 100644 index 0000000000..6f5f353f99 --- /dev/null +++ b/linux/crates/app/src/ui/data_grid/grid_cell.rs @@ -0,0 +1,358 @@ +use std::cell::{Cell, OnceCell, RefCell}; +use std::rc::Rc; + +use gtk4::prelude::*; +use gtk4::subclass::prelude::*; +use gtk4::{self as gtk, glib, pango}; +use tablepro_core::Value; + +use super::columns::ColumnPaint; +use super::tracked_values::cell_description; +use super::{DataGrid, GridRow}; +use crate::services::change_tracker::CellState; +use crate::services::grid::display_text::{DisplayText, auto_filled_sentinel, display_text, editable_null_sentinel}; +use crate::services::grid::{CellLock, GridColumn}; + +/// Enough for any number, date or short text. +const SCRATCH_KEPT_BYTES: usize = 256; + +/// How a pending change marks a cell's text, so it reads without colour. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum TextMark { + Plain, + Modified, + /// Wins over `Modified`: an edited cell of a row marked for deletion + /// goes with the row. + Deleted, +} + +/// The marks a grid's cells draw their text with, built once per grid so +/// a bind sets a list rather than building one. Pango attributes, since +/// CSS `text-decoration` does not reach an Inscription's text reliably. +#[derive(Debug)] +pub struct TextAttributes { + modified: pango::AttrList, + modified_placeholder: pango::AttrList, + deleted: pango::AttrList, + deleted_placeholder: pango::AttrList, + placeholder: pango::AttrList, + /// The stand-in texts, translated once rather than on every bind. + editable_null: String, + auto_filled: String, +} + +impl TextAttributes { + pub fn new() -> Self { + let list = |mark: TextMark, italic: bool| { + let attrs = pango::AttrList::new(); + match mark { + TextMark::Plain => {} + TextMark::Modified => attrs.insert(pango::AttrInt::new_weight(pango::Weight::Bold)), + TextMark::Deleted => attrs.insert(pango::AttrInt::new_strikethrough(true)), + } + if italic { + attrs.insert(pango::AttrInt::new_style(pango::Style::Italic)); + } + attrs + }; + Self { + modified: list(TextMark::Modified, false), + modified_placeholder: list(TextMark::Modified, true), + deleted: list(TextMark::Deleted, false), + deleted_placeholder: list(TextMark::Deleted, true), + placeholder: list(TextMark::Plain, true), + editable_null: editable_null_sentinel(), + auto_filled: auto_filled_sentinel(), + } + } + + fn for_text(&self, mark: TextMark, placeholder: bool) -> Option<&pango::AttrList> { + match (mark, placeholder) { + (TextMark::Plain, false) => None, + (TextMark::Plain, true) => Some(&self.placeholder), + (TextMark::Modified, false) => Some(&self.modified), + (TextMark::Modified, true) => Some(&self.modified_placeholder), + (TextMark::Deleted, false) => Some(&self.deleted), + (TextMark::Deleted, true) => Some(&self.deleted_placeholder), + } + } +} + +mod imp { + use super::*; + + #[derive(Default)] + pub struct GridCell { + pub list_item: glib::WeakRef, + pub paint: OnceCell>, + pub display: OnceCell, + /// The inline editor while an edit is open in the cell. + pub editor: RefCell>, + /// An editor that closed while it held the focus, hidden until the + /// cell's next edit. + pub closed: RefCell>, + pub binding: RefCell>, + pub lock: Cell>, + /// Where a value that is not text is written to draw it, kept from + /// one bind to the next. + pub scratch: RefCell, + #[cfg(test)] + pub renders: Cell, + } + + #[glib::object_subclass] + impl ObjectSubclass for GridCell { + const NAME: &'static str = "TableProGridCell"; + type Type = super::GridCell; + type ParentType = gtk::Widget; + + fn class_init(klass: &mut Self::Class) { + klass.set_layout_manager_type::(); + klass.set_css_name("tp-grid-cell"); + } + } + + impl ObjectImpl for GridCell { + fn dispose(&self) { + self.obj().disconnect_row(); + for editor in [self.editor.take(), self.closed.take()].into_iter().flatten() { + editor.unparent(); + } + if let Some(display) = self.display.get() { + display.unparent(); + } + } + } + + impl WidgetImpl for GridCell { + fn unroot(&self) { + let cell = self.obj(); + if let Some(grid) = cell.grid() { + grid.edit().cell_detaching(&cell); + } + self.parent_unroot(); + } + } +} + +glib::wrapper! { + /// One cell of the grid. It resolves its row through the column view + /// cell when an event fires, so the row is the one the cell shows + /// then, not the one it was bound to. + pub struct GridCell(ObjectSubclass) + @extends gtk::Widget, + @implements gtk::Accessible, gtk::Buildable, gtk::ConstraintTarget; +} + +impl GridCell { + pub fn new(paint: Rc) -> Self { + let cell: Self = glib::Object::new(); + let display = gtk::Inscription::new(None); + display.set_hexpand(true); + // An ellipsized Inscription draws from its top edge, so it is kept + // one line tall and centred rather than stretched to the row. + display.set_valign(gtk::Align::Center); + display.set_text_overflow(gtk::InscriptionOverflow::EllipsizeEnd); + display.set_min_chars(1); + display.set_parent(&cell); + let _ = cell.imp().display.set(display); + let _ = cell.imp().paint.set(paint); + cell + } + + pub fn attach(&self, list_item: >k::ColumnViewCell) { + self.imp().list_item.set(Some(list_item)); + } + + pub fn display(&self) -> Option<>k::Inscription> { + self.imp().display.get() + } + + pub(crate) fn paint(&self) -> Option<&Rc> { + self.imp().paint.get() + } + + pub fn grid_column(&self) -> Option<&GridColumn> { + self.paint().map(|paint| &paint.column) + } + + /// The column's place in the result, which is what its cells hold. + pub fn column(&self) -> usize { + self.paint().map_or(0, |paint| paint.column.index) + } + + pub fn row(&self) -> Option { + self.imp().list_item.upgrade()?.item().and_downcast() + } + + pub fn position(&self) -> Option { + let position = self.imp().list_item.upgrade()?.position(); + (position != gtk::INVALID_LIST_POSITION).then_some(position) + } + + pub fn lock(&self) -> Option { + self.imp().lock.get() + } + + pub(crate) fn grid(&self) -> Option { + self.ancestor(DataGrid::static_type()).and_downcast() + } + + pub fn is_editing(&self) -> bool { + self.imp().editor.borrow().is_some() + } + + /// The cell that holds the keyboard focus. A focused column view cell + /// widget is this cell's parent, so its first child is tried after its + /// ancestors. + pub fn from_focus(focus: >k::Widget) -> Option { + if let Some(cell) = focus.ancestor(GridCell::static_type()) { + return cell.downcast().ok(); + } + focus.first_child()?.downcast().ok() + } + + pub(super) fn bind(&self, row: &GridRow) { + self.disconnect_row(); + let handler = row.connect_revision_notify(glib::clone!( + #[weak(rename_to = cell)] + self, + move |_| cell.render() + )); + self.imp().binding.replace(Some((row.clone(), handler))); + self.render(); + } + + pub(super) fn unbind(&self) { + if let Some(grid) = self.grid() { + grid.edit().cell_unbinding(self); + } + self.disconnect_row(); + self.reset_property(gtk::AccessibleProperty::Description); + } + + fn disconnect_row(&self) { + if let Some((row, handler)) = self.imp().binding.take() { + row.disconnect(handler); + } + } + + fn bound_row(&self) -> Option { + self.imp().binding.borrow().as_ref().map(|(row, _)| row.clone()) + } + + pub(crate) fn render(&self) { + #[cfg(test)] + self.imp().renders.set(self.imp().renders.get() + 1); + let (Some(row), Some(paint), Some(display)) = (self.bound_row(), self.paint().cloned(), self.display()) else { + return; + }; + let column = paint.column.index; + let mark = paint.tracked.mark(&row, column); + let lock = paint.tracked.lock_marked(&paint.column, &row, mark.row_deleted); + self.imp().lock.set(lock); + let text_mark = match (mark.row_deleted, mark.state) { + (true, _) => TextMark::Deleted, + (false, CellState::Modified) => TextMark::Modified, + (false, _) => TextMark::Plain, + }; + let null = paint.tracked.with_value(&row, column, |value, default| { + let mut scratch = self.imp().scratch.borrow_mut(); + let shown = shown_text(&paint, value, default, lock.is_none(), &mut scratch); + display.set_text(Some(shown.text)); + display.set_attributes(paint.attributes.for_text(text_mark, shown.placeholder)); + set_class( + display, + "dimmed", + (value.is_null() && lock.is_some()) || mark.row_deleted, + ); + !default && value.is_null() + }); + if self.imp().scratch.borrow().capacity() > SCRATCH_KEPT_BYTES { + self.imp().scratch.replace(String::new()); + } + let numbers = paint.numbers.get(); + display.set_xalign(if numbers { 1.0 } else { 0.0 }); + set_class(display, "numeric", numbers); + // Adwaita's own warning colour, beside the bold, so a changed value + // reads apart from the rest without a tint of the app's own. + set_class(display, "warning", text_mark == TextMark::Modified); + // On the cell, since GtkColumnViewCell passes on no accessible + // property of its own. + match cell_description(mark.state, null) { + Some(description) => self.update_property(&[gtk::accessible::Property::Description(&description)]), + None => self.reset_property(gtk::AccessibleProperty::Description), + } + } + + /// Lay `editor` over the cell's text until `close_editor`. + pub(crate) fn open_editor(&self, editor: &impl IsA) { + self.close_editor(); + if let Some(closed) = self.imp().closed.take() { + closed.unparent(); + } + if let Some(display) = self.display() { + display.set_visible(false); + } + editor.as_ref().set_parent(self); + self.imp().editor.replace(Some(editor.as_ref().clone())); + } + + /// The editor goes and the text shows again. GTK 4.22 loops forever + /// ending a focus change from a widget that lost its parent, so an + /// editor that still holds the focus is only hidden until the next + /// edit. + pub(crate) fn close_editor(&self) { + if let Some(editor) = self.imp().editor.take() { + match holds_window_focus(&editor) { + true => { + editor.set_visible(false); + if let Some(previous) = self.imp().closed.replace(Some(editor)) { + previous.unparent(); + } + } + false => editor.unparent(), + } + } + if let Some(display) = self.display() { + display.set_visible(true); + } + } + + #[cfg(test)] + pub(crate) fn render_count(&self) -> u32 { + self.imp().renders.get() + } +} + +/// An editable cell shows NULL as ``, so it reads apart from an +/// empty string; a locked one dims a plain NULL. +fn shown_text<'a>( + paint: &'a ColumnPaint, + value: &'a Value, + default: bool, + editable: bool, + scratch: &'a mut String, +) -> DisplayText<'a> { + match (default, value) { + (true, _) if paint.column.auto_filled() => DisplayText::placeholder(&paint.attributes.auto_filled), + (true, _) => DisplayText::placeholder("DEFAULT"), + (false, Value::Null) if editable => DisplayText::placeholder(&paint.attributes.editable_null), + (false, value) => display_text(value, &paint.column.column_type, scratch), + } +} + +fn set_class(widget: &impl IsA, class: &str, on: bool) { + match on { + true => widget.as_ref().add_css_class(class), + false => widget.as_ref().remove_css_class(class), + } +} + +pub(crate) fn holds_window_focus(widget: >k::Widget) -> bool { + widget + .root() + .and_downcast::() + .and_then(|window| gtk::prelude::GtkWindowExt::focus(&window)) + .is_some_and(|focus| focus == *widget || focus.is_ancestor(widget)) +} diff --git a/linux/crates/app/src/ui/data_grid/grid_config.rs b/linux/crates/app/src/ui/data_grid/grid_config.rs new file mode 100644 index 0000000000..763c091a7d --- /dev/null +++ b/linux/crates/app/src/ui/data_grid/grid_config.rs @@ -0,0 +1,67 @@ +use std::rc::Rc; +use std::sync::Arc; + +use tablepro_core::dialect::SqlDialect; +use tablepro_core::meta::{RowIdentity, TableRef}; +use tablepro_session::runtime::Tasks; +use tablepro_storage::AppSettings; + +use super::{KeysHandle, LayoutBinding}; +use crate::services::grid::{EditPolicy, GridColumn}; +use crate::services::tab_tracker_handle::TabTrackerHandle; + +/// How a grid's headers sort its rows. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum GridSorting { + /// The server sorts: a header click asks the host for the rows again + /// in that order. `initial` is the order the first rows arrive in. + Server { initial: Option<(usize, bool)> }, + /// The grid sorts the rows it holds. + Client, + /// The engine reads in its own order, which nothing changes. + None, +} + +/// The dialect a copy writes SQL literals in, asked for at each copy +/// since a reconnect can reach another server version. +pub type DialectSource = Rc Option>>; + +/// What a grid may change in the rows of its column set. It names +/// columns, so it comes and goes with them. +#[derive(Debug, Clone)] +pub struct EditRules { + /// What names a saved row, which locks the columns that name it. + pub identity: RowIdentity, + pub policy: EditPolicy, +} + +/// Everything a host decides about its grid. +#[derive(Clone)] +pub struct GridConfig { + /// The first column set, its sort and its rules; `set_columns` + /// replaces all three. + pub columns: Arc<[GridColumn]>, + pub sorting: GridSorting, + pub rules: Option, + /// Where edits go. `None` for a grid that only shows rows. + pub tracker: Option, + /// Read when it is needed, so a change in Preferences reaches open + /// grids. + pub settings: Rc, + /// Where the table's column layout is kept. `None` keeps it for the + /// grid's life only. + pub layout: Option, + /// The foreign keys of the table the rows come from. + pub references: Option, + /// `None` where the rows have no SQL spelling: no IN list to copy. + pub dialect: Option, + /// The table an INSERT copy writes into. + pub table: Option, + /// Ctrl+F searches the rows the grid holds. + pub find: bool, + /// The host can add a filter rule from a cell's value. + pub filter_by_value: bool, + pub tasks: Tasks, + /// What a screen reader calls the grid. + pub label: String, +} diff --git a/linux/crates/app/src/ui/data_grid/grid_menu.rs b/linux/crates/app/src/ui/data_grid/grid_menu.rs new file mode 100644 index 0000000000..46f0bf3f58 --- /dev/null +++ b/linux/crates/app/src/ui/data_grid/grid_menu.rs @@ -0,0 +1,299 @@ +use gtk4::prelude::*; +use gtk4::subclass::prelude::*; +use gtk4::{self as gtk, gdk, gio}; + +use super::selection::Target; +use super::{DataGrid, GridCell, GridRow, GridSorting}; +use crate::i18n::gettext; +use crate::services::grid::GridColumn; + +/// The grid's menus, built once from what the grid offers and parented to +/// the grid, so their items reach its `grid` actions. +#[derive(Debug)] +pub struct GridMenus { + pub cell: gtk::PopoverMenu, + /// Below the last row, for a grid that takes new rows. + pub empty: Option, + /// Filled from the table's foreign keys as a menu opens. + pub references: gio::Menu, + /// The keys a column is in, for Ctrl+J to choose from. + pub reference_chooser: gtk::PopoverMenu, +} + +/// An item shown only while its action is enabled, so a cell it does not +/// apply to shows no dead entry. +fn hidden_while_disabled(label: &str, action: &str) -> gio::MenuItem { + let item = crate::application::menu_item(label, action); + item.set_attribute_value("hidden-when", Some(&"action-disabled".to_variant())); + item +} + +fn item(label: &str, action: &str) -> gio::MenuItem { + crate::application::menu_item(label, action) +} + +fn section(items: impl IntoIterator) -> gio::Menu { + let menu = gio::Menu::new(); + for item in items { + menu.append_item(&item); + } + menu +} + +impl GridMenus { + pub fn new(grid: &DataGrid) -> Self { + let editable = grid.config().is_some_and(|config| config.tracker.is_some()); + let references = gio::Menu::new(); + let cell = popover(grid, &cell_menu(grid, &references)); + let empty = editable.then(|| { + popover( + grid, + §ion([ + item(&gettext("Insert Row"), "grid.insert-row"), + item(&gettext("Paste"), "grid.paste"), + ]), + ) + }); + let reference_chooser = popover(grid, &gio::Menu::new()); + Self { + cell, + empty, + references, + reference_chooser, + } + } + + /// The popovers are the grid's children beside its template's, which + /// the grid lets go of itself. + pub fn unparent(&self) { + self.cell.unparent(); + if let Some(empty) = &self.empty { + empty.unparent(); + } + self.reference_chooser.unparent(); + } +} + +fn popover(grid: &DataGrid, model: &gio::Menu) -> gtk::PopoverMenu { + let popover = gtk::PopoverMenu::from_model(Some(model)); + popover.set_has_arrow(false); + popover.set_halign(gtk::Align::Start); + popover.set_parent(grid); + let weak = grid.downgrade(); + popover.connect_closed(move |_| { + if let Some(grid) = weak.upgrade() { + grid.release_menu_target_soon(); + } + }); + popover +} + +fn cell_menu(grid: &DataGrid, references: &gio::Menu) -> gio::Menu { + let config = grid.config(); + let editable = config.is_some_and(|config| config.tracker.is_some()); + let sorts = config.is_some_and(|config| config.sorting != GridSorting::None); + let menu = gio::Menu::new(); + if editable { + menu.append_section( + None, + §ion([ + hidden_while_disabled(&gettext("Edit Cell"), "grid.edit"), + hidden_while_disabled(&gettext("Set to Empty"), "grid.set-empty"), + hidden_while_disabled(&gettext("Set to NULL"), "grid.set-null"), + hidden_while_disabled(&gettext("Set to DEFAULT"), "grid.set-default"), + ]), + ); + } + let copy_as = section([ + item(&gettext("Rows"), "grid.copy-rows"), + item(&gettext("With Headers"), "grid.copy-rows-headers"), + item(&gettext("JSON"), "grid.copy-json"), + item(&gettext("CSV"), "grid.copy-csv"), + item(&gettext("CSV With Headers"), "grid.copy-csv-headers"), + item(&gettext("Markdown"), "grid.copy-markdown"), + ]); + copy_as.append_section( + None, + §ion([ + hidden_while_disabled(&gettext("IN Clause"), "grid.copy-in-clause"), + hidden_while_disabled(&gettext("INSERT Statement"), "grid.copy-insert"), + ]), + ); + copy_as.append_section(None, §ion([item(&gettext("Column Name"), "grid.copy-column-name")])); + let clipboard = section([item(&gettext("Copy"), "grid.copy")]); + clipboard.append_submenu(Some(&gettext("Copy As")), ©_as); + if editable { + clipboard.append_item(&item(&gettext("Paste"), "grid.paste")); + } + menu.append_section(None, &clipboard); + if config.is_some_and(|config| config.filter_by_value) { + menu.append_section( + None, + §ion([ + hidden_while_disabled(&gettext("Filter by Value"), "grid.filter-by-value"), + hidden_while_disabled(&gettext("Exclude Value"), "grid.exclude-value"), + ]), + ); + } + menu.append_section(None, references); + menu.append_section( + None, + §ion([ + item(&gettext("Row Details"), "grid.show-details"), + item(&gettext("Show Row as JSON"), "grid.show-row-json"), + ]), + ); + menu.append_section(None, &column_section(sorts)); + let rows = section([item(&gettext("Export…"), "grid.export")]); + if editable { + rows.append_item(&item(&gettext("Insert Row"), "grid.insert-row")); + rows.append_item(&item(&gettext("Duplicate"), "grid.duplicate-rows")); + rows.append_item(&item(&gettext("Delete"), "grid.delete-rows")); + } + menu.append_section(None, &rows); + menu +} + +/// What a header's click and drag do, which no key reaches, so the cell +/// menu offers them for the cell's column. +fn column_section(sorts: bool) -> gio::Menu { + let column = gio::Menu::new(); + if sorts { + column.append_item(&hidden_while_disabled( + &gettext("Sort Ascending"), + "grid.sort-ascending", + )); + column.append_item(&hidden_while_disabled( + &gettext("Sort Descending"), + "grid.sort-descending", + )); + column.append_item(&item(&gettext("Clear Sort"), "grid.clear-sort")); + } + column.append_section( + None, + §ion([ + item(&gettext("Hide Column"), "grid.hide-column"), + item(&gettext("Size to Fit"), "grid.size-to-fit"), + item(&gettext("Columns…"), "grid.columns"), + ]), + ); + let menu = gio::Menu::new(); + menu.append_submenu(Some(&gettext("Column")), &column); + menu +} + +/// A header's own menu, on a secondary click. It names its column, since +/// GTK opens it without telling the grid. +pub fn header_menu(column: &GridColumn, sorting: GridSorting) -> gio::Menu { + let index = (column.index as u32).to_variant(); + let at = |label: String, action: &str| { + let item = gio::MenuItem::new(Some(&label), None); + item.set_action_and_target_value(Some(action), Some(&index)); + item + }; + let menu = gio::Menu::new(); + if sorting != GridSorting::None && column.sortable() { + menu.append_section( + None, + §ion([ + at(gettext("Sort Ascending"), "grid.sort-column-ascending"), + at(gettext("Sort Descending"), "grid.sort-column-descending"), + item(&gettext("Clear Sort"), "grid.clear-sort"), + ]), + ); + } + menu.append_section( + None, + §ion([ + at(gettext("Hide Column"), "grid.hide-column-at"), + at(gettext("Size to Fit"), "grid.size-column-to-fit"), + at(gettext("Copy Column Name"), "grid.copy-column-name-at"), + ]), + ); + menu.append_section(None, §ion([item(&gettext("Columns…"), "grid.columns")])); + menu +} + +impl DataGrid { + pub(crate) fn menus(&self) -> Option<&GridMenus> { + self.imp().menus.get() + } + + /// The cell menu for `cell`, at `at` in the view's coordinates, or + /// pointing at the whole cell for a key. + pub(crate) fn open_cell_menu(&self, cell: &GridCell, at: Option) -> bool { + let (Some(row), Some(position), Some(menus)) = (cell.row(), cell.position(), self.menus()) else { + return false; + }; + self.select_for_menu(position, Some(cell.column())); + let target = Target { + cell: Some((row.pin(), cell.column())), + rows: self.rows_for(&row, position), + }; + self.arm_menu(target, Some(cell)); + let at = at.or_else(|| self.bounds_in_view(cell.upcast_ref())); + self.show_popover(&menus.cell, at); + true + } + + pub(crate) fn open_row_menu(&self, row: &GridRow, position: u32, at: gdk::Rectangle) -> bool { + let Some(menus) = self.menus() else { + return false; + }; + self.select_for_menu(position, None); + let target = Target { + cell: None, + rows: self.rows_for(row, position), + }; + self.arm_menu(target, None); + self.show_popover(&menus.cell, Some(at)); + true + } + + pub(crate) fn open_empty_menu(&self, at: gdk::Rectangle) -> bool { + let Some(empty) = self.menus().and_then(|menus| menus.empty.clone()) else { + return false; + }; + self.arm_menu( + Target { + cell: None, + rows: Vec::new(), + }, + None, + ); + self.show_popover(&empty, Some(at)); + true + } + + fn arm_menu(&self, target: Target, cell: Option<&GridCell>) { + self.hold_menu_target(target); + self.refresh_actions(); + self.arm_references(cell); + } + + /// A popover parented to the grid points in the grid's coordinates. + pub(crate) fn show_popover(&self, popover: >k::PopoverMenu, at_in_view: Option) { + let at = at_in_view.and_then(|rect| { + let point = gtk::graphene::Point::new(rect.x() as f32, rect.y() as f32); + let point = self.column_view().compute_point(self, &point)?; + Some(gdk::Rectangle::new( + point.x() as i32, + point.y() as i32, + rect.width(), + rect.height(), + )) + }); + popover.set_pointing_to(at.as_ref()); + popover.popup(); + } + + pub(crate) fn bounds_in_view(&self, widget: >k::Widget) -> Option { + let bounds = widget.compute_bounds(&self.column_view())?; + Some(gdk::Rectangle::new( + bounds.x() as i32, + bounds.y() as i32, + bounds.width() as i32, + bounds.height() as i32, + )) + } +} diff --git a/linux/crates/app/src/ui/data_grid/grid_request.rs b/linux/crates/app/src/ui/data_grid/grid_request.rs new file mode 100644 index 0000000000..aaf3436756 --- /dev/null +++ b/linux/crates/app/src/ui/data_grid/grid_request.rs @@ -0,0 +1,17 @@ +use tablepro_core::FilterRule; +use tablepro_core::browse::RowReference; + +use crate::ui::export::ExportSource; + +/// What a grid asks of its host: only what the grid cannot do itself. +#[derive(Debug, Clone)] +pub enum GridRequest { + /// Read the rows again in this order, `None` in the server's own. + Sort(Option<(usize, bool)>), + Export(ExportSource), + FollowReference(RowReference), + FilterByValue(FilterRule), + Toast(String), + /// The tracker's pending changes changed: count, undo or redo. + PendingChanged, +} diff --git a/linux/crates/app/src/ui/data_grid/grid_row.rs b/linux/crates/app/src/ui/data_grid/grid_row.rs new file mode 100644 index 0000000000..6cba43087b --- /dev/null +++ b/linux/crates/app/src/ui/data_grid/grid_row.rs @@ -0,0 +1,236 @@ +use std::cell::{Cell, RefCell}; +use std::sync::Arc; + +use gtk4::glib; +use gtk4::prelude::*; +use gtk4::subclass::prelude::*; +use tablepro_core::Value; + +use crate::services::change_tracker::RowKey; +use crate::services::grid::GridRowId; + +/// What one row of a grid shows: a saved row's key and cells as read, a +/// result row's cells, or a draft, whose cells live in the tracker. +#[derive(Debug, Clone, PartialEq)] +pub struct RowData { + pub id: GridRowId, + pub cells: Option>, +} + +impl RowData { + pub fn persisted(key: Option, cells: Arc<[Value]>) -> Self { + let id = match key { + Some(key) => GridRowId::Persisted(key), + None => GridRowId::Unkeyed, + }; + Self { id, cells: Some(cells) } + } + + pub fn result(cells: Arc<[Value]>) -> Self { + Self { + id: GridRowId::Result, + cells: Some(cells), + } + } + + pub fn draft(draft_id: u64) -> Self { + Self { + id: GridRowId::Draft(draft_id), + cells: None, + } + } + + fn same_as(&self, other: &RowData) -> bool { + let same_cells = match (&self.cells, &other.cells) { + (Some(left), Some(right)) => Arc::ptr_eq(left, right), + (None, None) => true, + _ => false, + }; + same_cells && self.id == other.id + } +} + +mod imp { + use super::*; + + #[derive(glib::Properties)] + #[properties(wrapper_type = super::GridRow)] + pub struct GridRow { + pub data: RefCell, + /// Moves whenever the row draws differently: its data or its + /// pending state changed. + #[property(get)] + pub revision: Cell, + /// Moves only when the slot is given another row, so anything that + /// captured the row (a menu, an editor) can tell it is gone. + pub generation: Cell, + } + + impl Default for GridRow { + fn default() -> Self { + Self { + data: RefCell::new(RowData::result(Arc::from([]))), + revision: Cell::new(0), + generation: Cell::new(0), + } + } + } + + #[glib::object_subclass] + impl ObjectSubclass for GridRow { + const NAME: &'static str = "TableProGridRow"; + type Type = super::GridRow; + } + + #[glib::derived_properties] + impl ObjectImpl for GridRow {} +} + +glib::wrapper! { + /// A slot of the grid's store. A reload writes the new rows into the + /// slots it already has, so GTK keeps every row and cell widget and + /// only the cells draw again. + pub struct GridRow(ObjectSubclass); +} + +impl GridRow { + pub fn new(data: RowData) -> Self { + let row: Self = glib::Object::new(); + row.imp().data.replace(data); + row + } + + pub fn id(&self) -> GridRowId { + self.imp().data.borrow().id.clone() + } + + pub fn key(&self) -> Option { + self.imp().data.borrow().id.key().cloned() + } + + pub fn tracker_key(&self) -> Option { + self.imp().data.borrow().id.tracker_key() + } + + pub fn draft_id(&self) -> Option { + self.imp().data.borrow().id.draft_id() + } + + pub fn cells(&self) -> Option> { + self.imp().data.borrow().cells.clone() + } + + pub fn data(&self) -> RowData { + self.imp().data.borrow().clone() + } + + pub fn generation(&self) -> u64 { + self.imp().generation.get() + } + + /// Give the slot another row, and say whether it took one. The same + /// row again changes nothing, so a draft that stays on top keeps its + /// open edit. + pub fn set_data(&self, data: RowData) -> bool { + if self.imp().data.borrow().same_as(&data) { + return false; + } + self.imp().data.replace(data); + let generation = &self.imp().generation; + generation.set(generation.get().wrapping_add(1)); + self.bump_revision(); + true + } + + /// The row left the store: whatever holds it pinned acts on nothing. + pub fn retire(&self) { + let generation = &self.imp().generation; + generation.set(generation.get().wrapping_add(1)); + } + + pub fn bump_revision(&self) { + let revision = &self.imp().revision; + revision.set(revision.get().wrapping_add(1)); + self.notify_revision(); + } + + /// A hold on the row as it is now, which stops resolving once the + /// slot is given another row. + pub fn pin(&self) -> PinnedRow { + PinnedRow { + row: self.clone(), + generation: self.generation(), + } + } +} + +/// A row captured by a menu, an editor or a copy. It resolves only while +/// its slot still holds the row it captured. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PinnedRow { + row: GridRow, + generation: u64, +} + +impl PinnedRow { + pub fn get(&self) -> Option<&GridRow> { + (self.row.generation() == self.generation).then_some(&self.row) + } + + pub fn is_current(&self) -> bool { + self.get().is_some() + } +} + +#[cfg(test)] +mod tests { + use std::cell::Cell; + use std::rc::Rc; + + use super::*; + + fn key(id: i64) -> RowKey { + RowKey::from_pk_values(&[Value::Int(id)]).expect("a key") + } + + #[test] + fn a_new_row_moves_the_generation_and_the_revision() { + let row = GridRow::new(RowData::persisted(Some(key(1)), Arc::from([Value::Int(1)]))); + let heard = Rc::new(Cell::new(0)); + let counter = heard.clone(); + row.connect_revision_notify(move |_| counter.set(counter.get() + 1)); + let pinned = row.pin(); + + row.set_data(RowData::persisted(Some(key(2)), Arc::from([Value::Int(2)]))); + + assert_eq!(row.key(), Some(key(2))); + assert_eq!(heard.get(), 1); + assert!(!pinned.is_current(), "a pin on the row it replaced resolves to nothing"); + } + + #[test] + fn the_same_row_again_keeps_the_generation() { + let cells: Arc<[Value]> = Arc::from([Value::Int(1)]); + let row = GridRow::new(RowData::persisted(Some(key(1)), cells.clone())); + let pinned = row.pin(); + + row.set_data(RowData::persisted(Some(key(1)), cells)); + row.set_data(RowData::draft(4)); + let draft = row.pin(); + row.set_data(RowData::draft(4)); + + assert!(!pinned.is_current()); + assert!(draft.is_current(), "a draft that stays keeps its pins"); + } + + #[test] + fn a_pending_change_redraws_without_releasing_pins() { + let row = GridRow::new(RowData::draft(1)); + let pinned = row.pin(); + + row.bump_revision(); + + assert_eq!(row.revision(), 1); + assert!(pinned.is_current()); + } +} diff --git a/linux/crates/app/src/ui/data_grid/grid_store.rs b/linux/crates/app/src/ui/data_grid/grid_store.rs new file mode 100644 index 0000000000..6a5252ae53 --- /dev/null +++ b/linux/crates/app/src/ui/data_grid/grid_store.rs @@ -0,0 +1,321 @@ +use std::cell::{Cell, RefCell}; +use std::collections::{HashMap, HashSet}; + +use gtk4::gio; +use gtk4::prelude::*; + +use super::{GridRow, RowData}; +use crate::services::change_tracker::RowKey; + +/// The grid's rows: a list of stable slots and an index of them by +/// tracker key. Drafts sit on top, above the rows that were read. +#[derive(Debug)] +pub struct GridStore { + list: gio::ListStore, + index: RefCell>, + drafts: Cell, +} + +impl Default for GridStore { + fn default() -> Self { + Self { + list: gio::ListStore::new::(), + index: RefCell::default(), + drafts: Cell::new(0), + } + } +} + +impl GridStore { + pub fn list(&self) -> &gio::ListStore { + &self.list + } + + pub fn len(&self) -> u32 { + self.list.n_items() + } + + pub fn is_empty(&self) -> bool { + self.len() == 0 + } + + pub fn row(&self, position: u32) -> Option { + self.list.item(position).and_downcast() + } + + pub fn rows(&self) -> impl Iterator + '_ { + (0..self.len()).filter_map(|position| self.row(position)) + } + + /// The drafts on top of the rows that were read. + pub fn draft_count(&self) -> u32 { + self.drafts.get() + } + + pub fn row_for(&self, key: &RowKey) -> Option { + self.index.borrow().get(key).cloned() + } + + pub fn position_of(&self, row: &GridRow) -> Option { + self.list.find(row) + } + + /// Write `rows` into the slots the store has and add or drop only the + /// difference, so the rows GTK already built are kept. + pub fn replace(&self, rows: Vec) { + let renumbered = self.count_drafts(&rows); + let kept = self.len().min(rows.len() as u32) as usize; + let mut rows = rows.into_iter(); + for (position, data) in rows.by_ref().take(kept).enumerate() { + if let Some(row) = self.row(position as u32) + && !row.set_data(data) + && renumbered + { + row.bump_revision(); + } + } + let added: Vec = rows.map(GridRow::new).collect(); + match added.is_empty() { + false => self.list.splice(self.len(), 0, &added), + true if self.len() as usize > kept => { + self.retire_from(kept as u32, self.len()); + self.list + .splice(kept as u32, self.len() - kept as u32, &[] as &[GridRow]); + } + true => {} + } + self.reindex(); + } + + /// New slots for every row, for a list a sort model reads. + pub fn reset(&self, rows: Vec) { + self.count_drafts(&rows); + let added: Vec = rows.into_iter().map(GridRow::new).collect(); + self.retire_from(0, self.len()); + self.list.splice(0, self.len(), &added); + self.reindex(); + } + + pub fn append(&self, rows: Vec) { + let added: Vec = rows.into_iter().map(GridRow::new).collect(); + for row in &added { + self.index_row(row); + } + self.list.splice(self.len(), 0, &added); + } + + pub fn clear(&self) { + self.drafts.set(0); + self.retire_from(0, self.len()); + self.list.remove_all(); + self.index.borrow_mut().clear(); + } + + /// The tracker lists new drafts oldest first, and the newest goes on + /// top. A paste can add thousands, so they go in as one change. + pub fn insert_drafts(&self, draft_ids: &[u64]) { + if draft_ids.is_empty() { + return; + } + let rows: Vec = draft_ids + .iter() + .rev() + .map(|draft_id| GridRow::new(RowData::draft(*draft_id))) + .collect(); + for row in &rows { + self.index_row(row); + } + self.drafts.set(self.drafts.get() + rows.len() as u32); + self.list.splice(0, 0, &rows); + } + + /// One change per run of removed rows rather than one per row, since + /// an undo can take thousands of drafts away at once. + pub fn remove_drafts(&self, draft_ids: &[u64]) { + if draft_ids.is_empty() { + return; + } + let removed: HashSet = draft_ids.iter().copied().collect(); + let is_removed = |position: u32| { + self.row(position) + .and_then(|row| row.draft_id()) + .is_some_and(|draft_id| removed.contains(&draft_id)) + }; + let left = self + .rows() + .filter_map(|row| row.draft_id()) + .filter(|draft_id| !removed.contains(draft_id)) + .count(); + self.drafts.set(left as u32); + let mut position = self.len(); + while position > 0 { + position -= 1; + if !is_removed(position) { + continue; + } + let end = position + 1; + while position > 0 && is_removed(position - 1) { + position -= 1; + } + self.retire_from(position, end); + self.list.splice(position, end - position, &[] as &[GridRow]); + } + let mut index = self.index.borrow_mut(); + for draft_id in draft_ids { + index.remove(&RowKey::Draft(*draft_id)); + } + } + + /// The gutter numbers saved rows past the drafts, so the count moves + /// before any row redraws. Says whether it moved. + fn count_drafts(&self, rows: &[RowData]) -> bool { + let drafts = rows.iter().filter(|row| row.id.draft_id().is_some()).count() as u32; + self.drafts.replace(drafts) != drafts + } + + fn retire_from(&self, start: u32, end: u32) { + for position in start..end { + if let Some(row) = self.row(position) { + row.retire(); + } + } + } + + fn reindex(&self) { + self.index.borrow_mut().clear(); + for row in self.rows() { + self.index_row(&row); + } + } + + fn index_row(&self, row: &GridRow) { + if let Some(key) = row.tracker_key() { + self.index.borrow_mut().insert(key, row.clone()); + } + } +} + +#[cfg(test)] +mod tests { + use std::cell::RefCell; + use std::rc::Rc; + use std::sync::Arc; + + use tablepro_core::Value; + + use super::*; + + fn key(id: i64) -> RowKey { + RowKey::from_pk_values(&[Value::Int(id)]).expect("a key") + } + + fn saved(id: i64) -> RowData { + RowData::persisted(Some(key(id)), Arc::from([Value::Int(id)])) + } + + fn changes(store: &GridStore) -> Rc>> { + let seen = Rc::new(RefCell::new(Vec::new())); + let log = seen.clone(); + store.list().connect_items_changed(move |_, position, removed, added| { + log.borrow_mut().push((position, removed, added)) + }); + seen + } + + #[test] + fn a_page_of_the_same_size_keeps_every_slot() { + let store = GridStore::default(); + store.replace(vec![saved(1), saved(2)]); + let first = store.row(0).expect("a row"); + let seen = changes(&store); + + store.replace(vec![saved(3), saved(4)]); + + assert!(seen.borrow().is_empty(), "no slot came or went"); + assert_eq!(store.row(0), Some(first)); + assert_eq!(store.row(0).and_then(|row| row.key()), Some(key(3))); + assert_eq!(store.row_for(&key(4)).and_then(|row| store.position_of(&row)), Some(1)); + assert!(store.row_for(&key(1)).is_none()); + } + + #[test] + fn a_shorter_or_longer_page_changes_only_the_tail() { + let store = GridStore::default(); + store.replace(vec![saved(1), saved(2), saved(3)]); + let seen = changes(&store); + + store.replace(vec![saved(4)]); + store.replace(vec![saved(5), saved(6), saved(7), saved(8)]); + + assert_eq!(*seen.borrow(), [(1, 2, 0), (1, 0, 3)]); + assert_eq!(store.len(), 4); + } + + #[test] + fn a_row_that_leaves_the_store_lets_go_of_its_pins() { + let store = GridStore::default(); + let rows = vec![saved(1), saved(2), saved(3)]; + store.replace(rows.clone()); + let kept = store.row(0).expect("a row").pin(); + let dropped = store.row(2).expect("a row").pin(); + + store.replace(rows[..2].to_vec()); + + assert!(kept.is_current(), "a row the page kept lost its pin"); + assert!(!dropped.is_current(), "a row a shorter page dropped still resolves"); + store.insert_drafts(&[7]); + let draft = store.row(0).expect("the draft").pin(); + store.remove_drafts(&[7]); + assert!(!draft.is_current(), "a removed draft still resolves"); + store.clear(); + assert!(!kept.is_current(), "a cleared row still resolves"); + } + + #[test] + fn every_row_redraws_with_the_draft_count_it_lands_under() { + let store = Rc::new(GridStore::default()); + let rows = vec![RowData::draft(1), saved(1), saved(2)]; + store.replace(rows.clone()); + let heard = Rc::new(RefCell::new(Vec::new())); + for row in store.rows() { + let (store, heard) = (Rc::downgrade(&store), heard.clone()); + row.connect_revision_notify(move |_| { + if let Some(store) = store.upgrade() { + heard.borrow_mut().push(store.draft_count()); + } + }); + } + + store.replace(vec![saved(3), rows[1].clone(), rows[2].clone()]); + + assert_eq!(*heard.borrow(), [0, 0, 0], "a saved draft renumbers the rows below it"); + } + + #[test] + fn drafts_go_on_top_newest_first_and_leave_in_runs() { + let store = GridStore::default(); + store.replace(vec![saved(1)]); + + store.insert_drafts(&[10, 11, 12]); + let order: Vec<_> = store.rows().map(|row| row.draft_id()).collect(); + assert_eq!(order, [Some(12), Some(11), Some(10), None]); + + let seen = changes(&store); + store.remove_drafts(&[12, 11]); + + assert_eq!(*seen.borrow(), [(0, 2, 0)]); + assert!(store.row_for(&RowKey::Draft(11)).is_none()); + assert!(store.row_for(&RowKey::Draft(10)).is_some()); + } + + #[test] + fn append_adds_after_the_rows_held() { + let store = GridStore::default(); + store.replace(vec![saved(1)]); + + store.append(vec![saved(2), saved(3)]); + + assert_eq!(store.len(), 3); + assert_eq!(store.row_for(&key(3)).and_then(|row| store.position_of(&row)), Some(2)); + } +} diff --git a/linux/crates/app/src/ui/data_grid/imp.rs b/linux/crates/app/src/ui/data_grid/imp.rs new file mode 100644 index 0000000000..11ff4f66f3 --- /dev/null +++ b/linux/crates/app/src/ui/data_grid/imp.rs @@ -0,0 +1,133 @@ +use std::cell::{Cell, OnceCell, RefCell}; +use std::sync::OnceLock; + +use glib::subclass::Signal; +use gtk4::prelude::*; +use gtk4::subclass::prelude::*; +use gtk4::{gio, glib}; +use libadwaita as adw; +use libadwaita::subclass::prelude::*; + +use super::columns::ColumnSet; +use super::edit::EditController; +use super::find_bar::FindState; +use super::grid_menu::GridMenus; +use super::references::Armed; +use super::row_detail::RowDetailPane; +use super::selection::Target; +use super::{GridConfig, GridStore}; + +/// The grid's selection model, made with the grid and given its rows once +/// the grid knows how it sorts. +#[derive(Debug)] +pub struct Selection(pub gtk4::MultiSelection); + +impl Default for Selection { + fn default() -> Self { + Self(gtk4::MultiSelection::new(None::)) + } +} + +#[derive(Default, gtk4::CompositeTemplate)] +#[template(resource = "/app/tablepro/TablePro/ui/data-grid.ui")] +pub struct DataGrid { + #[template_child] + pub split_view: TemplateChild, + #[template_child] + pub search_bar: TemplateChild, + #[template_child] + pub search_entry: TemplateChild, + #[template_child] + pub match_label: TemplateChild, + #[template_child] + pub find_previous: TemplateChild, + #[template_child] + pub find_next: TemplateChild, + #[template_child] + pub scrolled: TemplateChild, + #[template_child] + pub column_view: TemplateChild, + #[template_child] + pub empty_page: TemplateChild, + #[template_child] + pub detail_pane: TemplateChild, + + pub config: OnceCell, + pub store: GridStore, + pub selection: Selection, + /// Set for a grid that sorts the rows it holds. + pub sort_model: OnceCell, + pub columns: RefCell, + pub actions: gio::SimpleActionGroup, + pub menus: OnceCell, + pub edit: EditController, + pub find: FindState, + /// What a menu was opened on, until it closes. + pub menu_target: RefCell>, + /// The key choices the latest menu or chooser offered, which its items + /// act on whatever the keys or rows are by then. + pub armed_references: RefCell, + /// Set while the grid puts a sort on the headers itself, which asks + /// the server for nothing. + pub quiet_sort: Cell, + /// The number the first row read is shown as, less one. + pub row_base: Cell, + /// An inline or pane editor is open. + pub editing: Cell, + /// The window's focus handler, while the grid is in a window. + pub focus_watch: RefCell, glib::SignalHandlerId)>>, +} + +#[glib::object_subclass] +impl ObjectSubclass for DataGrid { + const NAME: &'static str = "TableProDataGrid"; + type Type = super::DataGrid; + type ParentType = adw::Bin; + + fn class_init(klass: &mut Self::Class) { + RowDetailPane::ensure_type(); + klass.bind_template(); + } + + fn instance_init(object: &glib::subclass::InitializingObject) { + object.init_template(); + } +} + +impl ObjectImpl for DataGrid { + fn signals() -> &'static [Signal] { + static SIGNALS: OnceLock> = OnceLock::new(); + SIGNALS.get_or_init(|| { + vec![ + Signal::builder("request") + .param_types([glib::BoxedAnyObject::static_type()]) + .build(), + Signal::builder("selection-changed").build(), + Signal::builder("editing-changed").build(), + ] + }) + } + + fn dispose(&self) { + self.obj().stop_watching_focus(); + if let Some(menus) = self.menus.get() { + menus.unparent(); + } + self.columns.borrow_mut().release(); + self.store.clear(); + } +} + +impl WidgetImpl for DataGrid { + fn root(&self) { + self.parent_root(); + self.obj().watch_focus(); + } + + fn unroot(&self) { + self.obj().stop_watching_focus(); + self.parent_unroot(); + } +} + +impl BinImpl for DataGrid {} diff --git a/linux/crates/app/src/ui/data_grid/keys.rs b/linux/crates/app/src/ui/data_grid/keys.rs new file mode 100644 index 0000000000..17b126b103 --- /dev/null +++ b/linux/crates/app/src/ui/data_grid/keys.rs @@ -0,0 +1,113 @@ +use gtk4::glib::translate::IntoGlib; +use gtk4::prelude::*; +use gtk4::{self as gtk, gdk, glib}; + +use super::{DataGrid, PinnedRow}; +use crate::application::{ShortcutOwner, local_shortcut_controller}; + +/// The grid's keys, each through the `grid` action it names, so a +/// disabled action lets its key go on: Space selects the row when the +/// cell is no boolean, Return activates it when it cannot be edited, and +/// an open editor keeps every key. +/// +/// GtkListFactoryWidget binds Return, Space and Menu in its class and +/// stops them, so the cell's keys are taken on the view in the capture +/// phase. The grid-wide keys bubble up to the grid itself, after a focused +/// editor had its turn, so they work from the detail pane and the find bar +/// too. +pub fn install(grid: &DataGrid) { + let view = grid.column_view(); + view.add_controller(local_shortcut_controller(ShortcutOwner::GridCell)); + grid.add_controller(local_shortcut_controller(ShortcutOwner::Grid)); + view.add_controller(type_to_edit(grid)); + // A double click activates inside the row's own click handler, which + // then grabs the focus and would take it from the editor or the pane + // opened here, so they open once the click is handled, on the row it + // was. + view.connect_activate(glib::clone!( + #[weak] + grid, + move |_, _| { + let Some((row, column)) = grid + .focused_cell() + .and_then(|cell| Some((cell.row()?.pin(), cell.column()))) + else { + return; + }; + glib::idle_add_local_once(glib::clone!( + #[weak] + grid, + move || grid.activate_cell(&row, column) + )); + } + )); +} + +/// Text typed on a cell that takes edits opens its editor and hands it +/// the key, so the editor's input method sees it: a dead key, a composed +/// character or a preedit starts in the editor. Space and keys with a +/// modifier never start one. +fn type_to_edit(grid: &DataGrid) -> gtk::EventControllerKey { + let keys = gtk::EventControllerKey::new(); + let weak = grid.downgrade(); + keys.connect_key_pressed(move |controller, keyval, _, state| { + let Some(grid) = weak.upgrade() else { + return glib::Propagation::Proceed; + }; + let modified = state + .intersects(gdk::ModifierType::CONTROL_MASK | gdk::ModifierType::ALT_MASK | gdk::ModifierType::SUPER_MASK); + let types = keyval.to_unicode().is_some_and(|c| !c.is_control() && c != ' ') || starts_composition(keyval); + if modified || !types { + return glib::Propagation::Proceed; + } + let Some(editor) = grid.edit().open_for_typing() else { + return glib::Propagation::Proceed; + }; + match controller.forward(&editor) { + true => glib::Propagation::Stop, + false => glib::Propagation::Proceed, + } + }); + keys +} + +/// A dead key or Compose has no character of its own; the editor's input +/// method composes the next key with it. 0xfe50 to 0xfe93 is GDK's block +/// of dead keys. +fn starts_composition(keyval: gdk::Key) -> bool { + (0xfe50..=0xfe93).contains(&keyval.into_glib()) || keyval == gdk::Key::Multi_key +} + +impl DataGrid { + /// Return on a cell no shortcut took, or a double click: edit the cell + /// where it can be edited in place, else show its whole value in the + /// detail pane, while the focus is still on it and its slot holds the + /// row it held. + fn activate_cell(&self, row: &PinnedRow, column: usize) { + let Some(cell) = self + .focused_cell() + .filter(|cell| cell.column() == column && row.get().is_some_and(|row| cell.row().as_ref() == Some(row))) + else { + return; + }; + if !self.edit().edit_cell(&cell) { + self.show_details_for(column); + } + } +} + +#[cfg(test)] +mod tests { + use gtk4::gdk; + + use super::starts_composition; + + #[test] + fn dead_keys_and_compose_start_a_composition() { + assert!(starts_composition(gdk::Key::dead_grave)); + assert!(starts_composition(gdk::Key::dead_acute)); + assert!(starts_composition(gdk::Key::Multi_key)); + assert!(!starts_composition(gdk::Key::a)); + assert!(!starts_composition(gdk::Key::Return)); + } +} diff --git a/linux/crates/app/src/ui/data_grid/layout_binding.rs b/linux/crates/app/src/ui/data_grid/layout_binding.rs new file mode 100644 index 0000000000..a58e877812 --- /dev/null +++ b/linux/crates/app/src/ui/data_grid/layout_binding.rs @@ -0,0 +1,26 @@ +use uuid::Uuid; + +use crate::persistence::ColumnLayoutStore; +use crate::services::grid::ColumnLayout; + +/// Where a table's column layout is kept. The schema is part of it, so +/// `public.users` and `audit.users` keep their own. +#[derive(Debug, Clone)] +pub struct LayoutBinding { + pub store: ColumnLayoutStore, + pub connection_id: Uuid, + pub schema: Option, + pub table: String, +} + +impl LayoutBinding { + pub fn load(&self) -> ColumnLayout { + self.store + .layout(self.connection_id, self.schema.as_deref(), &self.table) + } + + pub fn save(&self, layout: &ColumnLayout) { + self.store + .save(self.connection_id, self.schema.as_deref(), &self.table, layout); + } +} diff --git a/linux/crates/app/src/ui/data_grid/mod.rs b/linux/crates/app/src/ui/data_grid/mod.rs new file mode 100644 index 0000000000..b6bbf63a5a --- /dev/null +++ b/linux/crates/app/src/ui/data_grid/mod.rs @@ -0,0 +1,385 @@ +//! The data grid every surface shows rows in: the table tab, the SQL +//! editor's results and a key-value engine's key tab. + +mod client_sorter; +mod column_chooser; +mod columns; +mod copy; +mod edit; +mod find_bar; +mod grid_actions; +mod grid_cell; +mod grid_config; +mod grid_menu; +mod grid_request; +mod grid_row; +mod grid_store; +mod imp; +mod keys; +mod layout_binding; +mod pointer; +mod references; +mod row_detail; +mod row_json_dialog; +mod row_number_column; +mod selection; +mod server_sorter; +mod table_keys; +#[cfg(test)] +pub(crate) mod test_grid; +#[cfg(test)] +mod tests; +mod tracked_values; + +use std::sync::Arc; + +use gtk4::prelude::*; +use gtk4::subclass::prelude::*; +use gtk4::{self as gtk, gio, glib}; +use libadwaita as adw; + +#[cfg(test)] +pub(crate) use self::column_chooser::ColumnChooser; +pub(crate) use self::edit::BULK_DELETE_CONFIRM_THRESHOLD; +pub(crate) use self::grid_cell::GridCell; +pub(crate) use self::grid_config::{DialectSource, EditRules, GridConfig, GridSorting}; +pub(crate) use self::grid_request::GridRequest; +pub(crate) use self::grid_row::{GridRow, PinnedRow, RowData}; +pub(crate) use self::grid_store::GridStore; +pub(crate) use self::layout_binding::LayoutBinding; +#[cfg(test)] +pub(crate) use self::row_detail::RowDetailPane; +pub(crate) use self::row_json_dialog::{RowJsonRequest, present_row_json}; +pub(crate) use self::table_keys::{KeysHandle, TableKeys}; +pub(crate) use self::tracked_values::TrackedValues; +use crate::services::change_tracker::RowKey; +use crate::services::grid::GridColumn; +use crate::services::tracker_delta::TrackerDelta; +use crate::ui::export::ExportSource; + +glib::wrapper! { + pub struct DataGrid(ObjectSubclass) + @extends adw::Bin, gtk::Widget, + @implements gtk::Accessible, gtk::Buildable, gtk::ConstraintTarget; +} + +impl DataGrid { + pub fn new(config: GridConfig) -> Self { + let grid: Self = glib::Object::new(); + let imp = grid.imp(); + let columns = config.columns.clone(); + let sorting = config.sorting; + let rules = config.rules.clone(); + imp.column_view + .update_property(&[gtk::accessible::Property::Label(&config.label)]); + let _ = imp.config.set(config); + grid.build_models(); + imp.edit.attach(&grid); + grid.insert_action_group("grid", Some(&imp.actions)); + grid_actions::install(&grid); + let _ = imp.menus.set(grid_menu::GridMenus::new(&grid)); + keys::install(&grid); + pointer::install(&grid); + row_number_column::install_row_factory(&grid); + find_bar::install(&grid); + imp.detail_pane.attach(&grid); + grid.set_columns(columns, sorting, rules); + grid + } + + /// `None` only inside `glib::Object::new`, before `new` sets it. + pub(crate) fn config(&self) -> Option<&GridConfig> { + self.imp().config.get() + } + + pub fn connect_request(&self, f: F) -> glib::SignalHandlerId { + self.connect_closure( + "request", + false, + // Each handler gets its own copy: a taken payload would reach + // only the first. + glib::closure_local!(move |grid: &Self, payload: glib::BoxedAnyObject| { + let request = payload.borrow::().clone(); + f(grid, request); + }), + ) + } + + pub fn connect_selection_changed(&self, f: F) -> glib::SignalHandlerId { + self.connect_closure( + "selection-changed", + false, + glib::closure_local!(move |grid: &Self| f(grid)), + ) + } + + /// An inline or detail pane edit opened or closed. A host's Save stays + /// on while one is open, so Ctrl+S reaches a first typed value. + pub fn connect_editing_changed(&self, f: F) -> glib::SignalHandlerId { + self.connect_closure( + "editing-changed", + false, + glib::closure_local!(move |grid: &Self| f(grid)), + ) + } + + pub fn is_editing(&self) -> bool { + self.imp().editing.get() + } + + pub(crate) fn set_editing(&self, editing: bool) { + if self.imp().editing.replace(editing) != editing { + self.refresh_actions(); + self.emit_by_name::<()>("editing-changed", &[]); + } + } + + pub(crate) fn request(&self, request: GridRequest) { + self.emit_by_name::<()>("request", &[&glib::BoxedAnyObject::new(request)]); + } + + pub(crate) fn toast(&self, text: impl Into) { + self.request(GridRequest::Toast(text.into())); + } + + /// The `grid` actions, for a host to insert where its own buttons can + /// reach them. + pub fn actions(&self) -> gio::SimpleActionGroup { + self.imp().actions.clone() + } + + pub fn column_view(&self) -> gtk::ColumnView { + self.imp().column_view.get() + } + + pub fn selection(&self) -> gtk::MultiSelection { + self.imp().selection.0.clone() + } + + pub(crate) fn store(&self) -> &GridStore { + &self.imp().store + } + + pub(crate) fn edit(&self) -> &edit::EditController { + &self.imp().edit + } + + pub(crate) fn tracked(&self) -> TrackedValues { + self.edit().tracked() + } + + pub fn grid_columns(&self) -> Arc<[GridColumn]> { + self.imp().columns.borrow().columns() + } + + /// Build the columns for another column set, with what may change in + /// its rows. The rows go, since they were read under the old columns. + pub fn set_columns(&self, columns: Arc<[GridColumn]>, sorting: GridSorting, rules: Option) { + self.commit_editors(); + self.store().clear(); + self.edit().set_rules(self, rules); + self.rebuild_columns(columns, sorting); + self.show_rows_or_empty(); + self.refresh_actions(); + } + + /// Show `rows` under the drafts the tracker holds. The rows take the + /// slots the grid has, and the selection and the focused cell follow + /// their rows by key when the new rows hold them. + pub fn replace_rows(&self, rows: Vec, row_base: u64) { + self.commit_editors(); + let restore = selection::Restore::capture(self); + self.imp().row_base.set(row_base); + let mut all = self.edit().draft_rows(); + all.extend(rows); + self.write_rows(all); + self.columns_took_rows(); + restore.apply(self); + self.show_rows_or_empty(); + self.find_rows_changed(); + self.refresh_actions(); + } + + /// Add rows under the ones the grid holds, as Load More does. + pub fn append_rows(&self, rows: Vec) { + self.store().append(rows); + self.columns_took_rows(); + self.show_rows_or_empty(); + self.find_rows_changed(); + self.refresh_actions(); + } + + pub fn clear_rows(&self) { + self.commit_editors(); + self.store().clear(); + self.show_rows_or_empty(); + self.refresh_actions(); + } + + pub fn row_count(&self) -> u32 { + self.store().len() + } + + pub fn selected_count(&self) -> u32 { + self.selection().selection().size() as u32 + } + + pub(crate) fn row_base(&self) -> u64 { + self.imp().row_base.get() + } + + /// The placeholder shown while the grid holds no row. + pub fn set_empty_text(&self, title: &str, description: Option<&str>) { + let page = &self.imp().empty_page; + page.set_title(title); + page.set_description(description); + } + + /// Commit the cell edit open in the grid or its detail pane, as Return + /// does, so a save or a close counts what was typed. + pub fn commit_edit(&self) { + self.commit_editors(); + } + + /// Show what a tracker change outside the grid did: drafts come or go + /// and changed rows draw again. + pub fn apply_delta(&self, delta: &TrackerDelta) { + self.edit().apply_delta(delta); + } + + /// Bring the drafts and every row back in line with the tracker after + /// a change whose delta was not kept, as a close prompt's Discard. + pub fn resync(&self) { + self.commit_editors(); + let saved: Vec = self + .store() + .rows() + .filter(|row| row.draft_id().is_none()) + .map(|row| row.data()) + .collect(); + let mut all = self.edit().draft_rows(); + all.extend(saved); + self.write_rows(all); + self.show_rows_or_empty(); + self.refresh_actions(); + } + + /// Commit the inline editor and the detail pane's, before any slot is + /// written or the tracker is read. + pub(crate) fn commit_editors(&self) { + self.imp().detail_pane.commit_open_edit(); + self.edit().commit_open_edit(); + } + + pub fn undo(&self) { + self.edit().undo(); + } + + pub fn redo(&self) { + self.edit().redo(); + } + + pub fn discard(&self) { + self.edit().discard(); + } + + /// Draw every row again after the tracker changed outside the grid, + /// as a save settling does. + pub fn redraw(&self) { + for row in self.store().rows() { + row.bump_revision(); + } + self.refresh_actions(); + } + + /// Select, focus and scroll to the row `key` names, as a failed save + /// does for the statement that failed. `false` when the grid does not + /// hold it. + pub fn reveal_row(&self, key: &RowKey) -> bool { + let Some(row) = self.store().row_for(key) else { + return false; + }; + let Some(position) = self.view_position(&row) else { + return false; + }; + self.selection().select_item(position, true); + self.column_view().scroll_to( + position, + None, + gtk::ListScrollFlags::FOCUS | gtk::ListScrollFlags::SELECT, + None, + ); + row.bump_revision(); + true + } + + /// The rows as the grid shows them, pending edits included, for an + /// export of what the user is looking at. + pub fn export_source(&self) -> ExportSource { + copy::export_source(self) + } + + /// Put the sort on the headers without asking for the rows again, as + /// a restored tab does. + pub fn set_sort(&self, sort: Option<(usize, bool)>) { + self.apply_sort_quietly(sort); + } + + /// Move the keyboard focus to the grid's focused cell, or its first. + pub fn focus_rows(&self) -> bool { + if self.store().is_empty() { + return false; + } + self.column_view().grab_focus() + } + + fn build_models(&self) { + let imp = self.imp(); + let list: gio::ListModel = imp.store.list().clone().upcast(); + let sorting = self.config().map_or(GridSorting::None, |config| config.sorting); + let model: gio::ListModel = match sorting { + GridSorting::Client => { + // At once rather than in steps, so a copy right after a + // click takes the order on screen. + let sorted = gtk::SortListModel::new(Some(list), imp.column_view.sorter()); + sorted.set_incremental(false); + let _ = imp.sort_model.set(sorted.clone()); + sorted.upcast() + } + GridSorting::Server { .. } | GridSorting::None => list, + }; + let selection = &imp.selection.0; + selection.set_model(Some(&model)); + selection.connect_selection_changed(glib::clone!( + #[weak(rename_to = grid)] + self, + move |_, _, _| grid.selection_moved() + )); + imp.column_view.set_model(Some(selection)); + imp.store.list().connect_items_changed(glib::clone!( + #[weak(rename_to = grid)] + self, + move |_, _, _, _| grid.show_rows_or_empty() + )); + } + + fn selection_moved(&self) { + self.refresh_actions(); + self.imp().detail_pane.follow_focus(); + self.emit_by_name::<()>("selection-changed", &[]); + } + + /// A sort model hears no write into a slot, so a grid that sorts what + /// it holds takes new rows as new rows. + fn write_rows(&self, rows: Vec) { + match self.imp().sort_model.get() { + Some(_) => self.store().reset(rows), + None => self.store().replace(rows), + } + } + + pub(crate) fn show_rows_or_empty(&self) { + let imp = self.imp(); + imp.empty_page.set_visible(imp.store.is_empty()); + } +} diff --git a/linux/crates/app/src/ui/data_grid/pointer.rs b/linux/crates/app/src/ui/data_grid/pointer.rs new file mode 100644 index 0000000000..a0274b900c --- /dev/null +++ b/linux/crates/app/src/ui/data_grid/pointer.rs @@ -0,0 +1,203 @@ +use gtk4::prelude::*; +use gtk4::{self as gtk, gdk}; + +use super::{DataGrid, GridCell, GridRow}; + +/// Where a click landed in the view. +enum Hit { + Cell(GridCell), + /// A row outside its data cells: the row number column. + Row(GridRow, u32), + /// The list below its last row. + Empty, + /// The header, which GTK handles itself. + Header, + /// An open inline editor, whose own menu cuts, copies and pastes. + Editor, +} + +/// One gesture per button on the view for every cell, resolving the cell +/// under the pointer when a click lands: a gesture per cell adds a quarter +/// or more to every scroll at a hundred columns. Capture phase, so each +/// runs before the list's own row gesture. A double click reaches the grid +/// as the view's own `activate`. +pub fn install(grid: &DataGrid) { + grid.column_view().add_controller(focus_on_click(grid)); + let gesture = gtk::GestureClick::builder().button(gdk::BUTTON_SECONDARY).build(); + gesture.set_propagation_phase(gtk::PropagationPhase::Capture); + let weak = grid.downgrade(); + gesture.connect_pressed(move |gesture, n_press, x, y| { + let Some(grid) = weak.upgrade() else { + return; + }; + let claimed = n_press == 1 && grid.menu_at_pointer(hit_at(&grid, x, y), x, y); + if claimed { + gesture.set_state(gtk::EventSequenceState::Claimed); + } + }); + grid.column_view().add_controller(gesture); + grid.column_view() + .connect_query_tooltip(|view, x, y, _, tooltip| cut_text_tooltip(view, x, y, tooltip)); +} + +/// GTK gives a column view cell no click of its own, so a click focuses +/// the row, on the column focused before. The clicked cell takes the +/// focus first, as a focusable widget in it would, and the row's gesture +/// then keeps it there and selects. The sequence stays the list's. +fn focus_on_click(grid: &DataGrid) -> gtk::GestureClick { + let gesture = gtk::GestureClick::builder().button(gdk::BUTTON_PRIMARY).build(); + gesture.set_propagation_phase(gtk::PropagationPhase::Capture); + let weak = grid.downgrade(); + gesture.connect_pressed(move |_, _, x, y| { + if let Some(grid) = weak.upgrade() + && let Hit::Cell(cell) = hit_at(&grid, x, y) + && let Some(focusable) = cell.parent() + { + focusable.grab_focus(); + } + }); + gesture +} + +/// The longest tooltip; the detail pane shows the rest. +const TOOLTIP_MAX_CHARS: usize = 300; + +/// A cell's text when the cell cuts it, one handler for the whole view. +fn cut_text_tooltip(view: >k::ColumnView, x: i32, y: i32, tooltip: >k::Tooltip) -> bool { + let Some(text) = cut_text(view, x, y) else { + return false; + }; + tooltip.set_text(Some(&text)); + true +} + +/// The text of the cell at `x`, `y` when the cell cuts it, up to +/// `TOOLTIP_MAX_CHARS`. +pub(super) fn cut_text(view: >k::ColumnView, x: i32, y: i32) -> Option { + let cell = view + .pick(f64::from(x), f64::from(y), gtk::PickFlags::DEFAULT) + .and_then(|picked| GridCell::from_focus(&picked))?; + let display = cell.display()?; + let text = display.text().filter(|text| !text.is_empty())?; + let (width, _) = display.create_pango_layout(Some(&text)).pixel_size(); + if width <= display.width() { + return None; + } + let mut shown: String = text.chars().take(TOOLTIP_MAX_CHARS).collect(); + if shown.len() < text.len() { + shown.push('…'); + } + Some(shown) +} + +fn hit_at(grid: &DataGrid, x: f64, y: f64) -> Hit { + let view = grid.column_view(); + let Some(picked) = view.pick(x, y, gtk::PickFlags::DEFAULT) else { + return Hit::Header; + }; + let Some(list) = picked + .ancestor(gtk::ListView::static_type()) + .or_else(|| picked.downcast_ref::().map(|list| list.clone().upcast())) + else { + return Hit::Header; + }; + if picked.is::() || picked.ancestor(gtk::Text::static_type()).is_some() { + return Hit::Editor; + } + if let Some(cell) = GridCell::from_focus(&picked) { + return Hit::Cell(cell); + } + // A row widget is the list's direct child; the row number column's + // cell sits under it. + let mut widget = Some(picked); + while let Some(current) = widget { + if current.parent().as_ref() == Some(&list) { + return row_of(¤t).map_or(Hit::Empty, |(row, position)| Hit::Row(row, position)); + } + widget = current.parent(); + } + Hit::Empty +} + +/// The row a row widget shows, read from one of its cells. +fn row_of(row_widget: >k::Widget) -> Option<(GridRow, u32)> { + let mut child = row_widget.first_child(); + while let Some(cell_widget) = child { + if let Some(cell) = cell_widget.first_child().and_downcast::() + && let (Some(row), Some(position)) = (cell.row(), cell.position()) + { + return Some((row, position)); + } + child = cell_widget.next_sibling(); + } + None +} + +impl DataGrid { + fn menu_at_pointer(&self, hit: Hit, x: f64, y: f64) -> bool { + let at = gdk::Rectangle::new(x as i32, y as i32, 1, 1); + match hit { + Hit::Cell(cell) => self.open_cell_menu(&cell, Some(at)), + Hit::Row(row, position) => self.open_row_menu(&row, position, at), + Hit::Empty => self.open_empty_menu(at), + Hit::Header | Hit::Editor => false, + } + } +} + +#[cfg(test)] +mod tests { + use gtk4::graphene; + use gtk4::prelude::*; + + use super::{Hit, hit_at}; + use crate::test_support::{KeyInjector, wait_until}; + use crate::ui::data_grid::test_grid::{Grid, WAIT, two_text_columns, two_text_values}; + + fn point_in(grid: &Grid, widget: &impl IsA) -> (f64, f64) { + let view = grid.grid.column_view(); + let mut at = None; + wait_until(WAIT, || { + at = widget + .compute_point(&view, &graphene::Point::new(4.0, 4.0)) + .filter(|_| widget.width() > 0); + at.is_some() + }) + .expect("the widget is laid out"); + let at = at.expect("a point in the view"); + (f64::from(at.x()), f64::from(at.y())) + } + + /// GtkText's own menu cuts, copies and pastes in the open editor. + #[gtk4::test] + fn a_right_click_in_the_open_editor_is_left_to_it() { + let grid = Grid::open(two_text_columns(), two_text_values()); + let other = grid.cell_of(2, 1); + assert!(grid.grid.edit().edit_cell(&grid.cell_of(1, 1))); + let editor = grid.grid.edit().open_text().expect("the editor"); + + let (x, y) = point_in(&grid, &editor); + assert!(matches!(hit_at(&grid.grid, x, y), Hit::Editor)); + let (x, y) = point_in(&grid, &other); + assert!(matches!(hit_at(&grid.grid, x, y), Hit::Cell(cell) if cell == other)); + } + + /// A click focuses the cell under it, not the column focused before, + /// and a double click edits that cell. + #[gtk4::test] + fn a_click_focuses_the_cell_under_it_and_a_double_click_edits_it() { + let grid = Grid::open(two_text_columns(), two_text_values()); + KeyInjector::focus_only(&grid.window); + grid.focus(0, 0); + let mouse = KeyInjector::connect().expect("an X11 display"); + let clicked = grid.cell_of(2, 1); + + point_in(&grid, &clicked); + mouse.click(&clicked, (4.0, 4.0), 1).expect("the click"); + wait_until(WAIT, || grid.focused_cell().as_ref() == Some(&clicked)) + .expect("the click left the focus off the clicked cell"); + mouse.click(&clicked, (4.0, 4.0), 2).expect("the double click"); + wait_until(WAIT, || grid.grid.edit().editing_cell().as_ref() == Some(&clicked)) + .expect("the double click edited another cell"); + } +} diff --git a/linux/crates/app/src/ui/data_grid/references/mod.rs b/linux/crates/app/src/ui/data_grid/references/mod.rs new file mode 100644 index 0000000000..3d7f16b93f --- /dev/null +++ b/linux/crates/app/src/ui/data_grid/references/mod.rs @@ -0,0 +1,348 @@ +use std::rc::Rc; + +use gtk4::prelude::*; +use gtk4::subclass::prelude::*; +use gtk4::{gio, glib}; +use tablepro_core::Value; +use tablepro_core::browse::{ReferenceError, RowReference}; +use tablepro_core::column::ResultColumn; +use tablepro_core::ddl::{InboundForeignKey, OutboundForeignKey}; +use tablepro_core::meta::TableRef; + +use super::grid_actions::{add, enable}; +use super::selection::Resolved; +use super::{DataGrid, GridCell, GridRequest, GridRow, KeysHandle, PinnedRow, TableKeys}; +use crate::i18n::{gettext, gettext_f}; +use crate::services::grid::GridColumn; +use crate::ui::menu_label; + +#[cfg(test)] +mod tests; + +const FOLLOW: &str = "follow-reference"; +const OPEN_CHOICE: &str = "open-reference"; +const REFERENCING: &str = "show-referencing-rows"; + +/// The keys a row leads somewhere by from one of its cells, under the +/// keys read when they were taken. +#[derive(Debug, Default)] +struct Offers { + keys: Option>, + /// The table's keys that hold the cell's column, by position. + open: Vec, + /// The other tables' keys that point at the row, by position. + referencing: Vec, +} + +/// What a menu or the chooser offered, and the row it was opened on. The +/// choices act on this rather than on the keys or rows of the moment, so +/// a key read or a page that lands while a menu is open changes nothing +/// it does. +#[derive(Debug, Default)] +pub struct Armed { + offers: Offers, + row: Option, +} + +impl Armed { + fn new(grid: &DataGrid, row: &GridRow, column: usize) -> Self { + Self { + offers: offers(grid, row, column), + row: Some(row.pin()), + } + } + + fn referenced(&self, grid: &DataGrid, index: usize) -> Option> { + let keys = self.offers.keys.as_ref()?; + let key = keys.outbound.get(index)?; + Some(referenced_by(grid, &keys.table, key, self.row.as_ref()?.get()?)) + } + + fn referencing(&self, grid: &DataGrid, index: usize) -> Option> { + let keys = self.offers.keys.as_ref()?; + let key = keys.inbound.get(index)?; + referencing_by(grid, &keys.table, key, self.row.as_ref()?.get()?) + } +} + +pub fn install_actions(grid: &DataGrid) { + add(grid, FOLLOW, follow_reference); + add_choice(grid, OPEN_CHOICE, |grid, index| { + let reference = grid.imp().armed_references.borrow().referenced(grid, index); + if let Some(reference) = reference { + follow(grid, reference); + } + }); + add_choice(grid, REFERENCING, |grid, index| { + let reference = grid.imp().armed_references.borrow().referencing(grid, index); + if let Some(reference) = reference { + follow(grid, reference); + } + }); +} + +pub fn refresh_actions(grid: &DataGrid, target: Option<&Resolved>) { + let follows = !grid.is_editing() + && target + .and_then(|target| target.cell.as_ref()) + .is_some_and(|(row, column)| !offers(grid, row, *column).open.is_empty()); + enable(grid, FOLLOW, follows); +} + +impl DataGrid { + /// The table's foreign keys were read: Ctrl+J follows what they + /// offer now. + pub(crate) fn keys_changed(&self) { + self.refresh_actions(); + } + + /// Fill the cell menu's key items for `cell` as the menu opens; a row + /// or the space below the rows offers none. + pub(crate) fn arm_references(&self, cell: Option<&GridCell>) { + let armed = cell + .and_then(|cell| Some((cell.row()?, cell.column()))) + .map(|(row, column)| Armed::new(self, &row, column)) + .unwrap_or_default(); + self.fill_reference_menus(&armed); + self.imp().armed_references.replace(armed); + } + + fn fill_reference_menus(&self, armed: &Armed) { + let Some(menus) = self.menus() else { + return; + }; + let section = &menus.references; + section.remove_all(); + let Some(choices) = menus.reference_chooser.menu_model().and_downcast::() else { + return; + }; + choices.remove_all(); + let Some(keys) = armed.offers.keys.as_deref() else { + return; + }; + let open = armed.offers.open.iter().filter_map(|&index| { + let key = &keys.outbound.get(index)?.key; + let target = TableRef::new(key.ref_schema.clone(), key.ref_table.clone()); + Some((index, KeyName::new(&keys.table, &target, &key.columns, &key.name))) + }); + for (index, label) in labelled(open.collect()) { + choices.append_item(&choice(&label, OPEN_CHOICE, index)); + } + let open_label = gettext("Open Referenced Row"); + match armed.offers.open.as_slice() { + [] => {} + // The item shows Ctrl+J but acts on the key it offered, as a key + // read while the menu is open leaves the menu as it was. + [index] => { + let item = crate::application::menu_item(&open_label, &format!("grid.{FOLLOW}")); + let target = u32::try_from(*index).unwrap_or(u32::MAX); + item.set_action_and_target_value(Some(&format!("grid.{OPEN_CHOICE}")), Some(&target.to_variant())); + section.append_item(&item); + } + _ => section.append_submenu(Some(&open_label), &choices), + } + let inbound = armed.offers.referencing.iter().filter_map(|&index| { + let key = keys.inbound.get(index)?; + Some((index, KeyName::new(&keys.table, &key.table, &key.columns, &key.name))) + }); + let referencing = gio::Menu::new(); + for (index, label) in labelled(inbound.collect()) { + referencing.append_item(&choice(&label, REFERENCING, index)); + } + if referencing.n_items() > 0 { + section.append_submenu(Some(&gettext("Show Referencing Rows")), &referencing); + } + } + + /// The keys to choose from, pointing at the cell they were offered + /// for when it is the focused one. + fn open_reference_chooser(&self, armed: Armed) { + let Some(chooser) = self.menus().map(|menus| menus.reference_chooser.clone()) else { + return; + }; + let row = armed.row.as_ref().and_then(PinnedRow::get).cloned(); + let at = self + .focused_cell() + .filter(|cell| cell.row() == row) + .and_then(|cell| self.bounds_in_view(cell.upcast_ref())); + self.fill_reference_menus(&armed); + self.imp().armed_references.replace(armed); + self.show_popover(&chooser, at); + } +} + +/// Ctrl+J: follow the one key the target cell's column is in, or offer +/// them when it is in several. +fn follow_reference(grid: &DataGrid) { + let Some((row, column)) = grid.target().and_then(|target| target.cell) else { + return; + }; + let armed = Armed::new(grid, &row, column); + match armed.offers.open.as_slice() { + [] => {} + [index] => { + if let Some(reference) = armed.referenced(grid, *index) { + follow(grid, reference); + } + } + _ => grid.open_reference_chooser(armed), + } +} + +fn add_choice(grid: &DataGrid, name: &str, act: impl Fn(&DataGrid, usize) + 'static) { + let action = gio::SimpleAction::new(name, Some(glib::VariantTy::UINT32)); + let weak = grid.downgrade(); + action.connect_activate(move |_, parameter| { + let (Some(grid), Some(index)) = (weak.upgrade(), parameter.and_then(glib::Variant::get::)) else { + return; + }; + act(&grid, index as usize); + }); + grid.actions().add_action(&action); +} + +/// What `row` offers from `column`, under the keys read so far. A grid +/// that was given no keys offers nothing. +fn offers(grid: &DataGrid, row: &GridRow, column: usize) -> Offers { + let Some(keys) = grid + .config() + .and_then(|config| config.references.as_ref()) + .map(KeysHandle::get) + else { + return Offers::default(); + }; + let columns = grid.grid_columns(); + let Some(name) = columns.get(column).map(|column| column.name.as_str()) else { + return Offers::default(); + }; + let open = keys + .outbound + .iter() + .enumerate() + .filter(|(_, outbound)| outbound.key.columns.iter().any(|key_column| key_column == name)) + .filter(|(_, outbound)| leads_somewhere(referenced_by(grid, &keys.table, outbound, row))) + .map(|(index, _)| index) + .collect(); + let referencing = keys + .inbound + .iter() + .enumerate() + .filter(|(_, inbound)| referencing_by(grid, &keys.table, inbound, row).is_some_and(leads_somewhere)) + .map(|(index, _)| index) + .collect(); + Offers { + keys: Some(keys), + open, + referencing, + } +} + +/// The row `key` points at from `row` as the grid shows it, pending +/// edits included. +fn referenced_by( + grid: &DataGrid, + source: &TableRef, + key: &OutboundForeignKey, + row: &GridRow, +) -> Result { + let tracked = grid.tracked(); + let (columns, values) = key_cells(&grid.grid_columns(), &key.key.columns, |index| { + Some(tracked.with_value(row, index, |value, _| value.clone())) + }); + RowReference::referenced(source, key, &columns, &values) +} + +/// The rows of `key` that point at `row` as it was saved, since that is +/// what they reference. A draft is not saved, so `None`. +fn referencing_by( + grid: &DataGrid, + source: &TableRef, + key: &InboundForeignKey, + row: &GridRow, +) -> Option> { + let saved = row.cells()?; + let (columns, values) = key_cells(&grid.grid_columns(), &key.referenced_columns, |index| { + saved.get(index).cloned() + }); + Some(RowReference::referencing(source, key, &columns, &values)) +} + +/// The columns named `names` with the row's values, read through +/// `value`, so a row of large cells copies only its key's. +fn key_cells( + columns: &[GridColumn], + names: &[String], + value: impl Fn(usize) -> Option, +) -> (Vec, Vec) { + names + .iter() + .filter_map(|name| { + let column = columns.iter().find(|column| column.name == *name)?; + Some((column.result_column(), value(column.index)?)) + }) + .unzip() +} + +/// Open what a key leads to, or say why it cannot. +fn follow(grid: &DataGrid, reference: Result) { + match reference { + Ok(reference) => grid.request(GridRequest::FollowReference(reference)), + Err(error) => grid.toast(crate::ui::error_text::reference_message(&error)), + } +} + +/// A key that cannot be followed is still offered, so the toast it +/// raises says why; one with nothing to follow is not. +fn leads_somewhere(reference: Result) -> bool { + match reference { + Ok(_) => true, + Err(error) => !error.leaves_nothing_to_follow(), + } +} + +fn choice(label: &str, action: &str, index: usize) -> gio::MenuItem { + let item = gio::MenuItem::new(Some(label), None); + let target = u32::try_from(index).unwrap_or(u32::MAX); + item.set_action_and_target_value(Some(&format!("grid.{action}")), Some(&target.to_variant())); + item +} + +/// How an item names its key: the other table, with its schema where +/// that is not the source's, and the key's columns on the side that +/// holds it, which tell two keys into one table apart. +struct KeyName { + short: String, + constraint: String, +} + +impl KeyName { + fn new(source: &TableRef, table: &TableRef, columns: &[String], constraint: &str) -> Self { + let name = match (&table.schema, table.schema == source.schema) { + (Some(schema), false) => format!("{schema}.{}", table.name), + _ => table.name.clone(), + }; + Self { + short: format!("{name} ({})", columns.join(", ")), + constraint: constraint.to_owned(), + } + } +} + +/// Each key's label. Two keys from the same columns into the same table +/// also name their constraint, since they point at different columns. +fn labelled(names: Vec<(usize, KeyName)>) -> Vec<(usize, String)> { + let shared = |short: &str| names.iter().filter(|(_, name)| name.short == short).count() > 1; + names + .iter() + .map(|(index, name)| { + let label = match shared(&name.short) { + true => gettext_f( + "{key} through {constraint}", + &[("key", &name.short), ("constraint", &name.constraint)], + ), + false => name.short.clone(), + }; + (*index, menu_label(&label)) + }) + .collect() +} diff --git a/linux/crates/app/src/ui/data_grid/references/tests.rs b/linux/crates/app/src/ui/data_grid/references/tests.rs new file mode 100644 index 0000000000..397b7f1f17 --- /dev/null +++ b/linux/crates/app/src/ui/data_grid/references/tests.rs @@ -0,0 +1,454 @@ +use gtk4 as gtk; +use gtk4::gio; +use gtk4::prelude::*; +use tablepro_core::ddl::{InboundForeignKey, OutboundForeignKey}; +use tablepro_core::filter::{Combinator, FilterOp, FilterRule, FilterSet, FilterValue}; +use tablepro_core::meta::TableRef; +use tablepro_core::{ColumnInfo, ForeignKeyInfo, Value}; + +use crate::application::ShortcutOwner; +use crate::test_support::shortcut_keys::{keystroke, owner_controller, shortcuts, trigger_keys}; +use crate::test_support::wait_until; +use crate::ui::data_grid::test_grid::{Grid, GridSpec, WAIT, named, persisted}; +use crate::ui::data_grid::{GridRequest, KeysHandle, TableKeys}; + +type Item = (String, Option, Option); + +fn public(name: &str) -> TableRef { + TableRef::new(Some("public".to_owned()), name) +} + +fn key(columns: &[&str], to: TableRef, ref_columns: &[&str]) -> ForeignKeyInfo { + ForeignKeyInfo { + name: format!("{}_fkey", columns.join("_")), + columns: columns.iter().map(|name| (*name).to_owned()).collect(), + ref_schema: to.schema, + ref_table: to.name, + ref_columns: ref_columns.iter().map(|name| (*name).to_owned()).collect(), + on_delete: None, + on_update: None, + } +} + +fn filter(rules: &[(&str, &str)]) -> FilterSet { + FilterSet { + combinator: Combinator::And, + rules: rules + .iter() + .map(|(column, text)| FilterRule { + column: (*column).to_owned(), + op: FilterOp::Eq, + value: Some(FilterValue::Single((*text).to_owned())), + }) + .collect(), + extra_sql: None, + } +} + +fn with_keys(columns: Vec, rows: Vec>, keys: TableKeys) -> Grid { + let handle = KeysHandle::new(keys.table.clone()); + handle.set(keys); + Grid::build( + GridSpec { + references: Some(handle), + ..GridSpec::table(columns) + }, + persisted(&rows), + ) +} + +/// `orders` keyed by `id`, whose `customer_id` points at customers. +fn orders_grid(outbound: Vec) -> Grid { + with_keys( + vec![ + named("id", "integer", true), + named("customer_id", "integer", false), + named("tenant_id", "integer", false), + ], + vec![ + vec![Value::Int(1), Value::Int(42), Value::Int(7)], + vec![Value::Int(2), Value::Null, Value::Int(7)], + ], + TableKeys { + table: public("orders"), + outbound: outbound + .into_iter() + .map(|key| OutboundForeignKey { + key, + parent_compares_alike: true, + }) + .collect(), + inbound: Vec::new(), + }, + ) +} + +fn open_menu(grid: &Grid, id: i64, column: usize) { + assert!( + grid.grid.open_cell_menu(&grid.cell_of(id, column), None), + "the menu opens" + ); +} + +fn close_menus(grid: &Grid) { + if let Some(menus) = grid.grid.menus() { + menus.cell.popdown(); + menus.reference_chooser.popdown(); + } +} + +/// The foreign key items the cell menu holds, submenus opened, as each +/// one's label, action and the index it carries. +fn reference_items(grid: &Grid) -> Vec { + let menus = grid.grid.menus().expect("the grid's menus"); + items(menus.references.upcast_ref()) +} + +fn items(menu: &gio::MenuModel) -> Vec { + let mut found = Vec::new(); + for position in 0..menu.n_items() { + let attribute = |name: &str| menu.item_attribute_value(position, name, None); + if let Some(label) = attribute("label").and_then(|value| value.get::()) { + found.push(( + label, + attribute("action").and_then(|value| value.get::()), + attribute("target").and_then(|value| value.get::()), + )); + } + for link in ["section", "submenu"] { + if let Some(inner) = menu.item_link(position, link) { + found.extend(items(&inner)); + } + } + } + found +} + +fn item(label: &str, action: Option<&str>, target: Option) -> Item { + (label.to_owned(), action.map(str::to_owned), target) +} + +fn followed(grid: &Grid) -> Vec<(TableRef, FilterSet)> { + grid.take_requests() + .into_iter() + .filter_map(|request| match request { + GridRequest::FollowReference(reference) => Some((reference.table, reference.filter)), + _ => None, + }) + .collect() +} + +/// Ctrl+J on the focused cell, as a key press reaching the grid's +/// controller would. `true` when the grid took the key. +fn ctrl_j(grid: &Grid) -> bool { + let view = grid.grid.column_view(); + let controller = owner_controller(&view, ShortcutOwner::GridCell).expect("the grid's cell keys"); + let action = shortcuts(&controller) + .into_iter() + .find(|shortcut| { + shortcut + .trigger() + .is_some_and(|trigger| trigger_keys(&trigger).contains(&keystroke("j"))) + }) + .and_then(|shortcut| shortcut.action()) + .expect("the grid binds Ctrl+J"); + action.activate(gtk::ShortcutActionFlags::empty(), &view, None) +} + +#[gtk4::test] +fn a_key_cell_offers_open_referenced_row_and_sends_the_rows_it_names() { + let grid = orders_grid(vec![key(&["customer_id"], public("customers"), &["id"])]); + + open_menu(&grid, 1, 1); + let offered = reference_items(&grid); + grid.grid + .activate_action("grid.open-reference", Some(&0u32.to_variant())) + .expect("the menu's item"); + close_menus(&grid); + + assert_eq!( + offered, + [item("Open Referenced Row", Some("grid.open-reference"), Some(0))] + ); + assert_eq!(followed(&grid), [(public("customers"), filter(&[("id", "42")]))]); +} + +/// The menu acts on the key it offered: keys read while it is open +/// change Ctrl+J, not the menu. +#[gtk4::test] +fn a_menu_follows_the_key_it_offered_after_the_keys_are_read_again() { + let handle = KeysHandle::new(public("orders")); + handle.set(TableKeys { + table: public("orders"), + outbound: vec![OutboundForeignKey { + key: key(&["customer_id"], public("customers"), &["id"]), + parent_compares_alike: true, + }], + inbound: Vec::new(), + }); + let grid = Grid::build( + GridSpec { + references: Some(handle.clone()), + ..GridSpec::table(vec![ + named("id", "integer", true), + named("customer_id", "integer", false), + ]) + }, + persisted(&[vec![Value::Int(1), Value::Int(42)]]), + ); + open_menu(&grid, 1, 1); + + handle.set(TableKeys { + table: public("orders"), + outbound: Vec::new(), + inbound: Vec::new(), + }); + grid.grid.keys_changed(); + grid.grid + .activate_action("grid.open-reference", Some(&0u32.to_variant())) + .expect("the menu's item"); + close_menus(&grid); + + assert_eq!(followed(&grid), [(public("customers"), filter(&[("id", "42")]))]); +} + +#[gtk4::test] +fn a_null_key_and_a_column_in_no_key_offer_nothing() { + let grid = orders_grid(vec![key(&["customer_id"], public("customers"), &["id"])]); + + open_menu(&grid, 2, 1); + let on_null = reference_items(&grid); + open_menu(&grid, 1, 2); + let on_other = reference_items(&grid); + close_menus(&grid); + + assert_eq!(on_null, []); + assert_eq!(on_other, []); +} + +#[gtk4::test] +fn a_column_in_two_keys_offers_each_and_follows_the_one_chosen() { + let crm = TableRef::new(Some("crm".to_owned()), "customers"); + let grid = orders_grid(vec![ + key(&["tenant_id"], public("tenants"), &["id"]), + key(&["tenant_id", "customer_id"], crm.clone(), &["tenant_id", "id"]), + ]); + + open_menu(&grid, 1, 2); + let offered = reference_items(&grid); + grid.grid + .activate_action("grid.open-reference", Some(&1u32.to_variant())) + .expect("the second key"); + close_menus(&grid); + + assert_eq!( + offered, + [ + item("Open Referenced Row", None, None), + item("tenants (tenant__id)", Some("grid.open-reference"), Some(0)), + item( + "crm.customers (tenant__id, customer__id)", + Some("grid.open-reference"), + Some(1) + ), + ] + ); + assert_eq!(followed(&grid), [(crm, filter(&[("tenant_id", "7"), ("id", "42")]))]); +} + +/// One column pointing at two columns of one table gives the two keys +/// the same columns, so their constraints tell them apart. +#[gtk4::test] +fn keys_from_the_same_columns_into_one_table_name_their_constraints() { + let mut by_id = key(&["customer_id"], public("customers"), &["id"]); + by_id.name = "by_id".to_owned(); + let mut by_code = key(&["customer_id"], public("customers"), &["code"]); + by_code.name = "by_code".to_owned(); + let grid = orders_grid(vec![by_id, by_code]); + + open_menu(&grid, 1, 1); + let offered = reference_items(&grid); + close_menus(&grid); + + assert_eq!( + offered, + [ + item("Open Referenced Row", None, None), + item( + "customers (customer__id) through by__id", + Some("grid.open-reference"), + Some(0) + ), + item( + "customers (customer__id) through by__code", + Some("grid.open-reference"), + Some(1) + ), + ] + ); +} + +#[gtk4::test] +fn a_key_the_catalogue_could_not_pair_is_offered_and_says_why_it_cannot_open() { + let grid = orders_grid(vec![key(&["customer_id"], public("ghost"), &[])]); + + open_menu(&grid, 1, 1); + let offered = reference_items(&grid).len(); + grid.activate("follow-reference"); + close_menus(&grid); + + assert_eq!(offered, 1); + let toasts: Vec = grid + .take_requests() + .into_iter() + .filter_map(|request| match request { + GridRequest::Toast(text) => Some(text), + GridRequest::FollowReference(reference) => panic!("opened {reference:?}"), + _ => None, + }) + .collect(); + assert_eq!(toasts.len(), 1); + assert!(toasts[0].contains("customer_id_fkey"), "{toasts:?}"); +} + +#[gtk4::test] +fn referencing_rows_are_offered_for_a_saved_row_and_not_for_a_draft() { + let orders = TableRef::new(Some("sales".to_owned()), "orders"); + let grid = with_keys( + vec![named("id", "integer", true), named("name", "text", false)], + vec![vec![Value::Int(1), Value::Text("Ada".into())]], + TableKeys { + table: public("customers"), + outbound: Vec::new(), + inbound: vec![InboundForeignKey { + child_compares_alike: true, + name: "orders_customer_id_fkey".to_owned(), + table: orders.clone(), + columns: vec!["customer_id".to_owned()], + referenced_columns: vec!["id".to_owned()], + }], + }, + ); + + open_menu(&grid, 1, 1); + let offered = reference_items(&grid); + grid.grid + .activate_action("grid.show-referencing-rows", Some(&0u32.to_variant())) + .expect("the menu's Show Referencing Rows"); + close_menus(&grid); + let opened = followed(&grid); + grid.insert_draft(); + let mut draft = None; + wait_until(WAIT, || { + draft = grid + .cells() + .into_iter() + .find(|cell| cell.row().is_some_and(|row| row.draft_id().is_some())); + draft.is_some() + }) + .expect("the draft is drawn"); + grid.grid.open_cell_menu(&draft.expect("the draft's cell"), None); + let on_draft = reference_items(&grid); + close_menus(&grid); + + assert_eq!( + offered, + [ + item("Show Referencing Rows", None, None), + item( + "sales.orders (customer__id)", + Some("grid.show-referencing-rows"), + Some(0) + ), + ] + ); + assert_eq!(opened, [(orders, filter(&[("customer_id", "1")]))]); + assert_eq!(on_draft, [], "a draft is offered rows that point at it"); +} + +#[gtk4::test] +fn a_grid_given_no_keys_offers_no_reference_items() { + let grid = Grid::open( + vec![named("id", "integer", true), named("customer_id", "integer", false)], + vec![vec![Value::Int(1), Value::Int(42)]], + ); + + open_menu(&grid, 1, 1); + let offered = reference_items(&grid); + let enabled = grid.is_enabled("follow-reference"); + grid.activate("follow-reference"); + close_menus(&grid); + + assert_eq!(offered, []); + assert!(!enabled); + assert_eq!(followed(&grid), []); +} + +#[gtk4::test] +fn ctrl_j_follows_the_one_key_of_the_focused_cell_and_lets_the_key_go_on_elsewhere() { + let grid = orders_grid(vec![key(&["customer_id"], public("customers"), &["id"])]); + + grid.focus(0, 1); + assert!(ctrl_j(&grid)); + grid.focus(0, 2); + assert!(!ctrl_j(&grid), "a column in no key took Ctrl+J"); + grid.focus(1, 1); + assert!(!ctrl_j(&grid), "a NULL key took Ctrl+J"); + + assert_eq!(followed(&grid), [(public("customers"), filter(&[("id", "42")]))]); +} + +#[gtk4::test] +fn ctrl_j_on_a_column_in_two_keys_offers_them_at_the_cell() { + let crm = TableRef::new(Some("crm".to_owned()), "customers"); + let grid = orders_grid(vec![ + key(&["tenant_id"], public("tenants"), &["id"]), + key(&["tenant_id", "customer_id"], crm.clone(), &["tenant_id", "id"]), + ]); + grid.focus(0, 2); + + assert!(ctrl_j(&grid)); + + let chooser = grid + .grid + .menus() + .map(|menus| menus.reference_chooser.clone()) + .expect("the chooser"); + wait_until(WAIT, || chooser.is_visible()).expect("the keys are offered"); + let offered = chooser.menu_model().map(|model| items(&model)).unwrap_or_default(); + assert!(followed(&grid).is_empty(), "a key was followed before one was chosen"); + grid.grid + .activate_action("grid.open-reference", Some(&1u32.to_variant())) + .expect("the chooser's second key"); + close_menus(&grid); + + assert_eq!(offered.len(), 2, "{offered:?}"); + assert_eq!(followed(&grid), [(crm, filter(&[("tenant_id", "7"), ("id", "42")]))]); +} + +#[gtk4::test] +fn keys_read_after_the_focus_landed_turn_ctrl_j_on() { + let columns = vec![named("id", "integer", true), named("customer_id", "integer", false)]; + let handle = KeysHandle::new(public("orders")); + let grid = Grid::build( + GridSpec { + references: Some(handle.clone()), + ..GridSpec::table(columns) + }, + persisted(&[vec![Value::Int(1), Value::Int(42)]]), + ); + grid.focus(0, 1); + assert!(!grid.is_enabled("follow-reference"), "no keys yet"); + + handle.set(TableKeys { + table: public("orders"), + outbound: vec![OutboundForeignKey { + key: key(&["customer_id"], public("customers"), &["id"]), + parent_compares_alike: true, + }], + inbound: Vec::new(), + }); + grid.grid.keys_changed(); + + assert!(grid.is_enabled("follow-reference")); +} diff --git a/linux/crates/app/src/ui/data_grid/row_detail/calendar.rs b/linux/crates/app/src/ui/data_grid/row_detail/calendar.rs new file mode 100644 index 0000000000..cccb56a40b --- /dev/null +++ b/linux/crates/app/src/ui/data_grid/row_detail/calendar.rs @@ -0,0 +1,144 @@ +use chrono::{Datelike, FixedOffset, Local, NaiveDate, NaiveTime, Offset, TimeDelta, TimeZone}; +use gtk4::prelude::*; +use gtk4::subclass::prelude::*; +use gtk4::{self as gtk, glib}; +use tablepro_core::column::{ColumnKind, TemporalZone}; + +use super::RowDetailPane; +use super::value_editor::Commit; +use crate::application::component_matches; + +/// The day a value's text starts with, when it starts with one the +/// calendar can show. +pub fn day_of(text: &str) -> Option { + let head = text.get(..10)?; + NaiveDate::parse_from_str(head, "%Y-%m-%d") + .ok() + .filter(|day| (1..=9999).contains(&day.year())) +} + +/// The value's text with `day` picked: a date, or a timestamp in `zone`, +/// which keeps the time it had. A new zoned timestamp starts the day at +/// `offset`, as one without an offset is refused. +pub fn with_day(text: &str, day: NaiveDate, zone: Option<&TemporalZone>, offset: FixedOffset) -> String { + let date = day.format("%Y-%m-%d").to_string(); + match (zone, day_of(text)) { + (None, _) => date, + (Some(_), Some(_)) => format!("{date}{}", text.get(10..).unwrap_or_default()), + (Some(TemporalZone::Offset), None) => format!("{date} 00:00:00{offset}"), + (Some(_), None) => format!("{date} 00:00:00"), + } +} + +/// The offset `zone` has as `day` starts. Where a clock change skips +/// midnight, the day starts at the change, which is midnight at the +/// offset before it. +pub fn day_start_offset(zone: &Tz, day: NaiveDate) -> FixedOffset { + let midnight = day.and_time(NaiveTime::MIN); + [midnight, midnight - TimeDelta::hours(1)] + .iter() + .find_map(|local| zone.from_local_datetime(local).earliest()) + .map_or_else( + || zone.offset_from_utc_datetime(&midnight).fix(), + |at| at.offset().fix(), + ) +} + +impl RowDetailPane { + pub(super) fn connect_calendar(&self) { + let imp = self.imp(); + imp.calendar_popover.connect_show(glib::clone!( + #[weak(rename_to = pane)] + self, + move |_| pane.calendar_opens() + )); + imp.calendar.connect_day_selected(glib::clone!( + #[weak(rename_to = pane)] + self, + move |_| { + if !pane.imp().quiet_calendar.get() { + pane.pick_day(); + } + } + )); + // day-selected fires only when the day changes, so a click or + // Space on the day already selected, today on a NULL or the same + // day after a month change, would pick nothing. + let click = gtk::GestureClick::new(); + click.connect_released(glib::clone!( + #[weak(rename_to = pane)] + self, + move |gesture, _, x, y| { + let on_a_day = gesture + .widget() + .and_then(|calendar| calendar.pick(x, y, gtk::PickFlags::DEFAULT)) + .is_some_and(|label| label.has_css_class("day-number")); + if on_a_day { + pane.pick_day(); + } + } + )); + imp.calendar.add_controller(click); + let keys = gtk::EventControllerKey::new(); + keys.connect_key_released(glib::clone!( + #[weak(rename_to = pane)] + self, + move |_, keyval, _, state| { + if component_matches("cell-editor.pick-date", keyval, state) { + pane.pick_day(); + } + } + )); + imp.calendar.add_controller(keys); + } + + /// The calendar opens on the value's day, else on today, for the row + /// and field shown now. + fn calendar_opens(&self) { + let imp = self.imp(); + imp.calendar_target.replace(self.current_pin().zip(imp.field.get())); + let day = day_of(&self.typed_text()) + .and_then(|day| { + let month = i32::try_from(day.month()).ok()?; + let date = i32::try_from(day.day()).ok()?; + glib::DateTime::from_utc(day.year(), month, date, 0, 0, 0.0).ok() + }) + .or_else(|| glib::DateTime::now_local().ok()); + if let Some(day) = day { + // Moving the calendar to the day is no pick. + imp.quiet_calendar.set(true); + imp.calendar.set_date(&day); + imp.quiet_calendar.set(false); + } + } + + /// Write the picked day into the value as if typed, and commit it, + /// while the pane still shows the row and field it opened on. + fn pick_day(&self) { + let imp = self.imp(); + let target = imp.calendar_target.take(); + if target.is_none() || target != self.current_pin().zip(imp.field.get()) { + imp.calendar_popover.popdown(); + return; + } + if !imp.value_view.is_editable() { + return; + } + let (year, month, day) = imp.calendar.date().ymd(); + let Some(day) = u32::try_from(month) + .ok() + .zip(u32::try_from(day).ok()) + .and_then(|(month, day)| NaiveDate::from_ymd_opt(year, month, day)) + else { + return; + }; + let zone = self.field_column().and_then(|column| match &column.value_type().kind { + ColumnKind::Timestamp { zone, .. } => Some(zone.clone()), + _ => None, + }); + let text = with_day(&self.typed_text(), day, zone.as_ref(), day_start_offset(&Local, day)); + imp.calendar_popover.popdown(); + imp.value_buffer.set_text(&text); + self.commit(Commit::Keep); + } +} diff --git a/linux/crates/app/src/ui/data_grid/row_detail/fields.rs b/linux/crates/app/src/ui/data_grid/row_detail/fields.rs new file mode 100644 index 0000000000..e0c6c85f65 --- /dev/null +++ b/linux/crates/app/src/ui/data_grid/row_detail/fields.rs @@ -0,0 +1,369 @@ +use std::collections::HashSet; + +use gtk4::prelude::*; +use gtk4::subclass::prelude::*; +use gtk4::{self as gtk, glib, pango}; +use tablepro_core::Value; + +use super::RowDetailPane; +use crate::services::change_tracker::CellState; +use crate::services::grid::{GridColumn, auto_filled_sentinel, display_text}; +use crate::ui::data_grid::tracked_values::cell_description; +use crate::ui::data_grid::{DataGrid, GridRow, TrackedValues}; +use crate::ui::handler_guard::HandlerGuard; + +/// The data columns in the order the fields list shows them: the ones on +/// screen as the grid shows them, then the hidden ones. +pub fn field_order(shown: &[usize], count: usize) -> Vec { + let on_screen: HashSet = shown.iter().copied().collect(); + shown + .iter() + .copied() + .filter(|index| *index < count) + .chain((0..count).filter(|index| !on_screen.contains(index))) + .collect() +} + +/// The data column a fields list item stands for. +fn field_of(item: Option) -> Option { + let boxed = item.and_downcast::()?; + boxed.try_borrow::().ok().map(|index| *index) +} + +/// One field's value as its row in the list draws it. +#[derive(Debug, Clone, PartialEq)] +pub struct FieldValue { + pub text: String, + pub placeholder: bool, + pub null: bool, + pub modified: bool, + pub deleted: bool, +} + +pub fn field_value(tracked: &TrackedValues, row: &GridRow, column: &GridColumn) -> FieldValue { + let mark = tracked.mark(row, column.index); + let (text, placeholder, null) = tracked.with_value(row, column.index, |value, default| match (default, value) { + (true, _) if column.auto_filled() => (auto_filled_sentinel(), true, false), + (true, _) => ("DEFAULT".to_owned(), true, false), + (false, Value::Null) => ("NULL".to_owned(), true, true), + (false, value) => { + let mut scratch = String::new(); + let shown = display_text(value, &column.column_type, &mut scratch); + (shown.text.to_owned(), shown.placeholder, false) + } + }); + FieldValue { + text, + placeholder, + null, + modified: mark.state == CellState::Modified && !mark.row_deleted, + deleted: mark.row_deleted, + } +} + +/// A field's row: its name and type on one line, its value on the next. +fn field_row() -> gtk::Box { + let name = gtk::Label::builder() + .xalign(0.0) + .hexpand(true) + .ellipsize(pango::EllipsizeMode::End) + .build(); + let kind = gtk::Label::builder() + .xalign(1.0) + .ellipsize(pango::EllipsizeMode::End) + .build(); + kind.add_css_class("caption"); + kind.add_css_class("dimmed"); + let top = gtk::Box::new(gtk::Orientation::Horizontal, 6); + top.append(&name); + top.append(&kind); + let value = gtk::Inscription::builder() + .hexpand(true) + .min_chars(1) + .text_overflow(gtk::InscriptionOverflow::EllipsizeEnd) + .build(); + let row = gtk::Box::builder() + .orientation(gtk::Orientation::Vertical) + .spacing(2) + .margin_top(6) + .margin_bottom(6) + .margin_start(12) + .margin_end(12) + .build(); + row.append(&top); + row.append(&value); + row +} + +fn set_class(widget: &impl IsA, class: &str, on: bool) { + match on { + true => widget.as_ref().add_css_class(class), + false => widget.as_ref().remove_css_class(class), + } +} + +/// Italic for a stand-in, bold for a pending value and struck through on +/// a row marked for deletion, so each reads without colour. +fn value_attributes(value: &FieldValue) -> pango::AttrList { + let attributes = pango::AttrList::new(); + if value.placeholder { + attributes.insert(pango::AttrInt::new_style(pango::Style::Italic)); + } + if value.modified { + attributes.insert(pango::AttrInt::new_weight(pango::Weight::Bold)); + } + if value.deleted { + attributes.insert(pango::AttrInt::new_strikethrough(true)); + } + attributes +} + +impl RowDetailPane { + pub(super) fn connect_fields(&self, grid: &DataGrid) { + let imp = self.imp(); + imp.selection.set_autoselect(false); + imp.selection.set_can_unselect(true); + imp.selection.set_model(Some(&imp.fields.0)); + imp.field_list.set_model(Some(&imp.selection)); + imp.field_list.set_factory(Some(&self.field_factory())); + imp.selection.connect_selected_notify(glib::clone!( + #[weak(rename_to = pane)] + self, + move |_| pane.field_picked() + )); + imp.field_list.connect_activate(glib::clone!( + #[weak(rename_to = pane)] + self, + move |_, _| pane.focus_value() + )); + let columns = grid.column_view().columns(); + let reordered = columns.connect_items_changed(glib::clone!( + #[weak(rename_to = pane)] + self, + move |_, _, _, _| pane.schedule_field_sync() + )); + imp.guards.borrow_mut().push(HandlerGuard::new(&columns, reordered)); + } + + fn field_factory(&self) -> gtk::SignalListItemFactory { + let factory = gtk::SignalListItemFactory::new(); + factory.connect_setup(|_, item| { + if let Some(item) = item.downcast_ref::() { + item.set_child(Some(&field_row())); + } + }); + factory.connect_bind(glib::clone!( + #[weak(rename_to = pane)] + self, + move |_, item| { + if let Some(item) = item.downcast_ref::() { + pane.imp().bound.borrow_mut().push(item.clone()); + pane.draw_field(item); + } + } + )); + factory.connect_unbind(glib::clone!( + #[weak(rename_to = pane)] + self, + move |_, item| pane + .imp() + .bound + .borrow_mut() + .retain(|bound| bound.upcast_ref::() != item) + )); + factory + } + + pub(super) fn redraw_fields(&self) { + let bound: Vec = self.imp().bound.borrow().clone(); + for item in &bound { + self.draw_field(item); + } + } + + fn draw_field(&self, item: >k::ListItem) { + let (Some(grid), Some(index)) = (self.grid(), field_of(item.item())) else { + return; + }; + let Some(column) = grid.grid_columns().get(index).cloned() else { + return; + }; + let Some(row_box) = item.child() else { + return; + }; + let top = row_box.first_child(); + let (Some(name), Some(kind), Some(shown)) = ( + top.as_ref() + .and_then(|top| top.first_child()) + .and_downcast::(), + top.as_ref() + .and_then(|top| top.last_child()) + .and_downcast::(), + row_box.last_child().and_downcast::(), + ) else { + return; + }; + name.set_label(&column.name); + kind.set_label(&column.value_type().name); + item.set_accessible_label(&column.name); + let Some(row) = self.current_row() else { + shown.set_text(None); + return; + }; + let value = field_value(&grid.tracked(), &row, &column); + shown.set_text(Some(&value.text)); + shown.set_attributes(Some(&value_attributes(&value))); + set_class(&shown, "dimmed", value.placeholder || value.deleted); + // Adwaita's own warning colour beside the bold, as the grid's + // cells mark a pending value. + set_class(&shown, "warning", value.modified); + let state = match value.modified { + true => CellState::Modified, + false => CellState::Clean, + }; + let description = match (cell_description(state, value.null), value.null) { + (Some(said), true) => said, + (Some(said), false) => format!("{}, {said}", value.text), + (None, _) => value.text.clone(), + }; + item.set_accessible_description(&description); + } + + fn field_picked(&self) { + let imp = self.imp(); + if imp.syncing.get() { + return; + } + let Some(column) = field_of(imp.selection.selected_item()) else { + return; + }; + self.take_field(column); + self.load_value(); + self.move_grid_focus(column); + } + + /// Show `column`'s field, selected in the list, as the grid asks. + pub(super) fn set_field(&self, column: usize) { + self.take_field(column); + self.select_in_list(Some(column)); + self.load_value(); + } + + /// Make `column` the selected field. What was typed in another field + /// is committed first. + pub(super) fn take_field(&self, column: usize) { + if self.imp().field.get() == Some(column) { + return; + } + self.commit(super::value_editor::Commit::Teardown); + self.imp().field.set(Some(column)); + } + + pub(super) fn select_in_list(&self, column: Option) { + let imp = self.imp(); + let store = &imp.fields.0; + let position = column + .and_then(|column| (0..store.n_items()).find(|position| field_of(store.item(*position)) == Some(column))) + .unwrap_or(gtk::INVALID_LIST_POSITION); + if imp.selection.selected() == position { + return; + } + imp.syncing.set(true); + imp.selection.set_selected(position); + imp.syncing.set(false); + if position != gtk::INVALID_LIST_POSITION { + imp.field_list.scroll_to(position, gtk::ListScrollFlags::NONE, None); + } + } + + /// Move the grid's focused cell to `column` of the shown row. The + /// list holds the keyboard, and `scroll_to` only marks the cell the + /// grid focuses next while the grid does not, so the keyboard stays + /// in the pane. + fn move_grid_focus(&self, column: usize) { + let (Some(grid), Some(row)) = (self.grid(), self.current_row()) else { + return; + }; + let Some(position) = grid.view_position(&row) else { + return; + }; + let Some(view_column) = grid.data_view_column(column).filter(gtk::ColumnViewColumn::is_visible) else { + return; + }; + grid.column_view() + .scroll_to(position, Some(&view_column), gtk::ListScrollFlags::FOCUS, None); + } + + /// A header drag, a hidden column or a new column set reorders the + /// fields once the grid has finished changing its columns. + fn schedule_field_sync(&self) { + if self.imp().sync_queued.replace(true) { + return; + } + glib::idle_add_local_once(glib::clone!( + #[weak(rename_to = pane)] + self, + move || { + pane.imp().sync_queued.set(false); + pane.watch_column_visibility(); + if pane.sync_fields() { + pane.render(); + } + } + )); + } + + /// Put the fields in the grid's column order. `true` when it changed. + pub(super) fn sync_fields(&self) -> bool { + let Some(grid) = self.grid() else { + return false; + }; + let imp = self.imp(); + let wanted = field_order(&grid.shown_columns(), grid.grid_columns().len()); + let store = &imp.fields.0; + let held: Vec = (0..store.n_items()) + .filter_map(|position| field_of(store.item(position))) + .collect(); + if held == wanted { + return false; + } + let items: Vec = wanted.into_iter().map(glib::BoxedAnyObject::new).collect(); + imp.syncing.set(true); + store.splice(0, store.n_items(), &items); + imp.syncing.set(false); + // A column set without the field's column shows none. + if imp.field.get().is_some_and(|field| field >= items.len()) { + imp.field.set(None); + } + self.select_in_list(imp.field.get()); + true + } + + fn watch_column_visibility(&self) { + self.stop_watching_columns(); + let Some(grid) = self.grid() else { + return; + }; + let columns = grid.column_view().columns(); + let watches = (0..columns.n_items()) + .filter_map(|position| columns.item(position).and_downcast::()) + .map(|column| { + let handler = column.connect_visible_notify(glib::clone!( + #[weak(rename_to = pane)] + self, + move |_| pane.schedule_field_sync() + )); + (column.downgrade(), handler) + }) + .collect(); + self.imp().column_watches.replace(watches); + } + + pub(super) fn stop_watching_columns(&self) { + for (column, handler) in self.imp().column_watches.take() { + if let Some(column) = column.upgrade() { + column.disconnect(handler); + } + } + } +} diff --git a/linux/crates/app/src/ui/data_grid/row_detail/imp.rs b/linux/crates/app/src/ui/data_grid/row_detail/imp.rs new file mode 100644 index 0000000000..1ae83b565a --- /dev/null +++ b/linux/crates/app/src/ui/data_grid/row_detail/imp.rs @@ -0,0 +1,118 @@ +use std::cell::{Cell, RefCell}; + +use gtk4::prelude::*; +use gtk4::subclass::prelude::*; +use gtk4::{self as gtk, gio, glib}; +use libadwaita as adw; +use libadwaita::subclass::prelude::*; + +use super::Shown; +use super::value_editor::{Loaded, OpenEdit, Placeholders}; +use crate::ui::data_grid::{DataGrid, PinnedRow}; +use crate::ui::handler_guard::HandlerGuard; + +/// The fields the list shows, each a data column's index, made with the +/// pane since a list store has no default item type. +#[derive(Debug)] +pub struct Fields(pub gio::ListStore); + +impl Default for Fields { + fn default() -> Self { + Self(gio::ListStore::new::()) + } +} + +#[derive(Default, gtk::CompositeTemplate)] +#[template(resource = "/app/tablepro/TablePro/ui/row-detail-pane.ui")] +pub struct RowDetailPane { + #[template_child] + pub stack: TemplateChild, + #[template_child] + pub title_label: TemplateChild, + #[template_child] + pub state_box: TemplateChild, + #[template_child] + pub state_icon: TemplateChild, + #[template_child] + pub state_label: TemplateChild, + #[template_child] + pub close_button: TemplateChild, + #[template_child] + pub field_list: TemplateChild, + #[template_child] + pub field_label: TemplateChild, + #[template_child] + pub calendar_button: TemplateChild, + #[template_child] + pub calendar_popover: TemplateChild, + #[template_child] + pub calendar: TemplateChild, + #[template_child] + pub value_menu_button: TemplateChild, + #[template_child] + pub value_view: TemplateChild, + #[template_child] + pub value_buffer: TemplateChild, + #[template_child] + pub placeholder_label: TemplateChild, + #[template_child] + pub message_label: TemplateChild, + + pub grid: glib::WeakRef, + pub fields: Fields, + pub selection: gtk::SingleSelection, + /// The row the pane shows, while it has one. + pub shown: RefCell>, + /// The data column of the selected field. + pub field: Cell>, + /// What the value editor holds, so a focus move that changes nothing + /// does not write the buffer again. + pub loaded: RefCell>, + pub placeholders: RefCell, + pub edit: RefCell>, + /// Set while the pane writes the buffer itself, which is no edit. + pub loading: Cell, + /// Set while the pane selects a field itself, which moves no focus. + pub syncing: Cell, + /// An input method holds a preedit in the value editor. + pub composing: Cell, + pub commit_when_composed: Cell, + /// Set while the pane puts the calendar on a day, which is no pick. + pub quiet_calendar: Cell, + /// The row and field the open calendar picks a day for. + pub calendar_target: RefCell>, + pub sync_queued: Cell, + /// The field rows bound now, which redraw when the row changes. + pub bound: RefCell>, + pub column_watches: RefCell, glib::SignalHandlerId)>>, + pub(super) guards: RefCell>, +} + +#[glib::object_subclass] +impl ObjectSubclass for RowDetailPane { + const NAME: &'static str = "TableProRowDetailPane"; + type Type = super::RowDetailPane; + type ParentType = adw::Bin; + + fn class_init(klass: &mut Self::Class) { + sourceview5::View::ensure_type(); + sourceview5::Buffer::ensure_type(); + klass.bind_template(); + } + + fn instance_init(object: &glib::subclass::InitializingObject) { + object.init_template(); + } +} + +impl ObjectImpl for RowDetailPane { + fn dispose(&self) { + self.guards.take(); + self.obj().stop_watching_columns(); + self.obj().watch_row(None); + self.bound.take(); + } +} + +impl WidgetImpl for RowDetailPane {} +impl BinImpl for RowDetailPane {} diff --git a/linux/crates/app/src/ui/data_grid/row_detail/mod.rs b/linux/crates/app/src/ui/data_grid/row_detail/mod.rs new file mode 100644 index 0000000000..d40b030092 --- /dev/null +++ b/linux/crates/app/src/ui/data_grid/row_detail/mod.rs @@ -0,0 +1,315 @@ +//! The row detail pane: every field of the grid's focused row, and an +//! editor for the whole value of one of them. + +mod calendar; +mod fields; +mod imp; +#[cfg(test)] +mod tests; +mod value_editor; + +use gtk4::prelude::*; +use gtk4::subclass::prelude::*; +use gtk4::{self as gtk, gio, glib}; +use libadwaita as adw; + +use self::value_editor::Commit; +use super::tracked_values::RowPendingState; +use super::{DataGrid, GridRow, PinnedRow}; +use crate::i18n::{gettext, gettext_f}; +use crate::ui::handler_guard::HandlerGuard; + +/// The row the pane shows and the handler that redraws it. +#[derive(Debug)] +pub struct Shown { + row: GridRow, + pin: PinnedRow, + handler: glib::SignalHandlerId, +} + +glib::wrapper! { + pub struct RowDetailPane(ObjectSubclass) + @extends adw::Bin, gtk::Widget, + @implements gtk::Accessible, gtk::Buildable, gtk::ConstraintTarget; +} + +impl RowDetailPane { + pub fn attach(&self, grid: &DataGrid) { + self.imp().grid.set(Some(grid)); + self.connect_fields(grid); + self.connect_value_editor(grid); + self.connect_value_menu(grid); + self.connect_calendar(); + self.connect_close(grid); + self.watch_details(grid); + self.watch_rows(grid); + self.show_nothing(); + } + + /// The grid's focused cell or its selection moved. + pub fn follow_focus(&self) { + let Some(grid) = self.grid() else { + return; + }; + match grid.focused_cell().and_then(|cell| Some((cell.row()?, cell.column()))) { + Some((row, column)) => self.show_row(Some(row), Some(column)), + None => { + let row = self.row_in_selection(&grid); + self.show_row(row, None); + } + } + } + + /// Show the field of data column `column`, as the grid asks when it + /// opens the pane on a cell. + pub fn select_field(&self, column: usize) { + if self.current_row().is_none() { + self.follow_focus(); + } + self.set_field(column); + } + + /// Move the keyboard focus to the value editor. + pub fn focus_value(&self) { + if self.current_row().is_none() { + return; + } + self.render(); + let view = self.imp().value_view.get(); + // The pane may only now be on its way to the screen. + if !view.grab_focus() { + glib::idle_add_local_once(move || { + view.grab_focus(); + }); + } + } + + /// Commit what was typed in the value editor before the grid writes + /// its rows. A value the column refuses goes, since it was never a + /// pending change. + pub fn commit_open_edit(&self) { + self.commit(Commit::Teardown); + } + + pub fn cancel_open_edit(&self) { + self.cancel_edit(); + } + + fn grid(&self) -> Option { + self.imp().grid.upgrade() + } + + /// The row shown, while its slot still holds it. + fn current_row(&self) -> Option { + self.imp() + .shown + .borrow() + .as_ref() + .and_then(|shown| shown.pin.get().cloned()) + } + + fn current_pin(&self) -> Option { + self.imp() + .shown + .borrow() + .as_ref() + .filter(|shown| shown.pin.is_current()) + .map(|shown| shown.pin.clone()) + } + + /// With no focused cell, the row shown while it stays selected, else + /// the first selected row. + fn row_in_selection(&self, grid: &DataGrid) -> Option { + let selection = grid.selection(); + if let Some(row) = self.current_row() + && grid + .view_position(&row) + .is_some_and(|position| selection.is_selected(position)) + { + return Some(row); + } + let selected = selection.selection(); + if selected.is_empty() { + return None; + } + selection.item(selected.minimum()).and_downcast() + } + + /// Show `row`, with `column`'s field when the grid's focus is on one. + /// What was typed for another row is committed first. + fn show_row(&self, row: Option, column: Option) { + let same = match (self.imp().shown.borrow().as_ref(), row.as_ref()) { + (Some(shown), Some(row)) => shown.row == *row && shown.pin.is_current(), + (None, None) => true, + _ => false, + }; + if !same { + self.imp().calendar_popover.popdown(); + self.commit(Commit::Teardown); + self.watch_row(row.as_ref()); + } + if let Some(column) = column { + self.take_field(column); + } + self.render(); + if column.is_some() { + self.select_in_list(column); + } + } + + fn watch_row(&self, row: Option<&GridRow>) { + if let Some(old) = self.imp().shown.take() { + old.row.disconnect(old.handler); + } + let Some(row) = row else { + return; + }; + let handler = row.connect_revision_notify(glib::clone!( + #[weak(rename_to = pane)] + self, + move |_| pane.row_changed() + )); + self.imp().shown.replace(Some(Shown { + row: row.clone(), + pin: row.pin(), + handler, + })); + } + + /// The row's values or pending state changed, or its slot was given + /// another row. + fn row_changed(&self) { + let replaced = self + .imp() + .shown + .borrow() + .as_ref() + .is_some_and(|shown| !shown.pin.is_current()); + match replaced { + true => self.follow_focus(), + false => self.render(), + } + } + + fn is_open(&self) -> bool { + self.grid() + .and_then(|grid| grid.actions().action_state("show-details")) + .and_then(|state| state.get::()) + .unwrap_or(false) + } + + /// A closed pane loads nothing, so it draws once it opens. + fn watch_details(&self, grid: &DataGrid) { + let Some(action) = grid + .actions() + .lookup_action("show-details") + .and_downcast::() + else { + return; + }; + action.connect_state_notify(glib::clone!( + #[weak(rename_to = pane)] + self, + move |_| pane.render() + )); + } + + /// A clear, a shorter page, a new result or an undone draft takes + /// rows away without a selection change or a write into their slots. + fn watch_rows(&self, grid: &DataGrid) { + let rows = grid.store().list().clone(); + let handler = rows.connect_items_changed(glib::clone!( + #[weak(rename_to = pane)] + self, + move |_, _, _, _| pane.rows_changed() + )); + self.imp().guards.borrow_mut().push(HandlerGuard::new(&rows, handler)); + } + + fn rows_changed(&self) { + let Some(grid) = self.grid() else { + return; + }; + let gone = self + .imp() + .shown + .borrow() + .as_ref() + .is_some_and(|shown| grid.store().position_of(&shown.row).is_none()); + if gone { + self.follow_focus(); + } + } + + fn connect_close(&self, grid: &DataGrid) { + let button = self.imp().close_button.get(); + crate::ui::a11y::announce_key_shortcuts(&button, "grid.show-details"); + button.connect_clicked(glib::clone!( + #[weak(rename_to = pane)] + self, + #[weak] + grid, + move |_| { + // Before the pane hides, or GTK sends the focus to the + // window's first control. + crate::ui::a11y::pass_focus(&pane, &grid.column_view()); + if let Some(details) = grid.actions().lookup_action("show-details") { + details.change_state(&false.to_variant()); + } + } + )); + } + + /// Draw the shown row, while the pane is open. + fn render(&self) { + if !self.is_open() { + return; + } + let Some(row) = self.current_row() else { + self.show_nothing(); + return; + }; + self.imp().stack.set_visible_child_name("row"); + self.render_header(&row); + self.sync_fields(); + self.redraw_fields(); + self.load_value(); + } + + fn show_nothing(&self) { + self.imp().stack.set_visible_child_name("empty"); + self.imp().loaded.take(); + } + + fn render_header(&self, row: &GridRow) { + let Some(grid) = self.grid() else { + return; + }; + let imp = self.imp(); + let title = match row_number(&grid, row) { + Some(number) => gettext_f("Row {number}", &[("number", &number.to_string())]), + None if row.draft_id().is_some() => gettext("New row"), + None => gettext("Row Details"), + }; + imp.title_label.set_label(&title); + let state = grid.tracked().row_state(row); + // A new row's title says so already. + let text = match state { + RowPendingState::Inserted => None, + other => other.text(), + }; + imp.state_icon.set_icon_name(state.icon()); + imp.state_label.set_label(text.as_deref().unwrap_or_default()); + imp.state_box.set_visible(text.is_some()); + } +} + +/// The number the grid's gutter shows for `row`: a draft has none. +fn row_number(grid: &DataGrid, row: &GridRow) -> Option { + if row.draft_id().is_some() { + return None; + } + let position = grid.view_position(row)?; + let drafts = grid.store().draft_count(); + Some(grid.row_base() + u64::from(position.saturating_sub(drafts)) + 1) +} diff --git a/linux/crates/app/src/ui/data_grid/row_detail/tests.rs b/linux/crates/app/src/ui/data_grid/row_detail/tests.rs new file mode 100644 index 0000000000..3c9661be31 --- /dev/null +++ b/linux/crates/app/src/ui/data_grid/row_detail/tests.rs @@ -0,0 +1,828 @@ +use std::cell::RefCell; +use std::rc::Rc; +use std::time::Duration; + +use chrono::NaiveDate; +use gtk4::prelude::*; +use gtk4::subclass::prelude::*; +use gtk4::{self as gtk, gdk}; +use tablepro_core::column::TemporalZone; +use tablepro_core::edit::CellInput; +use tablepro_core::value::{JsonText, Temporal}; +use tablepro_core::{ColumnInfo, Value}; + +use super::RowDetailPane; +use super::calendar::{day_of, day_start_offset, with_day}; +use super::fields::field_order; +use super::value_editor::value_content; +use crate::services::change_tracker::RowKey; +use crate::services::grid::{CellLock, GridColumn, GridRowId, grid_columns}; +use crate::services::tracker_edit::TrackerEdit; +use crate::test_support::{ + descendants, drain_main_context, finish_on_main, test_backend_records_properties, unlabelled_icon_buttons, + wait_until, +}; +use crate::ui::data_grid::GridRequest; +use crate::ui::data_grid::test_grid::{ + Grid, GridSpec, WAIT, focus_in_grid, key, named, page, persisted, text, two_text_columns, two_text_values, +}; +use crate::ui::error_text::edit_parse_message; + +fn pane_of(grid: &Grid) -> RowDetailPane { + grid.grid.imp().detail_pane.get() +} + +/// A grid of `rows` keyed by their first cell, with its pane open. +fn opened(columns: Vec, rows: Vec>) -> (Grid, RowDetailPane) { + let grid = Grid::open(columns, rows); + grid.activate("show-details"); + let pane = pane_of(&grid); + (grid, pane) +} + +/// The window has the keyboard, so a focus move inside it reaches the +/// controllers. Other tests' windows take it too, so it is asked for +/// until it comes. +fn take_keyboard(grid: &Grid) { + wait_until(WAIT, || { + if !grid.window.is_active() { + grid.window.present(); + } + grid.window.is_active() + }) + .expect("the window takes the keyboard"); +} + +/// Focus the grid's cell, then the value editor, as a click into the +/// pane does. +fn edit_field(grid: &Grid, pane: &RowDetailPane, position: u32, column: usize) { + take_keyboard(grid); + grid.focus(position, column); + pane.focus_value(); + wait_until(WAIT, || crate::ui::a11y::holds_focus(&pane.imp().value_view.get())) + .expect("the value editor takes the focus"); +} + +/// Change the value editor's text as typing does: the pane is not +/// filling it itself. +fn type_value(pane: &RowDetailPane, typed: &str) { + pane.imp().value_buffer.set_text(typed); +} + +/// Press `key` in the value editor, through the handler a real key press +/// reaches first. +fn press_in_value(pane: &RowDetailPane, key: gdk::Key, modifiers: gdk::ModifierType) -> bool { + let controllers = pane.imp().value_view.observe_controllers(); + let keys = (0..controllers.n_items()) + .filter_map(|position| controllers.item(position).and_downcast::()) + .find(|controller| controller.name().as_deref() == Some(super::value_editor::KEYS_NAME)) + .expect("the value editor's key handler"); + keys.emit_by_name::("key-pressed", &[&key, &0u32, &modifiers]) +} + +fn pending(grid: &Grid, id: i64, column: usize) -> Option { + grid.tracker.cell_overlay(&key(id), column).pending +} + +fn selected_field(pane: &RowDetailPane) -> Option { + pane.imp().field.get() +} + +fn title(pane: &RowDetailPane) -> String { + pane.imp().title_label.label().to_string() +} + +fn message(pane: &RowDetailPane) -> Option { + let label = pane.imp().message_label.get(); + label.is_visible().then(|| label.label().to_string()) +} + +/// The position of data column `column` in the fields list. +fn field_position(pane: &RowDetailPane, column: usize) -> u32 { + let store = &pane.imp().fields.0; + (0..store.n_items()) + .find(|position| { + store + .item(*position) + .and_downcast::() + .is_some_and(|boxed| *boxed.borrow::() == column) + }) + .expect("the column has a field") +} + +fn fields(pane: &RowDetailPane) -> Vec { + let store = &pane.imp().fields.0; + (0..store.n_items()) + .filter_map(|position| store.item(position).and_downcast::()) + .map(|boxed| *boxed.borrow::()) + .collect() +} + +fn three_text_columns() -> Vec { + vec![ + named("id", "integer", true), + named("name", "text", false), + named("note", "text", false), + ] +} + +fn three_text_values() -> Vec> { + vec![ + vec![Value::Int(1), Value::Text("A".into()), Value::Text("x".into())], + vec![Value::Int(2), Value::Text("B".into()), Value::Text("y".into())], + ] +} + +fn a_date(year: i32, month: u32, day: u32) -> Value { + Value::Date(Temporal::Finite( + NaiveDate::from_ymd_opt(year, month, day).expect("a date"), + )) +} + +fn column_of(info: ColumnInfo) -> GridColumn { + grid_columns(&[info.result_column()], std::slice::from_ref(&info), |_| true)[0].clone() +} + +#[gtk4::test] +fn the_pane_follows_the_focused_cell() { + let (grid, pane) = opened(two_text_columns(), two_text_values()); + + grid.focus(1, 1); + assert_eq!(title(&pane), "Row 2"); + assert_eq!(selected_field(&pane), Some(1)); + assert_eq!(pane.typed_text(), "B"); + + grid.focus(0, 0); + assert_eq!(title(&pane), "Row 1"); + assert_eq!(selected_field(&pane), Some(0)); + assert_eq!(pane.typed_text(), "1"); + assert_eq!(pane.imp().stack.visible_child_name().as_deref(), Some("row")); +} + +#[gtk4::test] +fn with_no_row_the_pane_says_so() { + let (grid, pane) = opened(two_text_columns(), two_text_values()); + grid.focus(0, 1); + + grid.grid.clear_rows(); + drain_main_context(); + + assert_eq!(pane.imp().stack.visible_child_name().as_deref(), Some("empty")); +} + +#[gtk4::test] +fn selecting_a_field_moves_the_grid_focus_column_without_taking_the_keyboard() { + let (grid, pane) = opened(three_text_columns(), three_text_values()); + take_keyboard(&grid); + grid.focus(1, 1); + let list = pane.imp().field_list.get(); + list.grab_focus(); + + pane.imp().selection.set_selected(field_position(&pane, 2)); + drain_main_context(); + + assert_eq!(pane.typed_text(), "y"); + assert!(crate::ui::a11y::holds_focus(&list), "the list lost the keyboard"); + assert!(!focus_in_grid(&grid)); + grid.grid.column_view().grab_focus(); + let focused = grid.focused_cell().expect("the grid focuses a cell"); + assert_eq!((focused.position(), focused.column()), (Some(1), 2)); +} + +#[gtk4::test] +fn editing_a_value_commits_on_focus_out() { + let (grid, pane) = opened(two_text_columns(), two_text_values()); + edit_field(&grid, &pane, 0, 1); + + type_value(&pane, "A2"); + assert!(grid.grid.is_editing(), "typing opened no edit"); + pane.imp().field_list.grab_focus(); + drain_main_context(); + + assert_eq!(pending(&grid, 1, 1), Some(text("A2"))); + assert!(!grid.grid.is_editing()); +} + +#[gtk4::test] +fn ctrl_return_commits_the_value_and_keeps_the_focus() { + let (grid, pane) = opened(two_text_columns(), two_text_values()); + edit_field(&grid, &pane, 1, 1); + + type_value(&pane, "B2"); + assert!(press_in_value(&pane, gdk::Key::Return, gdk::ModifierType::CONTROL_MASK)); + + assert_eq!(pending(&grid, 2, 1), Some(text("B2"))); + assert!(!grid.grid.is_editing()); + assert!(crate::ui::a11y::holds_focus(&pane.imp().value_view.get())); +} + +#[gtk4::test] +fn escape_restores_the_value() { + let (grid, pane) = opened(two_text_columns(), two_text_values()); + edit_field(&grid, &pane, 0, 1); + + type_value(&pane, "typed"); + assert!(press_in_value(&pane, gdk::Key::Escape, gdk::ModifierType::empty())); + + assert_eq!(pane.typed_text(), "A"); + assert_eq!(pending(&grid, 1, 1), None); + assert!(!grid.grid.is_editing()); + assert!( + !press_in_value(&pane, gdk::Key::Escape, gdk::ModifierType::empty()), + "Escape with nothing typed was kept from the window" + ); +} + +#[gtk4::test] +fn a_refused_value_stays_with_its_reason() { + let document = Value::Json(JsonText::parse(r#"{"a":1}"#.to_owned()).expect("JSON")); + let (grid, pane) = opened( + vec![named("id", "integer", true), named("doc", "json", false)], + vec![vec![Value::Int(1), document]], + ); + edit_field(&grid, &pane, 0, 1); + + type_value(&pane, r#"{"a":"#); + press_in_value(&pane, gdk::Key::Return, gdk::ModifierType::CONTROL_MASK); + + assert_eq!(pane.typed_text(), r#"{"a":"#, "the refused text was thrown away"); + assert!(pane.imp().value_view.has_css_class("error")); + assert!( + message(&pane).is_some_and(|reason| !reason.is_empty()), + "no reason under the editor" + ); + assert!(grid.grid.is_editing()); + assert_eq!(pending(&grid, 1, 1), None); + + type_value(&pane, r#"{"a":2}"#); + press_in_value(&pane, gdk::Key::Return, gdk::ModifierType::CONTROL_MASK); + + assert!(matches!(pending(&grid, 1, 1), Some(CellInput::Value(Value::Json(_))))); + assert!(!pane.imp().value_view.has_css_class("error")); + assert_eq!(message(&pane), None); +} + +#[gtk4::test] +fn a_read_only_connection_locks_every_field() { + let grid = Grid::build( + GridSpec { + policy: Some(crate::services::grid::edit_policy(true, true, true)), + ..GridSpec::table(two_text_columns()) + }, + page(1..3), + ); + grid.activate("show-details"); + let pane = pane_of(&grid); + + grid.focus(0, 1); + + assert!( + !pane.imp().value_view.is_editable(), + "a read-only connection took typing" + ); + assert_eq!( + message(&pane), + Some(CellLock::ReadOnlySession.explanation(edit_parse_message)) + ); +} + +#[gtk4::test] +fn a_locked_cell_is_read_only_with_its_sentence() { + let (grid, pane) = opened(two_text_columns(), two_text_values()); + + grid.focus(0, 0); + + assert!(!pane.imp().value_view.is_editable()); + assert_eq!( + message(&pane), + Some(CellLock::KeyColumn.explanation(edit_parse_message)) + ); + pane.imp().value_menu_button.popup(); + assert!(!grid.is_enabled("set-null"), "the menu offered to change a locked cell"); + assert!(grid.is_enabled("copy")); +} + +#[gtk4::test] +fn a_page_that_replaces_the_row_commits_the_open_edit_against_the_pinned_row() { + let (grid, pane) = opened(two_text_columns(), two_text_values()); + edit_field(&grid, &pane, 0, 1); + type_value(&pane, "A2"); + + grid.grid.replace_rows(page(10..12), 0); + drain_main_context(); + + assert_eq!(pending(&grid, 1, 1), Some(text("A2")), "the edit went to another row"); + assert_eq!(pending(&grid, 10, 1), None); + assert!(!grid.grid.is_editing()); +} + +#[gtk4::test] +fn set_to_null_writes_null_into_the_field() { + let (grid, pane) = opened(two_text_columns(), two_text_values()); + take_keyboard(&grid); + grid.focus(0, 1); + pane.focus_value(); + pane.imp().value_menu_button.popup(); + assert!(grid.is_enabled("set-null"), "the menu does not act on the field"); + assert!(!grid.is_enabled("set-default"), "a saved row offered DEFAULT"); + + grid.activate("set-null"); + + assert_eq!(pending(&grid, 1, 1), Some(CellInput::Value(Value::Null))); + assert_eq!(pane.typed_text(), ""); + assert!(pane.imp().placeholder_label.is_visible(), "NULL left no placeholder"); +} + +#[gtk4::test] +fn the_values_redraw_when_the_row_changes() { + let (grid, pane) = opened(two_text_columns(), two_text_values()); + grid.focus(0, 1); + + let delta = grid.tracker.apply(TrackerEdit::Cell { + row_key: key(1), + col: 1, + original: Value::Text("A".into()), + value: Value::Text("changed".into()), + }); + grid.grid.apply_delta(&delta); + drain_main_context(); + + assert_eq!(pane.typed_text(), "changed"); + let shown: Vec = descendants(&pane.imp().field_list.get()) + .into_iter() + .filter_map(|widget| widget.downcast::().ok()) + .filter_map(|inscription| inscription.text().map(|text| text.to_string())) + .collect(); + assert!(shown.contains(&"changed".to_owned()), "the field list showed {shown:?}"); + assert_eq!(pane.imp().state_label.label(), "Modified"); +} + +#[gtk4::test] +fn copy_value_copies_the_field() { + let (grid, pane) = opened(two_text_columns(), two_text_values()); + grid.focus(1, 1); + pane.imp().field_list.grab_focus(); + let heard: Rc>> = Rc::default(); + let log = heard.clone(); + grid.grid.connect_request(move |_, request| { + if let GridRequest::Toast(text) = request { + log.borrow_mut().push(text); + } + }); + + pane.imp().value_menu_button.popup(); + grid.activate("copy"); + wait_until(WAIT, || !heard.borrow().is_empty()).expect("the copy lands"); + + let clipboard = grid.grid.clipboard(); + let copied = finish_on_main(WAIT, async move { clipboard.read_text_future().await }) + .expect("the clipboard to answer") + .expect("text on the clipboard"); + assert_eq!(copied.as_deref(), Some("B")); +} + +#[gtk4::test] +fn the_fields_follow_the_grid_column_order() { + let (grid, pane) = opened(three_text_columns(), three_text_values()); + grid.focus(0, 1); + assert_eq!(fields(&pane), [0, 1, 2]); + + grid.grid.set_column_visible(0, false); + drain_main_context(); + + assert_eq!(fields(&pane), [1, 2, 0], "a hidden column's field did not go last"); +} + +#[gtk4::test] +fn every_control_in_the_pane_is_named() { + let (grid, pane) = opened(two_text_columns(), two_text_values()); + grid.focus(0, 1); + if !test_backend_records_properties() { + return; + } + + assert_eq!(unlabelled_icon_buttons(&pane), Vec::::new()); + for widget in [ + pane.imp().field_list.get().upcast::(), + pane.imp().value_view.get().upcast(), + pane.imp().calendar.get().upcast(), + ] { + crate::test_support::assert_labelled(&widget).expect("the control is named"); + } +} + +/// The middle of the first of the calendar's labels that `matches`, +/// once the popover has laid it out. +fn calendar_label(calendar: >k::Calendar, matches: impl Fn(>k::Widget) -> bool) -> (f64, f64) { + let mut found = None; + wait_until(WAIT, || { + found = descendants(calendar) + .into_iter() + .find(|widget| widget.width() > 0 && matches(widget)); + found.is_some() + }) + .expect("the label is drawn"); + let label = found.expect("a label"); + let middle = gtk::graphene::Point::new(label.width() as f32 / 2.0, label.height() as f32 / 2.0); + let point = label + .compute_point(calendar, &middle) + .expect("the label is inside the calendar"); + (f64::from(point.x()), f64::from(point.y())) +} + +fn selected_day(calendar: >k::Calendar) -> (f64, f64) { + calendar_label(calendar, |label| { + label.has_css_class("day-number") && label.state_flags().contains(gtk::StateFlags::SELECTED) + }) +} + +/// What the calendar hears when a primary click at `(x, y)` ends. +fn release_on(calendar: >k::Calendar, (x, y): (f64, f64)) { + let controllers = calendar.observe_controllers(); + for gesture in + (0..controllers.n_items()).filter_map(|index| controllers.item(index).and_downcast::()) + { + gesture.emit_by_name::<()>("released", &[&1i32, &x, &y]); + } +} + +fn release_space(calendar: >k::Calendar) { + use gtk4::glib::translate::IntoGlib; + let controllers = calendar.observe_controllers(); + for keys in + (0..controllers.n_items()).filter_map(|index| controllers.item(index).and_downcast::()) + { + keys.emit_by_name::<()>( + "key-released", + &[&gdk::Key::space.into_glib(), &0u32, &gdk::ModifierType::empty()], + ); + } +} + +/// Open the pane's calendar until it shows. Test processes share one X +/// display, where another test's window can close an autohide popover +/// before it maps. +fn open_calendar(pane: &RowDetailPane) -> gtk::Calendar { + let calendar = pane.imp().calendar.get(); + for _ in 0..5 { + pane.imp().calendar_button.popup(); + if wait_until(Duration::from_secs(2), || calendar.is_mapped() && calendar.width() > 0).is_ok() { + return calendar; + } + pane.imp().calendar_button.popdown(); + drain_main_context(); + } + panic!("the calendar never showed"); +} + +fn shown_day(calendar: >k::Calendar) -> Value { + let (year, month, day) = calendar.date().ymd(); + a_date( + year, + u32::try_from(month).expect("a month"), + u32::try_from(day).expect("a day"), + ) +} + +fn date_grid(value: Value) -> (Grid, RowDetailPane) { + let (grid, pane) = opened( + vec![named("id", "integer", true), named("day", "date", false)], + vec![vec![Value::Int(1), value]], + ); + take_keyboard(&grid); + grid.focus(0, 1); + (grid, pane) +} + +#[gtk4::test] +fn the_calendar_writes_a_date() { + let (grid, pane) = date_grid(a_date(2024, 2, 29)); + assert!(pane.imp().calendar_button.is_visible(), "a date column has no calendar"); + pane.imp().calendar_button.popup(); + let calendar = pane.imp().calendar.get(); + assert_eq!( + calendar.date().ymd(), + (2024, 2, 29), + "the calendar did not open on the value" + ); + + calendar.set_date(>k4::glib::DateTime::from_utc(2024, 3, 1, 0, 0, 0.0).expect("a day")); + + assert_eq!(pending(&grid, 1, 1), Some(CellInput::Value(a_date(2024, 3, 1)))); + assert_eq!(pane.typed_text(), "2024-03-01"); +} + +#[gtk4::test] +fn clicking_today_on_a_null_date_writes_it() { + let (grid, pane) = date_grid(Value::Null); + let calendar = open_calendar(&pane); + let today = shown_day(&calendar); + + release_on( + &calendar, + calendar_label(&calendar, |label| label.has_css_class("day-name")), + ); + assert_eq!(pending(&grid, 1, 1), None, "a click on a weekday name picked a day"); + release_on(&calendar, selected_day(&calendar)); + + assert_eq!(pending(&grid, 1, 1), Some(CellInput::Value(today))); +} + +#[gtk4::test] +fn picking_the_same_day_in_another_month_writes_it() { + let (grid, pane) = date_grid(a_date(2024, 3, 15)); + let calendar = open_calendar(&pane); + + // As the next-month arrow does, this moves the date without a + // day-selected. + calendar.set_date(>k4::glib::DateTime::from_utc(2024, 4, 15, 0, 0, 0.0).expect("a day")); + assert_eq!(pending(&grid, 1, 1), None, "a month change picked a day"); + release_on(&calendar, selected_day(&calendar)); + + assert_eq!(pending(&grid, 1, 1), Some(CellInput::Value(a_date(2024, 4, 15)))); +} + +#[gtk4::test] +fn space_on_the_selected_day_writes_it() { + let (grid, pane) = date_grid(Value::Null); + let calendar = open_calendar(&pane); + let today = shown_day(&calendar); + + release_space(&calendar); + + assert_eq!(pending(&grid, 1, 1), Some(CellInput::Value(today))); +} + +#[test] +fn a_picked_day_keeps_a_timestamp_time() { + let day = NaiveDate::from_ymd_opt(2024, 3, 1).expect("a day"); + let plus_two = chrono::FixedOffset::east_opt(2 * 3600).expect("an offset"); + let naive = Some(&TemporalZone::Naive); + let zoned = Some(&TemporalZone::Offset); + + assert_eq!( + with_day("2024-02-29 10:30:00+02", day, zoned, plus_two), + "2024-03-01 10:30:00+02" + ); + assert_eq!(with_day("", day, naive, plus_two), "2024-03-01 00:00:00"); + assert_eq!(with_day("infinity", day, naive, plus_two), "2024-03-01 00:00:00"); + assert_eq!(with_day("2024-02-29", day, None, plus_two), "2024-03-01"); + let new_zoned = with_day("", day, zoned, plus_two); + assert_eq!(new_zoned, "2024-03-01 00:00:00+02:00"); + assert!( + tablepro_core::edit::parse_timestamp_with_offset(&new_zoned).is_ok(), + "a NULL zoned timestamp took no day" + ); + assert_eq!(day_of("2024-02-29 10:30"), NaiveDate::from_ymd_opt(2024, 2, 29)); + assert_eq!(day_of("12000-01-01"), None); + assert_eq!(day_of("2024-02-2é"), None, "a cut inside a character"); +} + +/// A zone whose offset moves from `before` to `after` at `change`, in UTC. +#[derive(Clone)] +struct OneChange { + before: chrono::FixedOffset, + after: chrono::FixedOffset, + change: chrono::NaiveDateTime, +} + +impl chrono::TimeZone for OneChange { + type Offset = chrono::FixedOffset; + + fn from_offset(offset: &chrono::FixedOffset) -> Self { + Self { + before: *offset, + after: *offset, + change: chrono::NaiveDateTime::MIN, + } + } + + fn offset_from_local_date(&self, _: &NaiveDate) -> chrono::MappedLocalTime { + chrono::MappedLocalTime::None + } + + fn offset_from_local_datetime( + &self, + local: &chrono::NaiveDateTime, + ) -> chrono::MappedLocalTime { + let fits = |offset: chrono::FixedOffset| (*local - offset < self.change) == (offset == self.before); + match (fits(self.before), fits(self.after)) { + (true, true) => chrono::MappedLocalTime::Ambiguous(self.before, self.after), + (true, false) => chrono::MappedLocalTime::Single(self.before), + (false, true) => chrono::MappedLocalTime::Single(self.after), + (false, false) => chrono::MappedLocalTime::None, + } + } + + fn offset_from_utc_date(&self, utc: &NaiveDate) -> chrono::FixedOffset { + self.offset_from_utc_datetime(&utc.and_time(chrono::NaiveTime::MIN)) + } + + fn offset_from_utc_datetime(&self, utc: &chrono::NaiveDateTime) -> chrono::FixedOffset { + if *utc < self.change { self.before } else { self.after } + } +} + +#[test] +fn a_picked_day_starts_at_the_local_offset_of_its_midnight() { + let hours = |hours: i32| chrono::FixedOffset::east_opt(hours * 3600).expect("an offset"); + let at = |date: &str| chrono::NaiveDateTime::parse_from_str(date, "%Y-%m-%d %H:%M").expect("a UTC time"); + let day = |date: &str| NaiveDate::parse_from_str(date, "%Y-%m-%d").expect("a day"); + // Sydney moves to +11 at 02:00 local on 2026-10-04, after its midnight. + let sydney = OneChange { + before: hours(10), + after: hours(11), + change: at("2026-10-03 16:00"), + }; + // A clock that skips from 00:00 to 01:00: the day starts at the change. + let skipped = OneChange { + before: hours(-4), + after: hours(-3), + change: at("2026-09-06 04:00"), + }; + + assert_eq!(day_start_offset(&sydney, day("2026-10-04")), hours(10)); + assert_eq!(day_start_offset(&sydney, day("2026-10-05")), hours(11)); + assert_eq!(day_start_offset(&skipped, day("2026-09-06")), hours(-4)); +} + +#[test] +fn hidden_fields_go_after_the_ones_on_screen() { + assert_eq!(field_order(&[2, 0], 4), [2, 0, 1, 3]); + assert_eq!(field_order(&[], 2), [0, 1]); + assert_eq!(field_order(&[5, 1], 2), [1, 0], "a column past the set"); +} + +#[test] +fn what_the_value_editor_shows_for_a_cell() { + let note = column_of(named("note", "text", false)); + let count = column_of(named("count", "integer", false)); + let doc = column_of(named("doc", "json", false)); + let day = column_of(named("day", "date", false)); + let saved = GridRowId::Persisted(RowKey::from_pk_values(&[Value::Int(1)]).expect("a key")); + let value = |value: Value| CellInput::Value(value); + + let null = value_content(¬e, &saved, None, &value(Value::Null)); + assert_eq!( + (null.text.as_str(), null.open_placeholder.as_deref()), + ("", Some("NULL")) + ); + let empty = value_content(¬e, &saved, None, &value(Value::Text(String::new()))); + assert_eq!(empty.open_placeholder, None, "an empty string read as NULL"); + let number = value_content(&count, &saved, None, &value(Value::Int(5))); + assert_eq!( + (number.text.as_str(), number.emptied_placeholder.as_deref()), + ("5", Some("NULL")) + ); + let nul = value_content(¬e, &saved, None, &value(Value::Text("a\0b".into()))); + assert!( + nul.message.is_some() && !nul.text.contains('\0'), + "a NUL reached the buffer" + ); + let locked = value_content(&day, &saved, Some(CellLock::PendingDelete), &value(a_date(2024, 1, 1))); + assert_eq!( + locked.message, + Some(CellLock::PendingDelete.explanation(edit_parse_message)) + ); + assert!(!locked.calendar, "a locked cell offered a calendar"); + assert!(value_content(&day, &saved, None, &value(a_date(2024, 1, 1))).calendar); + assert_eq!( + value_content(&doc, &saved, None, &value(Value::Null)).language, + Some("json") + ); + let draft = value_content(&count, &GridRowId::Draft(1), None, &CellInput::Default); + assert_eq!(draft.open_placeholder.as_deref(), Some("DEFAULT")); +} + +#[gtk4::test] +fn opening_the_calendar_and_closing_it_records_no_edit() { + let (grid, pane) = date_grid(a_date(2024, 2, 29)); + + open_calendar(&pane); + pane.imp().calendar_button.popdown(); + drain_main_context(); + + assert_eq!( + grid.tracker.pending_count(), + 0, + "opening the calendar recorded its own day" + ); +} + +#[gtk4::test] +fn a_date_past_the_calendar_shows_as_text() { + let (_grid, pane) = date_grid(a_date(12_000, 1, 1)); + + assert!(pane.typed_text().contains("12000"), "{}", pane.typed_text()); + assert_eq!( + day_of(&pane.typed_text()), + None, + "the calendar took a year it cannot show" + ); +} + +#[gtk4::test] +fn f005_unchanged_40kb_multiline_text_records_no_edit() { + let long = "lorem ipsum\n".repeat(3_500); + let (grid, pane) = opened( + vec![named("id", "integer", true), named("note", "text", true)], + vec![vec![Value::Int(1), Value::Text(long.clone())]], + ); + edit_field(&grid, &pane, 0, 1); + + assert_eq!( + pane.typed_text(), + long, + "the value editor opened on less than the whole text" + ); + assert!(press_in_value(&pane, gdk::Key::Return, gdk::ModifierType::CONTROL_MASK)); + + assert_eq!( + grid.tracker.pending_count(), + 0, + "committing the text as it was recorded an edit" + ); +} + +#[gtk4::test] +fn a_page_that_takes_the_row_closes_its_calendar_and_writes_nothing() { + let (grid, pane) = opened( + vec![named("id", "integer", true), named("day", "date", false)], + vec![ + vec![Value::Int(1), a_date(2024, 2, 29)], + vec![Value::Int(2), a_date(2024, 3, 1)], + ], + ); + take_keyboard(&grid); + grid.focus(0, 1); + let calendar = open_calendar(&pane); + + grid.grid + .replace_rows(persisted(&[vec![Value::Int(2), a_date(2024, 3, 1)]]), 0); + drain_main_context(); + calendar.set_date(>k::glib::DateTime::from_utc(2024, 3, 9, 0, 0, 0.0).expect("a day")); + drain_main_context(); + + assert_eq!( + grid.tracker.pending_count(), + 0, + "the calendar wrote into the row that took the slot" + ); + assert!(!pane.imp().calendar_popover.is_visible()); +} + +#[gtk4::test] +fn row_details_from_a_cell_menu_shows_that_cell() { + let grid = Grid::open(two_text_columns(), two_text_values()); + take_keyboard(&grid); + grid.focus(0, 0); + let pane = pane_of(&grid); + + assert!(grid.grid.open_cell_menu(&grid.cell_at(1, 1), None)); + grid.activate("show-details"); + + assert_eq!(title(&pane), "Row 2"); + assert_eq!(selected_field(&pane), Some(1)); + assert_eq!(pane.typed_text(), "B"); +} + +/// The gutter's menu has no cell, so the focus keeps its column. +#[gtk4::test] +fn row_details_from_a_row_menu_shows_that_row() { + let grid = Grid::open(two_text_columns(), two_text_values()); + take_keyboard(&grid); + grid.focus(0, 1); + let pane = pane_of(&grid); + let row = grid.grid.store().row(1).expect("the second row"); + + assert!(grid.grid.open_row_menu(&row, 1, gdk::Rectangle::new(0, 0, 1, 1))); + grid.activate("show-details"); + + assert_eq!(title(&pane), "Row 2"); + assert_eq!(selected_field(&pane), Some(1)); + assert_eq!(pane.typed_text(), "B"); +} + +/// Alt+Return closes Row Details from inside the pane, where the keyboard +/// is once a field was picked. +#[gtk4::test] +fn alt_return_closes_row_details_from_the_pane() { + let (grid, pane) = opened(two_text_columns(), two_text_values()); + crate::test_support::KeyInjector::focus_only(&grid.window); + take_keyboard(&grid); + grid.focus(1, 1); + pane.imp().field_list.grab_focus(); + let keys = crate::test_support::KeyInjector::connect().expect("an X11 display"); + + keys.press(gdk::Key::Return, gdk::ModifierType::ALT_MASK) + .expect("the key press"); + + wait_until(WAIT, || { + grid.grid + .actions() + .action_state("show-details") + .and_then(|state| state.get::()) + == Some(false) + }) + .expect("Alt+Return to close Row Details"); +} diff --git a/linux/crates/app/src/ui/data_grid/row_detail/value_editor.rs b/linux/crates/app/src/ui/data_grid/row_detail/value_editor.rs new file mode 100644 index 0000000000..457e316e58 --- /dev/null +++ b/linux/crates/app/src/ui/data_grid/row_detail/value_editor.rs @@ -0,0 +1,491 @@ +use std::rc::{Rc, Weak}; + +use gtk4::prelude::*; +use gtk4::subclass::prelude::*; +use gtk4::{self as gtk, gdk, glib}; +use libadwaita as adw; +use sourceview5::prelude::*; +use tablepro_core::Value; +use tablepro_core::column::ColumnKind; +use tablepro_core::edit::{CellInput, EditParseError, EditText, edit_text}; + +use super::RowDetailPane; +use crate::application::component_matches; +use crate::i18n::{gettext, gettext_f}; +use crate::services::grid::{ + CellLock, CommitResult, EDIT_MAX_BYTES, EditStart, GridColumn, GridRowId, auto_filled_sentinel, display_text, + empty_placeholder, +}; +use crate::ui::data_grid::selection::Target; +use crate::ui::data_grid::{DataGrid, GridRow, PinnedRow}; +use crate::ui::error_text::edit_parse_message; +use crate::ui::handler_guard::HandlerGuard; + +/// The scheme a grid without settings paints with, as the multi-line +/// editor did. The resolver follows it to its dark variant. +const FALLBACK_SCHEME: &str = "Adwaita"; + +/// The value editor's own key handler, named so it can be told from the +/// view's. +pub const KEYS_NAME: &str = "tablepro-row-detail-value-keys"; + +/// An edit open in the value editor. It commits to the row it began on, +/// measured against the value it began with. +#[derive(Debug)] +pub struct OpenEdit { + row: PinnedRow, + column: usize, + start: EditStart, +} + +/// What the value editor was last filled from. +#[derive(Debug, PartialEq)] +pub struct Loaded { + row: GridRow, + generation: u64, + revision: u32, + column: usize, +} + +/// What the placeholder over an empty value editor says. +#[derive(Debug, Default)] +pub struct Placeholders { + /// For the value as it opened. + open: Option, + /// What an emptied value commits. + emptied: Option, + /// Emptied text that opened empty commits nothing, so it still holds + /// what it opened with. + opened_empty: bool, +} + +/// How a commit takes a value the column refuses. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Commit { + /// Focus-out, Ctrl+Return or a picked date: the text stays, marked, + /// with the reason under it. + Keep, + /// A row change, a reload, a save or a close: the text goes. + Teardown, +} + +/// What the value editor shows for one cell. +#[derive(Debug, Clone, PartialEq)] +pub struct ValueContent { + pub text: String, + /// Why the value cannot be typed over, `None` when it can. + pub message: Option, + pub open_placeholder: Option, + pub emptied_placeholder: Option, + pub language: Option<&'static str>, + pub calendar: bool, +} + +pub fn value_content(column: &GridColumn, row: &GridRowId, lock: Option, input: &CellInput) -> ValueContent { + let kind = &column.value_type().kind; + let (text, refusal) = match input { + CellInput::Value(value) => typed_form(column, value), + CellInput::Default => (String::new(), None), + }; + let message = lock.map(|lock| lock.explanation(edit_parse_message)).or(refusal); + let editable = message.is_none(); + let emptied_placeholder = editable.then(|| empty_placeholder(column, row)).flatten(); + let open_placeholder = match input { + CellInput::Value(Value::Null) => Some("NULL".to_owned()), + CellInput::Default if column.auto_filled() => Some(auto_filled_sentinel()), + CellInput::Default => Some("DEFAULT".to_owned()), + CellInput::Value(_) => emptied_placeholder.clone(), + }; + ValueContent { + text, + open_placeholder, + emptied_placeholder, + language: match kind { + ColumnKind::Json { .. } => Some("json"), + ColumnKind::Xml => Some("xml"), + _ => None, + }, + calendar: editable && matches!(kind, ColumnKind::Date | ColumnKind::Timestamp { .. }), + message, + } +} + +/// The whole value to type over, or the text the grid draws for it and +/// why it cannot be typed over. +fn typed_form(column: &GridColumn, value: &Value) -> (String, Option) { + let refusal = match edit_text(value, column.value_type()) { + // A text buffer holds the text only up to a NUL, and committing + // it would cut the value there. + EditText::Editable(text) if text.contains('\0') => gettext("Values with a NUL character can't be edited here"), + // Laid out whole on the GTK thread, it would stall the window. + EditText::Editable(text) if text.len() > EDIT_MAX_BYTES => too_large_to_edit(), + EditText::Editable(text) => return (text, None), + EditText::ReadOnly(reason) => edit_parse_message(&EditParseError::NotEditable(reason)), + }; + let text = match value { + Value::Null => String::new(), + value => { + let mut scratch = String::new(); + display_text(value, &column.column_type, &mut scratch).text.to_owned() + } + }; + (text, Some(refusal)) +} + +fn too_large_to_edit() -> String { + let size = glib::format_size(EDIT_MAX_BYTES as u64); + gettext_f("Values over {size} can't be edited here", &[("size", size.as_str())]) +} + +fn set_class(widget: &impl IsA, class: &str, on: bool) { + match on { + true => widget.as_ref().add_css_class(class), + false => widget.as_ref().remove_css_class(class), + } +} + +impl RowDetailPane { + pub(super) fn connect_value_editor(&self, grid: &DataGrid) { + let imp = self.imp(); + imp.value_buffer.connect_changed(glib::clone!( + #[weak(rename_to = pane)] + self, + move |_| pane.text_changed() + )); + let view = imp.value_view.get(); + view.add_controller(self.edit_keys()); + view.add_controller(self.edit_focus()); + view.connect_preedit_changed(glib::clone!( + #[weak(rename_to = pane)] + self, + move |_, preedit| pane.preedit_changed(preedit) + )); + self.follow_style_scheme(grid); + } + + /// Capture phase, so Escape restores before the view takes it and + /// Ctrl+Return saves before the view adds a line. While a preedit is + /// open both keys are the input method's. + fn edit_keys(&self) -> gtk::EventControllerKey { + let keys = gtk::EventControllerKey::new(); + keys.set_name(Some(KEYS_NAME)); + keys.set_propagation_phase(gtk::PropagationPhase::Capture); + let weak = self.downgrade(); + keys.connect_key_pressed(move |_, keyval, _, state| match weak.upgrade() { + Some(pane) => pane.edit_key(keyval, state), + None => glib::Propagation::Proceed, + }); + keys + } + + fn edit_key(&self, keyval: gdk::Key, state: gdk::ModifierType) -> glib::Propagation { + if self.imp().composing.get() { + return glib::Propagation::Proceed; + } + if component_matches("cell-editor.save-text", keyval, state) { + self.commit(Commit::Keep); + return glib::Propagation::Stop; + } + if component_matches("cell-editor.cancel", keyval, state) && self.is_editing_value() { + self.cancel_edit(); + return glib::Propagation::Stop; + } + glib::Propagation::Proceed + } + + fn edit_focus(&self) -> gtk::EventControllerFocus { + let focus = gtk::EventControllerFocus::new(); + let weak = self.downgrade(); + focus.connect_leave(move |focus| { + let Some(pane) = weak.upgrade() else { + return; + }; + // Another window taking the focus leaves it in the view, so + // the edit stays open for when the window is back. A focus + // moving within the window is unset while the leave runs. + if focus.widget().is_some_and(|view| crate::ui::a11y::holds_focus(&view)) { + return; + } + // Focus moving to an input method's popup ends the edit mid + // composition, so the commit waits for the preedit to clear. + if pane.imp().composing.get() { + pane.imp().commit_when_composed.set(true); + return; + } + pane.commit(Commit::Keep); + }); + focus + } + + fn preedit_changed(&self, preedit: &str) { + let imp = self.imp(); + imp.composing.set(!preedit.is_empty()); + if preedit.is_empty() && imp.commit_when_composed.replace(false) { + self.commit(Commit::Keep); + } + } + + fn is_editing_value(&self) -> bool { + self.imp().edit.borrow().is_some() + } + + fn text_changed(&self) { + self.update_placeholder(); + if !self.imp().loading.get() { + self.begin_edit(); + } + } + + /// The user typed: the edit is pinned to the row and the value as + /// they were when it began. + fn begin_edit(&self) { + if self.is_editing_value() { + return; + } + let (Some(grid), Some(pin), Some(column)) = (self.grid(), self.current_pin(), self.imp().field.get()) else { + return; + }; + let Some(row) = pin.get() else { + return; + }; + let start = edit_start(&grid, row, column); + self.imp().edit.replace(Some(OpenEdit { + row: pin.clone(), + column, + start, + })); + grid.set_editing(true); + } + + pub(super) fn commit(&self, how: Commit) { + let imp = self.imp(); + imp.commit_when_composed.set(false); + // Out of the pane first: the commit redraws the row, which reaches + // the pane again, and a reload commits through it. + let Some(edit) = imp.edit.take() else { + return; + }; + let Some(grid) = self.grid() else { + return; + }; + let typed = self.typed_text(); + let result = grid.edit().commit_value(&edit.row, edit.column, &edit.start, &typed); + if let (Some(CommitResult::Rejected(error)), Commit::Keep) = (&result, how) { + self.show_message(Some(&edit_parse_message(error)), true); + imp.edit.replace(Some(edit)); + return; + } + self.end_edit(&grid); + } + + pub(super) fn cancel_edit(&self) { + if self.imp().edit.take().is_none() { + return; + } + self.imp().commit_when_composed.set(false); + if let Some(grid) = self.grid() { + self.end_edit(&grid); + } + } + + fn end_edit(&self, grid: &DataGrid) { + self.imp().loaded.take(); + grid.set_editing(false); + self.render(); + } + + pub(super) fn typed_text(&self) -> String { + let buffer = self.imp().value_buffer.get(); + buffer.text(&buffer.start_iter(), &buffer.end_iter(), true).to_string() + } + + pub(super) fn field_column(&self) -> Option { + let grid = self.grid()?; + grid.grid_columns().get(self.imp().field.get()?).cloned() + } + + /// Fill the value editor from the shown row's selected field, unless + /// the user is typing in it or it holds that value already. + pub(super) fn load_value(&self) { + let imp = self.imp(); + if !self.is_open() || self.is_editing_value() { + return; + } + let (Some(grid), Some(row), Some(column)) = (self.grid(), self.current_row(), self.field_column()) else { + self.show_no_value(); + return; + }; + let loaded = Loaded { + row: row.clone(), + generation: row.generation(), + revision: row.revision(), + column: column.index, + }; + if imp.loaded.borrow().as_ref() == Some(&loaded) { + return; + } + let tracked = grid.tracked(); + let input = tracked.edit_input(&row, column.index); + let content = value_content(&column, &row.id(), tracked.lock(&column, &row), &input); + self.show_value(&column.name, &content); + imp.loaded.replace(Some(loaded)); + } + + fn show_no_value(&self) { + let content = ValueContent { + text: String::new(), + message: None, + open_placeholder: None, + emptied_placeholder: None, + language: None, + calendar: false, + }; + self.show_value("", &content); + self.imp().value_view.set_editable(false); + self.imp().loaded.take(); + } + + fn show_value(&self, name: &str, content: &ValueContent) { + let imp = self.imp(); + imp.placeholders.replace(Placeholders { + open: content.open_placeholder.clone(), + emptied: content.emptied_placeholder.clone(), + opened_empty: content.text.is_empty(), + }); + let buffer = imp.value_buffer.get(); + let language = content + .language + .and_then(|id| sourceview5::LanguageManager::default().language(id)); + buffer.set_language(language.as_ref()); + imp.loading.set(true); + // So the view's undo cannot step back into another field's value. + buffer.begin_irreversible_action(); + buffer.set_text(&content.text); + buffer.end_irreversible_action(); + imp.loading.set(false); + buffer.place_cursor(&buffer.start_iter()); + let view = imp.value_view.get(); + view.set_editable(content.message.is_none()); + imp.field_label.set_label(name); + // Through the widget: sourceview5 maps GtkAccessible only from + // GTK 4.10, a feature the app does not turn on. + view.upcast_ref::() + .update_property(&[gtk::accessible::Property::Label(name)]); + self.show_message(content.message.as_deref(), false); + imp.calendar_button.set_visible(content.calendar); + self.update_placeholder(); + } + + fn update_placeholder(&self) { + let imp = self.imp(); + let text = match imp.value_buffer.char_count() { + 0 => { + let placeholders = imp.placeholders.borrow(); + match placeholders.opened_empty { + true => placeholders.open.clone(), + false => placeholders.emptied.clone(), + } + } + _ => None, + }; + imp.placeholder_label.set_label(text.as_deref().unwrap_or_default()); + imp.placeholder_label.set_visible(text.is_some()); + } + + /// The sentence under the editor: why the cell is locked, dimmed, or + /// why the column refused what was typed, as an error. + fn show_message(&self, message: Option<&str>, refused: bool) { + let imp = self.imp(); + let label = imp.message_label.get(); + label.set_label(message.unwrap_or_default()); + label.set_visible(message.is_some()); + set_class(&label, "error", refused); + set_class(&label, "dimmed", !refused); + let view = imp.value_view.get(); + set_class(&view, "error", refused); + crate::ui::a11y::show_invalid(&view, message.filter(|_| refused)); + if let (false, Some(message)) = (refused, message) { + view.upcast_ref::() + .update_property(&[gtk::accessible::Property::Description(message)]); + } + } + + /// The menu's items are the grid's own cell actions, held on the + /// shown row and field until it closes, as the cell menu holds its + /// cell, so they act on the field while the grid has no focus. + pub(super) fn connect_value_menu(&self, grid: &DataGrid) { + let button = self.imp().value_menu_button.get(); + button.set_create_popup_func(glib::clone!( + #[weak(rename_to = pane)] + self, + move |_| pane.arm_value_menu() + )); + if let Some(popover) = button.popover() { + popover.connect_closed(glib::clone!( + #[weak] + grid, + move |_| grid.release_menu_target_soon() + )); + } + } + + fn arm_value_menu(&self) { + let Some(grid) = self.grid() else { + return; + }; + let cell = self.current_pin().zip(self.imp().field.get()); + let rows = cell.iter().map(|(row, _)| row.clone()).collect(); + grid.hold_menu_target(Target { cell, rows }); + grid.refresh_actions(); + } + + /// The SQL editor's scheme where the grid has the settings, in the + /// variant for the current style, following a switch of either. + fn follow_style_scheme(&self, grid: &DataGrid) { + let settings = grid.config().map(|config| config.settings.clone()); + let paint = { + let buffer = self.imp().value_buffer.get().downgrade(); + let settings = settings.as_ref().map(Rc::downgrade); + Rc::new(move || { + let Some(buffer) = buffer.upgrade() else { + return; + }; + let preferred = settings + .as_ref() + .and_then(Weak::upgrade) + .map_or_else(|| FALLBACK_SCHEME.to_owned(), |settings| settings.style_scheme()); + buffer.set_style_scheme(crate::ui::style_scheme_resolver::scheme(&preferred).as_ref()); + }) + }; + paint(); + let style_manager = adw::StyleManager::default(); + let on_dark = { + let paint = paint.clone(); + style_manager.connect_dark_notify(move |_| paint()) + }; + let mut guards = vec![HandlerGuard::new(&style_manager, on_dark)]; + if let Some(settings) = settings { + let on_scheme = settings + .gio() + .connect_changed(Some(tablepro_storage::settings::keys::STYLE_SCHEME), move |_, _| { + paint() + }); + guards.push(HandlerGuard::new(settings.gio(), on_scheme)); + } + self.imp().guards.borrow_mut().extend(guards); + } +} + +/// An edit of `row`'s `column` as it stands now: the value the page +/// read, which a save is checked against, and the value with any +/// pending edit, which the editor shows. +fn edit_start(grid: &DataGrid, row: &GridRow, column: usize) -> EditStart { + EditStart { + row: row.id(), + read: row + .cells() + .and_then(|cells| cells.get(column).cloned()) + .unwrap_or(Value::Null), + original: grid.tracked().edit_input(row, column), + } +} diff --git a/linux/crates/app/src/ui/grid/row_json_dialog.rs b/linux/crates/app/src/ui/data_grid/row_json_dialog.rs similarity index 99% rename from linux/crates/app/src/ui/grid/row_json_dialog.rs rename to linux/crates/app/src/ui/data_grid/row_json_dialog.rs index 8343847491..4dd7bdb500 100644 --- a/linux/crates/app/src/ui/grid/row_json_dialog.rs +++ b/linux/crates/app/src/ui/data_grid/row_json_dialog.rs @@ -10,7 +10,7 @@ use tablepro_core::column::ResultColumn; use tablepro_core::export::EncodeError; use tablepro_session::runtime::Tasks; -use super::RowSnapshot; +use crate::services::grid::RowSnapshot; use crate::ui::clipboard::{ClipboardFormat, ClipboardPayload}; use crate::ui::format::{format_thousands, plural_count}; diff --git a/linux/crates/app/src/ui/data_grid/row_number_column.rs b/linux/crates/app/src/ui/data_grid/row_number_column.rs new file mode 100644 index 0000000000..d4f00c40ee --- /dev/null +++ b/linux/crates/app/src/ui/data_grid/row_number_column.rs @@ -0,0 +1,215 @@ +use std::cell::RefCell; +use std::collections::HashMap; +use std::rc::Rc; + +use gtk4::prelude::*; +use gtk4::{self as gtk, glib, pango}; + +use super::tracked_values::RowPendingState; +use super::{DataGrid, GridRow}; + +/// Icon, spacing and the cell's padding beside the digits. +const CHROME_PX: i32 = 16 + 4 + 13; + +/// The handlers a bound gutter cell or row keeps, dropped at unbind. +type Watches = Rc, glib::SignalHandlerId)>>>>; + +/// The first column: each row's number and the icon of its pending +/// change. GtkColumnView has no row headers, so it is a column like the +/// others, scrolled and dragged with them. +pub fn build(grid: &DataGrid) -> gtk::ColumnViewColumn { + let factory = gtk::SignalListItemFactory::new(); + factory.connect_setup(|_, item| { + let Some(cell) = item.downcast_ref::() else { + return; + }; + cell.set_property("focusable", false); + let icon = gtk::Image::builder().pixel_size(16).visible(false).build(); + icon.set_accessible_role(gtk::AccessibleRole::Presentation); + let number = gtk::Inscription::builder() + .xalign(1.0) + .hexpand(true) + .valign(gtk::Align::Center) + .build(); + number.add_css_class("numeric"); + number.add_css_class("dimmed"); + let content = gtk::Box::new(gtk::Orientation::Horizontal, 4); + content.append(&icon); + content.append(&number); + cell.set_child(Some(&content)); + }); + let watches: Watches = Rc::default(); + let weak = grid.downgrade(); + { + let watches = watches.clone(); + factory.connect_bind(move |_, item| { + let (Some(cell), Some(grid)) = (item.downcast_ref::(), weak.upgrade()) else { + return; + }; + let Some(row) = cell.item().and_downcast::() else { + return; + }; + bind_cell(&grid, cell, &row, &watches); + }); + } + factory.connect_unbind(move |_, item| unwatch(&watches, item.upcast_ref())); + let column = gtk::ColumnViewColumn::builder() + .title(crate::i18n::pgettext("row number column", "#")) + .factory(&factory) + .resizable(false) + .build(); + column.set_id(Some("row-numbers")); + column +} + +fn bind_cell(grid: &DataGrid, cell: >k::ColumnViewCell, row: &GridRow, watches: &Watches) { + let show = { + let (grid, cell) = (grid.downgrade(), cell.downgrade()); + move || { + if let (Some(grid), Some(cell)) = (grid.upgrade(), cell.upgrade()) { + show_cell(&grid, &cell); + } + } + }; + show(); + let on_row = { + let show = show.clone(); + row.connect_revision_notify(move |_| show()) + }; + let on_position = cell.connect_notify_local(Some("position"), move |_, _| show()); + watches.borrow_mut().insert( + cell.clone().upcast(), + vec![ + (row.upcast_ref::().downgrade(), on_row), + (cell.upcast_ref::().downgrade(), on_position), + ], + ); +} + +/// The tracked values are read each time, since the grid's rules follow +/// its column set. +fn show_cell(grid: &DataGrid, cell: >k::ColumnViewCell) { + let Some(row) = cell.item().and_downcast::() else { + return; + }; + let Some(content) = cell.child() else { + return; + }; + let (Some(icon), Some(number)) = ( + content.first_child().and_downcast::(), + content.last_child().and_downcast::(), + ) else { + return; + }; + let state = grid.tracked().row_state(&row); + icon.set_icon_name(state.icon()); + icon.set_visible(state.icon().is_some()); + icon.set_tooltip_text(state.text().as_deref()); + number.set_text( + row_number(grid, &row, cell.position()) + .map(|n| n.to_string()) + .as_deref(), + ); +} + +/// A saved row's number counts from the first row read; a draft has none. +fn row_number(grid: &DataGrid, row: &GridRow, position: u32) -> Option { + if row.draft_id().is_some() || position == gtk::INVALID_LIST_POSITION { + return None; + } + let drafts = grid.store().draft_count(); + Some(grid.row_base() + u64::from(position.saturating_sub(drafts)) + 1) +} + +fn unwatch(watches: &Watches, item: &glib::Object) { + if let Some(handlers) = watches.borrow_mut().remove(item) { + for (object, handler) in handlers { + if let Some(object) = object.upgrade() { + object.disconnect(handler); + } + } + } +} + +/// As wide as the largest number the rows held reach, in tabular digits. +pub fn fit(grid: &DataGrid) { + let Some(column) = grid.row_numbers_column() else { + return; + }; + let largest = grid.row_base() + u64::from(grid.store().len()); + let digits = largest.max(1).ilog10() as i32 + 1; + let metrics = grid.column_view().pango_context().metrics(None, None); + let digit_px = pango::units_to_double(metrics.approximate_digit_width()); + column.set_fixed_width((f64::from(digits.max(2)) * digit_px).ceil() as i32 + CHROME_PX); +} + +/// Each row names itself and its pending change to a screen reader, and +/// takes no focus of its own, so the focus always lands on a cell. +pub fn install_row_factory(grid: &DataGrid) { + let factory = gtk::SignalListItemFactory::new(); + factory.connect_setup(|_, item| { + if let Some(list_row) = item.downcast_ref::() { + list_row.set_focusable(false); + } + }); + let watches: Watches = Rc::default(); + let weak = grid.downgrade(); + { + let watches = watches.clone(); + factory.connect_bind(move |_, item| { + let (Some(list_row), Some(grid)) = (item.downcast_ref::(), weak.upgrade()) else { + return; + }; + let Some(row) = list_row.item().and_downcast::() else { + return; + }; + let show = { + let (grid, list_row) = (grid.downgrade(), list_row.downgrade()); + move || { + if let (Some(grid), Some(list_row)) = (grid.upgrade(), list_row.upgrade()) { + describe_row(&grid, &list_row); + } + } + }; + show(); + let on_row = { + let show = show.clone(); + row.connect_revision_notify(move |_| show()) + }; + let on_position = list_row.connect_notify_local(Some("position"), move |_, _| show()); + watches.borrow_mut().insert( + list_row.clone().upcast(), + vec![ + (row.upcast_ref::().downgrade(), on_row), + (list_row.upcast_ref::().downgrade(), on_position), + ], + ); + }); + } + factory.connect_unbind(move |_, item| { + unwatch(&watches, item.upcast_ref()); + if let Some(list_row) = item.downcast_ref::() { + list_row.set_property("accessible-label", None::); + list_row.set_property("accessible-description", None::); + } + }); + grid.column_view().set_row_factory(Some(&factory)); +} + +/// Through the properties, since their setters take no NULL to clear. +fn describe_row(grid: &DataGrid, list_row: >k::ColumnViewRow) { + let Some(row) = list_row.item().and_downcast::() else { + return; + }; + let state = grid.tracked().row_state(&row); + let label = match row_number(grid, &row, list_row.position()) { + Some(number) => crate::i18n::gettext_f("Row {number}", &[("number", &number.to_string())]), + None => crate::i18n::gettext("New row"), + }; + list_row.set_property("accessible-label", Some(label)); + let description = match state { + RowPendingState::Inserted => None, + other => other.row_description(), + }; + list_row.set_property("accessible-description", description); +} diff --git a/linux/crates/app/src/ui/data_grid/selection.rs b/linux/crates/app/src/ui/data_grid/selection.rs new file mode 100644 index 0000000000..5942c51ea1 --- /dev/null +++ b/linux/crates/app/src/ui/data_grid/selection.rs @@ -0,0 +1,239 @@ +use std::collections::HashSet; + +use gtk4::prelude::*; +use gtk4::subclass::prelude::*; +use gtk4::{self as gtk, glib}; + +use super::{DataGrid, GridCell, GridRow, PinnedRow}; +use crate::services::change_tracker::RowKey; + +/// What an action acts on: the cell a menu opened on, or the focused cell, +/// and the rows: the selection when that cell's row is in it, else the +/// row alone. Rows are pinned, so a page that lands meanwhile makes the +/// action act on nothing rather than on the rows that took their places. +#[derive(Debug, Clone)] +pub struct Target { + pub cell: Option<(PinnedRow, usize)>, + pub rows: Vec, +} + +/// A target whose rows are all still in the grid. +#[derive(Debug, Clone)] +pub struct Resolved { + pub cell: Option<(GridRow, usize)>, + pub rows: Vec, +} + +impl Target { + pub fn resolve(&self) -> Option { + let cell = match &self.cell { + Some((row, column)) => Some((row.get()?.clone(), *column)), + None => None, + }; + let rows = self + .rows + .iter() + .map(|row| row.get().cloned()) + .collect::>>()?; + Some(Resolved { cell, rows }) + } +} + +impl DataGrid { + pub(crate) fn focused_cell(&self) -> Option { + let window = self.root().and_downcast::()?; + let focus = gtk::prelude::GtkWindowExt::focus(&window)?; + let cell = GridCell::from_focus(&focus)?; + cell.is_ancestor(&self.column_view()).then_some(cell) + } + + /// The target of an action that is running now. + pub(crate) fn target(&self) -> Option { + if let Some(target) = self.imp().menu_target.borrow().as_ref() { + return target.resolve(); + } + self.focus_target().resolve() + } + + /// The focused cell and the rows it acts on. With no focused cell, the + /// selected rows. + pub(crate) fn focus_target(&self) -> Target { + match self + .focused_cell() + .and_then(|cell| Some((cell.row()?, cell.position()?, cell.column()))) + { + Some((row, position, column)) => Target { + cell: Some((row.pin(), column)), + rows: self.rows_for(&row, position), + }, + None => Target { + cell: None, + rows: self.selected_rows().iter().map(GridRow::pin).collect(), + }, + } + } + + /// The whole selection when `row` is in it, else `row` alone. + pub(crate) fn rows_for(&self, row: &GridRow, position: u32) -> Vec { + let selection = self.selection(); + let selected = selection.is_selected(position) && selection.item(position).as_ref() == Some(row.upcast_ref()); + match selected { + true => self.selected_rows().iter().map(GridRow::pin).collect(), + false => vec![row.pin()], + } + } + + pub(crate) fn selected_rows(&self) -> Vec { + let selection = self.selection(); + let bitset = selection.selection(); + let mut positions: Vec = (0..bitset.size()).map(|nth| bitset.nth(nth as u32)).collect(); + positions.sort_unstable(); + positions + .into_iter() + .filter_map(|position| selection.item(position).and_downcast::()) + .collect() + } + + /// A right click moves the focus to the row it was on, so Row Details + /// and the keys follow the row the menu acts on: to the clicked cell, + /// else to the focused column of that row. On a selected row it keeps + /// the selection, so a block still copies whole; any other row becomes + /// the selection. + pub(crate) fn select_for_menu(&self, position: u32, column: Option) { + let focused = self.focused_cell(); + let column = column + .or_else(|| focused.as_ref().map(GridCell::column)) + .or_else(|| self.shown_columns().first().copied()); + if let Some(column) = column + && !focused.is_some_and(|cell| cell.position() == Some(position) && cell.column() == column) + { + self.focus_cell(position, column); + } + let selection = self.selection(); + if position < selection.n_items() && !selection.is_selected(position) { + selection.select_item(position, true); + } + } + + /// Hold `target` for the actions a menu offers, until it closes. + pub(crate) fn hold_menu_target(&self, target: Target) { + self.imp().menu_target.replace(Some(target)); + } + + /// Let go of the menu's target once the item it activated, if any, + /// has run: GTK closes a menu before its item's action runs. + pub(crate) fn release_menu_target_soon(&self) { + let grid = self.downgrade(); + glib::idle_add_local_once(move || { + if let Some(grid) = grid.upgrade() { + grid.imp().menu_target.take(); + grid.refresh_actions(); + } + }); + } + + /// Move the focus to the cell at `position` in data column `column`, + /// scrolling it into view. + pub(crate) fn focus_cell(&self, position: u32, column: usize) -> Option { + let view = self.column_view(); + let view_column = self.data_view_column(column)?; + if position >= self.selection().n_items() { + return None; + } + view.scroll_to(position, Some(&view_column), gtk::ListScrollFlags::FOCUS, None); + let landed = |cell: &GridCell| cell.position() == Some(position) && cell.column() == column; + // `scroll_to` moves the focus only while it is in the list already. + // Otherwise it marks the cell the list focuses next, which the grab + // then reaches. + if !self.focused_cell().is_some_and(|cell| landed(&cell)) { + view.grab_focus(); + } + self.focused_cell().filter(landed) + } + + /// Give the focus back to `cell` once its editor closed, since the + /// editor took the focus with it. + pub(crate) fn return_focus(&self, cell: &GridCell) { + if let Some(position) = cell.position() { + self.focus_cell(position, cell.column()); + } + } + + pub(super) fn watch_focus(&self) { + self.stop_watching_focus(); + let Some(window) = self.root().and_downcast::() else { + return; + }; + let handler = window.connect_focus_widget_notify(glib::clone!( + #[weak(rename_to = grid)] + self, + move |_| grid.focus_moved() + )); + self.imp().focus_watch.replace(Some((window.downgrade(), handler))); + } + + pub(super) fn stop_watching_focus(&self) { + if let Some((window, handler)) = self.imp().focus_watch.take() + && let Some(window) = window.upgrade() + { + window.disconnect(handler); + } + } + + fn focus_moved(&self) { + if self.focused_cell().is_none() { + return; + } + self.refresh_actions(); + self.imp().detail_pane.follow_focus(); + } +} + +/// The selection and the focused cell, by key, across a reload that keeps +/// the slots but gives them other rows. +pub(super) struct Restore { + selected: HashSet, + focused: Option<(RowKey, usize)>, +} + +impl Restore { + pub(super) fn capture(grid: &DataGrid) -> Self { + let selected = grid.selected_rows().iter().filter_map(GridRow::tracker_key).collect(); + let focused = grid + .focused_cell() + .and_then(|cell| Some((cell.row()?.tracker_key()?, cell.column()))); + Self { selected, focused } + } + + /// One walk of the rows in view order: a lookup per key would scan the + /// list again for each row a Select All kept. + pub(super) fn apply(self, grid: &DataGrid) { + let selection = grid.selection(); + let bitset = gtk::Bitset::new_empty(); + let mut focus = None; + if !self.selected.is_empty() || self.focused.is_some() { + for position in 0..selection.n_items() { + let Some(key) = selection + .item(position) + .and_downcast::() + .and_then(|row| row.tracker_key()) + else { + continue; + }; + if self.selected.contains(&key) { + bitset.add(position); + } + if let Some((focused, column)) = &self.focused + && *focused == key + { + focus = Some((position, *column)); + } + } + } + let all = gtk::Bitset::new_range(0, selection.n_items()); + selection.set_selection(&bitset, &all); + if let Some((position, column)) = focus { + grid.focus_cell(position, column); + } + } +} diff --git a/linux/crates/app/src/ui/data_grid/server_sorter.rs b/linux/crates/app/src/ui/data_grid/server_sorter.rs new file mode 100644 index 0000000000..9c7fe5d6d0 --- /dev/null +++ b/linux/crates/app/src/ui/data_grid/server_sorter.rs @@ -0,0 +1,41 @@ +use gtk4::subclass::prelude::*; +use gtk4::{self as gtk, glib}; + +mod imp { + use super::*; + + #[derive(Default)] + pub struct ServerSorter; + + #[glib::object_subclass] + impl ObjectSubclass for ServerSorter { + const NAME: &'static str = "TableProServerSorter"; + type Type = super::ServerSorter; + type ParentType = gtk::Sorter; + } + + impl ObjectImpl for ServerSorter {} + + impl SorterImpl for ServerSorter { + fn compare(&self, _: &glib::Object, _: &glib::Object) -> gtk::Ordering { + gtk::Ordering::Equal + } + + fn order(&self) -> gtk::SorterOrder { + gtk::SorterOrder::None + } + } +} + +glib::wrapper! { + /// A header's sorter where the server sorts: it lets the header show + /// the sort and leaves the rows in the order they arrived. + pub struct ServerSorter(ObjectSubclass) + @extends gtk::Sorter; +} + +impl ServerSorter { + pub fn new() -> Self { + glib::Object::new() + } +} diff --git a/linux/crates/app/src/ui/grid/table_keys.rs b/linux/crates/app/src/ui/data_grid/table_keys.rs similarity index 83% rename from linux/crates/app/src/ui/grid/table_keys.rs rename to linux/crates/app/src/ui/data_grid/table_keys.rs index 31dba73e50..03c6eaeeb6 100644 --- a/linux/crates/app/src/ui/grid/table_keys.rs +++ b/linux/crates/app/src/ui/data_grid/table_keys.rs @@ -24,9 +24,9 @@ impl TableKeys { } } -/// Shared by a table tab and every grid it builds, so keys read after a -/// grid was built reach its menus. A menu takes the keys as it opens, -/// so a read that lands while it is open changes nothing it shows. +/// Shared by a table tab and its grid, so keys read after the grid was +/// built reach its menus. A menu takes the keys as it opens, so a read +/// that lands while it is open changes nothing it shows. #[derive(Debug, Clone)] pub struct KeysHandle(Rc>>); diff --git a/linux/crates/app/src/ui/data_grid/test_grid.rs b/linux/crates/app/src/ui/data_grid/test_grid.rs new file mode 100644 index 0000000000..7a53cf947b --- /dev/null +++ b/linux/crates/app/src/ui/data_grid/test_grid.rs @@ -0,0 +1,316 @@ +use std::cell::RefCell; +use std::rc::Rc; +use std::sync::Arc; +use std::time::Duration; + +use gtk4 as gtk; +use gtk4::prelude::*; +use tablepro_core::edit::CellInput; +use tablepro_core::meta::{RowIdentity, TableRef}; +use tablepro_core::{ColumnInfo, Value}; + +use super::{ + DataGrid, DialectSource, EditRules, GridCell, GridConfig, GridRequest, GridSorting, LayoutBinding, RowData, +}; +use crate::runtime::AppRuntime; +use crate::services::change_tracker::RowKey; +use crate::services::grid::{EditPolicy, GridColumn, edit_policy, grid_columns}; +use crate::services::tab_tracker_handle::TabTrackerHandle; +use crate::services::tracker_edit::TrackerEdit; +use crate::test_support::{MemorySettings, descendants, drain_main_context, wait_until}; + +pub(crate) const WAIT: Duration = Duration::from_secs(5); + +#[expect( + clippy::disallowed_methods, + reason = "a test fixture stands in for the catalogue, which is where this text comes from in production" +)] +pub(crate) fn col(data_type: &str, primary_key: bool) -> ColumnInfo { + let kind = tablepro_core::column::classify_type_name(data_type); + ColumnInfo { + name: "x".into(), + column_type: tablepro_core::column::ColumnType { + name: data_type.to_owned(), + dynamic_storage: tablepro_core::column::has_dynamic_storage(&kind), + kind, + catalog: Some(tablepro_core::column::CatalogType { + declaration: tablepro_core::column::SqlTypeExpr::from_catalog_text(data_type), + cast_target: tablepro_core::column::SqlTypeExpr::from_catalog_text(data_type), + }), + read_form: tablepro_core::column::ReadForm::Native, + }, + nullable: true, + primary_key, + is_auto_increment: false, + is_generated: false, + partition_scoped: false, + default: tablepro_core::column::ColumnDefault::None, + comment: None, + } +} + +pub(crate) fn named(name: &str, data_type: &str, primary_key: bool) -> ColumnInfo { + let mut column = col(data_type, primary_key); + column.name = name.to_owned(); + column +} + +pub(crate) fn key(id: i64) -> RowKey { + RowKey::from_pk_values(&[Value::Int(id)]).expect("a key") +} + +pub(crate) fn keyed_by_id() -> RowIdentity { + RowIdentity::UniqueKey { + columns: vec!["id".to_owned()], + } +} + +/// Saved rows keyed by their first cell, as a page read from a table is. +pub(crate) fn persisted(rows: &[Vec]) -> Vec { + rows.iter() + .map(|cells| { + let id = cells.first().cloned().unwrap_or(Value::Null); + RowData::persisted(RowKey::from_pk_values(&[id]), cells.clone().into()) + }) + .collect() +} + +pub(crate) fn results(rows: &[Vec]) -> Vec { + rows.iter().map(|cells| RowData::result(cells.clone().into())).collect() +} + +/// Saved rows keyed `ids`, as a page of a two-column table. +pub(crate) fn page(ids: std::ops::Range) -> Vec { + let rows: Vec> = ids + .map(|id| vec![Value::Int(id), Value::Text(format!("row {id}"))]) + .collect(); + persisted(&rows) +} + +pub(crate) fn two_text_columns() -> Vec { + vec![named("id", "integer", true), named("name", "text", false)] +} + +pub(crate) fn two_text_values() -> Vec> { + vec![ + vec![Value::Int(1), Value::Text("A".into())], + vec![Value::Int(2), Value::Text("B".into())], + ] +} + +pub(crate) fn text(value: &str) -> CellInput { + CellInput::Value(Value::Text(value.to_owned())) +} + +/// How a test grid is built: an editing table grid by default. +pub(crate) struct GridSpec { + pub columns: Vec, + pub editable: bool, + pub sorting: GridSorting, + pub layout: Option, + pub find: bool, + pub filter_by_value: bool, + pub policy: Option, + pub references: Option, + pub dialect: Option, + pub table: Option, + /// Columns no filter, sort or update can name, as a document's + /// nested fields (ADR 0026). + pub unaddressable: Vec, +} + +impl GridSpec { + pub(crate) fn table(columns: Vec) -> Self { + Self { + columns, + editable: true, + sorting: GridSorting::Server { initial: None }, + layout: None, + find: false, + filter_by_value: true, + policy: None, + references: None, + dialect: None, + table: None, + unaddressable: Vec::new(), + } + } + + pub(crate) fn results(columns: Vec) -> Self { + Self { + editable: false, + sorting: GridSorting::Client, + find: true, + filter_by_value: false, + ..Self::table(columns) + } + } +} + +/// A grid in a shown window, with the requests it made. +pub(crate) struct Grid { + pub window: gtk::Window, + pub grid: DataGrid, + pub tracker: TabTrackerHandle, + pub columns: Arc<[GridColumn]>, + requests: Rc>>, + _settings: MemorySettings, + _runtime: AppRuntime, +} + +impl Grid { + /// `rows` keyed by their first cell, in a grid that edits them. + pub(crate) fn open(columns: Vec, rows: Vec>) -> Self { + Self::build(GridSpec::table(columns), persisted(&rows)) + } + + pub(crate) fn build(spec: GridSpec, rows: Vec) -> Self { + crate::register_test_resources(); + let runtime = crate::test_support::test_runtime(); + let settings = MemorySettings::new(); + let tracker = TabTrackerHandle::default(); + let columns = grid_columns( + &spec.columns.iter().map(ColumnInfo::result_column).collect::>(), + &spec.columns, + |name| !spec.unaddressable.iter().any(|unaddressable| unaddressable == name), + ); + let has_key = spec.columns.iter().any(|column| column.primary_key); + let config = GridConfig { + columns: columns.clone(), + sorting: spec.sorting, + rules: spec.editable.then(|| EditRules { + identity: keyed_by_id(), + policy: spec.policy.unwrap_or_else(|| edit_policy(false, has_key, true)), + }), + tracker: spec.editable.then(|| tracker.clone()), + settings: settings.get().clone(), + layout: spec.layout, + references: spec.references, + dialect: spec.dialect, + table: spec.table, + find: spec.find, + filter_by_value: spec.filter_by_value, + tasks: runtime.tasks(), + label: "test grid".to_owned(), + }; + let grid = DataGrid::new(config); + let requests: Rc>> = Rc::default(); + let log = requests.clone(); + grid.connect_request(move |_, request| log.borrow_mut().push(request)); + let window = gtk::Window::new(); + window.set_default_size(800, 500); + window.set_child(Some(&grid)); + window.present(); + let count = rows.len(); + grid.replace_rows(rows, 0); + let opened = Self { + window, + grid, + tracker, + columns, + requests, + _settings: settings, + _runtime: runtime, + }; + // A saved layout can hide columns, which draw no cells. + let width = opened.grid.shown_columns().len(); + wait_until(WAIT, || opened.cells().len() >= width * count.min(20)).expect("the cells are drawn"); + opened + } + + pub(crate) fn cells(&self) -> Vec { + descendants(&self.grid.column_view()) + .into_iter() + .filter_map(|widget| widget.downcast::().ok()) + .filter(|cell| cell.row().is_some()) + .collect() + } + + /// The cell that shows `column` of the row named `id`. + pub(crate) fn cell_of(&self, id: i64, column: usize) -> GridCell { + self.cells() + .into_iter() + .find(|cell| cell.column() == column && cell.row().is_some_and(|row| row.key() == Some(key(id)))) + .expect("the row's cell is drawn") + } + + pub(crate) fn cell_at(&self, position: u32, column: usize) -> GridCell { + self.find_cell(position, column).expect("the cell is drawn") + } + + pub(crate) fn find_cell(&self, position: u32, column: usize) -> Option { + self.cells() + .into_iter() + .find(|cell| cell.column() == column && cell.position() == Some(position)) + } + + pub(crate) fn focused_cell(&self) -> Option { + let focus = gtk::prelude::GtkWindowExt::focus(&self.window)?; + GridCell::from_focus(&focus) + } + + /// Focus the cell of data column `column` at `position`, as the arrow + /// keys do. + pub(crate) fn focus(&self, position: u32, column: usize) -> GridCell { + let mut focused = None; + wait_until(WAIT, || { + focused = self.grid.focus_cell(position, column); + focused.is_some() + }) + .expect("the cell takes the focus"); + focused.expect("a focused cell") + } + + pub(crate) fn activate(&self, action: &str) { + self.grid + .activate_action(&format!("grid.{action}"), None) + .unwrap_or_else(|_| panic!("the grid has no {action}")); + drain_main_context(); + } + + pub(crate) fn is_enabled(&self, action: &str) -> bool { + self.grid + .actions() + .lookup_action(action) + .is_some_and(|action| action.is_enabled()) + } + + pub(crate) fn take_requests(&self) -> Vec { + self.requests.take() + } + + pub(crate) fn insert_draft(&self) -> u64 { + let delta = self.tracker.apply(TrackerEdit::InsertDrafts { + rows: vec![vec![CellInput::Value(Value::Null); self.columns.len()]], + }); + let draft_id = delta + .inserted_drafts + .first() + .copied() + .expect("an insert adds a draft row"); + self.grid.apply_delta(&delta); + drain_main_context(); + draft_id + } + + pub(crate) fn display_text(cell: &GridCell) -> String { + cell.display() + .and_then(|display| display.text()) + .map(|text| text.to_string()) + .unwrap_or_default() + } +} + +impl Drop for Grid { + fn drop(&mut self) { + self.window.destroy(); + drain_main_context(); + } +} + +/// Whether the window's focus is the grid or inside it. +pub(crate) fn focus_in_grid(grid: &Grid) -> bool { + let view = grid.grid.column_view(); + gtk::prelude::GtkWindowExt::focus(&grid.window) + .is_some_and(|focus| focus == *view.upcast_ref::() || focus.is_ancestor(&view)) +} diff --git a/linux/crates/app/src/ui/data_grid/tests/cells.rs b/linux/crates/app/src/ui/data_grid/tests/cells.rs new file mode 100644 index 0000000000..84a64b0d9a --- /dev/null +++ b/linux/crates/app/src/ui/data_grid/tests/cells.rs @@ -0,0 +1,432 @@ +use std::cell::Cell; +use std::rc::Rc; + +use gtk4 as gtk; +use gtk4::prelude::*; +use gtk4::{glib, pango}; +use tablepro_core::Value; +use tablepro_core::column::{BoolStorage, ColumnKind}; + +use super::{delete_saved, described, keyed, rename_first, row_widget, text, untyped}; +use crate::services::grid::display_text::editable_null_sentinel; +use crate::services::grid::grid_columns; +use crate::services::tracker_edit::TrackerEdit; +use crate::test_support::{descendants, drain_main_context, wait_until}; +use crate::ui::data_grid::columns::ColumnPaint; +use crate::ui::data_grid::grid_cell::TextAttributes; +use crate::ui::data_grid::test_grid::{Grid, GridSpec, WAIT, key, named, results, two_text_columns, two_text_values}; +use crate::ui::data_grid::{GridCell, GridRow, RowData, TrackedValues}; + +fn two_text_rows() -> Grid { + Grid::open(two_text_columns(), two_text_values()) +} + +/// What a cell of a text column outside any grid draws with. +fn paint() -> Rc { + let info = named("name", "text", false); + let columns = grid_columns(&[info.result_column()], &[info], |_| true); + Rc::new(ColumnPaint { + column: columns[0].clone(), + tracked: TrackedValues::untracked(), + attributes: Rc::new(TextAttributes::new()), + numbers: Cell::new(false), + }) +} + +fn has_attribute(cell: &GridCell, kind: pango::AttrType) -> bool { + cell.display() + .and_then(|display| display.attributes()) + .is_some_and(|list| list.attributes().iter().any(|attr| attr.type_() == kind)) +} + +fn has_class(cell: &GridCell, class: &str) -> bool { + cell.display().is_some_and(|display| display.has_css_class(class)) +} + +#[gtk4::test] +fn a_page_in_the_same_slots_keeps_every_cell_and_draws_each_once() { + let grid = Grid::build(GridSpec::table(two_text_columns()), keyed(&[1, 2, 3, 4, 5])); + drain_main_context(); + let before: Vec<(GridCell, u32)> = grid + .cells() + .into_iter() + .map(|cell| { + let renders = cell.render_count(); + (cell, renders) + }) + .collect(); + assert_eq!(before.len(), 10, "every row's cells are drawn"); + + grid.grid.replace_rows(keyed(&[11, 12, 13, 14, 15]), 0); + drain_main_context(); + + let after = grid.cells(); + assert_eq!(after.len(), before.len(), "a cell widget came or went"); + for (cell, renders) in &before { + assert!(after.contains(cell), "a cell widget was built again"); + assert_eq!( + cell.render_count(), + renders + 1, + "the cell of column {} at {:?} did not draw the new row once", + cell.column(), + cell.position() + ); + } + assert_eq!(Grid::display_text(&grid.cell_of(11, 1)), "row 11"); +} + +/// D9: one spelling in every grid, and no check box. +#[gtk4::test] +fn booleans_read_true_or_false_in_every_grid() { + let columns = vec![named("id", "integer", true), named("flag", "boolean", false)]; + let rows = vec![ + vec![Value::Int(1), Value::Bool(true)], + vec![Value::Int(2), Value::Bool(false)], + ]; + let table = Grid::open(columns.clone(), rows.clone()); + let shown = Grid::build(GridSpec::results(columns), results(&rows)); + + for grid in [&table, &shown] { + assert_eq!(Grid::display_text(&grid.cell_at(0, 1)), "true"); + assert_eq!(Grid::display_text(&grid.cell_at(1, 1)), "false"); + assert!( + descendants(&grid.grid.column_view()) + .iter() + .all(|widget| !widget.is::()), + "a boolean drew a check box" + ); + } +} + +/// D9: a boolean kept as a number reads as one stored natively, and a +/// number that is no boolean reads as itself. +#[gtk4::test] +fn an_integer_boolean_reads_true_or_false_and_other_numbers_as_themselves() { + let mut active = named("active", "tinyint(1)", false); + active.column_type.kind = ColumnKind::Bool(BoolStorage::Integer); + let grid = Grid::open( + vec![named("id", "integer", true), active], + vec![ + vec![Value::Int(1), Value::Int(1)], + vec![Value::Int(2), Value::Int(0)], + vec![Value::Int(3), Value::Int(2)], + ], + ); + + let shown: Vec = (1..=3).map(|id| Grid::display_text(&grid.cell_of(id, 1))).collect(); + + assert_eq!(shown, ["true", "false", "2"]); +} + +/// D10: a boolean draws as text, so its row is no taller. +#[gtk4::test] +fn a_row_with_a_boolean_is_as_tall_as_one_without() { + let texts = two_text_rows(); + let flags = Grid::open( + vec![named("id", "integer", true), named("flag", "boolean", false)], + vec![vec![Value::Int(1), Value::Bool(true)]], + ); + let height = |grid: &Grid| row_widget(&grid.cell_of(1, 1)).height(); + + wait_until(WAIT, || height(&texts) > 0 && height(&flags) > 0).expect("the rows are laid out"); + + assert_eq!(height(&flags), height(&texts)); +} + +#[gtk4::test] +fn column_view_carries_data_table_class() { + let grid = two_text_rows(); + + assert!(grid.grid.column_view().has_css_class("data-table")); +} + +#[gtk4::test] +fn numeric_column_cells_align_end_with_numeric_class() { + let grid = Grid::open( + vec![ + named("id", "integer", true), + named("name", "text", false), + named("price", "numeric(10,2)", false), + ], + vec![vec![ + Value::Int(1), + text("tea"), + Value::Decimal("4.5".parse().expect("a decimal")), + ]], + ); + let xalign = |column| grid.cell_of(1, column).display().map(|display| display.xalign()); + + for column in [0, 2] { + assert_eq!(xalign(column), Some(1.0), "column {column}"); + assert!(has_class(&grid.cell_of(1, column), "numeric"), "column {column}"); + } + assert_eq!(xalign(1), Some(0.0)); + assert!(!has_class(&grid.cell_of(1, 1), "numeric")); + assert_eq!(Grid::display_text(&grid.cell_of(1, 2)), "4.50", "the column's scale"); +} + +/// PostgreSQL's editor reads columns with no type, holding the server's +/// text. +#[gtk4::test] +fn untyped_numbers_line_up_as_the_browse_grid_lines_them_up() { + let result = Grid::build( + GridSpec::results(Vec::from(["id", "category", "products", "avg_price"].map(untyped))), + results(&[ + vec![text("1"), text("Coffee"), text("7"), text("23.79")], + vec![text("2"), text("Tea"), Value::Null, text("10.58")], + ]), + ); + let browse = Grid::open( + vec![ + named("id", "integer", true), + named("category", "text", false), + named("products", "bigint", false), + named("avg_price", "numeric", false), + ], + vec![vec![ + Value::Int(1), + text("Coffee"), + Value::Int(7), + Value::Decimal("23.79".parse().expect("a decimal")), + ]], + ); + let drawn = |cell: GridCell| { + let display = cell.display().expect("a text cell"); + (display.xalign(), display.has_css_class("numeric")) + }; + + for column in 0..4 { + assert_eq!( + drawn(result.cell_at(0, column)), + drawn(browse.cell_of(1, column)), + "column {column}" + ); + } + assert_eq!(drawn(result.cell_at(0, 2)), (1.0, true)); + assert_eq!(drawn(result.cell_at(0, 1)), (0.0, false)); +} + +#[gtk4::test] +fn pending_delete_row_uses_shared_strikethrough_attributes() { + let grid = two_text_rows(); + let attributes = |id, column| { + grid.cell_of(id, column) + .display() + .and_then(|display| display.attributes()) + .map(|attrs| attrs.as_ptr()) + }; + assert_eq!(attributes(1, 1), None, "plain text carries no attributes"); + + delete_saved(&grid, 1, "A"); + + let struck = attributes(1, 0).expect("the deleted row's text is struck through"); + assert_eq!(attributes(1, 1), Some(struck), "each cell built its own list"); + assert_eq!(attributes(2, 1), None); + assert!(has_attribute(&grid.cell_of(1, 1), pango::AttrType::Strikethrough)); + assert!(has_class(&grid.cell_of(1, 1), "dimmed")); +} + +#[gtk4::test] +fn modified_cell_renders_bold() { + let grid = two_text_rows(); + let cell = grid.cell_of(1, 1); + assert!(!has_attribute(&cell, pango::AttrType::Weight)); + + rename_first(&grid, "edited"); + assert!( + has_attribute(&cell, pango::AttrType::Weight), + "an edit showed only by its colour" + ); + assert!( + has_class(&cell, "warning"), + "an edit is not in Adwaita's warning colour" + ); + assert!(!has_attribute(&grid.cell_of(1, 0), pango::AttrType::Weight)); + + delete_saved(&grid, 1, "A"); + assert!(has_attribute(&cell, pango::AttrType::Strikethrough)); + assert!( + !has_attribute(&cell, pango::AttrType::Weight) && !has_class(&cell, "warning"), + "a cell going away with its row read as an edit" + ); +} + +#[gtk4::test] +fn a_null_placeholder_is_italic_and_a_locked_one_dimmed() { + let rows = vec![vec![Value::Int(1), Value::Null]]; + let grid = Grid::open(two_text_columns(), rows.clone()); + let editable = grid.cell_of(1, 1); + let locked_grid = Grid::build(GridSpec::results(two_text_columns()), results(&rows)); + let locked = locked_grid.cell_at(0, 1); + + assert_eq!(Grid::display_text(&editable), editable_null_sentinel()); + assert!( + has_attribute(&editable, pango::AttrType::Style), + "a placeholder reads like a value" + ); + assert!(!has_class(&editable, "dimmed"), "an editable NULL was dimmed"); + assert!( + editable + .display() + .is_some_and(|display| display.tooltip_text().is_none()) + ); + assert_eq!(Grid::display_text(&locked), "NULL"); + assert!(has_class(&locked, "dimmed"), "a NULL no one can change was not dimmed"); +} + +#[gtk4::test] +fn locked_cells_draw_in_an_inscription_with_no_label() { + let grid = two_text_rows(); + let locked = grid.cell_of(1, 0); + + assert!(locked.lock().is_some(), "a saved row's key was offered for editing"); + assert!(locked.display().is_some()); + assert!( + descendants(&locked).iter().all(|widget| !widget.is::()), + "a locked cell carries a label, whose own menu takes Shift+F10" + ); + assert_eq!(Grid::display_text(&locked), "1"); +} + +/// A revision alone redraws a cell, which reads the tracker as it draws. +#[gtk4::test] +fn a_revision_bump_draws_the_pending_value_in_the_same_cell() { + let grid = two_text_rows(); + let cell = grid.cell_of(1, 1); + let delta = grid.tracker.apply(TrackerEdit::Cell { + row_key: key(1), + col: 1, + original: text("A"), + value: text("new"), + }); + + grid.grid.store().row(0).expect("a row").bump_revision(); + + assert_eq!(delta.pending_count, 1); + assert_eq!(Grid::display_text(&cell), "new"); + assert!(has_class(&cell, "warning")); +} + +#[gtk4::test] +fn unbind_disconnects_revision_handler() { + let grid = two_text_rows(); + let cell = grid.cell_of(2, 1); + let row = cell.row().expect("a bound row"); + let bound_renders = cell.render_count(); + row.bump_revision(); + assert_eq!(cell.render_count(), bound_renders + 1, "a bound cell did not redraw"); + + cell.unbind(); + row.bump_revision(); + + assert_eq!( + cell.render_count(), + bound_renders + 1, + "a cell drew again for a row it no longer shows" + ); +} + +#[gtk4::test] +fn a_cell_follows_only_the_row_it_is_bound_to() { + let cell = GridCell::new(paint()); + let (first, second) = (GridRow::new(RowData::draft(1)), GridRow::new(RowData::draft(2))); + + cell.bind(&first); + cell.bind(&second); + let drawn = cell.render_count(); + first.bump_revision(); + assert_eq!(cell.render_count(), drawn, "the cell drew the row it showed before"); + second.bump_revision(); + cell.unbind(); + second.bump_revision(); + + assert_eq!(cell.render_count(), drawn + 1); +} + +#[gtk4::test] +fn pending_edit_sets_description_on_grid_cell() { + let grid = two_text_rows(); + let cell = grid.cell_of(1, 1); + assert!(!described(&cell)); + + rename_first(&grid, "edited"); + assert!(described(&cell), "an edited cell said nothing"); + assert!(!described(&grid.cell_of(2, 1))); + + grid.grid.undo(); + assert!(!described(&cell), "an undone edit still read as modified"); +} + +#[gtk4::test] +fn null_cell_has_description_text_null_does_not() { + let shown = Grid::build( + GridSpec::results(vec![named("id", "integer", false), named("name", "text", false)]), + results(&[vec![Value::Null, text("NULL")]]), + ); + assert!(described(&shown.cell_at(0, 0)), "a NULL read the same as the text NULL"); + assert!(!described(&shown.cell_at(0, 1)), "the text NULL read as a NULL"); + drop(shown); + + let editable = Grid::open( + two_text_columns(), + vec![vec![Value::Int(1), Value::Null], vec![Value::Int(2), text("NULL")]], + ); + assert!(described(&editable.cell_of(1, 1))); + assert!(!described(&editable.cell_of(2, 1))); +} + +#[gtk4::test] +fn from_focus_finds_the_cell_below_a_focused_cell_widget_or_above_its_editor() { + let cell_widget = gtk::Box::new(gtk::Orientation::Horizontal, 0); + let cell = GridCell::new(paint()); + let editor = gtk::Text::new(); + editor.set_parent(&cell); + cell_widget.append(&cell); + + let below = GridCell::from_focus(cell_widget.upcast_ref()); + let above = GridCell::from_focus(editor.upcast_ref()); + + assert_eq!(below.as_ref(), Some(&cell)); + assert_eq!(above.as_ref(), Some(&cell)); + assert!(GridCell::from_focus(gtk::Label::new(None).upcast_ref()).is_none()); + editor.unparent(); +} + +#[gtk4::test] +fn an_unattached_cell_has_no_row() { + let cell = GridCell::new(paint()); + + assert!(cell.row().is_none()); + assert!(cell.position().is_none()); + assert!(!cell.is_editing()); +} + +/// A menu's target and an open editor are holds on a cell too. +#[gtk4::test] +fn cells_are_freed_with_the_grid() { + let grid = Grid::open( + vec![ + named("id", "integer", true), + named("name", "text", false), + named("flag", "boolean", false), + ], + vec![vec![Value::Int(7), text("a"), Value::Bool(true)]], + ); + for cell in grid.cells() { + assert!(grid.grid.open_cell_menu(&cell, None), "the menu opens"); + super::close_menus(&grid); + } + let name = grid.focus(0, 1); + assert!(grid.grid.edit().edit_cell(&name)); + let freed: Vec> = grid.cells().iter().map(ObjectExt::downgrade).collect(); + assert_eq!(freed.len(), 3); + // GTK 4.22 leaks a focused widget that is hidden and then unparented. + gtk::prelude::GtkWindowExt::set_focus(&grid.window, None::<>k::Widget>); + drain_main_context(); + + drop((grid, name)); + drain_main_context(); + + let alive = freed.iter().filter(|cell| cell.upgrade().is_some()).count(); + assert_eq!(alive, 0, "cells outlived their grid"); +} diff --git a/linux/crates/app/src/ui/data_grid/tests/columns.rs b/linux/crates/app/src/ui/data_grid/tests/columns.rs new file mode 100644 index 0000000000..16e1c2f09f --- /dev/null +++ b/linux/crates/app/src/ui/data_grid/tests/columns.rs @@ -0,0 +1,433 @@ +use std::rc::Rc; + +use gtk4 as gtk; +use gtk4::pango; +use gtk4::prelude::*; +use gtk4::subclass::prelude::ObjectSubclassIsExt; +use tablepro_core::{ColumnInfo, Value}; + +use super::{Layouts, close_menus, data_column, keyed, laid_out, settled_width, shown_width, step, text, untyped}; +use crate::test_support::{drain_main_context, wait_until}; +use crate::ui::data_grid::columns::ColumnPaint; +use crate::ui::data_grid::test_grid::{ + Grid, GridSpec, WAIT, named, persisted, results, two_text_columns, two_text_values, +}; + +fn two_text_rows() -> Grid { + Grid::open(two_text_columns(), two_text_values()) +} + +fn saved_to(layouts: &Layouts) -> Grid { + Grid::build( + GridSpec { + layout: Some(layouts.binding("t")), + ..GridSpec::table(two_text_columns()) + }, + keyed(&[1, 2]), + ) +} + +/// Whether the grid lines column `index` up as numbers. +fn numbers(grid: &Grid, index: usize) -> bool { + grid.grid + .imp() + .columns + .borrow() + .paint(index) + .is_some_and(|paint| paint.numbers.get()) +} + +/// How wide `text` is in tabular figures, which libadwaita's `numeric` +/// class draws. +fn tabular_width(widget: &impl IsA, text: &str) -> i32 { + let layout = widget.create_pango_layout(Some(text)); + let tabular = pango::AttrList::new(); + tabular.insert(pango::AttrFontFeatures::new("tnum")); + layout.set_attributes(Some(&tabular)); + layout.pixel_size().0 +} + +/// The text of `columns` in the rows on screen. +fn drawn(grid: &Grid, columns: &[usize]) -> Vec { + grid.cells() + .into_iter() + .filter(|cell| columns.contains(&cell.column()) && cell.is_mapped()) + .filter_map(|cell| cell.display().cloned()) + .collect() +} + +fn shows_whole(display: >k::Inscription) -> bool { + let text = display.text().unwrap_or_default(); + display.width() > 0 && tabular_width(display, &text) <= display.width() +} + +fn shop_columns(more_text: usize) -> Vec { + let mut columns = vec![ + named("id", "bigint", true), + named("sku", "text", false), + named("name", "text", false), + named("price", "numeric(20,4)", false), + named("stock", "integer", false), + named("updated_at", "text", false), + named("note", "text", false), + named("comment", "text", false), + ]; + columns.extend((0..more_text).map(|index| named(&format!("tag_{index}"), "text", false))); + columns +} + +const NUMBER_COLUMNS: [usize; 3] = [0, 3, 4]; + +fn shop_row(id: i64, price: &str, width: usize) -> Vec { + let mut row = vec![ + Value::Int(id), + text("CF-1001"), + text("Ethiopia Yirgacheffe, 250 g"), + Value::Decimal(price.parse().expect("a decimal")), + Value::Int(123_456), + text("2026-09-02 09:14:00+00"), + text("Ground for a paper filter"), + text("Ships in two days"), + ]; + row.resize(width, text("seasonal")); + row +} + +/// An automatic width measures every row GTK builds and shifts as the +/// rows scroll by (research 2.3). +#[gtk4::test] +fn every_column_opens_at_a_fixed_width_and_never_expands() { + let columns: Vec<_> = (0..9) + .map(|index| named(&format!("c{index}"), "text", index == 0)) + .collect(); + let row = (0..9).map(|index| text(&format!("v{index}"))).collect(); + + let grid = Grid::open(columns, vec![row]); + + for index in 0..9 { + let column = data_column(&grid, index); + assert!(column.fixed_width() > 0, "column {index} has no width of its own"); + assert!(!column.expands(), "column {index} expands"); + } +} + +#[gtk4::test] +fn a_width_set_from_the_keyboard_comes_back_at_that_width() { + let layouts = Layouts::new(); + let first = laid_out(saved_to(&layouts)); + first.focus(0, 1); + first.activate("widen-column"); + let saved = *layouts.saved("t").widths.get("name").expect("the step's width saved"); + drop(first); + + let grid = laid_out(saved_to(&layouts)); + + assert_eq!(data_column(&grid, 1).fixed_width(), saved); + wait_until(WAIT, || shown_width(&grid, 1) == saved).expect("the column drawn at its saved width"); +} + +/// GtkColumnView's header drag sets the fixed width and nothing else, as +/// this does. +#[gtk4::test] +fn a_header_drag_width_is_the_width_drawn_and_saved() { + let layouts = Layouts::new(); + let grid = laid_out(saved_to(&layouts)); + + data_column(&grid, 1).set_fixed_width(200); + + assert_eq!(layouts.saved("t").widths.get("name"), Some(&200)); + wait_until(WAIT, || shown_width(&grid, 1) == 200).expect("the column drawn at the dragged width"); +} + +#[gtk4::test] +fn a_header_drag_in_editor_results_is_the_width_drawn() { + let grid = laid_out(Grid::build( + GridSpec::results(two_text_columns()), + results(&two_text_values()), + )); + + data_column(&grid, 1).set_fixed_width(200); + + wait_until(WAIT, || shown_width(&grid, 1) == 200).expect("the column drawn at the dragged width"); +} + +/// A double click on a header's edge clears the width, which the grid +/// replaces with its own guess. +#[gtk4::test] +fn a_cleared_width_takes_the_grids_guess_and_is_forgotten() { + let layouts = Layouts::new(); + let grid = saved_to(&layouts); + let column = data_column(&grid, 1); + + column.set_fixed_width(200); + column.set_fixed_width(-1); + + wait_until(WAIT, || column.fixed_width() > 0).expect("the grid gives the column a width again"); + assert_ne!(column.fixed_width(), 200); + assert_eq!(layouts.saved("t").widths.get("name"), None); + drop((grid, column)); + let reopened = saved_to(&layouts); + assert_ne!(data_column(&reopened, 1).fixed_width(), 200); +} + +#[gtk4::test] +fn a_step_wider_starts_from_the_width_on_screen_and_is_saved() { + let layouts = Layouts::new(); + let grid = laid_out(saved_to(&layouts)); + grid.focus(0, 1); + let after = settled_width(&grid, 1) + step(&grid); + + grid.activate("widen-column"); + + assert_eq!(data_column(&grid, 1).fixed_width(), after); + assert_eq!(layouts.saved("t").widths.get("name"), Some(&after)); + wait_until(WAIT, || shown_width(&grid, 1) == after).expect("the column to be drawn wider"); +} + +#[gtk4::test] +fn a_step_narrower_shrinks_the_column_on_screen() { + let grid = laid_out(two_text_rows()); + grid.focus(0, 1); + let after = settled_width(&grid, 1) - step(&grid); + + grid.activate("narrow-column"); + + wait_until(WAIT, || shown_width(&grid, 1) == after).expect("the column to be drawn narrower"); +} + +#[gtk4::test] +fn a_column_is_never_narrower_than_a_step() { + let grid = two_text_rows(); + data_column(&grid, 1).set_fixed_width(step(&grid) + 1); + grid.focus(0, 1); + + grid.activate("narrow-column"); + grid.activate("narrow-column"); + + assert_eq!(data_column(&grid, 1).fixed_width(), step(&grid)); +} + +#[gtk4::test] +fn narrow_leaves_a_column_already_narrower_than_a_step() { + let grid = two_text_rows(); + let narrow = step(&grid) - 1; + data_column(&grid, 1).set_fixed_width(narrow); + grid.focus(0, 1); + + grid.activate("narrow-column"); + + assert_eq!( + data_column(&grid, 1).fixed_width(), + narrow, + "Narrow made the column wider" + ); +} + +/// The menu's Size to Fit, and the header's, give the column the grid's +/// guess and forget the width the user gave it. +#[gtk4::test] +fn size_to_fit_forgets_the_dragged_width() { + let layouts = Layouts::new(); + let grid = saved_to(&layouts); + let column = data_column(&grid, 1); + column.set_fixed_width(300); + assert_eq!(layouts.saved("t").widths.get("name"), Some(&300)); + + assert!(grid.grid.open_cell_menu(&grid.cell_of(1, 1), None)); + grid.activate("size-to-fit"); + close_menus(&grid); + + wait_until(WAIT, || column.fixed_width() > 0).expect("the guess"); + assert_ne!(column.fixed_width(), 300); + assert_eq!(layouts.saved("t").widths.get("name"), None); + + column.set_fixed_width(300); + grid.grid + .activate_action("grid.size-column-to-fit", Some(&1u32.to_variant())) + .expect("the header's Size to Fit"); + wait_until(WAIT, || column.fixed_width() > 0).expect("the guess"); + assert_ne!(column.fixed_width(), 300); + assert_eq!(layouts.saved("t").widths.get("name"), None); +} + +#[gtk4::test] +fn a_typed_column_is_numbers_by_its_type_alone() { + let grid = Grid::open( + vec![named("id", "integer", true), named("code", "text", false)], + vec![vec![Value::Int(12), text("12")], vec![Value::Int(7), text("7")]], + ); + + assert!(numbers(&grid, 0)); + assert!(!numbers(&grid, 1), "a text column of digits lined up as numbers"); +} + +#[gtk4::test] +fn an_untyped_column_is_numbers_when_every_value_is_one() { + let cases: [(Vec, bool); 7] = [ + ( + vec![ + text("6"), + text("-18.15"), + Value::Null, + text("1e-05"), + text("NaN"), + text("Infinity"), + ], + true, + ), + (vec![Value::Int(2), Value::Float64(0.5)], true), + (vec![text("6"), text("Coffee")], false), + (vec![text(" 6")], false), + (vec![text("")], false), + (vec![Value::Null], false), + (Vec::new(), false), + ]; + let columns = (0..cases.len()).map(|index| untyped(&format!("c{index}"))).collect(); + let rows: Vec> = (0..6) + .map(|row| { + cases + .iter() + .map(|(values, _)| values.get(row).cloned().unwrap_or(Value::Null)) + .collect() + }) + .collect(); + + let grid = Grid::build(GridSpec::results(columns), results(&rows)); + + for (index, (values, expected)) in cases.iter().enumerate() { + assert_eq!(numbers(&grid, index), *expected, "{values:?}"); + } +} + +/// A new page only widens a column, so the columns stay put from page +/// to page. +#[gtk4::test] +fn rows_added_later_widen_the_column_and_a_draft_changes_nothing() { + let grid = Grid::open( + vec![named("id", "integer", true), named("n", "integer", false)], + vec![vec![Value::Int(1), Value::Int(123_456)]], + ); + let opened = data_column(&grid, 1).fixed_width(); + + grid.grid + .append_rows(persisted(&[vec![Value::Int(2), Value::Int(-12_345_678)]])); + let widened = data_column(&grid, 1).fixed_width(); + grid.insert_draft(); + + assert!(widened > opened, "the widest row so far did not widen the column"); + assert_eq!(data_column(&grid, 1).fixed_width(), widened); +} + +#[gtk4::test] +fn an_untyped_column_stops_being_numbers_at_the_first_word_added() { + let grid = Grid::build(GridSpec::results(vec![untyped("tag")]), results(&[vec![text("6")]])); + assert!(numbers(&grid, 0)); + + grid.grid + .append_rows(results(&[vec![Value::Null], vec![text("Coffee")]])); + assert!(!numbers(&grid, 0)); + grid.grid.append_rows(results(&[vec![text("7")]])); + + assert!(!numbers(&grid, 0), "one word in the column is enough"); +} + +#[gtk4::test] +fn a_number_column_is_as_wide_as_its_widest_number() { + for more_text in [0, 2] { + let columns = shop_columns(more_text); + let width = columns.len(); + let rows: Vec> = (0..20) + .map(|offset| shop_row(100_000_001 + offset, "123456789012.3456", width)) + .collect(); + let grid = Grid::open(columns, rows); + + let whole = wait_until(WAIT, || { + let numbers = drawn(&grid, &NUMBER_COLUMNS); + !numbers.is_empty() && numbers.iter().all(shows_whole) + }); + + assert!(whole.is_ok(), "a number was cut among {width} columns"); + } +} + +/// A count of average characters runs short of capitals and digits. +#[gtk4::test] +fn a_short_code_in_a_text_column_shows_whole() { + let columns = shop_columns(0); + let width = columns.len(); + let grid = Grid::open(columns, (1..=5).map(|id| shop_row(id, "1.5", width)).collect()); + + let whole = wait_until(WAIT, || { + let codes = drawn(&grid, &[1]); + !codes.is_empty() + && codes.iter().all(|display| { + let text = display.text().unwrap_or_default(); + display.width() > 0 && display.create_pango_layout(Some(&text)).pixel_size().0 <= display.width() + }) + }); + + assert!(whole.is_ok(), "CF-1001 was cut"); +} + +#[gtk4::test] +fn adding_a_draft_keeps_the_width_the_page_measured() { + let columns = shop_columns(0); + let width = columns.len(); + let mut rows: Vec> = (1..=20).map(|id| shop_row(id, "1.5", width)).collect(); + if let Some(last) = rows.last_mut() { + last[0] = Value::Int(100_000_000_001); + } + let grid = Grid::open(columns, rows); + let measured = data_column(&grid, 0).fixed_width(); + + grid.insert_draft(); + + assert_eq!(data_column(&grid, 0).fixed_width(), measured); + let wide = wait_until(WAIT, || { + let ids = drawn(&grid, &[0]); + !ids.is_empty() + && ids + .iter() + .all(|display| display.width() >= tabular_width(display, "100000000001")) + }); + assert!(wide.is_ok(), "the draft narrowed the column its rows had widened"); +} + +#[gtk4::test] +fn columns_are_freed_with_the_grid() { + let grid = two_text_rows(); + let column = data_column(&grid, 1).downgrade(); + let view = grid.grid.column_view().downgrade(); + + drop(grid); + drain_main_context(); + + assert!(column.upgrade().is_none(), "the column outlived its grid"); + assert!(view.upgrade().is_none(), "the column view outlived its grid"); +} + +/// GTK 4.22 leaks a reference to the column after the view's focus +/// column as the view removes its columns from the first, and that +/// column's factory holds what its cells draw from. +#[gtk4::test] +fn a_column_left_behind_by_its_view_lets_go_of_the_grid() { + let grid = two_text_rows(); + grid.focus(0, 0); + let paints: Vec> = (0..2) + .filter_map(|index| grid.grid.imp().columns.borrow().paint(index)) + .map(|paint| Rc::downgrade(&paint)) + .collect(); + assert_eq!(paints.len(), 2); + // GTK 4.22 leaks a focused widget that is hidden and then unparented; + // the view keeps its focus column. + gtk::prelude::GtkWindowExt::set_focus(&grid.window, None::<>k::Widget>); + drain_main_context(); + + drop(grid); + drain_main_context(); + + assert!( + paints.iter().all(|paint| paint.upgrade().is_none()), + "a column the view left behind kept the grid's columns" + ); +} diff --git a/linux/crates/app/src/ui/data_grid/tests/gutter.rs b/linux/crates/app/src/ui/data_grid/tests/gutter.rs new file mode 100644 index 0000000000..fa79974896 --- /dev/null +++ b/linux/crates/app/src/ui/data_grid/tests/gutter.rs @@ -0,0 +1,223 @@ +use std::collections::BTreeMap; + +use gtk4 as gtk; +use gtk4::prelude::*; +use tablepro_core::edit::CellInput; + +use super::{Layouts, delete_saved, described, draft_cell, keyed, rename_first, row_widget, sorts, text}; +use crate::services::tracker_edit::TrackerEdit; +use crate::test_support::{after_next_frame, descendants, drain_main_context}; +use crate::ui::data_grid::GridCell; +use crate::ui::data_grid::test_grid::{Grid, GridSpec, key, results, two_text_columns, two_text_values}; +use crate::ui::icons; + +fn two_text_rows() -> Grid { + Grid::open(two_text_columns(), two_text_values()) +} + +/// The gutter cell in `cell`'s row: its pending icon and its number. +fn gutter(cell: &GridCell) -> (gtk::Image, gtk::Inscription) { + let content = descendants(&row_widget(cell)) + .into_iter() + .find_map(|widget| widget.downcast::().ok()) + .expect("the row's gutter cell"); + ( + content.first_child().and_downcast().expect("the gutter's icon"), + content.last_child().and_downcast().expect("the gutter's number"), + ) +} + +fn number(cell: &GridCell) -> Option { + gutter(cell).1.text().map(|number| number.to_string()) +} + +/// The icon the gutter shows for `cell`'s row and the text it names it by. +fn state(cell: &GridCell) -> (Option, Option) { + let (icon, _) = gutter(cell); + let name = icon + .is_visible() + .then(|| icon.icon_name()) + .flatten() + .map(|name| name.to_string()); + (name, icon.tooltip_text().map(|text| text.to_string())) +} + +fn shown(icon: &str, text: &str) -> (Option, Option) { + (Some(icon.to_owned()), Some(text.to_owned())) +} + +#[gtk4::test] +fn row_numbers_count_from_the_base_under_the_drafts() { + let grid = two_text_rows(); + let numbers = |grid: &Grid| [1, 2].map(|id| number(&grid.cell_of(id, 1))); + assert_eq!(numbers(&grid), [Some("1".to_owned()), Some("2".to_owned())]); + + grid.grid.replace_rows(keyed(&[1, 2]), 100); + drain_main_context(); + assert_eq!(numbers(&grid), [Some("101".to_owned()), Some("102".to_owned())]); + + grid.insert_draft(); + let draft = draft_cell(&grid); + after_next_frame(&grid.window); + + assert_eq!(number(&draft).unwrap_or_default(), "", "a draft has no number"); + assert_eq!( + numbers(&grid), + [Some("101".to_owned()), Some("102".to_owned())], + "a draft on top moved the numbers" + ); +} + +#[gtk4::test] +fn a_results_grid_numbers_its_rows_too() { + let grid = Grid::build(GridSpec::results(two_text_columns()), results(&two_text_values())); + let first = grid + .grid + .column_view() + .columns() + .item(0) + .and_downcast::() + .expect("the first column"); + + assert_eq!(first.id().as_deref(), Some("row-numbers")); + assert_eq!(number(&grid.cell_at(1, 1)).as_deref(), Some("2")); +} + +/// The row keeps the error icon of a failed save until it is edited. +#[gtk4::test] +fn the_gutter_shows_each_pending_state() { + let grid = two_text_rows(); + let (icon, _) = gutter(&grid.cell_of(1, 1)); + assert_eq!(state(&grid.cell_of(1, 1)), (None, None)); + assert!(gtk::test_accessible_has_role(&icon, gtk::AccessibleRole::Presentation)); + assert!( + !icon + .parent() + .and_then(|content| content.parent()) + .is_some_and(|cell| cell.is_focusable()), + "the gutter took the focus a data cell should" + ); + + rename_first(&grid, "edited"); + assert_eq!(state(&grid.cell_of(1, 1)), shown(icons::DOCUMENT_EDIT, "Modified")); + grid.grid.apply_delta(&grid.tracker.mark_error_row(key(1))); + assert_eq!(state(&grid.cell_of(1, 1)), shown(icons::DIALOG_ERROR, "Not saved")); + rename_first(&grid, "again"); + assert_eq!(state(&grid.cell_of(1, 1)), shown(icons::DOCUMENT_EDIT, "Modified")); + + delete_saved(&grid, 2, "B"); + assert_eq!( + state(&grid.cell_of(2, 1)), + shown(icons::EDIT_DELETE, "Marked for deletion") + ); + + grid.insert_draft(); + assert_eq!(state(&draft_cell(&grid)), shown(icons::LIST_ADD, "New row")); + + grid.grid.discard(); + assert_eq!(state(&grid.cell_of(1, 1)), (None, None)); + assert_eq!(state(&grid.cell_of(2, 1)), (None, None)); +} + +#[gtk4::test] +fn pending_delete_sets_row_widget_description() { + let grid = two_text_rows(); + let row = row_widget(&grid.cell_of(1, 1)); + assert!(gtk::test_accessible_has_role(&row, gtk::AccessibleRole::Row)); + assert!(!described(&row)); + assert!(!row.is_focusable(), "a row took the focus a cell should"); + + delete_saved(&grid, 1, "A"); + assert!(described(&row), "a row marked for deletion said nothing"); + + grid.grid.undo(); + assert!(!described(&row), "a restored row still read as marked"); + rename_first(&grid, "edited"); + assert!(!described(&row), "an edited row repeats what its edited cells say"); + grid.grid.apply_delta(&grid.tracker.mark_error_row(key(1))); + assert!(described(&row), "a row a save failed on said nothing"); +} + +/// A new row's label says so, which a description would only repeat. +#[gtk4::test] +fn a_new_row_is_labelled_and_neither_it_nor_its_cells_described() { + let grid = two_text_rows(); + let draft_id = grid.insert_draft(); + let draft = draft_cell(&grid); + let row = row_widget(&draft); + // A NULL is named whatever the row, so the cell holds a value. + grid.grid.apply_delta(&grid.tracker.apply(TrackerEdit::DraftCell { + draft_id, + col: draft.column(), + input: CellInput::Value(text("typed")), + })); + + assert!(gtk::test_accessible_has_property(&row, gtk::AccessibleProperty::Label)); + assert!(!described(&row)); + assert!(!described(&draft)); +} + +/// The second row, since the list keeps the first row's widgets for its +/// focus. +#[gtk4::test] +fn an_unbound_gutter_cell_stops_following_its_row() { + let grid = two_text_rows(); + let (icon, _) = gutter(&grid.cell_of(2, 1)); + let row = grid.cell_of(2, 1).row().expect("a bound row"); + + grid.grid.replace_rows(keyed(&[1]), 0); + drain_main_context(); + let _ = grid.tracker.apply(TrackerEdit::Cell { + row_key: key(2), + col: 1, + original: text("B"), + value: text("edited"), + }); + row.bump_revision(); + + assert_eq!(icon.icon_name(), None, "a recycled icon drew a row it no longer shows"); +} + +/// The gutter is a column like the others for GTK, and none for the +/// layout, the sort or the data columns' numbering. +#[gtk4::test] +fn the_gutter_neither_sorts_nor_saves_its_width() { + let layouts = Layouts::new(); + let grid = Grid::build( + GridSpec { + layout: Some(layouts.binding("t")), + ..GridSpec::table(two_text_columns()) + }, + keyed(&[1]), + ); + let view = grid.grid.column_view(); + let gutter = view + .columns() + .item(0) + .and_downcast::() + .expect("the gutter"); + let titles: Vec = (0..2) + .filter_map(|index| grid.grid.data_view_column(index)) + .filter_map(|column| column.title().map(|title| title.to_string())) + .collect(); + + assert_eq!(gutter.id().as_deref(), Some("row-numbers")); + assert_eq!(gutter.title().as_deref(), Some("#")); + assert!(!gutter.is_resizable()); + assert!(gutter.sorter().is_none()); + assert!(gutter.fixed_width() > 0); + assert_eq!(titles, ["id", "name"]); + + gutter.set_fixed_width(60); + super::data_column(&grid, 1).set_fixed_width(200); + drain_main_context(); + assert_eq!(layouts.saved("t").widths, BTreeMap::from([("name".to_owned(), 200)])); + + view.sort_by_column(Some(&super::data_column(&grid, 0)), gtk::SortType::Descending); + drain_main_context(); + assert_eq!( + sorts(&grid), + [Some((0, false))], + "a sort by the first data column named another" + ); +} diff --git a/linux/crates/app/src/ui/data_grid/tests/keyboard.rs b/linux/crates/app/src/ui/data_grid/tests/keyboard.rs new file mode 100644 index 0000000000..c1b72aa289 --- /dev/null +++ b/linux/crates/app/src/ui/data_grid/tests/keyboard.rs @@ -0,0 +1,331 @@ +use std::collections::HashSet; + +use gtk4 as gtk; +use gtk4::gdk; +use gtk4::prelude::*; +use gtk4::subclass::prelude::ObjectSubclassIsExt; +use tablepro_core::Value; +use tablepro_core::edit::CellInput; + +use super::{details_shown, none, pane, press, text}; +use crate::application::{ACCELERATORS, AcceleratorScope, ShortcutOwner}; +use crate::test_support::shortcut_keys::{ + Keystroke, class_binding_keys, class_keys_on_focus_paths, controller_keys, keystroke, keystrokes_with_twins, + owner_controller, +}; +use crate::test_support::wait_until; +use crate::test_support::{descendants, drain_main_context}; +use crate::ui::data_grid::test_grid::{Grid, GridSpec, WAIT, key, results, two_text_columns, two_text_values}; + +fn two_text_rows() -> Grid { + Grid::open(two_text_columns(), two_text_values()) +} + +/// Every key the table gives `owner`, with the keypad twins it binds. +fn table_keys(owner: ShortcutOwner) -> HashSet { + ACCELERATORS + .iter() + .filter(|entry| matches!(entry.scope, AcceleratorScope::Local { owner: of, .. } if of == owner)) + .flat_map(|entry| entry.triggers.iter()) + .flat_map(|accel| keystrokes_with_twins(accel)) + .collect() +} + +fn value_text(grid: &Grid) -> String { + let buffer = pane(grid).imp().value_buffer.get(); + buffer.text(&buffer.start_iter(), &buffer.end_iter(), true).to_string() +} + +fn long_text() -> String { + format!("tpyo{}", "x".repeat(60_000)) +} + +#[gtk4::test] +fn the_grid_binds_the_keys_the_table_gives_it() { + let grid = two_text_rows(); + let view = grid.grid.column_view(); + let on_grid: gtk::Widget = grid.grid.clone().upcast(); + + for (owner, widget, phase) in [ + ( + ShortcutOwner::GridCell, + view.clone().upcast::(), + gtk::PropagationPhase::Capture, + ), + (ShortcutOwner::Grid, on_grid, gtk::PropagationPhase::Bubble), + ] { + let controller = owner_controller(&widget, owner).expect("the grid's keys"); + let bound: HashSet = controller_keys(&controller).into_iter().collect(); + assert_eq!(controller.propagation_phase(), phase, "{owner:?}"); + assert_eq!(bound, table_keys(owner), "{owner:?}"); + } + let cell_keys = owner_controller(&view, ShortcutOwner::GridCell).map(|controller| controller_keys(&controller)); + assert!( + cell_keys.is_some_and(|keys| keys.contains(&(gdk::Key::KP_Enter, none()))), + "the keypad Enter was lost" + ); +} + +#[gtk4::test] +fn grid_shortcut_controller_runs_in_capture_phase_above_focus() { + let grid = two_text_rows(); + let controller = owner_controller(&grid.grid.column_view(), ShortcutOwner::GridCell).expect("the grid's cell keys"); + + grid.focus(1, 1); + + assert_eq!(controller.propagation_phase(), gtk::PropagationPhase::Capture); + assert_eq!(controller.scope(), gtk::ShortcutScope::Local); + let focus = gtk::prelude::GtkWindowExt::focus(&grid.window).expect("a focused widget"); + let owner = controller.widget().expect("the controller is on a widget"); + assert!( + focus.is_ancestor(&owner), + "a key on the focused cell does not pass the controller" + ); + assert_eq!(focus.css_name(), "cell", "the focus is not on a cell"); +} + +/// A bubble-phase key on the column view reaches it only when no class +/// on the way up from the focused cell binds it first. +#[gtk4::test] +fn grid_keys_are_not_class_bindings_inside_the_grid() { + let grid = two_text_rows(); + grid.focus(0, 1); + + let class_keys = class_keys_on_focus_paths(&grid.grid.column_view(), |widget| widget.is::()); + + assert!( + class_keys.contains(&keystroke("a")), + "the list view's own bindings were not found, so the check reads nothing" + ); + for entry in ACCELERATORS { + let AcceleratorScope::Local { + owner: ShortcutOwner::Grid, + phase, + } = entry.scope + else { + continue; + }; + assert_eq!(phase, gtk::PropagationPhase::Bubble, "{}", entry.action); + for accel in entry.triggers { + assert!( + !class_keys.contains(&keystroke(accel)), + "{accel} of {} never reaches the column view", + entry.action + ); + } + } +} + +/// The table lists these as GtkListBase's own, so the list must bind +/// them. +#[gtk4::test] +fn stock_list_keys_are_list_view_bindings() { + let grid = two_text_rows(); + let list_view = descendants(&grid.grid.column_view()) + .into_iter() + .find(|widget| widget.is::()) + .expect("the column view's list"); + let bound = class_binding_keys(&list_view); + + for entry in ACCELERATORS.iter().filter(|entry| { + entry.scope == AcceleratorScope::Stock + && entry.action.starts_with("list.") + && entry.triggers.iter().all(|accel| !accel.contains("Pointer")) + }) { + for accel in entry.triggers { + assert!(bound.contains(&keystroke(accel)), "the list does not bind {accel}"); + } + } +} + +/// The grid lets Return go on a locked cell, and the list turns it, as +/// it turns a double click, into `activate`. +#[gtk4::test] +fn return_or_a_double_click_on_a_locked_cell_opens_its_field_in_the_detail_pane() { + let table = two_text_rows(); + let shown = Grid::build(GridSpec::results(two_text_columns()), results(&two_text_values())); + + for (grid, position, column) in [(&table, 1, 0), (&shown, 0, 1)] { + grid.focus(position, column); + assert!( + !press(grid, gdk::Key::Return, none()), + "the grid's Return took a locked cell" + ); + + grid.grid.column_view().emit_by_name::<()>("activate", &[&position]); + drain_main_context(); + + assert!(details_shown(grid), "the detail pane did not open"); + assert_eq!( + pane(grid).imp().field.get(), + Some(column), + "the pane shows another field" + ); + assert!(grid.grid.edit().open_text().is_none()); + } +} + +#[gtk4::test] +fn f2_on_a_locked_cell_does_nothing() { + let grid = two_text_rows(); + grid.focus(0, 0); + + assert!(!press(&grid, gdk::Key::F2, none()), "F2 took a locked cell"); + + assert!(grid.grid.edit().open_text().is_none()); + assert!(!details_shown(&grid), "F2 opened the detail pane"); + assert_eq!(grid.tracker.pending_count(), 0); +} + +#[gtk4::test] +fn editing_a_long_text_starts_from_the_whole_value() { + let grid = Grid::open(two_text_columns(), vec![vec![Value::Int(1), text(&long_text())]]); + let cell = grid.focus(0, 1); + + assert!(grid.grid.edit().edit_cell(&cell)); + + assert!( + grid.grid.edit().open_text().is_none(), + "a long text opened in a one-line field" + ); + wait_until(WAIT, || value_text(&grid).len() == long_text().len()).expect("the pane opened on the cut text"); + assert_eq!(value_text(&grid), long_text()); +} + +#[gtk4::test] +fn a_long_text_saved_from_its_editor_sends_the_whole_value() { + let grid = Grid::open(two_text_columns(), vec![vec![Value::Int(1), text(&long_text())]]); + let cell = grid.focus(0, 1); + assert!(grid.grid.edit().edit_cell(&cell)); + wait_until(WAIT, || value_text(&grid).len() == long_text().len()).expect("the pane holds the value"); + let fixed = long_text().replacen("tpyo", "typo", 1); + + pane(&grid).imp().value_buffer.set_text(&fixed); + assert!(grid.grid.is_editing(), "typing in the pane opened no edit"); + grid.grid.commit_edit(); + + assert_eq!( + grid.tracker.cell_overlay(&key(1), 1).pending, + Some(CellInput::Value(text(&fixed))) + ); + assert!( + Grid::display_text(&grid.cell_of(1, 1)).len() < 20_000, + "the cell drew the whole value" + ); +} + +/// These press real keys on the X display every test shares; nextest runs +/// them with no other test beside them. +mod real_keys { + use gtk4::gdk; + use gtk4::prelude::*; + + use super::super::{data_column, laid_out, none, settled_width, shown_width, step}; + use super::two_text_rows; + use crate::test_support::{KeyInjector, wait_until}; + use crate::ui::data_grid::test_grid::{Grid, WAIT}; + + const SHIFT_ALT: gdk::ModifierType = gdk::ModifierType::SHIFT_MASK.union(gdk::ModifierType::ALT_MASK); + + fn keys_for(grid: &Grid) -> KeyInjector { + KeyInjector::focus_only(&grid.window); + wait_until(WAIT, || grid.window.is_active()).expect("the window to take the focus"); + KeyInjector::connect().expect("an X11 display") + } + + fn editor_text(grid: &Grid) -> Option { + grid.grid.edit().open_text().map(|editor| editor.text().to_string()) + } + + /// The key reaches the editor through its input method, so a dead + /// key or a composed character starts there too. + #[gtk4::test] + fn typing_on_a_cell_opens_its_editor_with_the_typed_key() { + let grid = two_text_rows(); + let keys = keys_for(&grid); + grid.focus(1, 1); + + keys.press(gdk::Key::x, none()).expect("the key press"); + wait_until(WAIT, || editor_text(&grid).as_deref() == Some("x")).expect("the key to land in a new editor"); + keys.press(gdk::Key::Escape, none()).expect("the key press"); + + wait_until(WAIT, || editor_text(&grid).is_none()).expect("Escape to close the editor"); + assert_eq!(grid.tracker.pending_count(), 0); + assert_eq!(Grid::display_text(&grid.cell_of(2, 1)), "B"); + } + + /// Space on a cell that is no boolean is the list's, and a key with + /// Ctrl is a command, never text. + #[gtk4::test] + fn space_and_ctrl_keys_start_no_edit() { + let grid = laid_out(two_text_rows()); + let keys = keys_for(&grid); + grid.focus(0, 1); + let wider = settled_width(&grid, 1) + step(&grid); + + for (key, modifiers) in [ + (gdk::Key::space, none()), + (gdk::Key::space, gdk::ModifierType::CONTROL_MASK), + (gdk::Key::x, gdk::ModifierType::CONTROL_MASK), + ] { + keys.press(key, modifiers).expect("the key press"); + } + // The keys arrive in order, so once the column widens the ones + // before it have run. + keys.press(gdk::Key::Right, SHIFT_ALT).expect("the key press"); + wait_until(WAIT, || shown_width(&grid, 1) == wider).expect("the keys to arrive"); + + assert_eq!(editor_text(&grid), None, "a key started an edit"); + assert_eq!(grid.tracker.pending_count(), 0); + } + + #[gtk4::test] + fn shift_alt_right_and_left_resize_the_focused_cells_column() { + let grid = laid_out(two_text_rows()); + let keys = keys_for(&grid); + grid.focus(0, 1); + let id_width = data_column(&grid, 0).fixed_width(); + let wider = settled_width(&grid, 1) + step(&grid); + + keys.press(gdk::Key::Right, SHIFT_ALT).expect("the key press"); + wait_until(WAIT, || shown_width(&grid, 1) == wider).expect("Shift+Alt+Right to widen the column"); + keys.press(gdk::Key::Left, SHIFT_ALT).expect("the key press"); + keys.press(gdk::Key::Left, SHIFT_ALT).expect("the key press"); + + let narrower = wider - 2 * step(&grid); + wait_until(WAIT, || shown_width(&grid, 1) == narrower).expect("Shift+Alt+Left to narrow the column"); + assert_eq!( + grid.focused_cell().map(|cell| cell.column()), + Some(1), + "the keys moved the focus" + ); + assert_eq!( + data_column(&grid, 0).fixed_width(), + id_width, + "the keys resized another column" + ); + } + + #[gtk4::test] + fn an_open_editor_keeps_shift_alt_arrows() { + let grid = laid_out(two_text_rows()); + let keys = keys_for(&grid); + let cell = grid.focus(0, 1); + let width = data_column(&grid, 1).fixed_width(); + assert!(grid.grid.edit().edit_cell(&cell)); + let editor = grid.grid.edit().open_text().expect("the cell's editor"); + editor.set_text("typed"); + wait_until(WAIT, || editor.has_focus()).expect("the editor to take the focus"); + + keys.press(gdk::Key::Right, SHIFT_ALT).expect("the key press"); + keys.press(gdk::Key::z, none()).expect("the key press"); + wait_until(WAIT, || editor.text().contains('z')).expect("the keys to reach the editor"); + + assert_eq!( + data_column(&grid, 1).fixed_width(), + width, + "the key resized the column being edited" + ); + assert!(grid.grid.edit().open_text().is_some(), "the key ended the edit"); + } +} diff --git a/linux/crates/app/src/ui/data_grid/tests/layout.rs b/linux/crates/app/src/ui/data_grid/tests/layout.rs new file mode 100644 index 0000000000..e3320609a5 --- /dev/null +++ b/linux/crates/app/src/ui/data_grid/tests/layout.rs @@ -0,0 +1,147 @@ +use std::collections::{BTreeMap, BTreeSet}; + +use gtk4 as gtk; +use gtk4::prelude::*; +use tablepro_core::{ColumnInfo, Value}; + +use super::{Layouts, data_column, text}; +use crate::services::grid::{ColumnLayout, LayoutSlot}; +use crate::test_support::drain_main_context; +use crate::ui::data_grid::test_grid::{Grid, GridSpec, named, persisted, two_text_columns, two_text_values}; + +fn users() -> Vec { + vec![ + named("id", "integer", true), + named("name", "text", false), + named("email", "varchar(80)", false), + ] +} + +fn open_users(layouts: &Layouts, table: &str) -> Grid { + Grid::build( + GridSpec { + layout: Some(layouts.binding(table)), + ..GridSpec::table(users()) + }, + persisted(&[ + vec![Value::Int(1), text("Ada"), text("ada@example.com")], + vec![Value::Int(2), text("Grace"), text("grace@example.com")], + ]), + ) +} + +fn position_in_view(view: >k::ColumnView, column: >k::ColumnViewColumn) -> u32 { + (0..view.columns().n_items()) + .find(|position| view.columns().item(*position).as_ref() == Some(column.upcast_ref())) + .expect("the column is in the view") +} + +fn slot(name: &str) -> LayoutSlot { + LayoutSlot::Column(name.to_owned()) +} + +#[gtk4::test] +fn a_moved_hidden_and_resized_column_comes_back_in_a_new_grid() { + let layouts = Layouts::new(); + let first = open_users(&layouts, "users"); + let view = first.grid.column_view(); + let name = data_column(&first, 1); + + view.insert_column(position_in_view(&view, &name), &data_column(&first, 2)); + first.grid.set_column_visible(0, false); + name.set_fixed_width(321); + drain_main_context(); + assert_eq!(first.grid.shown_columns(), [2, 1]); + drop((first, view, name)); + + let again = open_users(&layouts, "users"); + let other = open_users(&layouts, "orders"); + + assert_eq!( + again.grid.shown_columns(), + [2, 1], + "the order or the hidden column was lost" + ); + assert!(!data_column(&again, 0).is_visible()); + assert_eq!(data_column(&again, 1).fixed_width(), 321); + assert_eq!(other.grid.shown_columns(), [0, 1, 2], "another table took the layout"); + assert_ne!(data_column(&other, 1).fixed_width(), 321); +} + +/// Two tabs on one table: each saves only what it changed. +#[gtk4::test] +fn two_grids_on_one_table_keep_each_others_changes() { + let layouts = Layouts::new(); + let first = open_users(&layouts, "users"); + let second = open_users(&layouts, "users"); + + data_column(&first, 1).set_fixed_width(321); + drain_main_context(); + data_column(&second, 2).set_fixed_width(222); + second.grid.set_column_visible(0, false); + drain_main_context(); + + let saved = layouts.saved("users"); + assert_eq!(saved.widths.get("name"), Some(&321), "the first tab's width was lost"); + assert_eq!(saved.widths.get("email"), Some(&222)); + assert!(saved.hidden.contains("id")); +} + +/// A read without a column keeps what the user gave it for when it is +/// back. +#[gtk4::test] +fn a_column_missing_from_one_read_keeps_its_entry() { + let layouts = Layouts::new(); + let stored = ColumnLayout { + order: vec![LayoutSlot::RowNumbers, slot("id"), slot("email"), slot("name")], + hidden: BTreeSet::from(["email".to_owned()]), + widths: BTreeMap::from([("email".to_owned(), 222), ("name".to_owned(), 150)]), + }; + layouts + .store + .save(layouts.connection_id, Some("public"), "users", &stored); + let without_email = Grid::build( + GridSpec { + layout: Some(layouts.binding("users")), + ..GridSpec::table(two_text_columns()) + }, + persisted(&two_text_values()), + ); + assert_eq!(data_column(&without_email, 1).fixed_width(), 150); + + data_column(&without_email, 1).set_fixed_width(180); + drain_main_context(); + + assert_eq!( + layouts.saved("users"), + ColumnLayout { + widths: BTreeMap::from([("email".to_owned(), 222), ("name".to_owned(), 180)]), + ..stored + } + ); + drop(without_email); + let all = open_users(&layouts, "users"); + assert_eq!(all.grid.shown_columns(), [0, 1]); + assert!(!data_column(&all, 2).is_visible()); + assert_eq!(data_column(&all, 2).fixed_width(), 222); +} + +/// A grid of no columns has nothing to focus and no header to bring one +/// back from. +#[gtk4::test] +fn the_last_shown_column_cannot_be_hidden() { + let grid = Grid::open(two_text_columns(), two_text_values()); + + grid.grid.set_column_visible(0, false); + grid.grid.set_column_visible(1, false); + assert_eq!(grid.grid.shown_columns(), [1]); + grid.grid + .activate_action("grid.hide-column-at", Some(&1u32.to_variant())) + .expect("the header's Hide Column"); + assert_eq!(grid.grid.shown_columns(), [1], "the header hid the last column"); + grid.focus(0, 1); + grid.activate("hide-column"); + + assert_eq!(grid.grid.shown_columns(), [1], "the cell menu hid the last column"); + assert!(data_column(&grid, 1).is_visible()); +} diff --git a/linux/crates/app/src/ui/data_grid/tests/menus.rs b/linux/crates/app/src/ui/data_grid/tests/menus.rs new file mode 100644 index 0000000000..375600132d --- /dev/null +++ b/linux/crates/app/src/ui/data_grid/tests/menus.rs @@ -0,0 +1,296 @@ +use gtk4 as gtk; +use gtk4::prelude::*; +use gtk4::{gdk, glib, graphene}; +use tablepro_core::Value; +use tablepro_core::edit::CellInput; + +use super::{ + accel_of, action_names, cell_menu, close_menus, data_column, keyed, menu_actions, none, press, submenu, text, +}; +use crate::test_support::{FocusThief, after_next_frame, drain_main_context, wait_until}; +use crate::ui::data_grid::test_grid::{ + Grid, GridSpec, WAIT, focus_in_grid, key, named, results, two_text_columns, two_text_values, +}; +use crate::ui::data_grid::{GridRow, GridSorting}; + +fn two_text_rows() -> Grid { + Grid::open(two_text_columns(), two_text_values()) +} + +fn people() -> Grid { + Grid::open( + vec![ + named("id", "integer", true), + named("name", "text", false), + named("email", "text", false), + ], + vec![ + vec![Value::Int(1), text("Ada"), text("ada@example.com")], + vec![Value::Int(2), text("Grace"), text("grace@example.com")], + ], + ) +} + +fn shown_menu(grid: &Grid) -> gtk::PopoverMenu { + let menu = grid + .grid + .menus() + .map(|menus| menus.cell.clone()) + .expect("the cell menu"); + wait_until(WAIT, || menu.is_visible()).expect("the cell menu opens"); + menu +} + +/// The cell menu's item for a header item, which names its column. +fn cell_menu_counterpart(header_action: &str) -> &str { + match header_action { + "grid.sort-column-ascending" => "grid.sort-ascending", + "grid.sort-column-descending" => "grid.sort-descending", + "grid.hide-column-at" => "grid.hide-column", + "grid.size-column-to-fit" => "grid.size-to-fit", + "grid.copy-column-name-at" => "grid.copy-column-name", + other => other, + } +} + +/// The header takes no keyboard focus, so the cell menu offers what its +/// menu does, for the cell's column. +#[gtk4::test] +fn every_header_action_is_in_the_cell_menus_column_submenu() { + let grid = people(); + let header = data_column(&grid, 1).header_menu().expect("the header's menu"); + let menu = cell_menu(&grid); + let in_column = action_names(&submenu(&menu, "Column").expect("the cell menu's Column submenu")); + let in_cell = action_names(&menu); + let header_actions = menu_actions(&header); + + assert_eq!(header_actions.len(), 7, "{header_actions:?}"); + for (action, column) in &header_actions { + assert!(column.is_none_or(|column| column == 1), "{action} names another column"); + let counterpart = cell_menu_counterpart(action.as_str()); + // Copy Column Name sits with the other copies. + let offered = match counterpart { + "grid.copy-column-name" => &in_cell, + _ => &in_column, + }; + assert!( + offered.iter().any(|item| item == counterpart), + "{action} has no {counterpart} in the cell menu" + ); + } +} + +#[gtk4::test] +fn the_cell_menus_column_items_act_on_its_cell_not_the_focused_one() { + let grid = people(); + grid.focus(0, 1); + data_column(&grid, 2).set_fixed_width(300); + + assert!(grid.grid.open_cell_menu(&grid.cell_of(2, 2), None)); + grid.activate("size-to-fit"); + grid.activate("hide-column"); + close_menus(&grid); + + assert_eq!(grid.grid.shown_columns(), [0, 1], "Hide Column hid another column"); + wait_until(WAIT, || data_column(&grid, 2).fixed_width() > 0).expect("Size to Fit gives a width"); + assert_ne!( + data_column(&grid, 2).fixed_width(), + 300, + "Size to Fit sized another column" + ); +} + +#[gtk4::test] +fn a_grid_in_the_engines_order_offers_no_sort() { + let sorted = two_text_rows(); + let unsorted = Grid::build( + GridSpec { + sorting: GridSorting::None, + ..GridSpec::table(two_text_columns()) + }, + keyed(&[1, 2]), + ); + let offered = |grid: &Grid| { + let mut actions = action_names(&cell_menu(grid)); + actions.extend(action_names( + &data_column(grid, 1).header_menu().expect("the header's menu"), + )); + actions + }; + + for action in [ + "grid.sort-ascending", + "grid.sort-descending", + "grid.clear-sort", + "grid.sort-column-ascending", + "grid.sort-column-descending", + ] { + assert!(offered(&sorted).iter().any(|item| item == action), "{action}"); + assert!(!offered(&unsorted).iter().any(|item| item == action), "{action}"); + } + assert!(offered(&unsorted).iter().any(|item| item == "grid.edit")); +} + +#[gtk4::test] +fn the_cell_menu_shows_the_cell_keys() { + let grid = two_text_rows(); + let menu = cell_menu(&grid); + + assert_eq!( + accel_of(&menu, "grid.set-null"), + Some(Some("BackSpace".to_owned())) + ); + assert_eq!(accel_of(&menu, "grid.edit"), Some(Some("F2".to_owned()))); + assert_eq!(accel_of(&menu, "grid.set-empty"), Some(None)); +} + +#[gtk4::test] +fn shift_f10_opens_menu_anchored_to_focused_cell() { + let grid = two_text_rows(); + let cell = grid.focus(1, 1); + let view = grid.grid.column_view(); + wait_until(WAIT, || { + cell.compute_bounds(&view) + .is_some_and(|bounds| bounds.width() > 0.0 && bounds.y() > 0.0) + }) + .expect("the cell is laid out"); + + assert!(press(&grid, gdk::Key::F10, gdk::ModifierType::SHIFT_MASK)); + + let menu = shown_menu(&grid); + let bounds = cell.compute_bounds(&view).expect("the cell's bounds"); + let corner = graphene::Point::new(bounds.x() as i32 as f32, bounds.y() as i32 as f32); + let corner = view + .compute_point(&grid.grid, &corner) + .expect("the cell's corner in the grid"); + let (pointed, rect) = menu.pointing_to(); + assert!(pointed); + assert_eq!( + (rect.x(), rect.y(), rect.width(), rect.height()), + ( + corner.x() as i32, + corner.y() as i32, + bounds.width() as i32, + bounds.height() as i32 + ) + ); + grid.activate("set-null"); + close_menus(&grid); + assert_eq!( + grid.tracker.cell_overlay(&key(2), 1).pending, + Some(CellInput::Value(Value::Null)), + "the menu acts on another cell" + ); +} + +#[gtk4::test] +fn a_result_grid_opens_its_read_only_menu_from_the_keyboard() { + let grid = Grid::build( + GridSpec::results(vec![named("id", "integer", true)]), + results(&[vec![Value::Int(7)]]), + ); + grid.focus(0, 0); + + assert!(!press(&grid, gdk::Key::Return, none()), "Return edited a result cell"); + assert!(press(&grid, gdk::Key::Menu, none())); + + shown_menu(&grid); + assert!(!grid.is_enabled("set-null"), "a result grid offers Set to NULL"); + assert!( + !action_names(&cell_menu(&grid)) + .iter() + .any(|item| item == "grid.set-null") + ); + close_menus(&grid); +} + +/// A menu holds its rows pinned, so a page that lands meanwhile leaves +/// its edit items acting on nothing. +#[gtk4::test] +fn a_page_that_lands_under_an_open_menu_leaves_its_items_acting_on_nothing() { + let grid = two_text_rows(); + assert!(grid.grid.open_cell_menu(&grid.cell_of(2, 1), None)); + + grid.grid.replace_rows(keyed(&[3, 4]), 0); + drain_main_context(); + for action in ["set-null", "delete-rows", "duplicate-rows"] { + grid.activate(action); + } + close_menus(&grid); + + assert_eq!( + grid.tracker.pending_count(), + 0, + "the menu acted on a row of the new page" + ); + assert_eq!(grid.grid.store().draft_count(), 0); +} + +/// A menu closed, or kept from opening by another client holding the +/// pointer, leaves the focus on a cell. A page that then takes that +/// cell's row out, and the window taking the focus back before the next +/// frame, crashed GTK 4.22 on the detached row. +#[gtk4::test] +fn a_page_under_a_closed_menu_then_a_focus_in_keeps_the_focus_in_the_grid() { + let grid = two_text_rows(); + grid.focus(0, 1); + assert!(grid.grid.open_cell_menu(&grid.cell_of(2, 1), None)); + close_menus(&grid); + wait_until(WAIT, || grid.focused_cell().is_some()).expect("the closed menu left the focus off the grid"); + let thief = FocusThief::take_from(&grid.window).expect("another window takes the focus"); + + let store = grid.grid.store(); + let rows: Vec = keyed(&[3, 4]).into_iter().map(GridRow::new).collect(); + store.list().splice(0, store.len(), &rows); + thief.give_back(&grid.window); + drain_main_context(); + after_next_frame(&grid.window); + + assert!(focus_in_grid(&grid), "the focus left the grid"); +} + +#[gtk4::test] +fn the_space_below_the_rows_offers_insert_and_paste_where_rows_can_be_added() { + let table = two_text_rows(); + let shown = Grid::build(GridSpec::results(two_text_columns()), results(&two_text_values())); + + let empty = table + .grid + .menus() + .and_then(|menus| menus.empty.clone()) + .and_then(|menu| menu.menu_model()) + .expect("the menu below the rows"); + + assert_eq!(action_names(&empty), ["grid.insert-row", "grid.paste"]); + assert!( + shown.grid.menus().is_some_and(|menus| menus.empty.is_none()), + "a grid that adds no rows offers to add one" + ); +} + +#[gtk4::test] +fn the_grid_takes_its_menus_with_it() { + let grid = two_text_rows(); + let menus: Vec> = grid + .grid + .menus() + .map(|menus| { + [Some(&menus.cell), menus.empty.as_ref(), Some(&menus.reference_chooser)] + .into_iter() + .flatten() + .map(ObjectExt::downgrade) + .collect() + }) + .unwrap_or_default(); + assert_eq!(menus.len(), 3); + assert!(grid.grid.open_cell_menu(&grid.cell_of(1, 1), None)); + close_menus(&grid); + + drop(grid); + drain_main_context(); + + assert!( + menus.iter().all(|menu| menu.upgrade().is_none()), + "a menu outlived its grid" + ); +} diff --git a/linux/crates/app/src/ui/data_grid/tests/mod.rs b/linux/crates/app/src/ui/data_grid/tests/mod.rs new file mode 100644 index 0000000000..d0d46eec1f --- /dev/null +++ b/linux/crates/app/src/ui/data_grid/tests/mod.rs @@ -0,0 +1,329 @@ +mod cells; +mod columns; +mod gutter; +mod keyboard; +mod layout; +mod menus; +mod selection; +mod sorting; +mod values; +mod view; + +use gtk4 as gtk; +use gtk4::prelude::*; +use gtk4::subclass::prelude::ObjectSubclassIsExt; +use gtk4::{gdk, gio, pango}; +use tablepro_core::column::ColumnType; +use tablepro_core::{ColumnInfo, Value}; +use tempfile::TempDir; +use uuid::Uuid; + +use super::row_detail::RowDetailPane; +use super::test_grid::{Grid, WAIT, key, named, persisted}; +use super::{GridCell, GridRequest, LayoutBinding, RowData}; +use crate::application::ShortcutOwner; +use crate::persistence::ColumnLayoutStore; +use crate::runtime::AppRuntime; +use crate::services::grid::ColumnLayout; +use crate::services::tracker_edit::TrackerEdit; +use crate::test_support::shortcut_keys::{owner_controller, shortcuts, trigger_keys}; +use crate::test_support::{drain_main_context, wait_until}; + +fn none() -> gdk::ModifierType { + gdk::ModifierType::empty() +} + +/// Run what `key` does on the focused cell, as a key press reaching the +/// grid's controllers would. `true` when the grid took the key. +fn press(grid: &Grid, key: gdk::Key, modifiers: gdk::ModifierType) -> bool { + let view: gtk::Widget = grid.grid.column_view().upcast(); + let on_grid: gtk::Widget = grid.grid.clone().upcast(); + let wanted = (key.to_lower(), modifiers); + [(ShortcutOwner::GridCell, view), (ShortcutOwner::Grid, on_grid)] + .into_iter() + .filter_map(|(owner, widget)| Some((owner_controller(&widget, owner)?, widget))) + .flat_map(|(controller, widget)| { + shortcuts(&controller) + .into_iter() + .map(move |shortcut| (shortcut, widget.clone())) + }) + .find(|(shortcut, _)| { + shortcut + .trigger() + .is_some_and(|trigger| trigger_keys(&trigger).contains(&wanted)) + }) + .and_then(|(shortcut, widget)| Some((shortcut.action()?, widget))) + .map(|(action, widget)| action.activate(gtk::ShortcutActionFlags::empty(), &widget, None)) + .expect("the grid binds the key") +} + +/// Saved rows of a two-column table, keyed `ids`, in that order. +fn keyed(ids: &[i64]) -> Vec { + let rows: Vec> = ids + .iter() + .map(|id| vec![Value::Int(*id), Value::Text(format!("row {id}"))]) + .collect(); + persisted(&rows) +} + +fn text(value: &str) -> Value { + Value::Text(value.to_owned()) +} + +/// A column with no type, as a PostgreSQL editor result reads one. +fn untyped(name: &str) -> ColumnInfo { + let mut column = named(name, "text", false); + column.column_type = ColumnType::untyped_text("text"); + column +} + +fn int(value: &Value) -> Option { + match value { + Value::Int(number) => Some(*number), + _ => None, + } +} + +/// `column` of each row, in the order on screen. +fn column_on_screen(grid: &Grid, column: usize) -> Vec { + grid.grid + .shown_rows() + .iter() + .map(|row| { + row.cells() + .and_then(|cells| cells.get(column).cloned()) + .unwrap_or(Value::Null) + }) + .collect() +} + +fn ids_on_screen(grid: &Grid) -> Vec { + column_on_screen(grid, 0).iter().filter_map(int).collect() +} + +fn selected_ids(grid: &Grid) -> Vec { + grid.grid + .selected_rows() + .iter() + .filter_map(|row| row.cells().and_then(|cells| cells.first().and_then(int))) + .collect() +} + +fn row_widget(cell: &GridCell) -> gtk::Widget { + cell.parent() + .and_then(|cell_widget| cell_widget.parent()) + .expect("the row widget") +} + +fn described(widget: &impl IsA) -> bool { + gtk::test_accessible_has_property(widget, gtk::AccessibleProperty::Description) +} + +/// The sorts the grid asked its host for, oldest first. +fn sorts(grid: &Grid) -> Vec> { + grid.take_requests() + .into_iter() + .filter_map(|request| match request { + GridRequest::Sort(sort) => Some(sort), + _ => None, + }) + .collect() +} + +fn details_shown(grid: &Grid) -> bool { + grid.grid + .actions() + .action_state("show-details") + .and_then(|state| state.get::()) + .unwrap_or(false) +} + +fn pane(grid: &Grid) -> RowDetailPane { + grid.grid.imp().detail_pane.get() +} + +fn close_menus(grid: &Grid) { + if let Some(menus) = grid.grid.menus() { + menus.cell.popdown(); + if let Some(empty) = &menus.empty { + empty.popdown(); + } + menus.reference_chooser.popdown(); + } + drain_main_context(); +} + +fn cell_menu(grid: &Grid) -> gio::MenuModel { + grid.grid + .menus() + .and_then(|menus| menus.cell.menu_model()) + .expect("the cell menu") +} + +/// Every action `menu` names, with its column for a header's item, +/// sections and submenus included. +fn menu_actions(menu: &gio::MenuModel) -> Vec<(String, Option)> { + let mut found = Vec::new(); + for position in 0..menu.n_items() { + if let Some(action) = menu + .item_attribute_value(position, "action", None) + .and_then(|value| value.get::()) + { + let target = menu + .item_attribute_value(position, "target", None) + .and_then(|value| value.get::()); + found.push((action, target)); + } + for link in ["section", "submenu"] { + if let Some(inner) = menu.item_link(position, link) { + found.extend(menu_actions(&inner)); + } + } + } + found +} + +fn action_names(menu: &gio::MenuModel) -> Vec { + menu_actions(menu).into_iter().map(|(action, _)| action).collect() +} + +fn submenu(menu: &gio::MenuModel, label: &str) -> Option { + (0..menu.n_items()).find_map(|position| { + let named = menu + .item_attribute_value(position, "label", None) + .and_then(|value| value.get::()); + let here = match named.as_deref() == Some(label) { + true => menu.item_link(position, "submenu"), + false => None, + }; + here.or_else(|| { + menu.item_link(position, "section") + .and_then(|section| submenu(§ion, label)) + }) + }) +} + +/// The key a menu item shows: `None` when `menu` has no such item. +fn accel_of(menu: &gio::MenuModel, action: &str) -> Option> { + for position in 0..menu.n_items() { + let named = menu + .item_attribute_value(position, "action", None) + .and_then(|value| value.get::()); + if named.as_deref() == Some(action) { + return Some( + menu.item_attribute_value(position, "accel", None) + .and_then(|value| value.get::()), + ); + } + for link in ["section", "submenu"] { + if let Some(found) = menu + .item_link(position, link) + .and_then(|inner| accel_of(&inner, action)) + { + return Some(found); + } + } + } + None +} + +fn data_column(grid: &Grid, index: usize) -> gtk::ColumnViewColumn { + grid.grid.data_view_column(index).expect("the column") +} + +/// A resize step: two digits of the grid's font. +fn step(grid: &Grid) -> i32 { + let metrics = grid.grid.column_view().pango_context().metrics(None, None); + (pango::units_to_double(metrics.approximate_digit_width()) * 2.0).ceil() as i32 +} + +/// How wide the first row's cell of `column` is drawn. +fn shown_width(grid: &Grid, column: usize) -> i32 { + let cell = grid.cell_at(0, column); + let cell_widget = cell.parent().expect("the cell's widget"); + cell_widget + .compute_bounds(&cell_widget) + .map_or(0, |bounds| bounds.width().round() as i32) +} + +/// The width on screen, once the column's own width is drawn. +fn settled_width(grid: &Grid, column: usize) -> i32 { + wait_until(WAIT, || { + shown_width(grid, column) == data_column(grid, column).fixed_width() + }) + .expect("the column drawn at its own width"); + shown_width(grid, column) +} + +/// `Grid::build` waits for the cells, not for the first layout, before +/// which no width is the one on screen. +fn laid_out(grid: Grid) -> Grid { + wait_until(WAIT, || grid.grid.column_view().width() > 0).expect("the grid to be laid out"); + grid +} + +fn rename_first(grid: &Grid, to: &str) { + grid.grid.apply_delta(&grid.tracker.apply(TrackerEdit::Cell { + row_key: key(1), + col: 1, + original: text("A"), + value: text(to), + })); +} + +/// Mark the saved row `id` of the two-text-column rows for deletion. +fn delete_saved(grid: &Grid, id: i64, name: &str) { + grid.grid.apply_delta(&grid.tracker.apply(TrackerEdit::DeleteRows { + persisted: vec![(key(id), vec![Value::Int(id), text(name)].into())], + drafts: Vec::new(), + })); +} + +/// The cell of the first draft once the list draws it. +fn draft_cell(grid: &Grid) -> GridCell { + let mut draft = None; + wait_until(WAIT, || { + draft = grid + .cells() + .into_iter() + .find(|cell| cell.row().is_some_and(|row| row.draft_id().is_some())); + draft.is_some() + }) + .expect("the draft is drawn"); + draft.expect("the draft's cell") +} + +/// A layout store in a directory of its own, for one connection. +struct Layouts { + store: ColumnLayoutStore, + connection_id: Uuid, + _root: TempDir, + _runtime: AppRuntime, +} + +impl Layouts { + fn new() -> Self { + let root = tempfile::tempdir().expect("tempdir"); + let runtime = crate::test_support::test_runtime(); + let store = ColumnLayoutStore::load(root.path().join("column_widths.json"), &runtime.tasks()); + Self { + store, + connection_id: Uuid::new_v4(), + _root: root, + _runtime: runtime, + } + } + + fn binding(&self, table: &str) -> LayoutBinding { + LayoutBinding { + store: self.store.clone(), + connection_id: self.connection_id, + schema: Some("public".to_owned()), + table: table.to_owned(), + } + } + + fn saved(&self, table: &str) -> ColumnLayout { + self.store.layout(self.connection_id, Some("public"), table) + } +} diff --git a/linux/crates/app/src/ui/data_grid/tests/selection.rs b/linux/crates/app/src/ui/data_grid/tests/selection.rs new file mode 100644 index 0000000000..66ef979860 --- /dev/null +++ b/linux/crates/app/src/ui/data_grid/tests/selection.rs @@ -0,0 +1,91 @@ +use gtk4::prelude::*; + +use super::{int, keyed, selected_ids}; +use crate::test_support::{drain_main_context, wait_until}; +use crate::ui::data_grid::PinnedRow; +use crate::ui::data_grid::test_grid::{Grid, GridSpec, WAIT, focus_in_grid, key, two_text_columns}; + +fn five_rows() -> Grid { + Grid::build(GridSpec::table(two_text_columns()), keyed(&[0, 1, 2, 3, 4])) +} + +fn pinned_ids(rows: &[PinnedRow]) -> Vec { + rows.iter() + .filter_map(|row| row.get().and_then(|row| row.cells())) + .filter_map(|cells| cells.first().and_then(int)) + .collect() +} + +/// The rows follow by key, wherever the new page puts them. +#[gtk4::test] +fn a_page_of_the_same_keys_keeps_the_selection_and_the_focused_cell() { + let grid = five_rows(); + grid.focus(3, 1); + let selection = grid.grid.selection(); + selection.select_item(1, true); + selection.select_item(3, false); + + grid.grid.replace_rows(keyed(&[2, 0, 4, 1, 3]), 0); + drain_main_context(); + + assert_eq!(selected_ids(&grid), [1, 3]); + wait_until(WAIT, || { + grid.focused_cell() + .is_some_and(|cell| cell.position() == Some(4) && cell.column() == 1) + }) + .expect("the focus follows its row"); + let focused = grid + .focused_cell() + .and_then(|cell| cell.row()) + .and_then(|row| row.key()); + assert_eq!(focused, Some(key(3))); +} + +/// Neither may stay on a row that took another's place. +#[gtk4::test] +fn a_page_of_other_keys_clears_the_selection_and_keeps_the_focus_in_the_grid() { + let grid = five_rows(); + grid.focus(3, 1); + let selection = grid.grid.selection(); + selection.select_item(1, true); + selection.select_item(3, false); + + grid.grid.replace_rows(keyed(&[10, 11, 12, 13, 14]), 0); + drain_main_context(); + + assert!( + selected_ids(&grid).is_empty(), + "rows of the new page took the selection" + ); + assert_eq!(grid.grid.selected_count(), 0); + assert!(focus_in_grid(&grid), "the focus left the grid"); +} + +#[gtk4::test] +fn menu_rows_uses_selection_when_clicked_row_is_selected() { + let grid = five_rows(); + let selection = grid.grid.selection(); + selection.select_item(3, false); + selection.select_item(1, false); + let row = grid.grid.store().row(3).expect("a row"); + + assert_eq!( + pinned_ids(&grid.grid.rows_for(&row, 3)), + [1, 3], + "the rows are not the selection in position order" + ); +} + +#[gtk4::test] +fn menu_rows_uses_only_clicked_row_outside_selection() { + let grid = five_rows(); + grid.grid.selection().select_range(0, 2, true); + let row = grid.grid.store().row(4).expect("a row"); + + assert_eq!(pinned_ids(&grid.grid.rows_for(&row, 4)), [4]); + assert_eq!( + pinned_ids(&grid.grid.rows_for(&row, 1)), + [4], + "a selected row at the clicked position stood in for the clicked one" + ); +} diff --git a/linux/crates/app/src/ui/data_grid/tests/sorting.rs b/linux/crates/app/src/ui/data_grid/tests/sorting.rs new file mode 100644 index 0000000000..575e3514e0 --- /dev/null +++ b/linux/crates/app/src/ui/data_grid/tests/sorting.rs @@ -0,0 +1,405 @@ +use std::time::{Duration, Instant}; + +use gtk4 as gtk; +use gtk4::glib; +use gtk4::prelude::*; +use tablepro_core::{ColumnInfo, Value}; + +use super::{ + action_names, close_menus, column_on_screen, data_column, ids_on_screen, keyed, selected_ids, sorts, text, +}; +use crate::test_support::drain_main_context; +use crate::ui::data_grid::GridSorting; +use crate::ui::data_grid::test_grid::{Grid, GridSpec, named, persisted, results, two_text_columns, two_text_values}; + +/// What a click on `column`'s header does. +fn header_click(grid: &Grid, column: usize, order: gtk::SortType) { + grid.grid + .column_view() + .sort_by_column(Some(&data_column(grid, column)), order); + drain_main_context(); +} + +fn header_clear(grid: &Grid) { + grid.grid + .column_view() + .sort_by_column(None::<>k::ColumnViewColumn>, gtk::SortType::Ascending); + drain_main_context(); +} + +/// The column and order the headers show. +fn sorted_by(grid: &Grid) -> Option<(usize, gtk::SortType)> { + let sorter = grid + .grid + .column_view() + .sorter() + .and_downcast::()?; + let column = sorter.primary_sort_column()?; + Some((grid.grid.column_index(&column)?, sorter.primary_sort_order())) +} + +fn said(grid: &Grid) -> Vec { + crate::ui::a11y::announced_in(&grid.grid) + .into_iter() + .map(|(text, _)| text) + .collect() +} + +fn results_of(columns: Vec, rows: &[Vec]) -> Grid { + Grid::build(GridSpec::results(columns), results(rows)) +} + +#[gtk4::test] +fn sort_by_column_descending_orders_result_rows() { + let grid = results_of( + vec![named("id", "integer", false), named("name", "text", false)], + &[ + vec![Value::Int(2), text("b")], + vec![Value::Int(10), text("j")], + vec![Value::Null, text("none")], + vec![Value::Int(-1), text("a")], + ], + ); + + header_click(&grid, 0, gtk::SortType::Descending); + + assert_eq!( + column_on_screen(&grid, 0), + [Value::Int(10), Value::Int(2), Value::Int(-1), Value::Null] + ); + assert_eq!( + column_on_screen(&grid, 1), + [text("j"), text("b"), text("a"), text("none")] + ); + header_click(&grid, 0, gtk::SortType::Ascending); + assert_eq!( + column_on_screen(&grid, 0), + [Value::Null, Value::Int(-1), Value::Int(2), Value::Int(10)] + ); +} + +#[gtk4::test] +fn a_sort_keeps_the_selected_rows_selected() { + let grid = results_of( + vec![named("id", "integer", false)], + &[vec![Value::Int(3)], vec![Value::Int(1)], vec![Value::Int(2)]], + ); + let selection = grid.grid.selection(); + selection.select_item(0, true); + selection.select_item(1, false); + + header_click(&grid, 0, gtk::SortType::Ascending); + + assert_eq!(ids_on_screen(&grid), [1, 2, 3]); + assert_eq!(selected_ids(&grid), [1, 3]); +} + +#[gtk4::test] +fn text_column_sorts_with_unicode_collation() { + let names = ["f", "é", "e", "E"]; + let mut rows: Vec> = names.iter().map(|name| vec![text(name)]).collect(); + rows.insert(2, vec![Value::Null]); + let grid = results_of(vec![named("name", "text", false)], &rows); + + header_click(&grid, 0, gtk::SortType::Ascending); + + // The order is the locale's: whatever it is, it is the order of the + // collation keys GLib builds for it, with NULL first. The sorter + // ignores case, as GtkStringSorter does by default, so `e` and `E` + // tie and keep their row order. + let key = |name: &str| glib::CollationKey::from(glib::casefold(name).as_str()); + let mut collated = names.to_vec(); + collated.sort_by_key(|name| key(name)); + let mut expected = vec![Value::Null]; + expected.extend(collated.iter().map(|name| text(name))); + assert_eq!(column_on_screen(&grid, 0), expected); + if key("e") < key("é") && key("é") < key("f") { + let order: Vec = column_on_screen(&grid, 0) + .into_iter() + .filter(|value| *value != text("E")) + .collect(); + assert_eq!(order, [Value::Null, text("e"), text("é"), text("f")]); + } + header_click(&grid, 0, gtk::SortType::Descending); + assert_eq!( + column_on_screen(&grid, 0).last(), + Some(&Value::Null), + "NULL is not last descending" + ); +} + +#[gtk4::test] +fn a_text_column_orders_its_numbers_before_its_text() { + let mut column = named("tag", "nvarchar(20)", false); + column.column_type.dynamic_storage = false; + let grid = results_of( + vec![column], + &[ + vec![Value::Int(10)], + vec![text("b")], + vec![Value::Int(9)], + vec![Value::Null], + vec![text("a")], + ], + ); + + header_click(&grid, 0, gtk::SortType::Ascending); + + assert_eq!( + column_on_screen(&grid, 0), + [Value::Null, Value::Int(9), Value::Int(10), text("a"), text("b")] + ); +} + +#[gtk4::test] +fn text_column_sorter_is_string_sorter_with_unicode_collation() { + let grid = results_of(vec![named("name", "varchar(20)", false)], &[]); + + let sorter = data_column(&grid, 0) + .sorter() + .and_downcast::() + .expect("a text column sorts in steps"); + + let collating = sorter + .item(sorter.n_items() - 1) + .and_downcast::() + .expect("the last step collates the text"); + assert_eq!(collating.collation(), gtk::Collation::Unicode); + assert!( + sorter.item(0).and_downcast::().is_some(), + "NULL is not placed first" + ); +} + +#[gtk4::test] +fn sorting_10k_text_rows_finishes_within_one_second() { + let rows: Vec> = (0..10_000_u64) + .map(|row| vec![text(&format!("name {:05}", row * 7_919 % 10_000))]) + .collect(); + let grid = results_of(vec![named("name", "text", false)], &rows); + + let started = Instant::now(); + grid.grid + .column_view() + .sort_by_column(Some(&data_column(&grid, 0)), gtk::SortType::Ascending); + let count = grid.grid.selection().n_items(); + let elapsed = started.elapsed(); + + assert_eq!(count, 10_000); + assert!(elapsed < Duration::from_secs(1), "sorting took {elapsed:?}"); + let names = column_on_screen(&grid, 0); + assert_eq!(names.first(), Some(&text("name 00000"))); + assert_eq!(names.last(), Some(&text("name 09999"))); +} + +#[gtk4::test] +fn the_cell_menu_sorts_the_results_and_restores_their_order() { + let grid = results_of( + vec![named("id", "integer", false), named("name", "text", false)], + &[ + vec![Value::Int(2), text("b")], + vec![Value::Int(3), text("c")], + vec![Value::Int(1), text("a")], + ], + ); + + assert!(grid.grid.open_cell_menu(&grid.cell_at(0, 0), None)); + grid.activate("sort-descending"); + close_menus(&grid); + assert_eq!( + column_on_screen(&grid, 0), + [Value::Int(3), Value::Int(2), Value::Int(1)] + ); + assert!(grid.grid.open_cell_menu(&grid.cell_at(0, 0), None)); + grid.activate("clear-sort"); + close_menus(&grid); + + assert_eq!( + column_on_screen(&grid, 0), + [Value::Int(2), Value::Int(3), Value::Int(1)] + ); +} + +/// The header's arrow says nothing to a screen reader on Linux. +#[gtk4::test] +fn a_header_click_in_a_grid_that_sorts_its_rows_is_announced() { + let grid = results_of(two_text_columns(), &two_text_values()); + + header_click(&grid, 1, gtk::SortType::Ascending); + header_click(&grid, 1, gtk::SortType::Descending); + header_clear(&grid); + + assert_eq!( + said(&grid), + [ + "Sorted by name, ascending", + "Sorted by name, descending", + "Sort cleared" + ] + ); +} + +#[gtk4::test] +fn a_header_click_asks_the_host_and_leaves_the_rows_in_place() { + let grid = Grid::build(GridSpec::table(two_text_columns()), keyed(&[3, 1, 2])); + + header_click(&grid, 0, gtk::SortType::Descending); + + assert_eq!(sorts(&grid), [Some((0, false))]); + assert_eq!(ids_on_screen(&grid), [3, 1, 2], "the grid sorted rows the server sorts"); + assert_eq!(sorted_by(&grid), Some((0, gtk::SortType::Descending))); + header_clear(&grid); + assert_eq!(sorts(&grid), [None]); + assert_eq!(sorted_by(&grid), None); +} + +#[gtk4::test] +fn a_header_click_in_a_grid_the_server_sorts_is_announced() { + let grid = Grid::open(two_text_columns(), two_text_values()); + + header_click(&grid, 1, gtk::SortType::Ascending); + + assert_eq!(said(&grid), ["Sorted by name, ascending"]); +} + +/// A restored tab's sort is the one its rows were read in. +#[gtk4::test] +fn set_sort_marks_the_header_without_asking() { + let grid = Grid::build( + GridSpec { + sorting: GridSorting::Server { + initial: Some((1, false)), + }, + ..GridSpec::table(two_text_columns()) + }, + keyed(&[1, 2]), + ); + assert_eq!(sorted_by(&grid), Some((1, gtk::SortType::Descending))); + + grid.grid.set_sort(Some((0, true))); + assert_eq!(sorted_by(&grid), Some((0, gtk::SortType::Ascending))); + grid.grid.set_sort(None); + drain_main_context(); + + assert_eq!(sorted_by(&grid), None); + assert!( + sorts(&grid).is_empty(), + "putting the sort on the headers asked for rows" + ); + assert!(said(&grid).is_empty(), "{:?}", said(&grid)); +} + +/// A header's click and drag take a pointer, so the menu, which the +/// Menu key opens, sorts too. +#[gtk4::test] +fn the_menu_sorts_by_its_column_and_clears_the_sort() { + let grid = Grid::open(two_text_columns(), two_text_values()); + assert!(grid.grid.open_cell_menu(&grid.cell_of(1, 1), None)); + assert!(!grid.is_enabled("clear-sort"), "Clear Sort was on with no sort"); + + grid.activate("sort-descending"); + close_menus(&grid); + assert!(grid.grid.open_cell_menu(&grid.cell_of(1, 1), None)); + grid.activate("clear-sort"); + close_menus(&grid); + + assert_eq!(sorts(&grid), [Some((1, false)), None]); + assert_eq!(sorted_by(&grid), None); +} + +#[gtk4::test] +fn no_header_sorts_a_table_in_the_engines_order() { + let grid = Grid::build( + GridSpec { + sorting: GridSorting::None, + ..GridSpec::table(two_text_columns()) + }, + keyed(&[1]), + ); + let view = grid.grid.column_view(); + + let sorters: Vec = (0..view.columns().n_items()) + .filter_map(|position| view.columns().item(position).and_downcast::()) + .map(|column| column.sorter().is_some()) + .collect(); + + assert_eq!(sorters, [false, false, false]); +} + +/// D4: a field no sort can name, as a document's nested field, sorts by +/// no header and no menu. +#[gtk4::test] +fn an_unaddressable_column_offers_no_sort() { + let grid = Grid::build( + GridSpec { + unaddressable: vec!["address.city".to_owned()], + ..GridSpec::table(vec![ + named("id", "integer", true), + named("name", "text", false), + named("address.city", "text", false), + ]) + }, + persisted(&[vec![Value::Int(1), text("Ada"), text("London")]]), + ); + let nested = data_column(&grid, 2); + let header = action_names(&nested.header_menu().expect("the header's menu")); + + assert!(nested.sorter().is_none()); + assert!(data_column(&grid, 1).sorter().is_some()); + assert!( + !header.iter().any(|action| action.starts_with("grid.sort")), + "{header:?}" + ); + grid.focus(0, 2); + assert!(!grid.is_enabled("sort-ascending")); + grid.focus(0, 1); + assert!(grid.is_enabled("sort-ascending")); +} + +#[gtk4::test] +fn reset_columns_keeps_the_sort_the_headers_show() { + let grid = Grid::open(two_text_columns(), two_text_values()); + header_click(&grid, 1, gtk::SortType::Descending); + sorts(&grid); + + grid.grid.reset_layout(); + drain_main_context(); + + let sorter = grid + .grid + .column_view() + .sorter() + .and_downcast::() + .expect("the column view's sorter"); + assert_eq!(sorter.primary_sort_column(), Some(data_column(&grid, 1))); + assert_eq!(sorter.primary_sort_order(), gtk::SortType::Descending); + assert!(sorts(&grid).is_empty(), "the reset asked the host for rows again"); +} + +/// Clear Sort follows the headers with no row selected, and for a sort +/// the host put there. +#[gtk4::test] +fn clear_sort_follows_every_sort_change() { + let grid = results_of(two_text_columns(), &two_text_values()); + grid.grid.selection().unselect_all(); + header_click(&grid, 1, gtk::SortType::Ascending); + let after_click = grid.is_enabled("clear-sort"); + header_clear(&grid); + let after_clear = grid.is_enabled("clear-sort"); + let table = Grid::build( + GridSpec { + sorting: GridSorting::Server { initial: None }, + ..GridSpec::table(two_text_columns()) + }, + keyed(&[1, 2]), + ); + table.grid.selection().unselect_all(); + table.grid.set_sort(Some((0, true))); + drain_main_context(); + + assert_eq!((after_click, after_clear), (true, false)); + assert!( + table.is_enabled("clear-sort"), + "a sort the host set left Clear Sort off" + ); +} diff --git a/linux/crates/app/src/ui/data_grid/tests/values.rs b/linux/crates/app/src/ui/data_grid/tests/values.rs new file mode 100644 index 0000000000..f652758732 --- /dev/null +++ b/linux/crates/app/src/ui/data_grid/tests/values.rs @@ -0,0 +1,294 @@ +use std::collections::HashSet; +use std::sync::Arc; + +use tablepro_core::Value; +use tablepro_core::edit::CellInput; + +use super::text; +use crate::services::change_tracker::{CellState, RowKey}; +use crate::services::grid::{GridRowId, cell_editability, grid_columns}; +use crate::services::tab_tracker_handle::TabTrackerHandle; +use crate::services::tracker_edit::TrackerEdit; +use crate::ui::data_grid::test_grid::{col, key, keyed_by_id}; +use crate::ui::data_grid::tracked_values::{RowPendingState, cell_description}; +use crate::ui::data_grid::{GridRow, GridStore, RowData, TrackedValues}; +use crate::ui::icons; + +fn tracked(tracker: &TabTrackerHandle) -> TrackedValues { + TrackedValues::tracked(tracker.clone(), keyed_by_id()) +} + +fn insert_draft(tracker: &TabTrackerHandle, inputs: Vec) -> GridRow { + let delta = tracker.apply(TrackerEdit::InsertDrafts { rows: vec![inputs] }); + let draft_id = delta + .inserted_drafts + .first() + .copied() + .expect("an insert adds a draft row"); + GridRow::new(RowData::draft(draft_id)) +} + +fn saved(id: i64, cells: Vec) -> GridRow { + GridRow::new(RowData::persisted(Some(key(id)), cells.into())) +} + +fn rename(tracker: &TabTrackerHandle, id: i64) { + let _ = tracker.apply(TrackerEdit::Cell { + row_key: key(id), + col: 1, + original: text("old"), + value: text("new"), + }); +} + +#[test] +fn a_tracked_draft_reads_its_cells_from_the_tracker() { + let tracker = TabTrackerHandle::default(); + let draft = insert_draft(&tracker, vec![CellInput::Value(Value::Int(4))]); + + assert_eq!(tracked(&tracker).edit_input(&draft, 0), CellInput::Value(Value::Int(4))); + assert_eq!(tracked(&tracker).row_state(&draft), RowPendingState::Inserted); +} + +#[test] +fn a_clean_cell_is_drawn_from_the_row_itself() { + let tracker = TabTrackerHandle::default(); + rename(&tracker, 1); + let row = saved(1, vec![Value::Int(1), text("old")]); + let cells = row.cells().expect("a saved row's cells"); + + let borrowed = tracked(&tracker).with_value(&row, 0, |value, _| std::ptr::eq(value, &cells[0])); + let pending = tracked(&tracker).with_value(&row, 1, |value, default| (value.clone(), default)); + + assert!(borrowed, "an unedited cell was copied out of its row"); + assert_eq!(pending, (text("new"), false)); +} + +#[test] +fn snapshots_include_pending_edits_and_draft_cells() { + let tracker = TabTrackerHandle::default(); + rename(&tracker, 1); + let draft = insert_draft( + &tracker, + vec![CellInput::Value(Value::Null), CellInput::Value(text("draft"))], + ); + let row = saved(1, vec![Value::Int(1), text("old")]); + + assert_eq!( + tracked(&tracker).snapshot(&row).to_values(), + [Value::Int(1), text("new")] + ); + assert_eq!( + tracked(&tracker).snapshot(&draft).to_values(), + [Value::Null, text("draft")] + ); +} + +/// A copy has no value to give a default. +#[test] +fn a_draft_cell_left_to_the_server_opens_as_the_default() { + let tracker = TabTrackerHandle::default(); + let draft = insert_draft(&tracker, vec![CellInput::Default, CellInput::Value(Value::Int(3))]); + + assert_eq!(tracked(&tracker).edit_input(&draft, 0), CellInput::Default); + assert_eq!(tracked(&tracker).edit_input(&draft, 1), CellInput::Value(Value::Int(3))); + assert_eq!( + tracked(&tracker).snapshot(&draft).to_values(), + [Value::Null, Value::Int(3)] + ); + assert_eq!( + tracked(&tracker).inputs(&draft), + [CellInput::Default, CellInput::Value(Value::Int(3))] + ); +} + +#[test] +fn row_state_icons_are_distinct_and_described() { + let pending = [ + RowPendingState::Modified, + RowPendingState::Inserted, + RowPendingState::MarkedForDeletion, + RowPendingState::SaveFailed, + ]; + + let shown: Vec<(Option<&str>, Option)> = pending.iter().map(|state| (state.icon(), state.text())).collect(); + + assert_eq!( + shown, + [ + (Some(icons::DOCUMENT_EDIT), Some("Modified".to_owned())), + (Some(icons::LIST_ADD), Some("New row".to_owned())), + (Some(icons::EDIT_DELETE), Some("Marked for deletion".to_owned())), + (Some(icons::DIALOG_ERROR), Some("Not saved".to_owned())), + ] + ); + let distinct: HashSet> = shown.iter().map(|(icon, _)| *icon).collect(); + assert_eq!(distinct.len(), pending.len()); + assert_eq!(RowPendingState::Unchanged.icon(), None); + assert_eq!(RowPendingState::Unchanged.text(), None); +} + +#[test] +fn a_failed_save_outranks_the_rows_change() { + let tracker = TabTrackerHandle::default(); + rename(&tracker, 1); + let edited = saved(1, vec![Value::Int(1), text("old")]); + let _ = tracker.apply(TrackerEdit::DeleteRows { + persisted: vec![(key(2), Arc::from([Value::Int(2), text("gone")]))], + drafts: Vec::new(), + }); + let deleted = saved(2, vec![Value::Int(2), text("gone")]); + let draft = insert_draft(&tracker, vec![CellInput::Value(Value::Null)]); + let clean = saved(3, vec![Value::Int(3), text("as read")]); + assert_eq!(tracked(&tracker).row_state(&edited), RowPendingState::Modified); + + let _ = tracker.mark_error_row(key(1)); + + assert_eq!(tracked(&tracker).row_state(&edited), RowPendingState::SaveFailed); + assert_eq!( + tracked(&tracker).row_state(&deleted), + RowPendingState::MarkedForDeletion + ); + assert_eq!(tracked(&tracker).row_state(&draft), RowPendingState::Inserted); + assert_eq!(tracked(&tracker).row_state(&clean), RowPendingState::Unchanged); +} + +#[test] +fn cell_and_row_descriptions() { + assert_eq!( + cell_description(CellState::Modified, false).as_deref(), + Some("Modified") + ); + assert_eq!(cell_description(CellState::Clean, false), None); + assert_eq!(cell_description(CellState::InsertDraft, false), None); + assert_eq!(RowPendingState::Inserted.row_description().as_deref(), Some("New row")); + assert_eq!( + RowPendingState::MarkedForDeletion.row_description().as_deref(), + Some("Marked for deletion") + ); + assert_eq!( + RowPendingState::SaveFailed.row_description().as_deref(), + Some("Not saved") + ); + assert_eq!(RowPendingState::Unchanged.row_description(), None); + assert_eq!( + RowPendingState::Modified.row_description(), + None, + "an edited row says nothing its edited cells do not" + ); +} + +#[test] +fn cell_description_marks_null_and_modified() { + assert_eq!( + cell_description(CellState::Modified, true).as_deref(), + Some("Modified, NULL value") + ); + assert_eq!(cell_description(CellState::Clean, true).as_deref(), Some("NULL value")); + assert_eq!( + cell_description(CellState::InsertDraft, true).as_deref(), + Some("NULL value") + ); + assert_eq!(cell_description(CellState::Clean, false), None); +} + +#[test] +fn a_row_keeps_the_identity_it_was_made_with() { + let persisted = saved(2, vec![Value::Int(2)]); + let unkeyed = GridRow::new(RowData::persisted(None, Arc::from([Value::Int(2)]))); + let result = GridRow::new(RowData::result(Arc::from([Value::Int(2)]))); + let draft = GridRow::new(RowData::draft(5)); + + assert_eq!(persisted.key(), Some(key(2))); + assert_eq!(persisted.cells().as_deref(), Some(&[Value::Int(2)][..])); + assert_eq!(unkeyed.id(), GridRowId::Unkeyed); + assert_eq!(unkeyed.tracker_key(), None, "a row with no key took an edit"); + assert_eq!(result.id(), GridRowId::Result); + assert_eq!(draft.draft_id(), Some(5)); + assert_eq!(draft.tracker_key(), Some(RowKey::Draft(5))); + assert!(draft.cells().is_none(), "a draft's cells are the tracker's"); +} + +#[test] +fn drafts_are_found_by_their_draft_key_and_unkeyed_rows_not_at_all() { + let store = GridStore::default(); + store.replace(vec![ + RowData::draft(7), + RowData::persisted(Some(key(1)), Arc::from([Value::Int(1)])), + RowData::persisted(None, Arc::from([Value::Int(3)])), + ]); + + let position = |row_key: &RowKey| store.row_for(row_key).and_then(|row| store.position_of(&row)); + + assert_eq!(position(&RowKey::Draft(7)), Some(0)); + assert_eq!(position(&key(1)), Some(1)); + assert_eq!(store.draft_count(), 1); + store.remove_drafts(&[7]); + assert_eq!(position(&RowKey::Draft(7)), None); + assert_eq!(position(&key(1)), Some(0)); +} + +#[test] +fn a_draft_is_never_taken_for_a_saved_row_with_the_same_number() { + let store = GridStore::default(); + store.replace(vec![RowData::persisted(Some(key(3)), Arc::from([Value::Int(3)]))]); + store.insert_drafts(&[3]); + + let draft = store.row_for(&RowKey::Draft(3)).expect("the draft"); + let row = store.row_for(&key(3)).expect("the saved row"); + + assert_ne!(draft, row); + assert_eq!(draft.draft_id(), Some(3)); + assert_eq!(row.key(), Some(key(3))); +} + +/// Clearing a text cell stores the empty string; clearing a number or a +/// date can only mean NULL. +#[test] +fn only_columns_with_an_empty_value_accept_an_empty_string() { + for text in ["text", "VARCHAR(255)", "char(3)", "longtext"] { + assert!( + col(text, false).column_type.kind.accepts_empty_string(), + "{text} should accept an empty string" + ); + } + for other in [ + "integer", + "bigint", + "numeric(10,2)", + "date", + "timestamp", + "uuid", + "boolean", + ] { + assert!( + !col(other, false).column_type.kind.accepts_empty_string(), + "{other} should not accept an empty string" + ); + } +} + +#[test] +fn a_blob_is_never_editable_whatever_the_engine_calls_it() { + let editable = |data_type: &str| { + let info = col(data_type, false); + let columns = grid_columns(&[info.result_column()], &[info], |_| true); + // A draft has the fewest locks: a column a draft cannot edit, no + // row can. + cell_editability(&columns[0], &GridRowId::Draft(0), &keyed_by_id(), false, false).is_ok() + }; + + for name in [ + "BYTEA", + "blob", + "LONGBLOB", + "mediumblob", + "tinyblob", + "VARBINARY", + "binary", + ] { + assert!(!editable(name), "{name} was offered as an editable cell"); + } + assert!(editable("text")); + assert!(editable("integer")); +} diff --git a/linux/crates/app/src/ui/data_grid/tests/view.rs b/linux/crates/app/src/ui/data_grid/tests/view.rs new file mode 100644 index 0000000000..e0ba1a8099 --- /dev/null +++ b/linux/crates/app/src/ui/data_grid/tests/view.rs @@ -0,0 +1,76 @@ +use gtk4 as gtk; +use gtk4::glib; +use gtk4::prelude::*; +use gtk4::subclass::prelude::ObjectSubclassIsExt; +use tablepro_core::Value; + +use super::{keyed, text}; +use crate::test_support::wait_until; +use crate::ui::data_grid::GridCell; +use crate::ui::data_grid::test_grid::{Grid, GridSpec, WAIT, two_text_columns}; + +/// Ask the view for a tooltip at the middle of `cell`, as GTK does when +/// the pointer rests there. `true` when one is shown. +fn asks_tooltip(grid: &Grid, cell: &GridCell) -> bool { + let view = grid.grid.column_view(); + let bounds = cell.compute_bounds(&view).expect("the cell's bounds"); + let x = (bounds.x() + bounds.width() / 2.0) as i32; + let y = (bounds.y() + bounds.height() / 2.0) as i32; + let tooltip = glib::Object::new::(); + view.emit_by_name::("query-tooltip", &[&x, &y, &false, &tooltip]) +} + +/// Drawn over the view rather than in its place, so the view stays +/// mapped and keeps its row widgets while it holds no row. +#[gtk4::test] +fn the_empty_page_shows_over_the_view_and_goes_with_the_first_row() { + let grid = Grid::build(GridSpec::table(two_text_columns()), Vec::new()); + let view = grid.grid.column_view(); + let empty = grid.grid.imp().empty_page.get(); + wait_until(WAIT, || view.is_mapped()).expect("the view is on screen"); + let overlay = empty + .parent() + .and_downcast::() + .expect("the placeholder is drawn over the view"); + + assert!(empty.is_visible(), "a grid of no rows showed no placeholder"); + assert!(view.is_ancestor(&overlay)); + assert!( + !empty.can_target(), + "the placeholder takes the clicks meant for the rows" + ); + + grid.insert_draft(); + assert!(!empty.is_visible(), "the placeholder stayed over the first row"); + assert!(view.is_mapped(), "the view left the screen while it held no row"); + + grid.grid.discard(); + assert!(empty.is_visible()); + assert!(view.is_mapped()); + grid.grid.replace_rows(keyed(&[1]), 0); + assert!(!empty.is_visible()); + grid.grid.clear_rows(); + assert!(empty.is_visible()); + grid.grid.set_empty_text("No Matches", Some("Change the filter")); + assert_eq!(empty.title().as_str(), "No Matches"); + assert_eq!(empty.description().as_deref(), Some("Change the filter")); +} + +/// The tooltip quotes the whole text, up to 300 characters; GTK keeps +/// no text a test can read back, so this checks only that it shows. +#[gtk4::test] +fn a_cut_cell_has_a_tooltip_and_one_that_fits_has_none() { + let grid = Grid::open( + two_text_columns(), + vec![ + vec![Value::Int(1), text(&"x".repeat(500))], + vec![Value::Int(2), text("B")], + ], + ); + let (cut, fits) = (grid.cell_of(1, 1), grid.cell_of(2, 1)); + wait_until(WAIT, || cut.width() > 0 && fits.width() > 0).expect("the cells are laid out"); + + assert!(grid.grid.column_view().has_tooltip()); + assert!(asks_tooltip(&grid, &cut), "a cut value has no tooltip"); + assert!(!asks_tooltip(&grid, &fits), "a value that fits has a tooltip"); +} diff --git a/linux/crates/app/src/ui/data_grid/tracked_values.rs b/linux/crates/app/src/ui/data_grid/tracked_values.rs new file mode 100644 index 0000000000..0bf12b3739 --- /dev/null +++ b/linux/crates/app/src/ui/data_grid/tracked_values.rs @@ -0,0 +1,299 @@ +use std::sync::Arc; + +use tablepro_core::Value; +use tablepro_core::edit::CellInput; +use tablepro_core::meta::RowIdentity; + +use super::GridRow; +use crate::services::change_tracker::{CellState, RowKey, RowState}; +use crate::services::grid::{CellLock, GridColumn, GridRowId, RowSnapshot, cell_editability}; +use crate::services::tab_tracker_handle::TabTrackerHandle; +use crate::ui::icons; + +/// A row's pending change, as the grid shows it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RowPendingState { + Unchanged, + Modified, + Inserted, + MarkedForDeletion, + /// The row produced the statement a save failed on. + SaveFailed, +} + +impl RowPendingState { + fn new(state: RowState, save_failed: bool) -> Self { + if save_failed { + return Self::SaveFailed; + } + match state { + RowState::Clean => Self::Unchanged, + RowState::Modified => Self::Modified, + RowState::InsertDraft => Self::Inserted, + RowState::PendingDelete => Self::MarkedForDeletion, + } + } + + pub fn icon(self) -> Option<&'static str> { + match self { + Self::Unchanged => None, + Self::Modified => Some(icons::DOCUMENT_EDIT), + Self::Inserted => Some(icons::LIST_ADD), + Self::MarkedForDeletion => Some(icons::EDIT_DELETE), + Self::SaveFailed => Some(icons::DIALOG_ERROR), + } + } + + pub fn text(self) -> Option { + let text = match self { + Self::Unchanged => return None, + Self::Modified => crate::i18n::gettext("Modified"), + Self::Inserted => crate::i18n::gettext("New row"), + Self::MarkedForDeletion => crate::i18n::gettext("Marked for deletion"), + Self::SaveFailed => crate::i18n::gettext("Not saved"), + }; + Some(text) + } + + /// What a screen reader adds to a row. An edited row says nothing of + /// its own: its edited cells do. + pub fn row_description(self) -> Option { + match self { + Self::Modified => None, + other => other.text(), + } + } +} + +/// What a screen reader adds to a cell's value. A NULL is named, since it +/// reads as the text "NULL" would. +pub fn cell_description(state: CellState, null: bool) -> Option { + let text = match (state, null) { + (CellState::Modified, true) => crate::i18n::gettext("Modified, NULL value"), + (CellState::Modified, false) => crate::i18n::gettext("Modified"), + (_, true) => crate::i18n::gettext("NULL value"), + (_, false) => return None, + }; + Some(text) +} + +/// How one cell draws its pending change. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct CellMark { + pub state: CellState, + pub row_deleted: bool, +} + +/// The values a grid shows: what was read, under what its tracker holds. +/// Every draw, copy, export and editor reads through here, so what leaves +/// the grid is what the user is looking at. +#[derive(Debug, Clone)] +pub struct TrackedValues { + tracker: Option, + identity: RowIdentity, + read_only: bool, +} + +impl Default for TrackedValues { + fn default() -> Self { + Self::untracked() + } +} + +impl TrackedValues { + pub fn untracked() -> Self { + Self { + tracker: None, + identity: RowIdentity::Unordered, + read_only: true, + } + } + + pub fn tracked(tracker: TabTrackerHandle, identity: RowIdentity) -> Self { + Self { + tracker: Some(tracker), + identity, + read_only: false, + } + } + + /// On a read-only connection every cell is locked, and says why. + pub fn on_read_only_connection(mut self, read_only: bool) -> Self { + self.read_only = read_only; + self + } + + pub fn tracker(&self) -> Option<&TabTrackerHandle> { + self.tracker.as_ref() + } + + pub fn identity(&self) -> &RowIdentity { + &self.identity + } + + pub fn tracks_changes(&self) -> bool { + !self.read_only && self.tracker.is_some() + } + + fn tracked_key(&self, row: &GridRow) -> Option<(&TabTrackerHandle, RowKey)> { + Some((self.tracker.as_ref()?, row.tracker_key()?)) + } + + /// Why the cell cannot change, `None` when it can. + pub fn lock(&self, column: &GridColumn, row: &GridRow) -> Option { + let row_deleted = self.mark(row, column.index).row_deleted; + self.lock_marked(column, row, row_deleted) + } + + /// `lock` for a cell whose mark the caller already read. + pub fn lock_marked(&self, column: &GridColumn, row: &GridRow, row_deleted: bool) -> Option { + if let Err(lock) = cell_editability(column, &row.id(), &self.identity, self.read_only, row_deleted) { + return Some(lock); + } + self.tracked_key(row) + .is_some_and(|(tracker, key)| tracker.cell_is_saving(&key, column.index)) + .then_some(CellLock::Saving) + } + + pub fn row_state(&self, row: &GridRow) -> RowPendingState { + self.tracked_key(row) + .map_or(RowPendingState::Unchanged, |(tracker, key)| { + RowPendingState::new(tracker.row_state(&key), tracker.is_error_row(&key)) + }) + } + + pub fn mark(&self, row: &GridRow, column: usize) -> CellMark { + match self.tracked_key(row) { + Some((tracker, key)) => { + let marks = tracker.cell_marks(&key, column); + CellMark { + state: marks.state, + row_deleted: marks.row_deleted, + } + } + None => CellMark { + state: CellState::Clean, + row_deleted: false, + }, + } + } + + /// Calls `f` with the value the cell shows. `default` is set for a + /// draft's cell left to the server, which then reads as NULL. + pub fn with_value(&self, row: &GridRow, column: usize, f: impl FnOnce(&Value, bool) -> R) -> R { + let pending = self + .tracked_key(row) + .and_then(|(tracker, key)| tracker.cell_overlay(&key, column).pending); + match pending { + Some(CellInput::Value(value)) => f(&value, false), + Some(CellInput::Default) => f(&Value::Null, true), + None => match row.cells() { + Some(cells) => f(cells.get(column).unwrap_or(&Value::Null), false), + None => f(&Value::Null, false), + }, + } + } + + /// What an editor opens on: a draft's cell as the tracker holds it, a + /// saved row's pending value, else the value as it was read. + pub fn edit_input(&self, row: &GridRow, column: usize) -> CellInput { + self.with_value(row, column, |value, default| match default { + true => CellInput::Default, + false => CellInput::Value(value.clone()), + }) + } + + /// The row as the grid shows it. A draft's cell left to the server + /// reads as NULL. + pub fn snapshot(&self, row: &GridRow) -> RowSnapshot { + if let (GridRowId::Draft(draft_id), Some(tracker)) = (row.id(), self.tracker.as_ref()) { + return RowSnapshot::new(tracker.draft_values(draft_id).unwrap_or_default().into(), Vec::new()); + } + let cells = row.cells().unwrap_or_else(|| Arc::from([])); + let overrides = self + .tracked_key(row) + .map(|(tracker, key)| tracker.pending_values(&key, cells.len())) + .unwrap_or_default(); + RowSnapshot::new(cells, overrides) + } + + /// Like `snapshot`, except a draft's cell left to the server stays + /// `Default`. + pub fn inputs(&self, row: &GridRow) -> Vec { + if let (GridRowId::Draft(draft_id), Some(tracker)) = (row.id(), self.tracker.as_ref()) { + return tracker.draft_inputs(draft_id).unwrap_or_default(); + } + self.snapshot(row) + .to_values() + .into_iter() + .map(CellInput::Value) + .collect() + } +} + +#[cfg(test)] +mod tests { + use tablepro_core::Value; + use tablepro_core::edit::CellInput; + + use super::*; + use crate::services::tracker_edit::TrackerEdit; + use crate::ui::data_grid::RowData; + + fn key(id: i64) -> RowKey { + RowKey::from_pk_values(&[Value::Int(id)]).expect("a key") + } + + fn keyed() -> RowIdentity { + RowIdentity::UniqueKey { + columns: vec!["id".to_owned()], + } + } + + #[test] + fn an_untracked_row_reads_as_it_was_read() { + let values = TrackedValues::untracked(); + let row = GridRow::new(RowData::result(Arc::from([Value::Int(1), Value::Text("a".into())]))); + + assert_eq!(values.row_state(&row), RowPendingState::Unchanged); + assert!(values.with_value(&row, 1, |value, _| *value == Value::Text("a".into()))); + assert_eq!( + values.snapshot(&row).to_values(), + [Value::Int(1), Value::Text("a".into())] + ); + } + + #[test] + fn a_pending_edit_shows_over_the_value_read() { + let tracker = TabTrackerHandle::default(); + let values = TrackedValues::tracked(tracker.clone(), keyed()); + let row = GridRow::new(RowData::persisted( + Some(key(1)), + Arc::from([Value::Int(1), Value::Int(5)]), + )); + let _delta = tracker.apply(TrackerEdit::Cell { + row_key: key(1), + col: 1, + original: Value::Int(5), + value: Value::Int(6), + }); + + assert!(values.with_value(&row, 1, |value, _| *value == Value::Int(6))); + assert_eq!(values.edit_input(&row, 1), CellInput::Value(Value::Int(6))); + assert_eq!(values.row_state(&row), RowPendingState::Modified); + assert_eq!(values.mark(&row, 1).state, CellState::Modified); + } + + #[test] + fn every_pending_state_has_an_icon_and_a_sentence() { + for state in [ + RowPendingState::Modified, + RowPendingState::Inserted, + RowPendingState::MarkedForDeletion, + RowPendingState::SaveFailed, + ] { + assert!(state.icon().is_some() && state.text().is_some(), "{state:?}"); + } + assert!(RowPendingState::Unchanged.icon().is_none()); + } +} diff --git a/linux/crates/app/src/ui/editor/imp.rs b/linux/crates/app/src/ui/editor/imp.rs index 9f9779c539..bc9ccfddf1 100644 --- a/linux/crates/app/src/ui/editor/imp.rs +++ b/linux/crates/app/src/ui/editor/imp.rs @@ -17,7 +17,6 @@ use super::results_pane::ResultsPane; use super::run_log::RunLog; use super::transaction_dirty::OpenTransaction; use super::vim_mode::VimMode; -use crate::ui::grid::GridSink; use crate::ui::handler_guard::HandlerGuard; /// A run in flight: its cancel switch, and the loop that brings its @@ -59,7 +58,6 @@ pub struct SqlEditor { pub slot: Arc, pub running: RefCell>, pub log: RefCell>, - pub grid_sink: OnceCell, /// Whether the last run left a transaction open, as last reported. pub transaction_dirty: Cell, pub open_transaction: Cell>, diff --git a/linux/crates/app/src/ui/editor/mod.rs b/linux/crates/app/src/ui/editor/mod.rs index 7d0c004ccd..25bfae0b0b 100644 --- a/linux/crates/app/src/ui/editor/mod.rs +++ b/linux/crates/app/src/ui/editor/mod.rs @@ -53,14 +53,15 @@ pub(crate) use self::editor_file::file_name; use self::editor_view::EditorView; use self::imp::RunningScript; use self::result_widgets::PageActions; +use self::results_pane::ResultPage; use self::run_log::{EntryKind, RunEvent, RunLog}; use self::run_scope::ScopedText; use self::script_runner::{RUN_ROW_LIMIT, RunBatch, ScriptRun, plan_batches, run_in_tab}; pub(crate) use self::transaction_dirty::OpenTransaction; -use super::clipboard::{CopyRequest, InClauseRows}; +use super::data_grid::{DialectSource, GridRequest}; use super::export::{ExportOffer, ExportOrigin, ExportQuery, ExportSource}; -use super::grid::{GridMsg, GridSink, present_row_json}; use super::handler_guard::HandlerGuard; +use super::result_pane::ResultGridDeps; use super::write_confirmation::{self, WriteConfirmation}; use crate::services::active_session::ActiveSession; use crate::services::history_service::HistoryService; @@ -565,9 +566,9 @@ impl SqlEditor { imp.results.append(log.entries().get(from..).unwrap_or_default()); } - fn result_page(&self, index: u32) -> Option { + fn result_page(&self, index: u32) -> Option { let imp = self.imp(); - let grid_sink = imp.grid_sink.get()?; + let grid_deps = self.result_grid_deps()?; let log = imp.log.borrow(); let entry = log.as_ref()?.entries().get(usize::try_from(index).ok()?)?; let (errors, exports) = (self.downgrade(), self.downgrade()); @@ -599,13 +600,47 @@ impl SqlEditor { available, }), }; - Some(result_widgets::entry_widget( - usize::try_from(index).ok()?, - entry, - grid_sink, - self.grammar().is_sql(), - actions, - )) + let page = result_widgets::entry_widget(usize::try_from(index).ok()?, entry, &grid_deps, actions); + if let Some(grid) = &page.grid { + let editor = self.downgrade(); + grid.connect_request(move |_, request| { + if let Some(editor) = editor.upgrade() { + editor.grid_request(index, request); + } + }); + } + Some(page) + } + + /// What a result grid reads: copies of IN lists take the dialect of + /// the server the editor runs on at the time of the copy. + fn result_grid_deps(&self) -> Option { + let deps = self.deps()?; + let session = deps.session.clone(); + let dialect: DialectSource = Rc::new(move || { + session + .get()? + .current_dialect() + .filter(|dialect| dialect.grammar().is_sql()) + }); + Some(ResultGridDeps { + settings: deps.settings.clone(), + tasks: deps.tasks.clone(), + dialect: Some(dialect), + }) + } + + /// What the grid of the result on line `index` asks of the editor; + /// copies, IN lists and row JSON are the grid's own. + fn grid_request(&self, index: u32, request: GridRequest) { + match request { + GridRequest::Export(shown) => self.offer_export(index, shown), + GridRequest::Toast(text) => self.emit_by_name::<()>("toast", &[&text]), + GridRequest::Sort(_) + | GridRequest::FollowReference(_) + | GridRequest::FilterByValue(_) + | GridRequest::PendingChanged => {} + } } fn finish_run( @@ -805,64 +840,12 @@ impl SqlEditor { imp.menu_button.set_icon_name(crate::ui::icons::VIEW_MORE); } - /// Result pages, and the context-menu actions from their grids. + /// Each result's page, built when its line is first shown. fn connect_grid(&self) { let weak = self.downgrade(); self.imp() .results .set_page_builder(move |index| weak.upgrade().and_then(|editor| editor.result_page(index))); - let weak = self.downgrade(); - let sink: GridSink = std::rc::Rc::new(move |message| { - let Some(editor) = weak.upgrade() else { - return; - }; - match message { - GridMsg::Copy(request) => editor.copy(request), - GridMsg::CopyInClause(values) => editor.copy_in_clause(values), - GridMsg::ShowRowJson(request) => { - if let Some(tasks) = editor.deps().map(|deps| deps.tasks.clone()) { - let parent = editor.clone().upcast(); - glib::spawn_future_local(async move { - present_row_json(parent, tasks, request).await; - }); - } - } - GridMsg::ShowToast(text) => editor.emit_by_name::<()>("toast", &[&text]), - GridMsg::ExportResults(shown) => { - if let Some(index) = editor.imp().results.selected() { - editor.offer_export(index, shown); - } - } - _ => {} - } - }); - let _ = self.imp().grid_sink.set(sink); - } - - /// Render a copy from a result grid off the GTK thread, and toast - /// how it went. - fn copy(&self, request: CopyRequest) { - let Some(tasks) = self.deps().map(|deps| deps.tasks.clone()) else { - return; - }; - let editor = self.downgrade(); - crate::ui::clipboard::copy_rows(self, &tasks, request, move |text| { - if let Some(editor) = editor.upgrade() { - editor.emit_by_name::<()>("toast", &[&text]); - } - }); - } - - /// An IN list in the spelling of the server the editor runs on. - fn copy_in_clause(&self, values: InClauseRows) { - let dialect = self - .deps() - .and_then(|deps| deps.session.get()) - .and_then(|session| session.current_dialect()); - match dialect { - Some(dialect) => self.copy(CopyRequest::InClause { dialect, values }), - None => self.emit_by_name::<()>("toast", &[&crate::i18n::gettext("Connect to a database first.")]), - } } /// Ask the window to export the result on line `index`: the rows diff --git a/linux/crates/app/src/ui/editor/result_widgets.rs b/linux/crates/app/src/ui/editor/result_widgets.rs index c4d2ea9e9d..629333d89e 100644 --- a/linux/crates/app/src/ui/editor/result_widgets.rs +++ b/linux/crates/app/src/ui/editor/result_widgets.rs @@ -1,11 +1,11 @@ use gtk4::prelude::*; use libadwaita as adw; +use super::results_pane::ResultPage; use super::run_log::{EntryKind, RunLogEntry}; use crate::ui::export::ExportSource; -use crate::ui::grid::GridSink; use crate::ui::icons; -use crate::ui::result_pane::result_set_page; +use crate::ui::result_pane::{ResultGridDeps, result_set_page}; /// What a page's buttons reach in the editor. pub struct PageActions { @@ -28,15 +28,13 @@ pub struct FixHook { pub available: gtk4::gio::SimpleAction, } -/// The widget the results list's `index`th line shows. `in_clause` is -/// false where the engine has no SQL to copy rows into. -pub fn entry_widget( +/// The page the results list's `index`th line shows. +pub(crate) fn entry_widget( index: usize, entry: &RunLogEntry, - grid_sink: &GridSink, - in_clause: bool, + grid_deps: &ResultGridDeps, actions: PageActions, -) -> gtk4::Widget +) -> ResultPage where G: Fn(usize) + 'static, E: Fn(ExportSource) + 'static, @@ -47,14 +45,14 @@ where export_all, fix, } = actions; - match &entry.kind { - EntryKind::ResultSet(result) => result_set_page( - result, - &result.summary.tab_label(index), - grid_sink.clone(), - in_clause, - export_all, - ), + let widget = match &entry.kind { + EntryKind::ResultSet(result) => { + let (widget, grid) = result_set_page(result, &result.summary.tab_label(index), grid_deps, export_all); + return ResultPage { + widget, + grid: Some(grid), + }; + } EntryKind::Command { rows_affected } => { let title = match rows_affected { Some(rows) => crate::i18n::ngettext_f( @@ -106,7 +104,8 @@ where icons::STATEMENT_PENDING, ) .upcast(), - } + }; + ResultPage::plain(widget) } /// A failed statement's page: Go to Error where the server says where, @@ -168,9 +167,29 @@ fn status_page(title: &str, description: &str, icon: &str) -> adw::StatusPage { #[cfg(test)] mod tests { use super::*; + use crate::runtime::AppRuntime; + use crate::test_support::MemorySettings; - fn sink() -> GridSink { - std::rc::Rc::new(|_| {}) + /// What a result grid reads, and what keeps it alive for the test. + struct GridDeps { + deps: ResultGridDeps, + _settings: MemorySettings, + _runtime: AppRuntime, + } + + fn grid_deps() -> GridDeps { + crate::register_test_resources(); + let settings = MemorySettings::new(); + let runtime = crate::test_support::test_runtime(); + GridDeps { + deps: ResultGridDeps { + settings: settings.get().clone(), + tasks: runtime.tasks(), + dialect: None, + }, + _settings: settings, + _runtime: runtime, + } } fn actions( @@ -198,12 +217,15 @@ mod tests { preview: String::new(), }; - let widget = entry_widget(1, &entry, &sink(), true, actions(|_| {})); + let deps = grid_deps(); + + let page = entry_widget(1, &entry, &deps.deps, actions(|_| {})); - let grid = crate::test_support::first_descendant_of_type::(&widget).expect("the grid"); + let grid = page.grid.expect("the grid"); + assert!(grid.is_ancestor(&page.widget)); assert_eq!(name, "Result 2 (0)"); assert!(gtk4::test_accessible_has_property( - &grid, + &grid.column_view(), gtk4::AccessibleProperty::Label )); } @@ -219,9 +241,10 @@ mod tests { preview: String::new(), }; - let widget = entry_widget(0, &entry, &sink(), true, actions(|_| {})); + let page = entry_widget(0, &entry, &grid_deps().deps, actions(|_| {})); - let page = widget.downcast::().expect("a status page"); + assert!(page.grid.is_none()); + let page = page.widget.downcast::().expect("a status page"); assert_eq!( page.description().as_deref(), Some("column <b>name</b> & more") @@ -241,14 +264,13 @@ mod tests { let reached = std::rc::Rc::new(std::cell::Cell::new(None)); let seen = reached.clone(); - let widget = entry_widget( + let page = entry_widget( 0, &entry, - &sink(), - true, + &grid_deps().deps, actions(move |position| seen.set(Some(position))), ); - let page = widget.downcast::().expect("a status page"); + let page = page.widget.downcast::().expect("a status page"); let button = page.child().and_downcast::().expect("Go to Error"); button.emit_clicked(); @@ -272,8 +294,7 @@ mod tests { let widget = entry_widget( 0, &entry, - &sink(), - true, + &grid_deps().deps, PageActions { go_to_error: |_| {}, export_all: |_| {}, @@ -282,7 +303,8 @@ mod tests { available: available.clone(), }), }, - ); + ) + .widget; let fix = crate::test_support::descendants(&widget) .into_iter() .filter_map(|widget| widget.downcast::().ok()) diff --git a/linux/crates/app/src/ui/editor/results_pane.rs b/linux/crates/app/src/ui/editor/results_pane.rs index 9d4e949f1b..440c68e8b6 100644 --- a/linux/crates/app/src/ui/editor/results_pane.rs +++ b/linux/crates/app/src/ui/editor/results_pane.rs @@ -6,12 +6,32 @@ use libadwaita as adw; use super::result_item::ResultItem; use super::run_log::{EntryKind, RunLogEntry}; use super::sidebar_policy::{self, SidebarState}; +use crate::ui::data_grid::DataGrid; /// How many results keep their page once shown, so going back to one /// keeps its scroll position without every line holding a page. const PAGES_KEPT: usize = 8; -type PageBuilder = Box Option>; +type PageBuilder = Box Option>; + +/// What a line of the results list shows, and the grid on it when it +/// shows rows. +pub struct ResultPage { + pub widget: gtk4::Widget, + pub grid: Option, +} + +impl ResultPage { + pub fn plain(widget: gtk4::Widget) -> Self { + Self { widget, grid: None } + } +} + +/// A built page's line, and its grid. +struct KeptPage { + index: u32, + grid: Option, +} mod imp { use std::cell::{Cell, RefCell}; @@ -31,6 +51,8 @@ mod imp { #[template_child] pub summary_label: TemplateChild, #[template_child] + pub details_toggle: TemplateChild, + #[template_child] pub transaction_bar: TemplateChild, #[template_child] pub transaction_label: TemplateChild, @@ -49,7 +71,7 @@ mod imp { pub sidebar: Cell, pub page_builder: RefCell>, /// The lines whose page is built, most recently shown first. - pub built: RefCell>, + pub(super) built: RefCell>, } #[glib::object_subclass] @@ -72,6 +94,11 @@ mod imp { self.parent_constructed(); let pane = self.obj(); self.sidebar_toggle.set_icon_name(crate::ui::icons::SIDEBAR_SHOW); + // The header's buttons find actions through the muxers above + // them when they are parented, so the pane needs its own before + // a result's grid actions go in it. + pane.insert_action_group("grid", None::<&gio::ActionGroup>); + crate::ui::a11y::announce_key_shortcuts(&*self.details_toggle, "grid.show-details"); // The toggle is the way back to a collapsed list, so the two // can never disagree about whether it shows. self.sidebar_toggle @@ -134,12 +161,13 @@ impl ResultsPane { if let Some(items) = imp.items.get() { items.remove_all(); } - for index in imp.built.take() { - if let Some(page) = imp.stack.child_by_name(&page_name(index)) { + for kept in imp.built.take() { + if let Some(page) = imp.stack.child_by_name(&page_name(kept.index)) { imp.stack.remove(&page); } } imp.stack.set_visible_child_name("empty"); + self.show_grid(None); imp.sidebar.set(SidebarState::default()); imp.sidebar_toggle.set_visible(false); imp.split_view.set_show_sidebar(false); @@ -148,7 +176,7 @@ impl ResultsPane { /// A page is built when its line is first shown, so a script with /// thousands of statements builds one, not thousands. - pub fn set_page_builder(&self, builder: impl Fn(u32) -> Option + 'static) { + pub fn set_page_builder(&self, builder: impl Fn(u32) -> Option + 'static) { self.imp().page_builder.replace(Some(Box::new(builder))); } @@ -225,35 +253,48 @@ impl ResultsPane { return; } imp.stack.set_visible_child_name(&page_name(index)); + let grid = imp.built.borrow().front().and_then(|kept| kept.grid.clone()); + self.show_grid(grid.as_ref()); // A collapsed list covers the result it just picked. if imp.split_view.is_collapsed() { imp.split_view.set_show_sidebar(false); } } + /// The header's buttons name `grid.*`, so they act on the grid of the + /// result on screen. + fn show_grid(&self, grid: Option<&DataGrid>) { + let actions = grid.map(DataGrid::actions); + self.insert_action_group("grid", actions.as_ref()); + self.imp().details_toggle.set_visible(grid.is_some()); + } + + /// Put the page of line `index` first among the kept ones, building + /// it when it is not kept. fn keep_page(&self, index: u32) -> bool { let imp = self.imp(); { let mut built = imp.built.borrow_mut(); - if let Some(position) = built.iter().position(|kept| *kept == index) { - built.remove(position); - built.push_front(index); + if let Some(position) = built.iter().position(|kept| kept.index == index) { + if let Some(kept) = built.remove(position) { + built.push_front(kept); + } return true; } } let page = imp.page_builder.borrow().as_ref().and_then(|build| build(index)); - let Some(page) = page else { + let Some(ResultPage { widget, grid }) = page else { return false; }; - imp.stack.add_named(&page, Some(&page_name(index))); - let evicted: Vec = { + imp.stack.add_named(&widget, Some(&page_name(index))); + let evicted: Vec = { let mut built = imp.built.borrow_mut(); - built.push_front(index); + built.push_front(KeptPage { index, grid }); let keep = built.len().min(PAGES_KEPT); built.drain(keep..).collect() }; for oldest in evicted { - if let Some(page) = imp.stack.child_by_name(&page_name(oldest)) { + if let Some(page) = imp.stack.child_by_name(&page_name(oldest.index)) { imp.stack.remove(&page); } } @@ -347,7 +388,7 @@ mod tests { fn pane() -> ResultsPane { crate::register_test_resources(); let pane = ResultsPane::default(); - pane.set_page_builder(|index| Some(gtk4::Label::new(Some(&index.to_string())).upcast())); + pane.set_page_builder(|index| Some(ResultPage::plain(gtk4::Label::new(Some(&index.to_string())).upcast()))); pane } @@ -475,10 +516,25 @@ mod tests { fn the_list_toggle_has_an_accessible_name() { let pane = pane(); - assert!(gtk4::test_accessible_has_property( - &pane.imp().sidebar_toggle.get(), - gtk4::AccessibleProperty::Label - )); + for toggle in [pane.imp().sidebar_toggle.get(), pane.imp().details_toggle.get()] { + assert!(gtk4::test_accessible_has_property( + &toggle, + gtk4::AccessibleProperty::Label + )); + } + } + + #[gtk4::test] + fn a_result_with_no_grid_offers_no_row_details() { + let pane = pane(); + + pane.append(&[command()]); + + assert!(!pane.imp().details_toggle.is_visible()); + assert!( + pane.activate_action("grid.show-details", None).is_err(), + "the pane reaches a grid it does not show" + ); } #[gtk4::test] diff --git a/linux/crates/app/src/ui/editor/tests.rs b/linux/crates/app/src/ui/editor/tests.rs index 6263276914..6e4ffb6e2e 100644 --- a/linux/crates/app/src/ui/editor/tests.rs +++ b/linux/crates/app/src/ui/editor/tests.rs @@ -11,6 +11,7 @@ use tablepro_test_support::{ConnectionScript, FakeEditorSession, FakeReply, Fake use super::*; use crate::services::history_preferences::HistoryPreferences; use crate::test_support::{MemorySettings, SignalLog, wait_until}; +use crate::ui::data_grid::DataGrid; const WAIT: Duration = Duration::from_secs(10); @@ -591,49 +592,6 @@ fn a_dropped_file_goes_in_at_the_cursor_as_one_undo_step() { assert_eq!(buffer_text(&buffer), "SELECT 1;\n"); } -#[gtk4::test] -fn a_copy_from_a_result_grid_reaches_the_clipboard() { - use tablepro_core::column::{ColumnType, ResultColumn}; - - use tablepro_core::Value; - - use crate::ui::clipboard::{CopyKind, CopyRequest}; - use crate::ui::grid::RowSnapshot; - - let harness = editor_with(answering(), "SELECT 1"); - let toasts = Rc::new(std::cell::RefCell::new(Vec::new())); - let sink = toasts.clone(); - harness - .editor - .connect_toast(move |_, text| sink.borrow_mut().push(text)); - let grid = harness.editor.imp().grid_sink.get().expect("the grid's sink").clone(); - let clipboard = harness.editor.clipboard(); - clipboard.set_text("before"); - let columns: std::sync::Arc<[ResultColumn]> = ["a", "b"] - .iter() - .map(|name| ResultColumn::new(*name, ColumnType::untyped_text("text"))) - .collect(); - let rows = vec![ - RowSnapshot::new(vec![Value::Int(1), Value::Text("x".into())].into(), Vec::new()), - RowSnapshot::new(vec![Value::Int(2), Value::Text("y".into())].into(), Vec::new()), - ]; - - grid(GridMsg::Copy(CopyRequest::Rows { - columns, - rows, - kind: CopyKind::Rows, - })); - wait_until(WAIT, || !toasts.borrow().is_empty()).expect("the copy to land"); - - assert_eq!(*toasts.borrow(), ["Copied 2 rows".to_owned()]); - assert!(clipboard.formats().contain_mime_type("text/tab-separated-values")); - let text: Option = clipboard - .content() - .and_then(|content| content.value(String::static_type()).ok()) - .and_then(|value| value.get().ok()); - assert_eq!(text.as_deref(), Some("1\tx\n2\ty\n")); -} - #[gtk4::test] fn closing_the_tab_closes_its_connection() { let harness = editor_with(answering(), "SELECT 1"); @@ -788,6 +746,98 @@ fn shown_banner(harness: &Harness) -> adw::Banner { crate::test_support::first_descendant_of_type::(&page).expect("the page's banner") } +/// The grid on the result page on show. +fn shown_grid(harness: &Harness) -> DataGrid { + let page = harness + .editor + .imp() + .results + .imp() + .stack + .visible_child() + .expect("a result page"); + crate::test_support::first_descendant_of_type::(&page).expect("the page's grid") +} + +/// Run what `accel` does in `grid`, as a key press reaching the grid's +/// own controller would. `true` when the grid took the key. +fn press_in_grid(grid: &DataGrid, accel: &str) -> bool { + use crate::test_support::shortcut_keys::{keystroke, owner_controller, shortcuts, trigger_keys}; + let controller = owner_controller(grid, crate::application::ShortcutOwner::Grid).expect("the grid's keys"); + shortcuts(&controller) + .into_iter() + .find(|shortcut| { + shortcut + .trigger() + .is_some_and(|trigger| trigger_keys(&trigger).contains(&keystroke(accel))) + }) + .and_then(|shortcut| shortcut.action()) + .expect("the grid binds the key") + .activate(gtk4::ShortcutActionFlags::empty(), grid, None) +} + +fn details_shown(grid: &DataGrid) -> bool { + grid.actions() + .action_state("show-details") + .and_then(|state| state.get::()) + .unwrap_or(false) +} + +/// D2: a result grid copies on Ctrl+C, and the editor says how it went. +#[gtk4::test] +fn ctrl_c_copies_from_a_result_grid() { + let mut runs = Replies::default(); + runs.always(result_set(2, Some(2), Truncation::Complete)); + let harness = editor_with(runs, "SELECT n FROM t"); + let _window = shown(&harness); + run_and_wait(&harness); + let toasts = SignalLog::connect(&harness.editor, "toast", |values| { + values[1].get::().expect("a message") + }); + let grid = shown_grid(&harness); + let clipboard = grid.clipboard(); + clipboard.set_text("before"); + + grid.selection().select_all(); + assert!(press_in_grid(&grid, "c"), "Ctrl+C went by"); + wait_until(WAIT, || toasts.count() > 0).expect("the copy to land"); + + assert_eq!(toasts.take(), ["Copied 2 rows".to_owned()]); + assert!(clipboard.formats().contain_mime_type("text/tab-separated-values")); + let text: Option = clipboard + .content() + .and_then(|content| content.value(String::static_type()).ok()) + .and_then(|value| value.get().ok()); + assert_eq!(text.as_deref(), Some("0\n1\n")); +} + +/// The pane's Row Details toggle acts on the grid of the result on +/// screen, and shows that grid's state. +#[gtk4::test] +fn row_details_reaches_the_shown_result_s_grid() { + let mut runs = Replies::default(); + runs.always(result_set(2, Some(2), Truncation::Complete)); + let harness = editor_with(runs, "SELECT n FROM a; SELECT n FROM b"); + let _window = shown(&harness); + run_and_wait(&harness); + let results = &harness.editor.imp().results; + let toggle = results.imp().details_toggle.get(); + results.select(0); + let first = shown_grid(&harness); + results.select(1); + let second = shown_grid(&harness); + + assert!(toggle.is_visible()); + wait_until(WAIT, || toggle.is_sensitive()).expect("the toggle reaches the grid"); + toggle.emit_clicked(); + + assert!(details_shown(&second), "the shown grid kept its details closed"); + assert!(!details_shown(&first), "a grid off screen opened its details"); + assert!(toggle.is_active()); + results.select(0); + assert!(!toggle.is_active(), "the toggle kept the other grid's state"); +} + #[gtk4::test] fn truncated_result_reveals_banner_and_complete_result_hides_it() { let mut runs = Replies::default(); diff --git a/linux/crates/app/src/ui/export/export_ledger/tests.rs b/linux/crates/app/src/ui/export/export_ledger/tests.rs index 9ff066042f..97e830ca17 100644 --- a/linux/crates/app/src/ui/export/export_ledger/tests.rs +++ b/linux/crates/app/src/ui/export/export_ledger/tests.rs @@ -18,9 +18,9 @@ use tablepro_session::runtime::CountingHold; use tablepro_test_support::{ConnectionScript, FakeReply, StreamEnd}; use super::*; +use crate::services::grid::RowSnapshot; use crate::test_support::{FakeSession, descendants, finish_on_main, first_descendant_of_type, wait_until}; use crate::ui::export::{AbortOutcome, ExpectedRows, ExportFormat, ExportOperationsButton, RowFormat, TableExport}; -use crate::ui::grid::RowSnapshot; use crate::workspace::{InhibitTarget, WorkOutcome, answer, visible_alert}; const WAIT: Duration = Duration::from_secs(20); diff --git a/linux/crates/app/src/ui/export/export_producer.rs b/linux/crates/app/src/ui/export/export_producer.rs index 727117035f..8e1630f30e 100644 --- a/linux/crates/app/src/ui/export/export_producer.rs +++ b/linux/crates/app/src/ui/export/export_producer.rs @@ -8,7 +8,7 @@ use tablepro_session::{OperationError, OperationKind, Session}; use tokio_util::sync::CancellationToken; use super::{ExportQuery, ExportSinkParts}; -use crate::ui::grid::RowSnapshot; +use crate::services::grid::RowSnapshot; /// Rows a producer encodes before it looks at the cancel again. const ROWS_PER_BATCH: usize = 256; diff --git a/linux/crates/app/src/ui/export/export_scope.rs b/linux/crates/app/src/ui/export/export_scope.rs index a5ba32aa0a..e7ae4bebf0 100644 --- a/linux/crates/app/src/ui/export/export_scope.rs +++ b/linux/crates/app/src/ui/export/export_scope.rs @@ -203,7 +203,7 @@ mod tests { ExportSource::Rows { columns: Vec::new().into(), rows: (0..rows) - .map(|_| crate::ui::grid::RowSnapshot::new(Vec::new().into(), Vec::new())) + .map(|_| crate::services::grid::RowSnapshot::new(Vec::new().into(), Vec::new())) .collect(), } } diff --git a/linux/crates/app/src/ui/export/export_source.rs b/linux/crates/app/src/ui/export/export_source.rs index 1819e205f8..8a0a493646 100644 --- a/linux/crates/app/src/ui/export/export_source.rs +++ b/linux/crates/app/src/ui/export/export_source.rs @@ -4,7 +4,7 @@ use tablepro_core::column::ResultColumn; use tablepro_session::Session; use super::ExportQuery; -use crate::ui::grid::RowSnapshot; +use crate::services::grid::RowSnapshot; /// Where an export reads its rows. #[derive(Debug, Clone)] diff --git a/linux/crates/app/src/ui/filter_strip/mod.rs b/linux/crates/app/src/ui/filter_strip/mod.rs index 10fd0ee4a5..32a528f120 100644 --- a/linux/crates/app/src/ui/filter_strip/mod.rs +++ b/linux/crates/app/src/ui/filter_strip/mod.rs @@ -25,7 +25,8 @@ use tablepro_core::dialect::SqlDialect; use tablepro_core::{ColumnInfo, Combinator, FilterRule, FilterSet, FilterValue}; use self::filter_rule_object::FilterRuleObject; -use self::rule_operators::{FilterVocabulary, operators_for}; +pub(crate) use self::rule_operators::FilterVocabulary; +use self::rule_operators::operators_for; pub(super) use self::rule_row::FilterRuleRow; glib::wrapper! { @@ -154,6 +155,16 @@ impl FilterStrip { } } + /// Add `rule` to the rules shown and apply them, as a cell's Filter by + /// Value does. The strip shows, so the user sees why the rows changed, + /// and the focus stays where it was. + pub fn add_and_apply(&self, rule: FilterRule) { + self.rules().append(&FilterRuleObject::new(rule)); + self.imp().rules_expander.set_expanded(true); + self.set_revealed(true); + self.emit_by_name::<()>("applied", &[&glib::BoxedAnyObject::new(self.filter())]); + } + /// The user applied a filter, or cleared it. pub fn connect_applied(&self, f: F) -> glib::SignalHandlerId { self.connect_closure( diff --git a/linux/crates/app/src/ui/filter_strip/rule_operators.rs b/linux/crates/app/src/ui/filter_strip/rule_operators.rs index 6261055f33..d265f3a993 100644 --- a/linux/crates/app/src/ui/filter_strip/rule_operators.rs +++ b/linux/crates/app/src/ui/filter_strip/rule_operators.rs @@ -45,7 +45,7 @@ impl FilterVocabulary { Self { dialect } } - pub(super) fn operators(&self, column: &ColumnInfo) -> &'static [FilterOp] { + pub(crate) fn operators(&self, column: &ColumnInfo) -> &'static [FilterOp] { match &self.dialect { Some(dialect) => dialect.filter_operators(column), None => tablepro_core::operators_for(&column.column_type.kind), diff --git a/linux/crates/app/src/ui/filter_strip/tests.rs b/linux/crates/app/src/ui/filter_strip/tests.rs index 9afcc6ae50..cad712100d 100644 --- a/linux/crates/app/src/ui/filter_strip/tests.rs +++ b/linux/crates/app/src/ui/filter_strip/tests.rs @@ -357,6 +357,29 @@ fn apply_emits_the_rules_and_sql_as_edited() { assert!(!strip.revealed(), "Apply left the strip open"); } +/// Filter by Value adds to the rules shown and applies them with the +/// strip open, where the user sees why the rows changed. +#[gtk4::test] +fn a_rule_added_from_a_cell_applies_with_the_others_and_shows_the_strip() { + let strip = Hosted::new(strip_with(vec![rule("a")])); + let applied = applied(&strip); + let mut by_value = rule("b"); + by_value.value = Some(FilterValue::Single("7".to_owned())); + + strip.add_and_apply(by_value.clone()); + + assert_eq!( + applied.take(), + [FilterSet { + rules: vec![rule("a"), by_value], + ..FilterSet::default() + }] + ); + assert!(strip.revealed(), "the strip that says why the rows changed is hidden"); + assert!(strip.imp().rules_expander.is_expanded()); + assert_eq!(rows(&strip).len(), 2); +} + #[gtk4::test] fn enter_in_the_sql_field_applies() { let strip = strip_with(Vec::new()); diff --git a/linux/crates/app/src/ui/grid/calendar_editor.rs b/linux/crates/app/src/ui/grid/calendar_editor.rs deleted file mode 100644 index a69969a938..0000000000 --- a/linux/crates/app/src/ui/grid/calendar_editor.rs +++ /dev/null @@ -1,202 +0,0 @@ -use chrono::Datelike; -use gtk4::prelude::*; -use gtk4::{self as gtk, glib}; -use tablepro_core::edit::CellInput; -use tablepro_core::value::Temporal; -use tablepro_core::{ColumnInfo, Value}; - -use super::cell_accessibility::name_editor; -use super::edit_commit::{commit_edit, send_commit}; -use super::inline_editor::{InlineEditor, InlineOptions}; -use super::{EditSnapshot, GridCell}; -use crate::application::component_matches; - -#[derive(Debug, Clone)] -enum CalendarStart { - On(glib::DateTime), - /// A NULL the column may keep, which stays until a day is picked. - Today, -} - -/// A date column's editor: a calendar for a day it can show, and the -/// one-line field for anything else, such as an infinity. -pub struct CalendarEditor; - -impl CalendarEditor { - /// `text` is the cell's edit text, for the one-line field. - pub(super) fn open( - cell: &GridCell, - snapshot: EditSnapshot, - column: &ColumnInfo, - text: String, - inline: InlineOptions, - ) { - let Some(start) = calendar_start(&snapshot.original, column) else { - InlineEditor::begin(cell, snapshot, &text, inline); - return; - }; - cell.cancel_edit(); - let calendar = gtk::Calendar::new(); - name_editor(&calendar, cell); - // Before the handler: set_date emits day-selected when the day - // changes, and that is not a pick. - if let CalendarStart::On(date) = &start { - calendar.set_date(date); - } - let type_value = gtk::Button::with_label(&crate::i18n::gettext("Type a Value")); - type_value.add_css_class("flat"); - let content = gtk::Box::builder() - .orientation(gtk::Orientation::Vertical) - .spacing(6) - .build(); - content.append(&calendar); - content.append(&type_value); - let popover = gtk::Popover::builder().child(&content).build(); - - let weak_cell = cell.downgrade(); - calendar.connect_day_selected(move |calendar| { - if let Some(cell) = weak_cell.upgrade() { - pick(&cell, calendar); - } - }); - // day-selected fires only when the date changes, so a click or - // Space on the day already selected, today on a NULL or the same - // day after a month change, would pick nothing. - let click = gtk::GestureClick::new(); - let weak_cell = cell.downgrade(); - click.connect_released(move |gesture, _, x, y| { - let Some(calendar) = gesture.widget().and_downcast::() else { - return; - }; - let on_a_day = calendar - .pick(x, y, gtk::PickFlags::DEFAULT) - .is_some_and(|label| label.has_css_class("day-number")); - if !on_a_day { - return; - } - if let Some(cell) = weak_cell.upgrade() { - pick(&cell, &calendar); - } - }); - calendar.add_controller(click); - let keys = gtk::EventControllerKey::new(); - let weak_cell = cell.downgrade(); - keys.connect_key_released(move |controller, keyval, _, state| { - if !component_matches("cell-editor.pick-date", keyval, state) { - return; - } - let Some(calendar) = controller.widget().and_downcast::() else { - return; - }; - if let Some(cell) = weak_cell.upgrade() { - pick(&cell, &calendar); - } - }); - calendar.add_controller(keys); - let weak_cell = cell.downgrade(); - type_value.connect_clicked(move |_| { - let Some(cell) = weak_cell.upgrade() else { - return; - }; - let Some(snapshot) = cell.take_edit() else { - return; - }; - if let Some(popover) = cell.take_popover() { - popover.popdown(); - } - InlineEditor::begin(&cell, snapshot, &text, inline.clone()); - }); - - cell.begin_edit(snapshot); - cell.present_popover(&popover); - calendar.grab_focus(); - } -} - -/// The popover closes before the tab hears of the day, as every editor's -/// does. A second pick of the same click finds the edit taken. -fn pick(cell: &GridCell, calendar: >k::Calendar) { - let Some(snapshot) = cell.take_edit() else { - return; - }; - let (year, month, day) = calendar.date().ymd(); - let day = format!("{year:04}-{month:02}-{day:02}"); - if let Some(popover) = cell.take_popover() { - popover.popdown(); - } - let Some(drawing) = cell.drawing() else { - return; - }; - let Some(column) = drawing.catalog.as_ref() else { - return; - }; - let result = commit_edit(column, &snapshot, &day); - send_commit(cell, snapshot, result); -} - -/// `None` for a value the calendar cannot show: an infinity, a year -/// outside 1 to 9999, the server's default or a NULL the column refuses. -fn calendar_start(input: &CellInput, column: &ColumnInfo) -> Option { - match input { - CellInput::Value(Value::Date(Temporal::Finite(date))) if (1..=9999).contains(&date.year()) => { - let month = i32::try_from(date.month()).ok()?; - let day = i32::try_from(date.day()).ok()?; - glib::DateTime::from_utc(date.year(), month, day, 0, 0, 0.0) - .ok() - .map(CalendarStart::On) - } - CellInput::Value(Value::Null) if column.nullable => Some(CalendarStart::Today), - _ => None, - } -} - -#[cfg(test)] -mod tests { - use chrono::NaiveDate; - use tablepro_core::column::{ColumnDefault, ColumnKind, ColumnType}; - use tablepro_core::edit::CellInput; - use tablepro_core::value::Temporal; - use tablepro_core::{ColumnInfo, Value}; - - use super::{CalendarStart, calendar_start}; - - fn date_column(nullable: bool) -> ColumnInfo { - let mut column_type = ColumnType::untyped_text("date"); - column_type.kind = ColumnKind::Date; - ColumnInfo { - name: "day".to_owned(), - column_type, - nullable, - primary_key: false, - is_auto_increment: false, - is_generated: false, - partition_scoped: false, - default: ColumnDefault::None, - comment: None, - } - } - - fn date(year: i32, month: u32, day: u32) -> CellInput { - CellInput::Value(Value::Date(Temporal::Finite( - NaiveDate::from_ymd_opt(year, month, day).expect("a date"), - ))) - } - - #[test] - fn the_calendar_opens_only_on_a_day_it_can_show() { - let nullable = date_column(true); - - let Some(CalendarStart::On(start)) = calendar_start(&date(2026, 9, 15), &nullable) else { - panic!("a date did not open the calendar on its day"); - }; - assert_eq!(start.ymd(), (2026, 9, 15)); - assert!(matches!( - calendar_start(&CellInput::Value(Value::Null), &nullable), - Some(CalendarStart::Today) - )); - assert!(calendar_start(&CellInput::Value(Value::Null), &date_column(false)).is_none()); - assert!(calendar_start(&CellInput::Default, &nullable).is_none()); - assert!(calendar_start(&date(12_000, 1, 1), &nullable).is_none()); - assert!(calendar_start(&CellInput::Value(Value::Date(Temporal::Infinity)), &nullable).is_none()); - } -} diff --git a/linux/crates/app/src/ui/grid/cell_accessibility.rs b/linux/crates/app/src/ui/grid/cell_accessibility.rs deleted file mode 100644 index 635270877c..0000000000 --- a/linux/crates/app/src/ui/grid/cell_accessibility.rs +++ /dev/null @@ -1,94 +0,0 @@ -use gtk4 as gtk; -use gtk4::prelude::*; - -use super::GridCell; -use super::row_state_icon::RowPendingState; -use crate::services::change_tracker::CellState; - -/// An editor opened over a cell takes its column's name: Tab moves the -/// edit across columns, and the header that names them is never focused. -pub(super) fn name_editor(editor: &impl IsA, cell: &GridCell) { - if let Some(name) = cell - .drawing() - .and_then(|drawing| drawing.catalog.as_ref().map(|column| column.name.clone())) - { - editor - .as_ref() - .update_property(&[gtk::accessible::Property::Label(&name)]); - } -} - -/// What a screen reader adds to a cell's value. A new row's cells say -/// nothing of their own: the row says it is new. A NULL is named, since -/// it reads as the text "NULL" would. -pub fn cell_description(state: CellState, null: bool) -> Option { - let text = match (state, null) { - (CellState::Modified, true) => crate::i18n::gettext("Modified, NULL value"), - (CellState::Modified, false) => crate::i18n::gettext("Modified"), - (_, true) => crate::i18n::gettext("NULL value"), - (_, false) => return None, - }; - Some(text) -} - -/// What a screen reader adds to a row. An edited row says nothing of its -/// own: its edited cells do. -pub fn row_description(state: RowPendingState) -> Option { - match state { - RowPendingState::Modified => None, - other => pending_state_text(other), - } -} - -pub(super) fn pending_state_text(state: RowPendingState) -> Option { - let text = match state { - RowPendingState::Unchanged => return None, - RowPendingState::Modified => crate::i18n::gettext("Modified"), - RowPendingState::Inserted => crate::i18n::gettext("New row"), - RowPendingState::MarkedForDeletion => crate::i18n::gettext("Marked for deletion"), - RowPendingState::SaveFailed => crate::i18n::gettext("Not saved"), - }; - Some(text) -} - -#[cfg(test)] -mod tests { - use super::{cell_description, row_description}; - use crate::services::change_tracker::CellState; - use crate::ui::grid::row_state_icon::RowPendingState; - - #[test] - fn cell_and_row_descriptions() { - assert_eq!( - cell_description(CellState::Modified, false).as_deref(), - Some("Modified") - ); - assert_eq!(cell_description(CellState::Clean, false), None); - assert_eq!(cell_description(CellState::InsertDraft, false), None); - assert_eq!(row_description(RowPendingState::Inserted).as_deref(), Some("New row")); - assert_eq!( - row_description(RowPendingState::MarkedForDeletion).as_deref(), - Some("Marked for deletion") - ); - assert_eq!( - row_description(RowPendingState::SaveFailed).as_deref(), - Some("Not saved") - ); - assert_eq!(row_description(RowPendingState::Unchanged), None); - assert_eq!(row_description(RowPendingState::Modified), None); - } - - #[test] - fn cell_description_marks_null_and_modified() { - assert_eq!( - cell_description(CellState::Modified, true).as_deref(), - Some("Modified, NULL value") - ); - assert_eq!(cell_description(CellState::Clean, true).as_deref(), Some("NULL value")); - assert_eq!( - cell_description(CellState::InsertDraft, true).as_deref(), - Some("NULL value") - ); - assert_eq!(cell_description(CellState::Clean, false), None); - } -} diff --git a/linux/crates/app/src/ui/grid/cell_binding.rs b/linux/crates/app/src/ui/grid/cell_binding.rs deleted file mode 100644 index fe124c0ba5..0000000000 --- a/linux/crates/app/src/ui/grid/cell_binding.rs +++ /dev/null @@ -1,39 +0,0 @@ -use gtk4::glib; -use gtk4::prelude::*; - -use super::{GridCell, RowObject}; - -/// A cell's hold on the row it shows. Dropping it disconnects the cell -/// from the row's revision, so a recycled cell never draws for a row it -/// left. -#[derive(Debug)] -pub struct CellBinding { - row: RowObject, - handler: Option, -} - -impl CellBinding { - pub fn new(cell: &GridCell, row: &RowObject) -> Self { - let handler = row.connect_revision_notify(glib::clone!( - #[weak] - cell, - move |_| cell.render() - )); - Self { - row: row.clone(), - handler: Some(handler), - } - } - - pub fn row(&self) -> &RowObject { - &self.row - } -} - -impl Drop for CellBinding { - fn drop(&mut self) { - if let Some(handler) = self.handler.take() { - self.row.disconnect(handler); - } - } -} diff --git a/linux/crates/app/src/ui/grid/cell_edit.rs b/linux/crates/app/src/ui/grid/cell_edit.rs deleted file mode 100644 index 6fc3b0ddeb..0000000000 --- a/linux/crates/app/src/ui/grid/cell_edit.rs +++ /dev/null @@ -1,149 +0,0 @@ -use gtk4 as gtk; -use gtk4::prelude::*; -use tablepro_core::edit::{CellInput, EditParseError, EditText, edit_text}; - -use super::calendar_editor::CalendarEditor; -use super::cell_editor_kind::{CellEditorKind, EDIT_MAX_BYTES}; -use super::edit_commit::empty_placeholder; -use super::inline_editor::{InlineEditor, InlineOptions}; -use super::multiline_editor::MultilineEditor; -use super::{EditSnapshot, GridCell, GridMsg}; -use crate::ui::error_text::edit_parse_message; - -/// Open the editor the cell's column and value call for. It starts from -/// the whole value with any pending edit, never from the text the cell -/// draws, which is cut short or stands in for the value. -/// -/// A boolean cell opens nothing: its check button is the editor, so -/// opening it never writes a value. -pub(crate) fn begin_edit(cell: &GridCell) { - let (Some(row), Some(drawing)) = (cell.row(), cell.drawing()) else { - return; - }; - let Some(column) = drawing.catalog.as_ref() else { - return; - }; - let kind = CellEditorKind::for_cell(column, cell.lock().map_or(Ok(()), Err)); - match kind { - CellEditorKind::ReadOnly(_) | CellEditorKind::Toggle(_) => return, - CellEditorKind::Inline { .. } | CellEditorKind::Multiline { .. } | CellEditorKind::Calendar => {} - } - let original = drawing.tab_ctx.edit_input(&row, cell.column()); - let text = match &original { - CellInput::Value(value) => edit_text(value, &column.column_type), - CellInput::Default => EditText::Editable(String::new()), - }; - let editable = match &text { - // A GTK editor would hold the text only up to the NUL, and - // committing it would cut the value there. - EditText::Editable(editable) if editable.contains('\0') => { - if let Some(sink) = cell.sink() { - sink(GridMsg::ShowToast(crate::i18n::gettext( - "Values with a NUL character can't be edited here", - ))); - } - return; - } - EditText::Editable(editable) if editable.len() > EDIT_MAX_BYTES => { - if let Some(sink) = cell.sink() { - sink(GridMsg::ShowToast(too_large_to_edit())); - } - return; - } - EditText::Editable(editable) => editable.clone(), - EditText::ReadOnly(reason) => { - if let Some(sink) = cell.sink() { - sink(GridMsg::ShowToast(edit_parse_message(&EditParseError::NotEditable( - *reason, - )))); - } - return; - } - }; - let inline = |accepts_line_breaks, purpose| InlineOptions { - accepts_line_breaks, - purpose, - placeholder: empty_placeholder(column, row.id()), - }; - let snapshot = EditSnapshot { - row: row.clone(), - column: cell.column(), - original, - }; - match kind.for_text(&text) { - CellEditorKind::Inline { - accepts_line_breaks, - purpose, - } => InlineEditor::begin(cell, snapshot, &editable, inline(accepts_line_breaks, purpose)), - CellEditorKind::Multiline { language } => MultilineEditor::open(cell, snapshot, &editable, language), - CellEditorKind::Calendar => CalendarEditor::open( - cell, - snapshot, - column, - editable, - inline(false, gtk::InputPurpose::FreeForm), - ), - CellEditorKind::Toggle(_) | CellEditorKind::ReadOnly(_) => {} - } -} - -fn too_large_to_edit() -> String { - let size = gtk::glib::format_size(EDIT_MAX_BYTES as u64); - crate::i18n::gettext_f("Values over {size} can't be edited here", &[("size", size.as_str())]) -} - -pub(super) fn install_edit_triggers(cell: &GridCell) { - // Editing takes a double-click, since a single click in a grid - // selects the row. Capture phase, because the column view's row - // selection takes the press in the bubble phase. The handler reads - // the cell from its gesture: the cell owns its controllers, so - // capturing it would be a cycle. - let gesture = gtk::GestureClick::builder().button(gtk::gdk::BUTTON_PRIMARY).build(); - gesture.set_propagation_phase(gtk::PropagationPhase::Capture); - gesture.connect_pressed(move |gesture, n_press, _, _| { - if n_press != 2 { - return; - } - let Some(cell) = gesture.widget().and_downcast::() else { - return; - }; - if cell.lock().is_some() || cell.is_editing() { - return; - } - gesture.set_state(gtk::EventSequenceState::Claimed); - begin_edit(&cell); - }); - cell.add_controller(gesture); -} - -#[cfg(test)] -mod tests { - use tablepro_core::Value; - - use super::*; - use crate::ui::grid::test_grid::{Grid, named}; - - #[gtk4::test] - fn a_value_too_large_to_lay_out_does_not_open_and_says_why() { - let large = "x".repeat(EDIT_MAX_BYTES + 1); - let grid = Grid::open( - vec![named("id", "integer", true), named("body", "text", false)], - vec![vec![Value::Int(1), Value::Text(large)]], - ); - let cell = grid.cell_of(1, 1); - - begin_edit(&cell); - - assert!(!cell.is_editing(), "an editor opened on the whole value"); - let toasts: Vec = grid - .take_messages() - .into_iter() - .filter_map(|message| match message { - GridMsg::ShowToast(text) => Some(text), - _ => None, - }) - .collect(); - // GLib puts a no-break space between the number and the unit. - assert_eq!(toasts, ["Values over 1.0\u{a0}MB can't be edited here"]); - } -} diff --git a/linux/crates/app/src/ui/grid/cell_editability.rs b/linux/crates/app/src/ui/grid/cell_editability.rs deleted file mode 100644 index 25d322884b..0000000000 --- a/linux/crates/app/src/ui/grid/cell_editability.rs +++ /dev/null @@ -1,157 +0,0 @@ -use tablepro_core::ColumnInfo; -use tablepro_core::edit::read_only_reason; -use tablepro_core::meta::RowIdentity; - -use super::{CellLock, GridRowId}; - -/// A draft is a new row, so it may set the key a saved row may not. -/// An auto-increment column stays locked on a draft too: an insert -/// leaves it to the server, and SQL Server IDENTITY and PostgreSQL -/// GENERATED ALWAYS refuse a value without a statement-level override. -pub fn cell_editability( - column: &ColumnInfo, - row: &GridRowId, - identity: &RowIdentity, - read_only: bool, -) -> Result<(), CellLock> { - match row { - GridRowId::Result => return Err(CellLock::ResultRow), - GridRowId::Unkeyed => return Err(CellLock::UnkeyedRow), - GridRowId::Persisted(_) | GridRowId::Draft(_) => {} - } - if read_only { - return Err(CellLock::ReadOnlySession); - } - if column.is_generated { - return Err(CellLock::Generated); - } - if let Some(reason) = read_only_reason(&column.column_type.kind) { - return Err(CellLock::Value(reason)); - } - if column.is_auto_increment { - return Err(CellLock::AutoIncrement); - } - if matches!(row, GridRowId::Persisted(_)) && names_rows(column, identity) { - return Err(CellLock::KeyColumn); - } - Ok(()) -} - -/// Whether some row of this column can be edited, which decides the -/// widget the column's cells are built with. A draft has the fewest -/// locks, so a column a draft cannot edit no row can. -pub fn column_takes_edits(column: &ColumnInfo, identity: &RowIdentity, read_only: bool) -> bool { - cell_editability(column, &GridRowId::Draft(0), identity, read_only).is_ok() -} - -fn names_rows(column: &ColumnInfo, identity: &RowIdentity) -> bool { - match identity { - RowIdentity::UniqueKey { columns } => columns.contains(&column.name), - RowIdentity::SortingKey { expressions } => expressions.contains(&column.name), - RowIdentity::EngineRowId(_) | RowIdentity::Unordered => false, - } -} - -#[cfg(test)] -mod tests { - use tablepro_core::column::{ColumnDefault, ColumnType}; - use tablepro_core::edit::ReadOnlyReason; - use tablepro_core::meta::RowIdentity; - use tablepro_core::{ColumnInfo, Value}; - - use super::{cell_editability, column_takes_edits}; - use crate::services::change_tracker::RowKey; - use crate::ui::grid::{CellLock, GridRowId}; - - fn column(name: &str, data_type: &str) -> ColumnInfo { - let kind = tablepro_core::column::classify_type_name(data_type); - ColumnInfo { - name: name.to_owned(), - column_type: ColumnType { - kind, - ..ColumnType::untyped_text(data_type) - }, - nullable: true, - primary_key: false, - is_auto_increment: false, - is_generated: false, - partition_scoped: false, - default: ColumnDefault::None, - comment: None, - } - } - - fn keyed_by_id() -> RowIdentity { - RowIdentity::UniqueKey { - columns: vec!["id".to_owned()], - } - } - - fn persisted() -> GridRowId { - GridRowId::Persisted(RowKey::from_pk_values(&[Value::Int(1)]).expect("a key")) - } - - #[test] - fn cell_editability_matrix() { - let identity = keyed_by_id(); - let id = column("id", "integer"); - let name = column("name", "text"); - let mut serial = column("seq", "integer"); - serial.is_auto_increment = true; - let mut total = column("total", "integer"); - total.is_generated = true; - let blob = column("data", "bytea"); - let draft = GridRowId::Draft(0); - let check = - |column: &ColumnInfo, row: &GridRowId, read_only: bool| cell_editability(column, row, &identity, read_only); - - assert_eq!(check(&name, &persisted(), false), Ok(())); - assert_eq!(check(&id, &persisted(), false), Err(CellLock::KeyColumn)); - assert_eq!(check(&id, &draft, false), Ok(()), "a draft sets its own key"); - assert_eq!(check(&total, &draft, false), Err(CellLock::Generated)); - assert_eq!(check(&total, &persisted(), false), Err(CellLock::Generated)); - assert_eq!(check(&serial, &persisted(), false), Err(CellLock::AutoIncrement)); - assert_eq!( - check(&serial, &draft, false), - Err(CellLock::AutoIncrement), - "an insert leaves an auto-increment column to the server" - ); - assert_eq!(check(&name, &GridRowId::Unkeyed, false), Err(CellLock::UnkeyedRow)); - assert_eq!(check(&name, &GridRowId::Result, false), Err(CellLock::ResultRow)); - assert_eq!(check(&name, &persisted(), true), Err(CellLock::ReadOnlySession)); - assert_eq!(check(&name, &draft, true), Err(CellLock::ReadOnlySession)); - assert_eq!(check(&blob, &draft, false), Err(CellLock::Value(ReadOnlyReason::Bytes))); - } - - #[test] - fn a_sorting_key_column_is_locked_on_saved_rows() { - let identity = RowIdentity::SortingKey { - expressions: vec!["day".to_owned()], - }; - - assert_eq!( - cell_editability(&column("day", "date"), &persisted(), &identity, false), - Err(CellLock::KeyColumn) - ); - assert_eq!( - cell_editability(&column("hits", "integer"), &persisted(), &identity, false), - Ok(()) - ); - } - - #[test] - fn a_column_no_row_can_edit_is_built_read_only() { - let identity = keyed_by_id(); - let mut total = column("total", "integer"); - total.is_generated = true; - - let mut serial = column("seq", "integer"); - serial.is_auto_increment = true; - - assert!(column_takes_edits(&column("id", "integer"), &identity, false)); - assert!(!column_takes_edits(&total, &identity, false)); - assert!(!column_takes_edits(&serial, &identity, false)); - assert!(!column_takes_edits(&column("data", "blob"), &identity, false)); - assert!(!column_takes_edits(&column("name", "text"), &identity, true)); - } -} diff --git a/linux/crates/app/src/ui/grid/cell_focus.rs b/linux/crates/app/src/ui/grid/cell_focus.rs deleted file mode 100644 index 98b1523c4c..0000000000 --- a/linux/crates/app/src/ui/grid/cell_focus.rs +++ /dev/null @@ -1,528 +0,0 @@ -use gtk4 as gtk; -use gtk4::prelude::*; - -use super::GridCell; -use super::cell_edit::begin_edit; -use super::edit_navigation::{EditDirection, next_edit_target}; -use super::grid_columns::ShownColumn; -use super::inline_editor::commit_inline; -use super::selection::row_at; - -fn window_focus(widget: &impl IsA) -> Option { - let window = widget.root().and_downcast::()?; - gtk::prelude::GtkWindowExt::focus(&window) -} - -pub(super) fn focused_cell(widget: &impl IsA) -> Option { - GridCell::from_focus(&window_focus(widget)?) -} - -/// Unlike `focused_cell`, not a cell whose editor has the focus. -fn cell_holding_focus(view: >k::ColumnView) -> Option { - window_focus(view)?.first_child()?.downcast().ok() -} - -fn n_rows(view: >k::ColumnView) -> u32 { - view.model().map_or(0, |model| model.n_items()) -} - -pub(super) fn focus_cell(view: >k::ColumnView, position: u32, column: &ShownColumn) -> Option { - if position >= n_rows(view) { - return None; - } - view.scroll_to(position, Some(&column.view_column), gtk::ListScrollFlags::FOCUS, None); - let landed = |cell: &GridCell| { - cell.position() == Some(position) && cell.column() == column.index && cell.column_view().as_ref() == Some(view) - }; - // `scroll_to` moves the focus only while it is in the list already. - // Otherwise it marks the cell the list focuses next, which the grab - // then reaches. - if !cell_holding_focus(view).is_some_and(|cell| landed(&cell)) { - view.grab_focus(); - } - cell_holding_focus(view).filter(landed) -} - -/// Give the focus back to `cell` once its editor closed, since the -/// editor took the focus with it. -pub(super) fn return_focus(cell: &GridCell) { - let (Some(view), Some(position), Some(columns)) = (cell.column_view(), cell.position(), cell.columns()) else { - return; - }; - if let Some(column) = columns - .shown(&view) - .into_iter() - .find(|column| column.index == cell.column()) - { - focus_cell(&view, position, &column); - } -} - -/// Commit the one-line edit open in `view`, as Return does. -pub(crate) fn commit_open_edit(view: >k::ColumnView) { - let Some(cell) = focused_cell(view) else { - return; - }; - if cell.inline_text().is_none() || cell.column_view().as_ref() != Some(view) { - return; - } - commit_inline(&cell); - return_focus(&cell); -} - -/// Edit the cell Tab or Shift+Tab goes to from `cell`. With none left -/// on the page, the focus stays on `cell`. -pub(super) fn edit_next(cell: &GridCell, direction: EditDirection) { - let (Some(view), Some(position), Some(columns)) = (cell.column_view(), cell.position(), cell.columns()) else { - return; - }; - let shown = columns.shown(&view); - let Some(from) = shown.iter().position(|column| column.index == cell.column()) else { - return; - }; - let opens_editor = |row: u32, column: usize| { - shown - .get(column) - .zip(row_at(&view, row)) - .is_some_and(|(column, row)| column.opens_editor(&row)) - }; - let target = next_edit_target(opens_editor, shown.len(), from, position, n_rows(&view), direction) - .and_then(|(row, column)| Some((row, shown.get(column)?))); - match target { - Some((row, column)) => { - if let Some(next) = focus_cell(&view, row, column) { - begin_edit(&next); - } - } - None => { - if let Some(column) = shown.get(from) { - focus_cell(&view, position, column); - } - } - } -} - -/// Select the row at `position` and edit its first cell that opens an -/// editor. A row with none keeps the focus on the cell the row gave it. -pub(crate) fn edit_row(view: >k::ColumnView, position: u32) { - if position >= n_rows(view) { - return; - } - // Every cell knows the grid's columns, so the row takes the focus - // first and its cell finds the column to edit. The grab reaches the - // row when the focus was outside the list, where `scroll_to` only - // marks it. - view.scroll_to( - position, - None, - gtk::ListScrollFlags::FOCUS | gtk::ListScrollFlags::SELECT, - None, - ); - view.grab_focus(); - let (Some(columns), Some(row)) = ( - focused_cell(view).and_then(|cell| cell.columns()), - row_at(view, position), - ) else { - return; - }; - let Some(column) = columns.shown(view).into_iter().find(|column| column.opens_editor(&row)) else { - return; - }; - if let Some(cell) = focus_cell(view, position, &column) { - begin_edit(&cell); - } -} - -/// Select the first `count` rows, where new rows land, and give the -/// first of them the focus. -pub(crate) fn select_top_rows(view: >k::ColumnView, count: u32) { - let Some(model) = view.model() else { - return; - }; - let count = count.min(model.n_items()); - if count == 0 { - return; - } - model.select_range(0, count, true); - view.scroll_to(0, None, gtk::ListScrollFlags::FOCUS, None); - view.grab_focus(); -} - -#[cfg(test)] -mod tests { - use std::time::Duration; - - use gtk4::prelude::*; - use gtk4::{self as gtk, gdk, glib}; - use tablepro_core::Value; - use tablepro_core::edit::CellInput; - use tablepro_core::value::Temporal; - - use super::edit_row; - use crate::test_support::{ - FocusThief, after_next_frame, assert_labelled, drain_main_context, first_descendant_of_type, wait_until, - }; - use crate::ui::grid::test_grid::{Grid, WAIT, edited_rows, key, named, text}; - use crate::ui::grid::{GridCell, begin_edit}; - - /// Press `key` in the cell's open one-line editor, through the - /// handler a real key press reaches first. - fn press_in_editor(cell: &GridCell, key: gdk::Key, modifiers: gdk::ModifierType) -> bool { - let editor = cell.inline_text().expect("an open one-line editor"); - let controllers = editor.observe_controllers(); - let keys = (0..controllers.n_items()) - .filter_map(|position| controllers.item(position).and_downcast::()) - .find(|controller| controller.propagation_phase() == gtk::PropagationPhase::Capture) - .expect("the editor's key handler"); - keys.emit_by_name::("key-pressed", &[&key, &0u32, &modifiers]) - } - - /// The editing cell once the focus is in its editor. - fn editing(grid: &Grid) -> GridCell { - let mut editing = None; - wait_until(WAIT, || { - editing = grid.focused_cell().filter(|cell| cell.inline_text().is_some()); - editing.is_some() - }) - .expect("a cell is open for editing with the focus"); - editing.expect("an editing cell") - } - - fn at(cell: &GridCell) -> (Option, usize) { - (cell.position(), cell.column()) - } - - /// A key, a text, a check box and a text: the key is locked on the - /// saved rows, and a check box opens no editor. - fn mixed_rows() -> Grid { - Grid::open( - vec![ - named("id", "integer", true), - named("name", "text", false), - named("flag", "boolean", false), - named("note", "text", false), - ], - vec![ - vec![ - Value::Int(1), - Value::Text("a".into()), - Value::Bool(false), - Value::Text("x".into()), - ], - vec![ - Value::Int(2), - Value::Text("b".into()), - Value::Bool(true), - Value::Text("y".into()), - ], - ], - ) - } - - #[gtk4::test] - fn tab_in_edit_mode_moves_to_next_editable_column() { - let grid = mixed_rows(); - let name = grid.focus(0, 1); - crate::ui::grid::begin_edit(&name); - name.inline_text().expect("an editor").set_text("a2"); - let none = gdk::ModifierType::empty(); - - assert!(press_in_editor(&name, gdk::Key::Tab, none)); - let note = editing(&grid); - assert_eq!(at(¬e), (Some(0), 3), "Tab did not skip the check box"); - assert_eq!( - note.inline_text().map(|editor| editor.text().to_string()).as_deref(), - Some("x") - ); - - assert!(press_in_editor(¬e, gdk::Key::Tab, none)); - let next_row = editing(&grid); - assert_eq!(at(&next_row), (Some(1), 1), "Tab did not skip the saved row's key"); - - assert!(press_in_editor( - &next_row, - gdk::Key::ISO_Left_Tab, - gdk::ModifierType::SHIFT_MASK - )); - let back = editing(&grid); - assert_eq!(back, note, "Shift+Tab did not go back a row"); - - assert!(!name.is_editing() && !next_row.is_editing()); - assert_eq!(edited_rows(&grid.take_messages()), vec![(Some(key(1)), 1, text("a2"))]); - } - - #[gtk4::test] - fn tab_past_the_last_editable_cell_keeps_the_focus_there() { - let grid = mixed_rows(); - let note = grid.focus(1, 3); - crate::ui::grid::begin_edit(¬e); - - assert!(press_in_editor(¬e, gdk::Key::Tab, gdk::ModifierType::empty())); - - assert!(!note.is_editing()); - assert_eq!(grid.focused_cell().as_ref(), Some(¬e)); - } - - #[gtk4::test] - fn escape_cancels_and_returns_focus_to_cell() { - let grid = mixed_rows(); - let name = grid.focus(1, 1); - crate::ui::grid::begin_edit(&name); - name.inline_text().expect("an editor").set_text("typed"); - - assert!(press_in_editor(&name, gdk::Key::Escape, gdk::ModifierType::empty())); - - assert!(!name.is_editing()); - assert_eq!(grid.focused_cell().as_ref(), Some(&name), "the focus did not come back"); - assert!(grid.take_messages().is_empty(), "Escape sent the typed text"); - } - - /// GTK ends the focus change by walking up from the field the edit - /// was in. GTK 4.22 logs a critical forever and never returns when the - /// field has left its cell by then. - #[gtk4::test] - fn the_focus_moving_to_another_cell_commits_the_edit() { - let grid = mixed_rows(); - let name = grid.focus(0, 1); - crate::ui::grid::begin_edit(&name); - name.inline_text().expect("an editor").set_text("a2"); - - let previous = glib::log_set_always_fatal(glib::LogLevels::LEVEL_CRITICAL); - let note = grid.focus(1, 3); - glib::log_set_always_fatal(previous); - - assert!(!name.is_editing(), "the edit outlived the focus"); - assert_eq!(edited_rows(&grid.take_messages()), vec![(Some(key(1)), 1, text("a2"))]); - assert_eq!(grid.focused_cell().as_ref(), Some(¬e)); - assert!( - !name.state_flags().contains(gtk::StateFlags::FOCUS_WITHIN), - "the cell the edit left still shows the focus" - ); - } - - /// Another window taking the focus is no focus change in this one, - /// so the edit stays open, and typing goes on there once it is back. - #[gtk4::test] - fn an_edit_stays_open_while_another_window_has_the_focus() { - let grid = mixed_rows(); - let name = grid.focus(1, 1); - crate::ui::grid::begin_edit(&name); - let editor = name.inline_text().expect("an editor"); - editor.set_text("typed"); - - let thief = FocusThief::take_from(&grid.window).expect("another window takes the focus"); - after_next_frame(&grid.window); - assert!(name.is_editing(), "another window taking the focus ended the edit"); - thief.give_back(&grid.window); - drain_main_context(); - after_next_frame(&grid.window); - - assert!(name.is_editing(), "the edit ended when the window came back"); - assert!(grid.take_messages().is_empty(), "the half-typed value was sent"); - assert_eq!( - gtk::prelude::GtkWindowExt::focus(&grid.window).as_ref(), - Some(editor.upcast_ref::()), - "the focus left the field" - ); - } - - #[gtk4::test] - fn ctrl_tab_in_an_editor_is_left_to_the_window() { - let grid = mixed_rows(); - let name = grid.focus(1, 1); - crate::ui::grid::begin_edit(&name); - - assert!(!press_in_editor(&name, gdk::Key::Tab, gdk::ModifierType::CONTROL_MASK)); - assert!(name.is_editing()); - } - - #[gtk4::test] - fn new_draft_enters_edit_mode_on_first_editable_cell() { - let mut serial = named("id", "integer", true); - serial.is_auto_increment = true; - let grid = Grid::open( - vec![serial, named("name", "text", false), named("note", "text", false)], - vec![vec![Value::Int(1), Value::Text("a".into()), Value::Text("x".into())]], - ); - grid.insert_draft(); - - edit_row(&grid.view, 0); - - let cell = editing(&grid); - assert!(cell.row().is_some_and(|row| row.draft_id().is_some())); - assert_eq!(cell.column(), 1, "the edit did not skip the auto-increment key"); - let selection = grid.view.model().expect("a selection model"); - assert!(selection.is_selected(0) && !selection.is_selected(1)); - } - - fn focus_in(grid: &Grid, popover: >k::Popover) -> bool { - gtk::prelude::GtkWindowExt::focus(&grid.window).is_some_and(|focus| focus.is_ancestor(popover)) - } - - /// Open the cell's editor popover with the focus in it. Test processes - /// share one X display, where another test's window can close an - /// autohide popover before it maps. - fn open_popover(grid: &Grid, cell: &GridCell) -> gtk::Popover { - for _ in 0..5 { - begin_edit(cell); - if let Some(popover) = first_descendant_of_type::(cell) - && wait_until(Duration::from_secs(2), || { - popover.is_mapped() && focus_in(grid, &popover) - }) - .is_ok() - { - return popover; - } - cell.cancel_edit(); - drain_main_context(); - } - panic!("the editor popover never showed"); - } - - fn button(popover: >k::Popover, label: &str) -> gtk::Button { - crate::test_support::descendants(popover) - .into_iter() - .filter_map(|widget| widget.downcast::().ok()) - .find(|button| button.label().as_deref() == Some(label)) - .expect("the popover's button") - } - - fn day(day: u32) -> Value { - Value::Date(Temporal::Finite( - chrono::NaiveDate::from_ymd_opt(2024, 3, day).expect("a date"), - )) - } - - fn dated_row() -> Grid { - Grid::open( - vec![named("id", "integer", true), named("day", "date", false)], - vec![vec![Value::Int(1), day(15)]], - ) - } - - #[gtk4::test] - fn picking_a_day_returns_focus_to_cell() { - let grid = dated_row(); - let cell = grid.focus(0, 1); - let popover = open_popover(&grid, &cell); - let calendar = first_descendant_of_type::(&popover).expect("the calendar"); - - calendar.set_date(&glib::DateTime::from_utc(2024, 3, 16, 0, 0, 0.0).expect("a day")); - after_next_frame(&grid.window); - - assert!(!cell.is_editing()); - assert_eq!(grid.focused_cell().as_ref(), Some(&cell), "the focus left the grid"); - assert_eq!( - edited_rows(&grid.take_messages()), - vec![(Some(key(1)), 1, CellInput::Value(day(16)))] - ); - } - - #[gtk4::test] - fn closing_the_calendar_returns_focus_to_cell() { - let grid = dated_row(); - let cell = grid.focus(0, 1); - let popover = open_popover(&grid, &cell); - - // As Escape does. - popover.popdown(); - after_next_frame(&grid.window); - - assert!(!cell.is_editing()); - assert_eq!(grid.focused_cell().as_ref(), Some(&cell), "the focus left the grid"); - assert!(grid.take_messages().is_empty()); - } - - #[gtk4::test] - fn removing_the_row_under_an_open_calendar_drops_its_edit() { - let grid = Grid::open( - vec![named("id", "integer", true), named("day", "date", false)], - vec![vec![Value::Int(1), day(15)], vec![Value::Int(2), day(20)]], - ); - let cell = grid.focus(0, 1); - let popover = open_popover(&grid, &cell); - - grid.store.remove(0); - after_next_frame(&grid.window); - - assert!(popover.parent().is_none(), "the popover outlived its row"); - assert!(!cell.is_editing()); - assert!(grid.take_messages().is_empty()); - let focus = gtk::prelude::GtkWindowExt::focus(&grid.window); - assert!( - focus.is_some_and(|focus| focus == *grid.view.upcast_ref::() || focus.is_ancestor(&grid.view)), - "the focus left the grid" - ); - } - - /// Tab moves an edit across the columns, and no header is focused on - /// the way, so each editor names the column it edits. - #[gtk4::test] - fn every_cell_editor_is_named_by_its_column() { - let grid = Grid::open( - vec![ - named("id", "integer", true), - named("note", "text", false), - named("day", "date", false), - ], - vec![ - vec![Value::Int(1), Value::Text("one\ntwo".into()), day(15)], - vec![Value::Int(2), Value::Text("plain".into()), day(16)], - ], - ); - - let one_line = grid.cell_of(2, 1); - begin_edit(&one_line); - let field = one_line.inline_text().expect("the one-line editor"); - assert_eq!(assert_labelled(&field), Ok(())); - one_line.cancel_edit(); - - let lines = grid.cell_of(1, 1); - begin_edit(&lines); - let view = first_descendant_of_type::(&lines).expect("the multi-line editor"); - assert_eq!(assert_labelled(&view), Ok(())); - let save = crate::test_support::descendants(&lines) - .into_iter() - .filter_map(|widget| widget.downcast::().ok()) - .find(|button| button.label().as_deref() == Some("Save")) - .expect("the Save button"); - assert!(gtk::test_accessible_has_property( - &save, - gtk::AccessibleProperty::KeyShortcuts - )); - lines.cancel_edit(); - - let dated = grid.cell_of(1, 2); - begin_edit(&dated); - let calendar = first_descendant_of_type::(&dated).expect("the calendar"); - assert_eq!(assert_labelled(&calendar), Ok(())); - dated.cancel_edit(); - } - - #[gtk4::test] - fn the_multi_line_editor_returns_focus_to_cell() { - let grid = Grid::open( - vec![named("id", "integer", true), named("note", "text", false)], - vec![vec![Value::Int(1), Value::Text("one\ntwo".into())]], - ); - let cell = grid.focus(0, 1); - - let popover = open_popover(&grid, &cell); - button(&popover, "Cancel").emit_clicked(); - after_next_frame(&grid.window); - assert!(!cell.is_editing()); - assert_eq!(grid.focused_cell().as_ref(), Some(&cell), "Cancel left the grid"); - - let popover = open_popover(&grid, &cell); - let view = first_descendant_of_type::(&popover).expect("the multi-line editor"); - view.buffer().set_text("one\nthree"); - button(&popover, "Save").emit_clicked(); - after_next_frame(&grid.window); - assert!(!cell.is_editing()); - assert_eq!(grid.focused_cell().as_ref(), Some(&cell), "Save left the grid"); - assert_eq!( - edited_rows(&grid.take_messages()), - vec![(Some(key(1)), 1, text("one\nthree"))] - ); - } -} diff --git a/linux/crates/app/src/ui/grid/cell_lock.rs b/linux/crates/app/src/ui/grid/cell_lock.rs deleted file mode 100644 index 5657569ece..0000000000 --- a/linux/crates/app/src/ui/grid/cell_lock.rs +++ /dev/null @@ -1,33 +0,0 @@ -use tablepro_core::edit::ReadOnlyReason; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum CellLock { - /// The column's values have no text form to type. - Value(ReadOnlyReason), - /// The row is named by this column, so changing it in place would - /// lose the row it names. - KeyColumn, - Generated, - AutoIncrement, - UnkeyedRow, - ResultRow, - ReadOnlySession, -} - -impl CellLock { - /// Why the cell takes no value, for an edit that cannot open an - /// editor to show it, such as a paste. - pub fn explanation(self) -> String { - match self { - Self::Value(reason) => { - crate::ui::error_text::edit_parse_message(&tablepro_core::edit::EditParseError::NotEditable(reason)) - } - Self::KeyColumn => crate::i18n::gettext("A saved row's key can't be changed here"), - Self::Generated => crate::i18n::gettext("The database computes this column's values"), - Self::AutoIncrement => crate::i18n::gettext("The database numbers this column's values"), - Self::UnkeyedRow => crate::i18n::gettext("Rows of a table without a primary key can't be edited"), - Self::ResultRow => crate::i18n::gettext("Query results can't be edited here"), - Self::ReadOnlySession => crate::i18n::gettext("This connection is read-only."), - } - } -} diff --git a/linux/crates/app/src/ui/grid/cell_tooltip.rs b/linux/crates/app/src/ui/grid/cell_tooltip.rs deleted file mode 100644 index 6683fdc6ea..0000000000 --- a/linux/crates/app/src/ui/grid/cell_tooltip.rs +++ /dev/null @@ -1,61 +0,0 @@ -/// Shorter text fits a typical 200 px column, so its ellipsized display -/// needs no tooltip. -const TOOLTIP_MIN_CHARS: usize = 40; -/// A tooltip is a glance, not a viewer: past this the full value is a -/// menu item away. -pub(super) const TOOLTIP_MAX_CHARS: usize = 300; - -/// The tooltip for a cell's text. `None` for a placeholder and for text -/// short enough to read in the cell. -pub(super) fn cell_tooltip(text: &str, truncated_bytes: usize, placeholder: bool) -> Option { - if placeholder { - return None; - } - let mut chars = text.char_indices(); - if truncated_bytes == 0 && chars.nth(TOOLTIP_MIN_CHARS).is_none() { - return None; - } - let cut = text.char_indices().nth(TOOLTIP_MAX_CHARS).map(|(index, _)| index); - let mut tooltip = match cut { - Some(end) => format!("{}…", &text[..end]), - None => text.to_owned(), - }; - if cut.is_some() || truncated_bytes > 0 { - tooltip.push('\n'); - tooltip.push_str(&crate::i18n::gettext("Use Show Row as JSON to see the full value")); - } - Some(tooltip) -} - -#[cfg(test)] -mod tests { - use super::{TOOLTIP_MAX_CHARS, cell_tooltip}; - - #[test] - fn cell_tooltip_none_for_short_and_placeholder() { - assert_eq!(cell_tooltip("short", 0, false), None); - assert_eq!(cell_tooltip(&"x".repeat(40), 0, false), None); - assert_eq!(cell_tooltip(&"x".repeat(500), 0, true), None, "a placeholder"); - assert_eq!( - cell_tooltip(&"x".repeat(41), 0, false).as_deref(), - Some("x".repeat(41).as_str()) - ); - } - - #[test] - fn cell_tooltip_capped_at_300_chars_with_hint() { - let tooltip = cell_tooltip(&"é".repeat(1_000), 0, false).expect("a tooltip"); - let (head, hint) = tooltip.split_once('\n').expect("a hint line"); - - assert_eq!(head.chars().count(), TOOLTIP_MAX_CHARS + 1); - assert!(head.ends_with('…')); - assert!(hint.contains("Show Row as JSON"), "{hint}"); - } - - #[test] - fn a_cut_cell_says_so_even_when_its_text_is_short() { - let tooltip = cell_tooltip("head", 12, false).expect("a tooltip"); - - assert!(tooltip.starts_with("head\n"), "{tooltip}"); - } -} diff --git a/linux/crates/app/src/ui/grid/column_drawing.rs b/linux/crates/app/src/ui/grid/column_drawing.rs deleted file mode 100644 index 0759379a4a..0000000000 --- a/linux/crates/app/src/ui/grid/column_drawing.rs +++ /dev/null @@ -1,147 +0,0 @@ -use std::rc::Rc; - -use gtk4 as gtk; -use gtk4::prelude::*; - -use tablepro_core::column::{BoolStorage, ColumnKind, ColumnType}; -use tablepro_core::{ColumnInfo, Value}; - -use super::cell_tooltip::cell_tooltip; -use super::column_figures::ColumnFigures; -use super::display_text::{DisplayText, display_text}; -use super::grid_attributes::{GridAttributes, TextMark}; -use super::toggle_cell::{ToggleCell, toggle_state}; -use super::{CellLock, GridCell, GridRowId, RowObject, TabGridContext, cell_editability}; -use crate::services::change_tracker::CellState; - -const PENDING_CSS_CLASSES: &[&str] = &[ - "tp-cell-modified", - "tp-row-pending-delete", - "tp-row-pending-insert", - "tp-row-leftmost-error-flash", -]; - -/// What a column's cells read to draw a row. The column makes one and -/// every cell it builds shares it. -#[derive(Debug)] -pub struct ColumnDrawing { - /// `None` for a column the grid knows only from the result set. - pub catalog: Option, - /// The type the result set gives the column, which a value is - /// written out in. - pub column_type: ColumnType, - pub tab_ctx: TabGridContext, - /// The server fills these in, so a new row shows a placeholder - /// rather than an empty cell the user should type in. - pub auto_filled: bool, - pub attributes: Rc, - pub figures: ColumnFigures, -} - -impl ColumnDrawing { - pub fn lock(&self, row: &GridRowId) -> Option { - match self.catalog.as_ref() { - Some(column) => cell_editability(column, row, &self.tab_ctx.identity, !self.tab_ctx.editable).err(), - None => Some(CellLock::ResultRow), - } - } - - pub fn draw(&self, cell: &GridCell, row: &RowObject) -> DrawnCell { - let column = cell.column(); - let (pending_classes, state) = self.tab_ctx.pending_marks(row, column); - self.tab_ctx.with_effective_value(row, column, |value, default| { - let drawn = DrawnCell { - state, - null: !default && value.is_null(), - }; - // An edit opened on a value the row no longer has would, once - // it ended, send that value back. An edit on the same value - // keeps what was typed. - if cell.edit_left_behind(value, default) { - cell.cancel_edit(); - } - let Some(content) = cell.content() else { - return drawn; - }; - apply_pending_classes(content, &pending_classes); - let pending_delete = pending_classes.contains(&"tp-row-pending-delete"); - if let Some(check) = content.downcast_ref::() { - let storage = match self.catalog.as_ref().map(|column| &column.column_type.kind) { - Some(ColumnKind::Bool(storage)) => *storage, - _ => BoolStorage::Native, - }; - ToggleCell::draw(cell, check, &toggle_state(value, default, &self.column_type, storage)); - check.set_sensitive(cell.lock().is_none()); - // A check box has no strikethrough, so a pending delete dims it. - check.set_opacity(if pending_delete { 0.5 } else { 1.0 }); - } else if let Some(display) = content.downcast_ref::() { - let mark = match (pending_delete, state) { - (true, _) => TextMark::PendingDelete, - (false, CellState::Modified) => TextMark::Modified, - (false, _) => TextMark::Plain, - }; - let shown = CellText { - value, - default, - editable: cell.lock().is_none(), - mark, - }; - cell.with_scratch(|scratch| self.draw_text(display, &shown, scratch)); - } - drawn - }) - } - - /// An editable cell shows NULL as ``, so it reads differently - /// from an empty string, and a locked one dims a plain NULL. - fn draw_text(&self, display: >k::Inscription, cell: &CellText<'_>, scratch: &mut String) { - let attributes = &self.attributes; - let shown = match (cell.default, cell.value) { - (true, _) if self.auto_filled => DisplayText::placeholder(&attributes.auto_filled), - (true, _) => DisplayText::placeholder("DEFAULT"), - (false, Value::Null) if cell.editable => DisplayText::placeholder(&attributes.editable_null), - (false, value) => display_text(value, &self.column_type, scratch), - }; - self.figures.apply(display, shown.text, shown.placeholder); - display.set_text(Some(shown.text)); - let widget = display.upcast_ref::(); - widget.set_tooltip_text(cell_tooltip(shown.text, shown.truncated_bytes, shown.placeholder).as_deref()); - set_class( - widget, - "dimmed", - !cell.default && cell.value.is_null() && !cell.editable, - ); - display.set_attributes(attributes.for_text(cell.mark, shown.placeholder)); - } -} - -/// What a draw tells a screen reader about the cell. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct DrawnCell { - pub state: CellState, - /// The cell holds NULL, not a draft's cell left to the server. - pub null: bool, -} - -/// One cell's value and the state it is drawn in. -struct CellText<'a> { - value: &'a Value, - /// A draft's cell left to the server. - default: bool, - editable: bool, - mark: TextMark, -} - -fn set_class(widget: >k::Widget, class: &str, on: bool) { - if on { - widget.add_css_class(class); - } else { - widget.remove_css_class(class); - } -} - -fn apply_pending_classes(widget: >k::Widget, pending_classes: &[&'static str]) { - for class in PENDING_CSS_CLASSES { - set_class(widget, class, pending_classes.contains(class)); - } -} diff --git a/linux/crates/app/src/ui/grid/column_factory.rs b/linux/crates/app/src/ui/grid/column_factory.rs deleted file mode 100644 index 15f576ea6c..0000000000 --- a/linux/crates/app/src/ui/grid/column_factory.rs +++ /dev/null @@ -1,1206 +0,0 @@ -use std::cell::RefCell; -use std::rc::Rc; - -use gtk4::prelude::*; -use gtk4::{self as gtk, gio, glib}; - -use tablepro_core::ColumnInfo; -use tablepro_core::column::{ColumnKind, ResultColumn}; - -use super::cell_edit::install_edit_triggers; -use super::cell_editability::column_takes_edits; -use super::column_drawing::ColumnDrawing; -use super::column_figures::ColumnFigures; -use super::context_menu::attach_cell_gesture; -use super::grid_attributes::GridAttributes; -use super::grid_columns::GridColumns; -use super::grid_keyboard::GridKeyboard; -use super::grid_menus::{GridMenuInit, GridMenus, install_grid_context_menus}; -use super::result_sorters::column_sorter; -use super::row_factory::install_row_factory; -use super::row_state_column::row_state_column; -use super::toggle_cell::ToggleCell; -use super::{GridCell, GridContent, GridMsg, GridSink, RowObject, TabGridContext}; - -const WIDE_TABLE_THRESHOLD: usize = 8; -/// About 14 characters at the default font size. -const MIN_COLUMN_WIDTH_PX: i32 = 120; - -/// How a grid's headers sort its rows. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum GridSorting { - /// The server sorts: a header click sends `SortChanged` and the rows - /// stay in the order they arrived, which `initial` describes. - Server { initial: Option<(usize, bool)> }, - /// The grid sorts the rows it holds, each column by its own sorter. - Client, - /// The engine reads a table in its own order, which no ORDER BY - /// changes, so no header sorts and the cell menu offers no sort. - EngineOrder, -} - -pub(crate) fn build_column_view( - content: GridContent, - schema_columns: &[ColumnInfo], - sink: GridSink, - sorting: GridSorting, - tab_ctx: TabGridContext, -) -> (gtk::ColumnView, gtk::MultiSelection) { - let GridContent { - columns: result_columns, - rows, - } = content; - let store = gio::ListStore::new::(); - store.splice(0, 0, &rows); - let grid_columns = Rc::new(GridColumns::default()); - // A browse page splices its rows into this store, so the columns - // measure them before the view binds any of them. - store.connect_items_changed({ - let grid_columns = grid_columns.clone(); - move |store, position, _, added| grid_columns.measure_change(store, position, added) - }); - // Tab leaves the grid, and the arrow keys move between cells. - let column_view = gtk::ColumnView::builder() - .show_row_separators(true) - .show_column_separators(true) - .tab_behavior(gtk::ListTabBehavior::Cell) - .build(); - let selection = match sorting { - GridSorting::Client => sorted_selection(store, column_view.sorter()), - GridSorting::Server { .. } | GridSorting::EngineOrder => gtk::MultiSelection::new(Some(store)), - }; - column_view.set_model(Some(&selection)); - // GTK's own denser rows for a grid of data. - column_view.add_css_class("data-table"); - install_row_factory(&column_view, tab_ctx.clone()); - - let grid_menus = install_grid_context_menus(GridMenuInit { - column_view: &column_view, - sink: sink.clone(), - columns: result_columns.clone(), - tab_ctx: tab_ctx.clone(), - sorts: !matches!(sorting, GridSorting::EngineOrder), - }); - GridKeyboard::install(&column_view, grid_menus.clone(), sink.clone()); - - // Sharing the viewport by `expand` alone leaves a wide table with 20 - // to 30 px columns that show nothing, so a text column with no saved - // width starts at a readable minimum there. - let default_min_width = if result_columns.len() > WIDE_TABLE_THRESHOLD { - Some(MIN_COLUMN_WIDTH_PX) - } else { - None - }; - let mut columns: Vec = Vec::with_capacity(result_columns.len()); - let mut releases = Vec::with_capacity(result_columns.len() + 1); - let attributes = Rc::new(GridAttributes::new()); - for (i, column) in result_columns.iter().enumerate() { - let sorter = match sorting { - // The server orders the rows, so the column's own sorter - // only marks the header and must leave them where they are. - GridSorting::Server { .. } => Some(gtk::CustomSorter::new(|_, _| gtk::Ordering::Equal).upcast()), - GridSorting::Client => Some(column_sorter(i, column)), - GridSorting::EngineOrder => None, - }; - let (col, width_saver) = build_column(ColumnInit { - info: column, - idx: i, - rows: &rows, - catalog: schema_columns.get(i).cloned(), - sink: sink.clone(), - sorter, - tab_ctx: tab_ctx.clone(), - default_min_width, - column_view: column_view.downgrade(), - grid_menus: grid_menus.clone(), - grid_columns: grid_columns.clone(), - attributes: attributes.clone(), - }); - column_view.append_column(&col); - releases.push(ColumnRelease { - column: col.downgrade(), - width_saver: Some(width_saver), - }); - columns.push(col); - } - if tab_ctx.tracks_changes() { - let state_column = row_state_column(tab_ctx.clone()); - column_view.insert_column(0, &state_column); - releases.push(ColumnRelease { - column: state_column.downgrade(), - width_saver: None, - }); - } - release_columns_with(&column_view, releases); - wire_server_sorting(&column_view, columns, sorting, sink); - - (column_view, selection) -} - -fn wire_server_sorting( - column_view: >k::ColumnView, - columns: Vec, - sorting: GridSorting, - sink: GridSink, -) { - // Applied before the notify handlers are wired, so it does not echo - // back as a SortChanged. - if let GridSorting::Server { - initial: Some((col_idx, ascending)), - } = sorting - && let Some(col) = columns.get(col_idx) - { - let direction = if ascending { - gtk::SortType::Ascending - } else { - gtk::SortType::Descending - }; - column_view.sort_by_column(Some(col), direction); - } - - if matches!(sorting, GridSorting::Server { .. }) - && let Some(view_sorter) = column_view - .sorter() - .and_then(|s| s.downcast::().ok()) - { - // A second click on the same column flips only - // `primary-sort-order`, so both notifies are wired. Each sends the - // sorter's post-click pair, which the receiver drops when it - // already matches. - let dispatch = move |sorter: >k::ColumnViewSorter| { - let Some(active) = sorter.primary_sort_column() else { - return; - }; - let ascending = matches!(sorter.primary_sort_order(), gtk::SortType::Ascending); - if let Some(idx) = columns.iter().position(|col| col == &active) { - sink(GridMsg::SortChanged(idx, ascending)); - } - }; - view_sorter.connect_primary_sort_column_notify({ - let dispatch = dispatch.clone(); - move |sorter| dispatch(sorter) - }); - view_sorter.connect_primary_sort_order_notify(move |sorter| dispatch(sorter)); - } -} - -/// A selection over `store` sorted by `sorter`, sorted at once on each -/// click rather than in steps, so the rows never show half sorted and a -/// copy right after a click takes the order on screen. -/// `GtkMultiSelection` follows its selected rows to where a sort puts -/// them. -fn sorted_selection(store: gio::ListStore, sorter: Option) -> gtk::MultiSelection { - let sorted = gtk::SortListModel::new(Some(store), sorter); - sorted.set_incremental(false); - gtk::MultiSelection::new(Some(sorted)) -} - -/// How a column's cells are built. Editors are made when an edit -/// opens, so only the display is chosen here. -#[derive(Clone, Copy)] -enum CellKind { - /// Drawn and never edited, so it takes no edit triggers. - ReadOnly, - Toggle, - Text, -} - -struct ColumnInit<'a> { - info: &'a ResultColumn, - idx: usize, - rows: &'a [RowObject], - /// `None` for a column the grid knows only from the result set. - catalog: Option, - sink: GridSink, - /// `None` where no header sorts. - sorter: Option, - tab_ctx: TabGridContext, - default_min_width: Option, - column_view: glib::WeakRef, - grid_menus: GridMenus, - grid_columns: Rc, - attributes: Rc, -} - -fn build_column(init: ColumnInit<'_>) -> (gtk::ColumnViewColumn, glib::SignalHandlerId) { - let ColumnInit { - info, - idx, - rows, - catalog, - sink, - sorter, - tab_ctx, - default_min_width, - column_view, - grid_menus, - grid_columns, - attributes, - } = init; - let factory = gtk::SignalListItemFactory::new(); - - let kind = CellKind::for_column(catalog.as_ref(), &tab_ctx); - let auto_filled = catalog - .as_ref() - .is_some_and(|column| column.is_auto_increment || column.is_generated); - let figures = ColumnFigures::default(); - figures.measure(&info.column_type, idx, rows); - let drawing = Rc::new(ColumnDrawing { - catalog, - column_type: info.column_type.clone(), - tab_ctx: tab_ctx.clone(), - auto_filled, - attributes, - figures, - }); - - let setup_drawing = drawing.clone(); - let setup_columns = grid_columns.clone(); - let setup_name = info.name.clone(); - factory.connect_setup(move |_, item| { - let Some(cell) = item.downcast_ref::() else { - return; - }; - // The focus lands on the cell, never inside it, so every key - // the grid answers sees the same widget. Set through the - // property: `ListItemExt::set_focusable` sets the GtkListItem - // field, which the column view's cell widget never reads. - cell.set_property("focusable", true); - let grid_cell = GridCell::new(); - grid_cell.attach(cell, idx); - grid_cell.set_drawing(setup_drawing.clone()); - grid_cell.set_sink(sink.clone()); - grid_cell.set_columns(setup_columns.clone()); - match kind { - CellKind::ReadOnly => grid_cell.set_content(&text_display(&setup_drawing.figures)), - CellKind::Toggle => ToggleCell::setup(&grid_cell, &setup_name), - CellKind::Text => { - grid_cell.set_content(&text_display(&setup_drawing.figures)); - install_edit_triggers(&grid_cell); - } - } - attach_cell_gesture(&grid_cell, &column_view, &grid_menus); - cell.set_child(Some(&grid_cell)); - }); - bind_rows_to_cells(&factory); - - let column = gtk::ColumnViewColumn::builder() - .title(&info.name) - .factory(&factory) - .resizable(true) - .expand(true) - .build(); - column.set_sorter(sorter.as_ref()); - // A number column is as wide as its widest number already. - let default_min_width = default_min_width.filter(|_| !drawing.figures.numbers()); - let width_saver = size_column(&column, &info.name, &tab_ctx, default_min_width); - grid_columns.push(idx, &column, drawing, default_min_width); - (column, width_saver) -} - -struct ColumnRelease { - column: glib::WeakRef, - width_saver: Option, -} - -// GTK 4.22 leaks a reference to the column next to the focus column when -// the view removes the focus column, so a column can outlive its view. -// Its factory and width saver must not keep the grid alive with it. -fn release_columns_with(column_view: >k::ColumnView, releases: Vec) { - let releases = RefCell::new(releases); - column_view.connect_destroy(move |_| { - for release in releases.take() { - let Some(column) = release.column.upgrade() else { - continue; - }; - if let Some(handler) = release.width_saver { - column.disconnect(handler); - } - column.set_factory(None::<>k::ListItemFactory>); - } - }); -} - -impl CellKind { - fn for_column(catalog: Option<&ColumnInfo>, tab_ctx: &TabGridContext) -> CellKind { - match catalog { - Some(column) if tab_ctx.editable && column_takes_edits(column, &tab_ctx.identity, false) => { - match column.column_type.kind { - ColumnKind::Bool(_) => CellKind::Toggle, - _ => CellKind::Text, - } - } - _ => CellKind::ReadOnly, - } - } -} - -fn bind_rows_to_cells(factory: >k::SignalListItemFactory) { - factory.connect_bind(|_, item| { - let Some(cell) = item.downcast_ref::() else { - return; - }; - if let (Some(row), Some(grid_cell)) = ( - cell.item().and_downcast::(), - cell.child().and_downcast::(), - ) { - grid_cell.bind(&row); - } - }); - - factory.connect_unbind(|_, item| { - if let Some(grid_cell) = item - .downcast_ref::() - .and_then(|cell| cell.child()) - .and_downcast::() - { - grid_cell.unbind(); - } - }); -} - -fn size_column( - column: >k::ColumnViewColumn, - name: &str, - tab_ctx: &TabGridContext, - default_min_width: Option, -) -> glib::SignalHandlerId { - let widths = tab_ctx.column_widths.clone(); - if let Some(saved) = widths.as_ref().and_then(|widths| widths.width(name)) { - column.set_expand(false); - column.set_fixed_width(saved); - } else if let Some(min) = default_min_width { - // Set before the handler, so the floor keeps expanding. - column.set_fixed_width(min); - } - let column_name = name.to_owned(); - // An expanding column draws wider than its fixed width, so a width - // the user gives turns expand off, as GtkTreeView does. A header drag - // sets only the fixed width, and a double click on its handle clears it. - column.connect_fixed_width_notify(move |column| { - let width = column.fixed_width(); - column.set_expand(width < 0); - if let Some(widths) = &widths { - if width < 0 { - widths.forget(&column_name); - } else { - widths.record(&column_name, width); - } - } - }) -} - -/// Every cell but a check box draws its value in an Inscription, which -/// lays out only what fits and offers no text selection of its own. -fn text_display(figures: &ColumnFigures) -> gtk::Inscription { - let display = gtk::Inscription::new(None); - display.set_hexpand(true); - display.set_text_overflow(gtk::InscriptionOverflow::EllipsizeEnd); - display.set_margin_start(8); - display.set_margin_end(8); - display.add_css_class("tp-cell-text"); - figures.apply(&display, "", true); - display -} - -#[cfg(test)] -mod tests { - use gtk4::prelude::*; - use gtk4::{self as gtk, glib}; - - use tablepro_core::Value; - use tablepro_core::edit::CellInput; - - use super::{GridSorting, build_column_view}; - use crate::services::change_tracker::RowKey; - use crate::services::tab_tracker_handle::TabTrackerHandle; - use crate::services::tracker_edit::TrackerEdit; - use crate::test_support::{OwnedWindow, descendants, drain_main_context, first_descendant_of_type, wait_until}; - use crate::ui::grid::cell_editability::column_takes_edits; - use crate::ui::grid::display_text::editable_null_sentinel; - use crate::ui::grid::test_grid::{ - Grid, WAIT, col, content, display_text, edited_rows, flag_rows, key, keyed_by_id, named, persisted, recorder, - text, tracked, two_text_rows, type_into, - }; - use crate::ui::grid::{CellPreset, ColumnWidthBinding, GridCell, GridMsg, RowObject, TabGridContext, begin_edit}; - - #[gtk4::test] - fn no_header_sorts_a_table_in_the_engines_order() { - let columns = vec![named("id", "int", true), named("name", "text", false)]; - let rows = vec![vec![Value::Int(1), Value::Text("A".into())]]; - let (sink, _messages) = recorder(); - let tracker = TabTrackerHandle::default(); - let (view, _selection) = build_column_view( - content(&columns, persisted(&rows)), - &columns, - sink, - GridSorting::EngineOrder, - tracked(&tracker), - ); - - let sorters: Vec = crate::ui::grid::data_columns(&view) - .iter() - .map(|column| column.sorter().is_some()) - .collect(); - assert_eq!(sorters, [false, false]); - } - - #[gtk4::test] - fn f004_edit_after_draft_insert_targets_edited_row() { - let grid = two_text_rows(); - grid.insert_draft(); - - let cell = grid.focus(2, 1); - assert_eq!(cell.row().and_then(|row| row.key().cloned()), Some(key(2))); - assert_eq!(cell.column(), 1); - let editor = type_into(&cell, "renamed"); - editor.emit_activate(); - - assert_eq!( - edited_rows(&grid.take_messages()), - vec![(Some(key(2)), 1, text("renamed"))] - ); - } - - #[gtk4::test] - fn f004_menu_set_null_after_draft_removal_targets_clicked_row() { - let grid = two_text_rows(); - grid.insert_draft(); - let cell = grid.cell_of(2, 1); - let gesture = menu_gesture(cell.upcast_ref()).expect("a cell answers a right-click"); - gesture.emit_by_name::<()>("pressed", &[&1i32, &1.0f64, &1.0f64]); - - grid.store.remove(0); - drain_main_context(); - grid.view - .activate_action("cell.set-null", None) - .expect("the menu's Set to NULL"); - - let set: Vec<(Option, usize, CellPreset)> = grid - .take_messages() - .iter() - .filter_map(|msg| match msg { - GridMsg::SetCellValue { row, column, preset } => Some((row.key().cloned(), *column, *preset)), - _ => None, - }) - .collect(); - assert_eq!(set, vec![(Some(key(2)), 1, CellPreset::Null)]); - } - - #[gtk4::test] - fn edit_started_before_rows_shift_commits_to_original_row() { - let grid = two_text_rows(); - let cell = grid.cell_of(2, 1); - let editor = type_into(&cell, "renamed"); - - grid.insert_draft(); - editor.emit_activate(); - - assert_eq!( - edited_rows(&grid.take_messages()), - vec![(Some(key(2)), 1, text("renamed"))] - ); - } - - #[gtk4::test] - fn toggle_after_draft_insert_targets_toggled_row() { - let grid = Grid::open( - vec![named("id", "integer", true), named("flag", "boolean", false)], - vec![ - vec![Value::Int(1), Value::Bool(true)], - vec![Value::Int(2), Value::Bool(false)], - ], - ); - grid.insert_draft(); - let toggle = grid.cell_of(2, 1).check_button().expect("a check button"); - - toggle.set_active(true); - - assert_eq!( - edited_rows(&grid.take_messages()), - vec![(Some(key(2)), 1, CellInput::Value(Value::Bool(true)))] - ); - } - - #[gtk4::test] - fn a_text_with_a_nul_draws_and_refuses_an_editor() { - let grid = Grid::open( - vec![named("id", "integer", true), named("name", "text", false)], - vec![vec![Value::Int(1), Value::Text("a\0b".into())]], - ); - let cell = grid.cell_of(1, 1); - assert_eq!(display_text(&cell), "a\u{2400}b"); - - begin_edit(&cell); - - assert!( - cell.inline_text().is_none(), - "an editor opened on a value it cannot hold" - ); - assert!( - matches!(grid.take_messages().as_slice(), [GridMsg::ShowToast(_)]), - "the refusal was not explained" - ); - } - - #[gtk4::test] - fn opening_a_boolean_cell_writes_nothing() { - let grid = flag_rows(); - let cell = grid.cell_of(1, 1); - - begin_edit(&cell); - - assert!(grid.take_messages().is_empty(), "opening the cell flipped it"); - assert!(!cell.check_button().expect("a check button").is_active()); - } - - #[gtk4::test] - fn a_saved_rows_key_is_locked_and_a_drafts_is_not() { - let grid = two_text_rows(); - grid.insert_draft(); - let draft_key_cell = grid - .cells() - .into_iter() - .find(|cell| cell.column() == 0 && cell.row().is_some_and(|row| row.draft_id().is_some())) - .expect("the draft's key cell"); - - assert!( - grid.cell_of(1, 0).lock().is_some(), - "a saved row's key was offered for editing" - ); - assert!(grid.cell_of(1, 1).lock().is_none()); - assert!(draft_key_cell.lock().is_none(), "a draft could not set its own key"); - - begin_edit(&grid.cell_of(1, 0)); - assert!(!grid.cell_of(1, 0).is_editing()); - begin_edit(&draft_key_cell); - assert!(draft_key_cell.inline_text().is_some()); - } - - #[gtk4::test] - fn locked_cells_draw_in_an_inscription_with_no_label() { - let grid = two_text_rows(); - let locked = grid.cell_of(1, 0); - - assert!( - locked.display().is_some(), - "a locked cell does not draw in an Inscription" - ); - assert!( - descendants(&locked).iter().all(|widget| !widget.is::()), - "a locked cell carries a label, whose own menu takes Shift+F10" - ); - assert_eq!(display_text(&locked), "1"); - } - - #[gtk4::test] - fn a_revision_bump_draws_the_pending_value_in_the_same_cell() { - let grid = two_text_rows(); - let cell = grid.cell_of(1, 1); - let delta = grid.tracker.apply(TrackerEdit::Cell { - row_key: key(1), - col: 1, - original: Value::Text("A".into()), - value: Value::Text("new".into()), - }); - - grid.store - .item(0) - .and_downcast::() - .expect("a row") - .bump_revision(); - - assert_eq!(delta.pending_count, 1); - assert_eq!(display_text(&cell), "new"); - assert!( - cell.content() - .is_some_and(|content| content.has_css_class("tp-cell-modified")) - ); - } - - #[gtk4::test] - fn an_edit_in_progress_keeps_its_text_when_the_row_is_drawn_again() { - let grid = two_text_rows(); - let cell = grid.cell_of(2, 1); - let editor = type_into(&cell, "typed"); - let row = cell.row().expect("a bound row"); - - row.bump_revision(); - - assert_eq!(editor.text(), "typed", "the redraw overwrote what was typed"); - cell.cancel_edit(); - assert_eq!(display_text(&cell), "B", "Escape did not go back to the row's value"); - assert!(cell.inline_text().is_none()); - assert_eq!(edited_rows(&grid.take_messages()), vec![]); - } - - #[gtk4::test] - fn unbind_disconnects_revision_handler() { - let grid = two_text_rows(); - let cell = grid.cell_of(2, 1); - let row = cell.row().expect("a bound row"); - let bound_renders = cell.render_count(); - row.bump_revision(); - assert_eq!(cell.render_count(), bound_renders + 1, "a bound cell did not redraw"); - - cell.unbind(); - row.bump_revision(); - - assert_eq!( - cell.render_count(), - bound_renders + 1, - "a cell drew again for a row it no longer shows" - ); - } - - fn name_rows(first: Value) -> Grid { - Grid::open( - vec![named("id", "integer", true), named("name", "text", false)], - vec![vec![Value::Int(1), first], vec![Value::Int(2), Value::Text("B".into())]], - ) - } - - /// Row 1's name edited from `from` to `to`, its editor opened on - /// `to`, then the edit undone under the open editor. - fn undo_under_open_editor(from: Value, to: Value) -> (Grid, GridCell) { - let grid = name_rows(from.clone()); - let cell = grid.cell_of(1, 1); - grid.redraw(&grid.tracker.apply(TrackerEdit::Cell { - row_key: key(1), - col: 1, - original: from, - value: to, - })); - begin_edit(&cell); - assert!(cell.inline_text().is_some(), "the cell did not open for editing"); - - grid.redraw(&grid.tracker.undo().expect("an edit to undo")); - - (grid, cell) - } - - #[gtk4::test] - fn escape_after_an_undo_under_the_editor_sends_nothing() { - let (grid, cell) = undo_under_open_editor(Value::Text("A".into()), Value::Text("X".into())); - assert!( - !cell.is_editing(), - "the editor stayed open on a value the row no longer has" - ); - - cell.cancel_edit(); - cell.unbind(); - - assert_eq!(edited_rows(&grid.take_messages()), vec![]); - assert_eq!(display_text(&cell), "A"); - assert_eq!(grid.tracker.pending_count(), 0); - } - - #[gtk4::test] - fn escape_after_undoing_an_edit_of_null_sends_nothing() { - let (grid, cell) = undo_under_open_editor(Value::Null, Value::Text("hello".into())); - - cell.cancel_edit(); - - assert_eq!(edited_rows(&grid.take_messages()), vec![]); - assert_eq!(display_text(&cell), editable_null_sentinel()); - } - - #[gtk4::test] - fn escape_on_a_null_cell_drawn_again_under_its_editor_sends_nothing() { - let grid = name_rows(Value::Null); - let cell = grid.cell_of(1, 1); - begin_edit(&cell); - - cell.row().expect("a bound row").bump_revision(); - assert!(cell.is_editing(), "a redraw of the same value closed the editor"); - cell.cancel_edit(); - - assert_eq!(edited_rows(&grid.take_messages()), vec![]); - } - - #[gtk4::test] - fn a_null_text_cell_left_as_it_opened_stays_null() { - let grid = name_rows(Value::Null); - let cell = grid.cell_of(1, 1); - - let editor = type_into(&cell, ""); - - assert_eq!( - editor.placeholder_text().unwrap_or_default(), - "", - "a text column keeps the empty string, so empty means nothing more" - ); - editor.emit_activate(); - assert_eq!(edited_rows(&grid.take_messages()), vec![]); - } - - /// Past the length the grid cuts a cell's text at. - fn long_text() -> String { - format!("tpyo{}", "x".repeat(60_000)) - } - - fn source_view(cell: &GridCell) -> Option { - first_descendant_of_type::(cell) - } - - fn buffer_text(view: &sourceview5::View) -> String { - let buffer = view.buffer(); - buffer.text(&buffer.start_iter(), &buffer.end_iter(), true).to_string() - } - - fn save_button(cell: &GridCell) -> gtk::Button { - descendants(cell) - .into_iter() - .filter_map(|widget| widget.downcast::().ok()) - .find(|button| button.has_css_class("suggested-action")) - .expect("the Save button") - } - - #[gtk4::test] - fn editing_a_long_text_starts_from_the_whole_value() { - let grid = name_rows(Value::Text(long_text())); - let cell = grid.cell_of(1, 1); - - begin_edit(&cell); - - assert!(cell.inline_text().is_none(), "a long text opened in a one-line field"); - let opened = buffer_text(&source_view(&cell).expect("the multi-line editor")); - assert_eq!(opened.len(), long_text().len(), "the editor opened on the cut text"); - assert_eq!(opened, long_text()); - } - - #[gtk4::test] - fn a_long_text_saved_from_its_editor_sends_the_whole_value() { - let grid = name_rows(Value::Text(long_text())); - let cell = grid.cell_of(1, 1); - begin_edit(&cell); - let fixed = long_text().replacen("tpyo", "typo", 1); - let view = source_view(&cell).expect("the multi-line editor"); - - view.buffer().set_text(&fixed); - save_button(&cell).emit_clicked(); - - assert_eq!( - edited_rows(&grid.take_messages()), - vec![(Some(key(1)), 1, text(&fixed))] - ); - assert!(!cell.is_editing()); - assert!(display_text(&cell).len() < 20_000, "the cell drew the whole value"); - } - - #[gtk4::test] - fn a_text_that_reads_as_the_null_sentinel_opens_as_itself() { - let grid = name_rows(Value::Text(editable_null_sentinel())); - let cell = grid.cell_of(1, 1); - - let opened = cell_editor_text(&cell); - - assert_eq!(opened, Some(editable_null_sentinel())); - } - - fn cell_editor_text(cell: &GridCell) -> Option { - begin_edit(cell); - cell.inline_text().map(|editor| editor.text().to_string()) - } - - #[gtk4::test] - fn a_blob_in_a_text_column_does_not_open_on_its_placeholder() { - let grid = name_rows(Value::Bytes(vec![1, 2, 3])); - let cell = grid.cell_of(1, 1); - - begin_edit(&cell); - - assert!(!cell.is_editing(), "the editor opened on the size placeholder"); - assert!( - grid.take_messages() - .iter() - .any(|msg| matches!(msg, GridMsg::ShowToast(_))), - "the cell did not say why it would not open" - ); - } - - /// A closed editor popover must leave nothing holding its cell. - #[gtk4::test] - fn a_closed_popover_editor_frees_its_cell() { - let grid = Grid::open( - vec![ - named("id", "integer", true), - named("doc", "jsonb", false), - named("day", "date", false), - ], - vec![vec![ - Value::Int(1), - Value::Json(tablepro_core::value::JsonText::parse("[1]".to_owned()).expect("json")), - Value::Date(tablepro_core::value::Temporal::Finite( - chrono::NaiveDate::from_ymd_opt(2026, 9, 15).expect("a date"), - )), - ]], - ); - for column in [1, 2] { - let cell = grid.cell_of(1, column); - begin_edit(&cell); - let popover = first_descendant_of_type::(&cell).expect("the editor popover"); - assert!(cell.is_editing()); - - popover.popdown(); - wait_until(WAIT, || popover.parent().is_none()).expect("the popover closes"); - - assert!(!cell.is_editing(), "column {column} left its popover in the cell"); - } - assert_eq!(edited_rows(&grid.take_messages()), vec![]); - let freed: Vec> = grid.cells().iter().map(ObjectExt::downgrade).collect(); - - drop(grid); - - assert!( - freed.iter().all(|cell| cell.upgrade().is_none()), - "an editor popover kept its cell alive" - ); - } - - fn width_binding(tasks: &tablepro_session::runtime::Tasks, root: &tempfile::TempDir) -> ColumnWidthBinding { - ColumnWidthBinding { - store: crate::persistence::ColumnWidthStore::load(root.path().join("column-widths.json"), tasks), - connection_id: uuid::Uuid::new_v4(), - schema: None, - table: "t".to_owned(), - } - } - - #[gtk4::test] - fn column_freed_with_column_view() { - let runtime = crate::test_support::test_runtime(); - let root = tempfile::tempdir().expect("tempdir"); - let columns = vec![named("name", "text", false)]; - let rows = vec![vec![Value::Text("a".into())]]; - let (sink, _messages) = recorder(); - let context = TabGridContext { - editable: true, - column_widths: Some(width_binding(&runtime.tasks(), &root)), - ..TabGridContext::default() - }; - let (view, selection) = build_column_view( - content(&columns, persisted(&rows)), - &columns, - sink, - GridSorting::Server { - initial: Some((0, true)), - }, - context, - ); - let column = view - .columns() - .item(0) - .and_downcast::() - .expect("a column") - .downgrade(); - let view_ref = view.downgrade(); - - drop((view, selection)); - drain_main_context(); - - assert!(column.upgrade().is_none(), "the column outlived its column view"); - assert!(view_ref.upgrade().is_none(), "the column view outlived its owner"); - } - - #[gtk4::test] - fn a_column_left_behind_by_its_view_lets_go_of_the_grid() { - let columns = vec![named("id", "integer", true), named("name", "text", false)]; - let rows = vec![vec![Value::Int(7), Value::Text("a".into())]]; - let tracker = TabTrackerHandle::default(); - let (sink, messages) = recorder(); - let (view, selection) = build_column_view( - content(&columns, persisted(&rows)), - &columns, - sink, - GridSorting::Server { initial: None }, - tracked(&tracker), - ); - let window = OwnedWindow::presented(gtk::Window::new()); - window.set_child(Some(&view)); - // GTK 4.22 leaks the column after the focus column as the view - // removes its columns from the first. - let id = crate::ui::grid::data_columns(&view) - .into_iter() - .next() - .expect("the id column"); - wait_until(WAIT, || descendants(&view).iter().any(|widget| widget.is::())) - .expect("the cells are drawn"); - view.scroll_to(0, Some(&id), gtk::ListScrollFlags::FOCUS, None); - view.grab_focus(); - wait_until(WAIT, || { - gtk::prelude::GtkWindowExt::focus(&*window) - .and_then(|focus| GridCell::from_focus(&focus)) - .is_some_and(|cell| cell.column() == 0) - }) - .expect("the id cell takes the focus"); - - drop(id); - drop(window); - drop((view, selection)); - drain_main_context(); - - assert_eq!( - std::rc::Rc::strong_count(&messages), - 1, - "a column the view left behind kept the grid's sink" - ); - } - - fn menu_gesture(cell: >k::Widget) -> Option { - let controllers = cell.observe_controllers(); - (0..controllers.n_items()) - .filter_map(|index| controllers.item(index).and_downcast::()) - .find(|gesture| gesture.button() == 3) - } - - #[gtk4::test] - fn cells_freed_with_column_view() { - let columns = vec![ - named("id", "integer", true), - named("name", "text", false), - named("flag", "boolean", false), - ]; - let rows = vec![vec![Value::Int(7), Value::Text("a".into()), Value::Bool(true)]]; - let tracker = TabTrackerHandle::default(); - let (sink, _messages) = recorder(); - let (view, selection) = build_column_view( - content(&columns, persisted(&rows)), - &columns, - sink, - GridSorting::Server { initial: None }, - tracked(&tracker), - ); - let window = gtk::Window::new(); - window.set_child(Some(&view)); - window.present(); - let cells = || -> Vec { - descendants(&view) - .into_iter() - .filter_map(|widget| widget.downcast::().ok()) - .filter(|cell| cell.row().is_some()) - .collect() - }; - wait_until(WAIT, || cells().len() == 3).expect("the key, text and bool cells are drawn"); - // A right-click is what fills the menu's target, and an open - // editor is another hold on a cell, so both are in the picture. - for cell in cells() { - let gesture = menu_gesture(cell.upcast_ref()).expect("a cell answers a right-click"); - gesture.emit_by_name::<()>("pressed", &[&1i32, &1.0f64, &1.0f64]); - } - if let Some(name) = cells().into_iter().find(|cell| cell.column() == 1) { - begin_edit(&name); - } - let freed: Vec> = cells().iter().map(ObjectExt::downgrade).collect(); - - window.destroy(); - drop(window); - drop((view, selection)); - drain_main_context(); - - let alive = freed.iter().filter(|cell| cell.upgrade().is_some()).count(); - assert_eq!(alive, 0, "cells outlived their column view"); - } - - #[gtk4::test] - fn column_view_carries_data_table_class() { - let grid = two_text_rows(); - - assert!(grid.view.has_css_class("data-table")); - } - - #[gtk4::test] - fn numeric_column_cells_align_end_with_numeric_class() { - let grid = Grid::open( - vec![ - named("id", "integer", true), - named("name", "text", false), - named("price", "numeric(10,2)", false), - ], - vec![vec![ - Value::Int(1), - Value::Text("tea".into()), - Value::Decimal("4.5".parse().expect("a decimal")), - ]], - ); - let display = |column| grid.cell_of(1, column).display().expect("a text cell"); - - for column in [0, 2] { - assert_eq!(display(column).xalign(), 1.0, "column {column}"); - assert!(display(column).has_css_class("numeric"), "column {column}"); - } - assert_eq!(display(1).xalign(), 0.0); - assert!(!display(1).has_css_class("numeric")); - assert_eq!(display_text(&grid.cell_of(1, 2)), "4.50", "the column's scale"); - } - - #[gtk4::test] - fn pending_delete_row_uses_shared_strikethrough_attributes() { - let grid = two_text_rows(); - let attributes = |id, column| { - grid.cell_of(id, column) - .display() - .and_then(|display| display.attributes()) - .map(|attrs| attrs.as_ptr()) - }; - assert_eq!(attributes(1, 1), None, "plain text carries no attributes"); - - grid.redraw(&grid.tracker.apply(TrackerEdit::DeleteRows { - persisted: vec![(key(1), vec![Value::Int(1), Value::Text("A".into())].into())], - drafts: Vec::new(), - })); - - let struck = attributes(1, 0).expect("the deleted row's text is struck through"); - assert_eq!(attributes(1, 1), Some(struck), "each cell built its own list"); - assert_eq!(attributes(2, 1), None); - } - - #[gtk4::test] - fn a_null_placeholder_is_italic_and_a_locked_one_dimmed() { - let grid = name_rows(Value::Null); - let name = grid.cell_of(1, 1).display().expect("a text cell"); - - assert_eq!(name.text().as_deref(), Some(editable_null_sentinel().as_str())); - assert!(name.attributes().is_some(), "a placeholder reads like a value"); - assert!(!name.has_css_class("dimmed"), "an editable NULL was dimmed"); - assert!(name.tooltip_text().is_none()); - } - - #[test] - fn only_columns_with_an_empty_value_accept_an_empty_string() { - // Clearing a text cell stores the empty string; clearing a - // number or a date can only mean NULL. - for text in ["text", "VARCHAR(255)", "char(3)", "longtext"] { - assert!( - col(text, false).column_type.kind.accepts_empty_string(), - "{text} should accept an empty string" - ); - } - for other in [ - "integer", - "bigint", - "numeric(10,2)", - "date", - "timestamp", - "uuid", - "boolean", - ] { - assert!( - !col(other, false).column_type.kind.accepts_empty_string(), - "{other} should not accept an empty string" - ); - } - } - - #[test] - fn a_blob_is_never_editable_whatever_the_engine_calls_it() { - let identity = keyed_by_id(); - for name in [ - "BYTEA", - "blob", - "LONGBLOB", - "mediumblob", - "tinyblob", - "VARBINARY", - "binary", - ] { - assert!( - !column_takes_edits(&col(name, false), &identity, false), - "{name} was offered as an editable cell" - ); - } - assert!(column_takes_edits(&col("text", false), &identity, false)); - assert!(column_takes_edits(&col("integer", false), &identity, false)); - } - - mod widths { - use super::*; - use crate::ui::grid::column_resize::tests::{laid_out, shown_width}; - use crate::ui::grid::column_resize::{ColumnResize, resize_column}; - use crate::ui::grid::test_grid::{two_text_columns, two_text_values}; - - // The test window leaves the two columns room to spare, which an - // expanding column would take a share of. - fn reopened(widths: &ColumnWidthBinding) -> Grid { - laid_out(Grid::open_with_widths( - two_text_columns(), - two_text_values(), - widths.clone(), - )) - } - - #[gtk4::test] - fn a_width_set_from_the_keyboard_comes_back_at_that_width() { - let runtime = crate::test_support::test_runtime(); - let root = tempfile::tempdir().expect("tempdir"); - let widths = width_binding(&runtime.tasks(), &root); - let first = reopened(&widths); - resize_column(&first.view, &first.cell_of(1, 1), ColumnResize::Wider); - let saved = widths.width("name").expect("the step's width saved"); - drop(first); - - let grid = reopened(&widths); - - assert_eq!(grid.column(1).fixed_width(), saved); - assert!(!grid.column(1).expands(), "the column came back expanding"); - wait_until(WAIT, || shown_width(&grid, 1) == saved).expect("the column drawn at its saved width"); - } - - // GtkColumnView's header drag sets the fixed width and nothing - // else, as this does. - #[gtk4::test] - fn a_header_drag_width_is_the_width_drawn_and_saved() { - let runtime = crate::test_support::test_runtime(); - let root = tempfile::tempdir().expect("tempdir"); - let widths = width_binding(&runtime.tasks(), &root); - let grid = reopened(&widths); - - grid.column(1).set_fixed_width(200); - - assert!(!grid.column(1).expands(), "the dragged column still expands"); - assert_eq!(widths.width("name"), Some(200)); - wait_until(WAIT, || shown_width(&grid, 1) == 200).expect("the column drawn at the dragged width"); - } - - #[gtk4::test] - fn a_header_drag_in_editor_results_is_the_width_drawn() { - let grid = laid_out(two_text_rows()); - - grid.column(1).set_fixed_width(200); - - assert!(!grid.column(1).expands(), "the dragged column still expands"); - wait_until(WAIT, || shown_width(&grid, 1) == 200).expect("the column drawn at the dragged width"); - } - - // A double click on the resize handle starts a drag with its first - // press, which saves a width, then clears it. - #[gtk4::test] - fn a_cleared_width_expands_again_and_is_forgotten() { - let runtime = crate::test_support::test_runtime(); - let root = tempfile::tempdir().expect("tempdir"); - let widths = width_binding(&runtime.tasks(), &root); - let grid = reopened(&widths); - - grid.column(1).set_fixed_width(200); - grid.column(1).set_fixed_width(-1); - - assert!(grid.column(1).expands(), "the column stayed at its natural width"); - assert_eq!(widths.width("name"), None); - drop(grid); - assert!(reopened(&widths).column(1).expands()); - } - - #[gtk4::test] - fn a_wide_tables_floor_keeps_expanding() { - let columns: Vec<_> = (0..9) - .map(|index| named(&format!("c{index}"), "text", index == 0)) - .collect(); - let row = (0..9).map(|index| Value::Text(format!("v{index}"))).collect(); - - let grid = Grid::open(columns, vec![row]); - - assert_eq!(grid.column(1).fixed_width(), super::super::MIN_COLUMN_WIDTH_PX); - assert!(grid.column(1).expands(), "the floor turned expand off"); - } - } -} diff --git a/linux/crates/app/src/ui/grid/column_figures.rs b/linux/crates/app/src/ui/grid/column_figures.rs deleted file mode 100644 index 4c3725e0b5..0000000000 --- a/linux/crates/app/src/ui/grid/column_figures.rs +++ /dev/null @@ -1,418 +0,0 @@ -use std::cell::{Cell, RefCell}; - -use gtk4::prelude::*; -use gtk4::{self as gtk, pango}; -use tablepro_core::Value; -use tablepro_core::column::{ColumnKind, ColumnType}; - -use super::RowObject; -use super::display_text::display_text; - -const TEXT_MIN_CHARS: u32 = 3; -const TEXT_NAT_CHARS: u32 = 14; - -/// How a column of either grid lays out its text. A column of numbers -/// lines them up on the last digit in tabular figures and asks for the -/// width of its longest number, so the column view never cuts one. -#[derive(Debug)] -pub struct ColumnFigures { - numbers: Cell, - /// The longest number the column has held, in characters. - chars: Cell, - /// An untyped column has held a value, and every one was a number. - held_value: Cell, - all_numbers: Cell, - /// The last width asked of `digits_width`, which every numeric cell - /// of the column asks again as it is bound. - digits: RefCell>, -} - -impl Default for ColumnFigures { - fn default() -> Self { - Self { - numbers: Cell::new(false), - chars: Cell::new(0), - held_value: Cell::new(false), - all_numbers: Cell::new(true), - digits: RefCell::new(None), - } - } -} - -#[derive(Debug)] -struct DigitsWidth { - chars: u32, - font: Option, - dpi: i32, - width: i32, -} - -impl ColumnFigures { - /// Decide from the column's type, or from `rows` when the driver - /// could not type it. - pub fn measure(&self, column_type: &ColumnType, index: usize, rows: &[RowObject]) { - self.numbers.set(false); - self.chars.set(0); - self.held_value.set(false); - self.all_numbers.set(true); - self.extend(column_type, index, rows); - } - - /// Take in rows added to those already measured. A draft has no - /// cells, so it changes nothing. - pub fn extend(&self, column_type: &ColumnType, index: usize, rows: &[RowObject]) { - let values = rows - .iter() - .filter_map(|row| row.cell(index)) - .filter(|value| !value.is_null()); - if column_type.kind.is_numeric() { - self.numbers.set(true); - self.chars.set(self.chars.get().max(widest(column_type, values))); - return; - } - // PostgreSQL editor results carry no type (ADR 0016), so such a - // column aligns by what it holds, as a spreadsheet does. - if !matches!(column_type.kind, ColumnKind::Other | ColumnKind::Variant) { - return; - } - let mut scratch = String::new(); - for value in values { - if !self.all_numbers.get() { - break; - } - self.held_value.set(true); - match is_number(value) { - true => self.chars.set( - self.chars - .get() - .max(char_count(display_text(value, column_type, &mut scratch).text)), - ), - false => self.all_numbers.set(false), - } - } - self.numbers.set(self.held_value.get() && self.all_numbers.get()); - } - - pub fn numbers(&self) -> bool { - self.numbers.get() - } - - /// Lay out `display` for `text`, the value it is about to show. - pub fn apply(&self, display: >k::Inscription, text: &str, placeholder: bool) { - if !self.numbers.get() { - display.set_xalign(0.0); - display.set_min_chars(TEXT_MIN_CHARS); - display.set_nat_chars(TEXT_NAT_CHARS); - display.set_width_request(-1); - display.remove_css_class("numeric"); - return; - } - // An edit can hold a longer number than any row did. - if !placeholder { - self.chars.set(self.chars.get().max(char_count(text))); - } - display.set_xalign(1.0); - display.set_min_chars(TEXT_MIN_CHARS); - display.set_nat_chars(TEXT_MIN_CHARS); - display.add_css_class("numeric"); - // GtkInscription sizes by an average character, which a run of - // digits outgrows, so the cell asks for the digits' own width. - display.set_width_request(self.digits_width(display, self.chars.get())); - } - - /// The width of `chars` digits, laid out once per width, font and - /// text scale rather than once per cell. - fn digits_width(&self, display: >k::Inscription, chars: u32) -> i32 { - let font = display.pango_context().font_description(); - let dpi = display.settings().gtk_xft_dpi(); - let mut cached = self.digits.borrow_mut(); - if let Some(digits) = cached.as_ref() - && digits.chars == chars - && digits.dpi == dpi - && digits.font == font - { - return digits.width; - } - let width = digits_width(display, chars); - *cached = Some(DigitsWidth { - chars, - font, - dpi, - width, - }); - width - } -} - -fn is_number(value: &Value) -> bool { - match value { - Value::Int(_) - | Value::UInt(_) - | Value::WideInt(_) - | Value::Float32(_) - | Value::Float64(_) - | Value::Decimal(_) => true, - Value::Text(text) => text.parse::().is_ok(), - Value::Other(other) => other.text.parse::().is_ok(), - _ => false, - } -} - -fn widest<'a>(column_type: &ColumnType, values: impl Iterator) -> u32 { - let mut scratch = String::new(); - values - .map(|value| char_count(display_text(value, column_type, &mut scratch).text)) - .max() - .unwrap_or(0) -} - -/// How wide `chars` digits are in tabular figures, which libadwaita's -/// `numeric` class draws. -pub(super) fn digits_width(widget: &impl IsA, chars: u32) -> i32 { - tabular_width(widget, &"0".repeat(usize::try_from(chars).unwrap_or_default())) -} - -fn tabular_width(widget: &impl IsA, text: &str) -> i32 { - let layout = widget.create_pango_layout(Some(text)); - let tabular = pango::AttrList::new(); - tabular.insert(pango::AttrFontFeatures::new("tnum")); - layout.set_attributes(Some(&tabular)); - layout.pixel_size().0 -} - -fn char_count(text: &str) -> u32 { - u32::try_from(text.chars().count()).unwrap_or(u32::MAX) -} - -#[cfg(test)] -mod tests { - use gtk4::prelude::*; - use gtk4::{self as gtk, gio}; - use tablepro_core::column::ColumnType; - use tablepro_core::{ColumnInfo, Value}; - - use super::{ColumnFigures, tabular_width}; - use crate::services::tab_tracker_handle::TabTrackerHandle; - use crate::test_support::{descendants, wait_until}; - use crate::ui::grid::test_grid::{WAIT, col, content, named, persisted, recorder, tracked}; - use crate::ui::grid::{GridCell, GridSorting, RowObject, build_column_view}; - - fn rows(values: &[Value]) -> Vec { - values - .iter() - .map(|value| RowObject::result(vec![value.clone()].into())) - .collect() - } - - fn measured(column_type: &ColumnType, values: &[Value]) -> ColumnFigures { - let figures = ColumnFigures::default(); - figures.measure(column_type, 0, &rows(values)); - figures - } - - fn text(value: &str) -> Value { - Value::Text(value.to_owned()) - } - - #[test] - fn a_typed_column_is_numbers_by_its_type_alone() { - assert!(measured(&col("integer", false).column_type, &[]).numbers()); - assert!(!measured(&col("text", false).column_type, &[text("12"), text("7")]).numbers()); - } - - #[test] - fn an_untyped_column_is_numbers_when_every_value_is_one() { - let untyped = ColumnType::untyped_text("text"); - - for numbers in [ - vec![ - text("6"), - text("-18.15"), - Value::Null, - text("1e-05"), - text("NaN"), - text("Infinity"), - ], - vec![Value::Int(2), Value::Float64(0.5)], - ] { - assert!(measured(&untyped, &numbers).numbers(), "{numbers:?}"); - } - for not_numbers in [ - vec![text("6"), text("Coffee")], - vec![text(" 6")], - vec![text("")], - vec![Value::Null], - vec![], - ] { - assert!(!measured(&untyped, ¬_numbers).numbers(), "{not_numbers:?}"); - } - } - - #[test] - fn rows_added_later_widen_the_column_and_a_draft_changes_nothing() { - let integer = col("integer", false).column_type; - let figures = measured(&integer, &[Value::Int(12)]); - - figures.extend(&integer, 0, &[RowObject::draft(1)]); - assert_eq!(figures.chars.get(), 2); - figures.extend(&integer, 0, &rows(&[Value::Int(-1_234)])); - assert_eq!(figures.chars.get(), 5, "the widest row so far"); - figures.measure(&integer, 0, &rows(&[Value::Int(7)])); - assert_eq!(figures.chars.get(), 1, "a new page measures from scratch"); - } - - #[test] - fn an_untyped_column_stops_being_numbers_at_the_first_word_added() { - let untyped = ColumnType::untyped_text("text"); - let figures = measured(&untyped, &[text("6")]); - assert!(figures.numbers()); - - figures.extend(&untyped, 0, &rows(&[Value::Null, text("Coffee")])); - assert!(!figures.numbers()); - figures.extend(&untyped, 0, &rows(&[text("7")])); - assert!(!figures.numbers(), "one word in the column is enough"); - } - - /// A browse page in a window too narrow for every column to get the - /// width it asks for. - struct Crowded { - window: gtk::Window, - view: gtk::ColumnView, - store: gio::ListStore, - } - - impl Drop for Crowded { - fn drop(&mut self) { - self.window.destroy(); - } - } - - fn crowded(columns: &[ColumnInfo], rows: &[Vec]) -> Crowded { - let (view, selection) = build_column_view( - content(columns, persisted(rows)), - columns, - recorder().0, - GridSorting::Server { initial: None }, - tracked(&TabTrackerHandle::default()), - ); - let store = selection - .model() - .and_downcast::() - .expect("a list store"); - let window = gtk::Window::new(); - window.set_default_size(725, 400); - window.set_child(Some(>k::ScrolledWindow::builder().child(&view).build())); - window.present(); - Crowded { window, view, store } - } - - fn shop_columns(more_text: usize) -> Vec { - let mut columns = vec![ - named("id", "bigint", true), - named("sku", "text", false), - named("name", "text", false), - named("price", "numeric(20,4)", false), - named("stock", "integer", false), - named("updated_at", "text", false), - named("note", "text", false), - named("comment", "text", false), - ]; - columns.extend((0..more_text).map(|index| named(&format!("tag_{index}"), "text", false))); - columns - } - - const NUMBER_COLUMNS: [usize; 3] = [0, 3, 4]; - - fn shop_row(id: i64, price: &str, width: usize) -> Vec { - let mut row = vec![ - Value::Int(id), - text("CF-1001"), - text("Ethiopia Yirgacheffe, 250 g"), - Value::Decimal(price.parse().expect("a decimal")), - Value::Int(123_456), - text("2026-09-02 09:14:00+00"), - text("Ground for a paper filter"), - text("Ships in two days"), - ]; - row.resize(width, text("seasonal")); - row - } - - /// The text of `columns` in the rows on screen. - fn drawn(view: >k::ColumnView, columns: &[usize]) -> Vec { - descendants(view) - .into_iter() - .filter_map(|widget| widget.downcast::().ok()) - .filter(|cell| cell.row().is_some() && columns.contains(&cell.column()) && cell.is_mapped()) - .filter_map(|cell| cell.display()) - .collect() - } - - fn shows_whole(display: >k::Inscription) -> bool { - let text = display.text().unwrap_or_default(); - display.width() > 0 && tabular_width(display, &text) <= display.width() - } - - #[gtk4::test] - fn a_number_column_is_as_wide_as_its_widest_number() { - // Eight columns share the width; past eight, each starts at a - // readable width of its own. - for more_text in [0, 2] { - let columns = shop_columns(more_text); - let rows: Vec> = (0..20) - .map(|offset| shop_row(100_000_001 + offset, "123456789012.3456", columns.len())) - .collect(); - let grid = crowded(&columns, &rows); - - let whole = wait_until(WAIT, || { - let numbers = drawn(&grid.view, &NUMBER_COLUMNS); - !numbers.is_empty() && numbers.iter().all(shows_whole) - }); - - assert!(whole.is_ok(), "a number was cut among {} columns", columns.len()); - } - } - - #[gtk4::test] - fn a_new_page_is_measured_down_to_its_last_row() { - let columns = shop_columns(0); - let first: Vec> = (1..=20).map(|id| shop_row(id, "1.5", columns.len())).collect(); - let grid = crowded(&columns, &first); - wait_until(WAIT, || !drawn(&grid.view, &[0]).is_empty()).expect("the first page is drawn"); - let mut next: Vec> = (21..=320).map(|id| shop_row(id, "1.5", columns.len())).collect(); - if let Some(last) = next.last_mut() { - last[0] = Value::Int(100_000_000_001); - } - - grid.store.splice(0, grid.store.n_items(), &persisted(&next)); - - let wide = wait_until(WAIT, || { - drawn(&grid.view, &[0]) - .iter() - .any(|display| display.width() >= tabular_width(display, "100000000001")) - }); - assert!(wide.is_ok(), "the column waits for its widest row to scroll into view"); - } - - #[gtk4::test] - fn adding_a_draft_keeps_the_width_the_page_measured() { - let columns = shop_columns(0); - let mut first: Vec> = (1..=20).map(|id| shop_row(id, "1.5", columns.len())).collect(); - if let Some(last) = first.last_mut() { - last[0] = Value::Int(100_000_000_001); - } - let grid = crowded(&columns, &first); - wait_until(WAIT, || !drawn(&grid.view, &[0]).is_empty()).expect("the page is drawn"); - - grid.store.insert(0, &RowObject::draft(1)); - - let wide = wait_until(WAIT, || { - let ids = drawn(&grid.view, &[0]); - !ids.is_empty() - && ids - .iter() - .all(|display| display.width() >= tabular_width(display, "100000000001")) - }); - assert!(wide.is_ok(), "the draft narrowed the column its rows had widened"); - } -} diff --git a/linux/crates/app/src/ui/grid/column_resize.rs b/linux/crates/app/src/ui/grid/column_resize.rs deleted file mode 100644 index a2b5025e11..0000000000 --- a/linux/crates/app/src/ui/grid/column_resize.rs +++ /dev/null @@ -1,222 +0,0 @@ -use gtk4 as gtk; -use gtk4::prelude::*; - -use super::GridCell; -use super::column_figures::digits_width; -use super::grid_menus::shown_column; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(super) enum ColumnResize { - Wider, - Narrower, -} - -/// A step is this many digits of the cell's font, so it stays the same -/// number of characters when the text is larger. -const STEP_DIGITS: u32 = 2; - -/// Make the column `cell` is in one step wider or narrower, and save the -/// width as a header drag's is. Expand goes off, as GtkTreeView did for -/// these keys: an expanding column takes back part of the space a step -/// frees. -pub(super) fn resize_column(view: >k::ColumnView, cell: &GridCell, change: ColumnResize) { - let Some(column) = shown_column(view, cell) else { - return; - }; - let column = column.view_column; - if !column.is_resizable() { - view.error_bell(); - return; - } - let Some(width) = current_width(&column, cell) else { - return; - }; - let step = digits_width(cell, STEP_DIGITS); - // Narrow stops at one step and never widens a column already below - // it: a fixed width goes below what the cells need, and the column the - // focus is in stays on screen to widen again. - let width = match change { - ColumnResize::Wider => width.saturating_add(step), - ColumnResize::Narrower => width.saturating_sub(step).max(step.min(width)), - }; - column.set_expand(false); - column.set_fixed_width(width); -} - -/// The width a step starts from: the one the last step set, which holds -/// even before the view has laid it out, or else the width on screen, -/// where a header drag starts too. -fn current_width(column: >k::ColumnViewColumn, cell: &GridCell) -> Option { - match (column.expands(), column.fixed_width()) { - (false, fixed) if fixed >= 0 => Some(fixed), - _ => column_width(cell), - } -} - -/// The width of the column `cell` is in, as the view laid it out. The -/// column view's cell widget spans the column, and `width()` would give -/// only its content box, narrower by the theme's padding. -fn column_width(cell: &GridCell) -> Option { - let cell_widget = cell.parent()?; - let bounds = cell_widget.compute_bounds(&cell_widget)?; - Some(bounds.width().round() as i32) -} - -#[cfg(test)] -pub(super) mod tests { - use std::time::Duration; - - use gtk4::gdk; - use gtk4::prelude::*; - - use super::{ColumnResize, STEP_DIGITS, column_width, resize_column}; - use crate::test_support::wait_until; - use crate::ui::grid::ColumnWidthBinding; - use crate::ui::grid::column_figures::digits_width; - use crate::ui::grid::test_grid::{Grid, WAIT, two_text_columns, two_text_rows, two_text_values, type_into}; - - pub(in crate::ui::grid) fn shown_width(grid: &Grid, column: usize) -> i32 { - column_width(&grid.cell_of(1, column)).expect("a drawn cell") - } - - pub(in crate::ui::grid) fn step(grid: &Grid) -> i32 { - digits_width(&grid.cell_of(1, 1), STEP_DIGITS) - } - - /// `Grid::open` waits for the cells, not for the first layout, before - /// which no width is the one on screen. - pub(in crate::ui::grid) fn laid_out(grid: Grid) -> Grid { - wait_until(WAIT, || grid.view.width() > 0).expect("the grid to be laid out"); - grid - } - - #[gtk4::test] - fn a_step_wider_starts_from_the_width_on_screen_and_is_saved() { - let runtime = crate::test_support::test_runtime(); - let root = tempfile::tempdir().expect("tempdir"); - let widths = ColumnWidthBinding { - store: crate::persistence::ColumnWidthStore::load(root.path().join("column-widths.json"), &runtime.tasks()), - connection_id: uuid::Uuid::new_v4(), - schema: None, - table: "t".to_owned(), - }; - let grid = laid_out(Grid::open_with_widths( - two_text_columns(), - two_text_values(), - widths.clone(), - )); - let after = shown_width(&grid, 1) + step(&grid); - - resize_column(&grid.view, &grid.cell_of(1, 1), ColumnResize::Wider); - - assert_eq!(grid.column(1).fixed_width(), after); - assert!( - !grid.column(1).expands(), - "an expanding column takes back part of a step" - ); - assert_eq!(widths.width("name"), Some(after)); - wait_until(WAIT, || shown_width(&grid, 1) == after).expect("the column to be drawn wider"); - } - - #[gtk4::test] - fn a_step_narrower_shrinks_the_column_on_screen() { - let grid = laid_out(two_text_rows()); - let after = shown_width(&grid, 1) - step(&grid); - - resize_column(&grid.view, &grid.cell_of(1, 1), ColumnResize::Narrower); - - wait_until(WAIT, || shown_width(&grid, 1) == after).expect("the column to be drawn narrower"); - } - - #[gtk4::test] - fn a_column_is_never_narrower_than_a_step() { - let grid = two_text_rows(); - grid.column(1).set_expand(false); - grid.column(1).set_fixed_width(step(&grid) + 1); - - resize_column(&grid.view, &grid.cell_of(1, 1), ColumnResize::Narrower); - resize_column(&grid.view, &grid.cell_of(1, 1), ColumnResize::Narrower); - - assert_eq!(grid.column(1).fixed_width(), step(&grid)); - } - - #[gtk4::test] - fn narrow_leaves_a_column_already_narrower_than_a_step() { - let grid = two_text_rows(); - let narrow = step(&grid) - 1; - grid.column(1).set_expand(false); - grid.column(1).set_fixed_width(narrow); - - resize_column(&grid.view, &grid.cell_of(1, 1), ColumnResize::Narrower); - - assert_eq!(grid.column(1).fixed_width(), narrow, "Narrow made the column wider"); - } - - #[gtk4::test] - fn a_column_that_is_not_resizable_keeps_its_width() { - let grid = two_text_rows(); - grid.column(1).set_resizable(false); - grid.column(1).set_fixed_width(300); - grid.column(1).set_expand(true); - - resize_column(&grid.view, &grid.cell_of(1, 1), ColumnResize::Wider); - - assert_eq!(grid.column(1).fixed_width(), 300); - assert!(grid.column(1).expands()); - } - - /// Pressed through XTEST, the keys meet the list's own arrow bindings - /// and the cell editor before the grid's controller. - mod real_keys { - use super::*; - - const SHIFT_ALT: gdk::ModifierType = gdk::ModifierType::SHIFT_MASK.union(gdk::ModifierType::ALT_MASK); - - fn focused_grid() -> (Grid, crate::test_support::KeyInjector) { - let grid = laid_out(two_text_rows()); - crate::test_support::KeyInjector::focus_only(&grid.window); - wait_until(Duration::from_secs(2), || grid.window.is_active()).expect("the window to take the focus"); - let keys = crate::test_support::KeyInjector::connect().expect("an X11 display"); - (grid, keys) - } - - #[gtk4::test] - fn shift_alt_right_and_left_resize_the_focused_cells_column() { - let (grid, keys) = focused_grid(); - grid.focus(0, 1); - let wider = shown_width(&grid, 1) + step(&grid); - - keys.press(gdk::Key::Right, SHIFT_ALT).expect("the key press"); - wait_until(WAIT, || shown_width(&grid, 1) == wider).expect("Shift+Alt+Right to widen the column"); - keys.press(gdk::Key::Left, SHIFT_ALT).expect("the key press"); - keys.press(gdk::Key::Left, SHIFT_ALT).expect("the key press"); - - let narrower = wider - 2 * step(&grid); - wait_until(WAIT, || shown_width(&grid, 1) == narrower).expect("Shift+Alt+Left to narrow the column"); - assert_eq!( - grid.focused_cell().map(|cell| cell.column()), - Some(1), - "the keys moved the focus" - ); - assert_eq!(grid.column(0).fixed_width(), -1, "the keys resized another column"); - } - - #[gtk4::test] - fn an_open_editor_keeps_shift_alt_arrows() { - let (grid, keys) = focused_grid(); - let cell = grid.focus(0, 1); - let editor = type_into(&cell, "typed"); - wait_until(Duration::from_secs(2), || editor.has_focus()).expect("the editor to take the focus"); - - keys.press(gdk::Key::Right, SHIFT_ALT).expect("the key press"); - crate::test_support::drain_main_context(); - - assert_eq!( - grid.column(1).fixed_width(), - -1, - "the key resized the column being edited" - ); - assert!(cell.is_editing(), "the key ended the edit"); - } - } -} diff --git a/linux/crates/app/src/ui/grid/column_width_binding.rs b/linux/crates/app/src/ui/grid/column_width_binding.rs deleted file mode 100644 index bd75914d48..0000000000 --- a/linux/crates/app/src/ui/grid/column_width_binding.rs +++ /dev/null @@ -1,36 +0,0 @@ -/// The schema is part of it, so `public.users` and `audit.users` keep -/// their own widths. -#[derive(Clone)] -pub struct ColumnWidthBinding { - pub store: crate::persistence::ColumnWidthStore, - pub connection_id: uuid::Uuid, - pub schema: Option, - pub table: String, -} - -impl std::fmt::Debug for ColumnWidthBinding { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("ColumnWidthBinding") - .field("connection_id", &self.connection_id) - .field("schema", &self.schema) - .field("table", &self.table) - .finish() - } -} - -impl ColumnWidthBinding { - pub(super) fn width(&self, column: &str) -> Option { - self.store - .width(self.connection_id, self.schema.as_deref(), &self.table, column) - } - - pub(super) fn record(&self, column: &str, width: i32) { - self.store - .record(self.connection_id, self.schema.as_deref(), &self.table, column, width); - } - - pub(super) fn forget(&self, column: &str) { - self.store - .forget(self.connection_id, self.schema.as_deref(), &self.table, column); - } -} diff --git a/linux/crates/app/src/ui/grid/context_menu.rs b/linux/crates/app/src/ui/grid/context_menu.rs deleted file mode 100644 index a72b712bf9..0000000000 --- a/linux/crates/app/src/ui/grid/context_menu.rs +++ /dev/null @@ -1,309 +0,0 @@ -use std::cell::RefCell; -use std::collections::HashSet; - -use gtk4::prelude::*; -use gtk4::{self as gtk, glib}; - -use super::grid_menus::GridMenus; -use super::menu_rows::menu_rows_of; -use super::selection::select_row_for_menu; -use super::{GridCell, RowObject}; - -/// What a menu was opened on, fixed as it opens. A page that lands while -/// the menu is open rebinds the cell to another row, so an item chosen -/// then acts on the rows the menu was opened for, or on nothing once -/// they have left the grid. Everything is held weakly: the cell holds -/// the menu through its gesture, and a row the grid let go of is gone. -#[derive(Default)] -pub(super) struct CellContext { - opened_on: RefCell>, -} - -struct OpenedOn { - cell: glib::WeakRef, - row: glib::WeakRef, - rows: Vec>, -} - -/// The cell, the row it showed and the rows a chosen item acts on. -pub(super) struct MenuTarget { - pub(super) cell: GridCell, - pub(super) row: RowObject, - pub(super) rows: Vec, -} - -impl CellContext { - fn set(&self, cell: &GridCell, rows: &[RowObject]) { - let opened_on = cell.row().map(|row| OpenedOn { - cell: cell.downgrade(), - row: row.downgrade(), - rows: rows.iter().map(|row| row.downgrade()).collect(), - }); - self.opened_on.replace(opened_on); - } - - /// The target while the grid still shows every row of it. - pub(super) fn target(&self, column_view: >k::ColumnView) -> Option { - let opened_on = self.opened_on.borrow(); - let opened_on = opened_on.as_ref()?; - let target = MenuTarget { - cell: opened_on.cell.upgrade()?, - row: opened_on.row.upgrade()?, - rows: opened_on - .rows - .iter() - .map(|row| row.upgrade()) - .collect::>>()?, - }; - let model = column_view.model()?; - let shown: HashSet = (0..model.n_items()) - .filter_map(|position| model.item(position).and_downcast::()) - .collect(); - let all_shown = shown.contains(&target.row) && target.rows.iter().all(|row| shown.contains(row)); - all_shown.then_some(target) - } -} - -/// Arm the cell menu for `cell` and pick the one its lock calls for. -/// The cell's lock follows the row bound to it, so the menu is picked -/// when it opens. -pub(super) fn prepare_menu(menus: &GridMenus, cell: &GridCell, column_view: >k::ColumnView) -> gtk::PopoverMenu { - if let Some(position) = cell.position() { - select_row_for_menu(column_view, position); - } - let rows = menu_rows_of(column_view, cell); - menus.context.set(cell, &rows); - menus.actions.arm(cell, &rows); - menus.references.arm(cell); - match cell.lock() { - None => menus.editable_popover.clone(), - Some(_) => menus.readonly_popover.clone(), - } -} - -pub(super) fn attach_cell_gesture(cell: &GridCell, column_view: &glib::WeakRef, menus: &GridMenus) { - // The handler reads the cell from its gesture: the cell owns its - // controllers, so capturing it would be a cycle. - let view_for_gesture = column_view.clone(); - let menus = menus.clone(); - let gesture = gtk::GestureClick::new(); - gesture.set_button(3); - gesture.connect_pressed(move |g, _, x, y| { - let (Some(cv), Some(cell)) = (view_for_gesture.upgrade(), g.widget().and_downcast::()) else { - return; - }; - g.set_state(gtk::EventSequenceState::Claimed); - let popover = prepare_menu(&menus, &cell, &cv); - // The popover is parented to the column view, so it points in - // the view's coordinates, not the cell's. - let local = gtk::graphene::Point::new(x as f32, y as f32); - let (cv_x, cv_y) = cell - .compute_point(&cv, &local) - .map(|p| (p.x() as i32, p.y() as i32)) - .unwrap_or((x as i32, y as i32)); - popover.set_pointing_to(Some(>k::gdk::Rectangle::new(cv_x, cv_y, 1, 1))); - popover.popup(); - }); - cell.add_controller(gesture); -} - -#[cfg(test)] -mod tests { - use gtk4 as gtk; - use gtk4::prelude::*; - - use crate::services::change_tracker::RowKey; - use crate::test_support::{FocusThief, after_next_frame, descendants, drain_main_context, wait_until}; - use crate::ui::grid::test_grid::{ - Grid, WAIT, focus_in_grid, key, open_menu, page, two_text_columns, two_text_rows, two_text_values, - }; - use crate::ui::grid::{ColumnWidthBinding, GridCell, GridMsg, RowObject}; - - fn choose(grid: &Grid, actions: &[&str]) { - for action in actions { - grid.view - .activate_action(action, None) - .unwrap_or_else(|_| panic!("the menu has no {action}")); - } - } - - /// Each row-changing message the menu sent, by the rows it names. - fn acted_on(grid: &Grid) -> Vec<(&'static str, Vec>)> { - let keys = |rows: &[RowObject]| rows.iter().map(|row| row.key().cloned()).collect(); - grid.take_messages() - .into_iter() - .filter_map(|msg| match msg { - GridMsg::SetCellValue { row, .. } => Some(("set", vec![row.key().cloned()])), - GridMsg::DeleteRows { rows } => Some(("delete", keys(&rows))), - GridMsg::DuplicateRows { rows } => Some(("duplicate", keys(&rows))), - GridMsg::CopyRowsAsInsert { rows } => Some(("insert", keys(&rows))), - _ => None, - }) - .collect() - } - - #[gtk4::test] - fn a_page_that_lands_under_an_open_menu_leaves_its_items_acting_on_nothing() { - let grid = two_text_rows(); - let cell = grid.cell_of(2, 1); - open_menu(&cell); - - grid.store.splice(0, grid.store.n_items(), &page(3..5)); - drain_main_context(); - choose( - &grid, - &[ - "cell.set-null", - "cell.delete-row", - "cell.duplicate-row", - "cell.copy-row-insert", - ], - ); - - assert_eq!(acted_on(&grid), Vec::new(), "the menu acted on a row of the new page"); - } - - fn sorts(grid: &Grid) -> Vec> { - let mut sorts: Vec> = grid - .take_messages() - .into_iter() - .filter_map(|msg| match msg { - GridMsg::SortChanged(column, ascending) => Some(Some((column, ascending))), - GridMsg::SortCleared => Some(None), - _ => None, - }) - .collect(); - // A sort on a new column notifies the column, then the order. - sorts.dedup(); - sorts - } - - /// A header's click and drag take a pointer, so the menu, which the - /// Menu key opens, sorts and resets the width too. - #[gtk4::test] - fn the_menu_sorts_by_its_column_and_clears_the_sort() { - let grid = two_text_rows(); - open_menu(&grid.cell_of(1, 1)); - choose(&grid, &["cell.clear-sort"]); - assert!(sorts(&grid).is_empty(), "Clear Sort was on with no sort"); - - choose(&grid, &["cell.sort-descending"]); - open_menu(&grid.cell_of(1, 1)); - choose(&grid, &["cell.clear-sort"]); - - assert_eq!(sorts(&grid), [Some((1, false)), None]); - let sorter = grid - .view - .sorter() - .and_downcast::() - .expect("the view's sorter"); - assert!(sorter.primary_sort_column().is_none()); - } - - #[gtk4::test] - fn reset_width_forgets_the_dragged_width() { - let runtime = crate::test_support::test_runtime(); - let root = tempfile::tempdir().expect("tempdir"); - let widths = ColumnWidthBinding { - store: crate::persistence::ColumnWidthStore::load(root.path().join("column-widths.json"), &runtime.tasks()), - connection_id: uuid::Uuid::new_v4(), - schema: None, - table: "t".to_owned(), - }; - let grid = Grid::open_with_widths(two_text_columns(), two_text_values(), widths.clone()); - grid.column(1).set_fixed_width(300); - assert_eq!(widths.width("name"), Some(300)); - - open_menu(&grid.cell_of(1, 1)); - choose(&grid, &["cell.reset-width"]); - - assert_eq!(grid.column(1).fixed_width(), -1); - assert_eq!(widths.width("name"), None); - } - - #[gtk4::test] - fn the_menu_widens_and_narrows_and_reset_lets_the_column_expand_again() { - use crate::ui::grid::column_resize::tests::{laid_out, shown_width, step}; - let grid = laid_out(two_text_rows()); - let before = shown_width(&grid, 1); - - open_menu(&grid.cell_of(1, 1)); - choose(&grid, &["cell.widen-column"]); - assert_eq!(grid.column(1).fixed_width(), before + step(&grid)); - open_menu(&grid.cell_of(1, 1)); - choose(&grid, &["cell.narrow-column"]); - assert_eq!(grid.column(1).fixed_width(), before); - open_menu(&grid.cell_of(1, 1)); - choose(&grid, &["cell.reset-width"]); - - assert_eq!(grid.column(1).fixed_width(), -1); - assert!(grid.column(1).expands(), "Reset left the column at its natural width"); - } - - fn shown_menu(grid: &Grid) -> Option { - descendants(&grid.view) - .into_iter() - .filter_map(|widget| widget.downcast::().ok()) - .find(|menu| menu.is_visible()) - } - - /// A menu closed, or kept from opening by another client holding the - /// pointer, leaves the focus on a cell. A page that then takes that - /// cell's row out, and the window taking the focus back before the - /// next frame, crashed GTK 4.22 on the detached row. - #[gtk4::test] - fn a_page_under_a_closed_menu_then_a_focus_in_keeps_the_focus_in_the_grid() { - let grid = two_text_rows(); - grid.focus(0, 1); - open_menu(&grid.cell_of(2, 1)); - if let Some(menu) = shown_menu(&grid) { - menu.popdown(); - } - assert!( - grid.focused_cell().is_some(), - "the closed menu left the focus off the grid" - ); - let thief = FocusThief::take_from(&grid.window).expect("another window takes the focus"); - - grid.store.splice(0, grid.store.n_items(), &page(3..5)); - thief.give_back(&grid.window); - drain_main_context(); - after_next_frame(&grid.window); - - assert!(focus_in_grid(&grid), "the focus left the grid"); - } - - /// The list keeps the widgets of the first row for its focus, so the - /// menu opens on a row further down, which scrolling hands on. - #[gtk4::test] - fn a_cell_scrolled_onto_another_row_leaves_the_menu_on_its_own() { - let grid = two_text_rows(); - grid.store.splice(grid.store.n_items(), 0, &page(3..300)); - let cell = drawn_cell(&grid, 6, 1); - open_menu(&cell); - - grid.view - .scroll_to(grid.store.n_items() - 1, None, gtk::ListScrollFlags::NONE, None); - wait_until(WAIT, || cell.row().is_some_and(|row| row.key() != Some(&key(6)))) - .expect("the scroll did not rebind the clicked cell"); - choose(&grid, &["cell.set-null", "cell.delete-row"]); - - assert_eq!( - acted_on(&grid), - [("set", vec![Some(key(6))]), ("delete", vec![Some(key(6))])] - ); - } - - fn drawn_cell(grid: &Grid, id: i64, column: usize) -> GridCell { - let mut drawn = None; - wait_until(WAIT, || { - drawn = grid - .cells() - .into_iter() - .find(|cell| cell.column() == column && cell.row().is_some_and(|row| row.key() == Some(&key(id)))); - drawn.is_some() - }) - .expect("the row's cell is drawn"); - drawn.expect("a drawn cell") - } -} diff --git a/linux/crates/app/src/ui/grid/edit_snapshot.rs b/linux/crates/app/src/ui/grid/edit_snapshot.rs deleted file mode 100644 index 85473a3110..0000000000 --- a/linux/crates/app/src/ui/grid/edit_snapshot.rs +++ /dev/null @@ -1,13 +0,0 @@ -use tablepro_core::edit::CellInput; - -use super::RowObject; - -/// An edit as it stood when it began. The commit goes to `row`, not to -/// whatever row the cell shows by then, and is measured against -/// `original`, the cell's value with any pending edit. -#[derive(Debug, Clone)] -pub struct EditSnapshot { - pub row: RowObject, - pub column: usize, - pub original: CellInput, -} diff --git a/linux/crates/app/src/ui/grid/grid_attributes.rs b/linux/crates/app/src/ui/grid/grid_attributes.rs deleted file mode 100644 index 231c0afc85..0000000000 --- a/linux/crates/app/src/ui/grid/grid_attributes.rs +++ /dev/null @@ -1,74 +0,0 @@ -use gtk4::pango; - -use super::display_text::{auto_filled_sentinel, editable_null_sentinel}; - -/// How a pending change marks a cell's text, beside its tint, so the -/// change reads without colour. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum TextMark { - Plain, - Modified, - /// Wins over `Modified`: an edited cell in a row marked for deletion - /// is going away with it. - PendingDelete, -} - -/// How a grid's cells mark their text, built once per column view and -/// shared by every cell in it, so a bind sets a list it does not build. -/// Italic marks a placeholder. Pango attributes, because CSS -/// `text-decoration` does not reach an Inscription's text reliably. -#[derive(Debug)] -pub struct GridAttributes { - modified: pango::AttrList, - modified_placeholder: pango::AttrList, - strikethrough: pango::AttrList, - placeholder: pango::AttrList, - strikethrough_placeholder: pango::AttrList, - /// The stand-in texts, translated once rather than on every bind. - pub editable_null: String, - pub auto_filled: String, -} - -impl Default for GridAttributes { - fn default() -> Self { - Self::new() - } -} - -impl GridAttributes { - pub fn new() -> Self { - let list = |mark: TextMark, italic: bool| { - let attrs = pango::AttrList::new(); - match mark { - TextMark::Plain => {} - TextMark::Modified => attrs.insert(pango::AttrInt::new_weight(pango::Weight::Bold)), - TextMark::PendingDelete => attrs.insert(pango::AttrInt::new_strikethrough(true)), - } - if italic { - attrs.insert(pango::AttrInt::new_style(pango::Style::Italic)); - } - attrs - }; - Self { - modified: list(TextMark::Modified, false), - modified_placeholder: list(TextMark::Modified, true), - strikethrough: list(TextMark::PendingDelete, false), - placeholder: list(TextMark::Plain, true), - strikethrough_placeholder: list(TextMark::PendingDelete, true), - editable_null: editable_null_sentinel(), - auto_filled: auto_filled_sentinel(), - } - } - - /// `None` for plain text. - pub fn for_text(&self, mark: TextMark, placeholder: bool) -> Option<&pango::AttrList> { - match (mark, placeholder) { - (TextMark::Plain, false) => None, - (TextMark::Plain, true) => Some(&self.placeholder), - (TextMark::Modified, false) => Some(&self.modified), - (TextMark::Modified, true) => Some(&self.modified_placeholder), - (TextMark::PendingDelete, false) => Some(&self.strikethrough), - (TextMark::PendingDelete, true) => Some(&self.strikethrough_placeholder), - } - } -} diff --git a/linux/crates/app/src/ui/grid/grid_cell.rs b/linux/crates/app/src/ui/grid/grid_cell.rs deleted file mode 100644 index 1108544811..0000000000 --- a/linux/crates/app/src/ui/grid/grid_cell.rs +++ /dev/null @@ -1,620 +0,0 @@ -use std::cell::{Cell, OnceCell, RefCell}; -use std::rc::Rc; - -use gtk4::prelude::*; -use gtk4::subclass::prelude::*; -use gtk4::{self as gtk, glib}; -use tablepro_core::Value; -use tablepro_core::edit::CellInput; - -use super::cell_accessibility::cell_description; -use super::cell_binding::CellBinding; -use super::cell_focus::return_focus; -use super::column_drawing::ColumnDrawing; -use super::grid_columns::GridColumns; -use super::inline_editor::{InlineEditor, commit_inline}; -use super::{CellLock, EditSnapshot, GridSink, RowObject}; - -/// Enough for any number, date or short text. -const SCRATCH_KEPT_BYTES: usize = 256; - -mod imp { - use super::*; - - #[derive(Default)] - pub struct GridCell { - pub list_item: glib::WeakRef, - pub column: Cell, - /// What the cell draws its value with: an Inscription, or a check - /// button for a boolean column. - pub content: OnceCell, - /// The one-line editor, laid over the hidden display while open. - pub inline: RefCell>, - /// A one-line field that lost the focus, hidden until the cell's - /// next edit. - pub closed_field: RefCell>, - /// An open editor popover. Unbind pops it down, or the recycled - /// cell is finalized with a GtkPopover child still in it. - pub popover: RefCell>, - pub edit: RefCell>, - /// Set while the bind draws a check button, which would otherwise - /// echo back as an edit of the value it just drew. - pub suppress_toggle: Cell, - pub lock: Cell>, - pub drawing: OnceCell>, - /// Where the cell's editors send their edits. - pub sink: OnceCell, - pub(super) columns: OnceCell>, - pub binding: RefCell>, - /// Where a value that is not text is written to draw it, kept - /// from one bind to the next. - pub scratch: RefCell, - #[cfg(test)] - pub renders: Cell, - } - - #[glib::object_subclass] - impl ObjectSubclass for GridCell { - const NAME: &'static str = "TableProGridCell"; - type Type = super::GridCell; - type ParentType = gtk::Widget; - - fn class_init(klass: &mut Self::Class) { - klass.set_layout_manager_type::(); - klass.set_css_name("tp-grid-cell"); - } - } - - impl ObjectImpl for GridCell { - fn dispose(&self) { - self.binding.take(); - self.edit.take(); - if let Some(editor) = self.inline.take() { - editor.widget().unparent(); - } - if let Some(field) = self.closed_field.take() { - field.unparent(); - } - if let Some(popover) = self.popover.take() { - popover.unparent(); - } - if let Some(content) = self.content.get() { - content.unparent(); - } - } - } - - impl WidgetImpl for GridCell { - fn unroot(&self) { - self.obj().leave_detached_row(); - self.parent_unroot(); - } - } -} - -glib::wrapper! { - /// One cell of the grid. It resolves its row through the column view - /// cell when an event fires, so the row is the one the cell shows - /// then, not the one it was bound to. - pub struct GridCell(ObjectSubclass) - @extends gtk::Widget, - @implements gtk::Accessible, gtk::Buildable, gtk::ConstraintTarget; -} - -impl Default for GridCell { - fn default() -> Self { - Self::new() - } -} - -impl GridCell { - pub fn new() -> Self { - glib::Object::new() - } - - pub fn attach(&self, cell: >k::ColumnViewCell, column: usize) { - self.imp().list_item.set(Some(cell)); - self.imp().column.set(column); - } - - pub fn set_content(&self, content: &impl IsA) { - let content = content.as_ref(); - if self.imp().content.set(content.clone()).is_ok() { - content.set_parent(self); - } - } - - pub fn content(&self) -> Option<>k::Widget> { - self.imp().content.get() - } - - /// The text display every cell but a check box draws with. - pub fn display(&self) -> Option { - self.content()?.clone().downcast().ok() - } - - pub fn check_button(&self) -> Option { - self.content()?.clone().downcast().ok() - } - - /// The one-line editor's text field while it is open. - pub fn inline_text(&self) -> Option { - self.imp() - .inline - .borrow() - .as_ref() - .map(|editor| editor.widget().clone()) - } - - pub fn row(&self) -> Option { - self.imp().list_item.upgrade()?.item().and_downcast() - } - - pub fn position(&self) -> Option { - let position = self.imp().list_item.upgrade()?.position(); - (position != gtk::INVALID_LIST_POSITION).then_some(position) - } - - pub fn column(&self) -> usize { - self.imp().column.get() - } - - pub fn is_editing(&self) -> bool { - let imp = self.imp(); - imp.edit.borrow().is_some() || imp.popover.borrow().is_some() || imp.inline.borrow().is_some() - } - - /// The cell that holds the keyboard focus. A focused column view - /// cell widget is this cell's parent, so its first child is tried - /// after its ancestors. - pub fn from_focus(focus: >k::Widget) -> Option { - if let Some(cell) = focus.ancestor(GridCell::static_type()) { - return cell.downcast().ok(); - } - focus.first_child()?.downcast().ok() - } - - pub fn lock(&self) -> Option { - self.imp().lock.get() - } - - pub(super) fn set_drawing(&self, drawing: Rc) { - let _ = self.imp().drawing.set(drawing); - } - - pub(super) fn drawing(&self) -> Option> { - self.imp().drawing.get().cloned() - } - - pub(super) fn set_sink(&self, sink: GridSink) { - let _ = self.imp().sink.set(sink); - } - - pub(super) fn sink(&self) -> Option { - self.imp().sink.get().cloned() - } - - pub(super) fn set_columns(&self, columns: Rc) { - let _ = self.imp().columns.set(columns); - } - - pub(super) fn columns(&self) -> Option> { - self.imp().columns.get().cloned() - } - - pub(super) fn column_view(&self) -> Option { - self.ancestor(gtk::ColumnView::static_type()).and_downcast() - } - - pub(super) fn bind(&self, row: &RowObject) { - let lock = match self.imp().drawing.get() { - Some(drawing) => drawing.lock(row.id()), - None => Some(CellLock::ResultRow), - }; - self.imp().lock.set(lock); - self.imp().binding.replace(Some(CellBinding::new(self, row))); - self.render(); - } - - /// An edit or popover still open is cancelled, since the row it was - /// for is no longer this cell's. - pub(super) fn unbind(&self) { - self.imp().binding.take(); - self.cancel_edit(); - self.reset_property(gtk::AccessibleProperty::Description); - } - - /// The snapshot goes first, so an editor closing sends nothing. - pub(crate) fn cancel_edit(&self) { - self.take_edit(); - if let Some(editor) = self.take_inline() { - editor.close(self); - } - if let Some(popover) = self.take_popover() { - popover.popdown(); - } - } - - pub(super) fn render(&self) { - #[cfg(test)] - self.imp().renders.set(self.imp().renders.get() + 1); - let row = self - .imp() - .binding - .borrow() - .as_ref() - .map(|binding| binding.row().clone()); - let (Some(row), Some(drawing)) = (row, self.drawing()) else { - return; - }; - let drawn = drawing.draw(self, &row); - // On the cell, since GtkColumnViewCell passes on no accessible - // property of its own. - match cell_description(drawn.state, drawn.null) { - Some(description) => self.update_property(&[gtk::accessible::Property::Description(&description)]), - None => self.reset_property(gtk::AccessibleProperty::Description), - } - } - - #[cfg(test)] - pub(super) fn render_count(&self) -> u32 { - self.imp().renders.get() - } - - pub(super) fn begin_edit(&self, snapshot: EditSnapshot) { - self.imp().edit.replace(Some(snapshot)); - } - - pub(super) fn take_edit(&self) -> Option { - self.imp().edit.take() - } - - /// Whether an open edit started from another value than `value`. - /// `default` is a draft's cell left to the server. - pub(super) fn edit_left_behind(&self, value: &Value, default: bool) -> bool { - match self.imp().edit.borrow().as_ref().map(|snapshot| &snapshot.original) { - None => false, - Some(CellInput::Default) => !default, - Some(CellInput::Value(original)) => default || original != value, - } - } - - /// The cell's text buffer. One grown past a short value is let go, - /// so a page of long values does not keep a long buffer per cell. - pub(super) fn with_scratch(&self, f: impl FnOnce(&mut String) -> R) -> R { - let mut scratch = self.imp().scratch.borrow_mut(); - let drawn = f(&mut scratch); - if scratch.capacity() > SCRATCH_KEPT_BYTES { - *scratch = String::new(); - } - drawn - } - - pub(super) fn set_inline(&self, editor: InlineEditor) { - self.imp().inline.replace(Some(editor)); - } - - pub(super) fn take_inline(&self) -> Option { - self.imp().inline.take() - } - - pub(super) fn keep_closed_field(&self, field: gtk::Text) { - if let Some(previous) = self.imp().closed_field.replace(Some(field)) { - previous.unparent(); - } - } - - pub(super) fn remove_closed_field(&self) { - if let Some(field) = self.imp().closed_field.take() { - field.unparent(); - } - } - - /// Parent `popover` to the cell and keep it there until it closes. - /// A popover the user closes ends its edit; one the editor took back - /// first has already dealt with it. - pub(super) fn present_popover(&self, popover: >k::Popover) { - popover.set_parent(self); - if let Some(previous) = self.imp().popover.replace(Some(popover.clone())) { - previous.popdown(); - } - // The cell holds the popover in its slot and as its child, so the - // popover holds the cell weakly and itself not at all. - let cell = self.downgrade(); - popover.connect_closed(move |popover| { - let cell = cell.upgrade(); - // GTK moves a focus left in a hidden popover to the popover's - // parent after the next frame, and by then it has none, so the - // focus would leave the grid. An unbound cell's row is gone. - if let Some(cell) = &cell - && cell.is_bound() - && holds_focus(popover) - { - return_focus(cell); - } - popover.unparent(); - if let Some(cell) = cell - && cell.release_popover(popover) - { - cell.take_edit(); - } - }); - popover.popup(); - } - - /// GTK detaches a removed row before it unbinds it, and moves a focus - /// left inside only after the next frame; a window focus-in between - /// the two crashes GTK 4.22 on the detached row. So the focus goes - /// before the edit ends, and the grid takes it back after the frame. - fn leave_detached_row(&self) { - let window = self.root().and_downcast::(); - let held = window - .as_ref() - .and_then(gtk::prelude::GtkWindowExt::focus) - .is_some_and(|focus| self.row_holds(&focus)); - if let Some(window) = window.as_ref().filter(|_| held) { - gtk::prelude::GtkWindowExt::set_focus(window, None::<>k::Widget>); - // The focus leaving commits a one-line edit, but a field that - // lost it to another window already left and hears nothing. - commit_inline(self); - } - self.cancel_edit(); - let (Some(window), true) = (window, held) else { - return; - }; - let (Some(view), Some(clock)) = (self.column_view(), window.frame_clock()) else { - return; - }; - let view = view.downgrade(); - let handler: Rc>> = Rc::default(); - let slot = handler.clone(); - let id = clock.connect_after_paint(move |clock| { - if let Some(id) = slot.borrow_mut().take() { - clock.disconnect(id); - } - if let Some(view) = view.upgrade() - && view.root().is_some() - { - view.grab_focus(); - } - }); - handler.replace(Some(id)); - } - - /// Whether `focus` is in this cell's row: in the cell or its editor, - /// or on the list's own cell or row widget above it, where the - /// keyboard sits while nothing is being edited. - fn row_holds(&self, focus: >k::Widget) -> bool { - if focus == self.upcast_ref::() || focus.is_ancestor(self) { - return true; - } - self.ancestor(gtk::ListView::static_type()) - .is_some_and(|list| self.is_ancestor(focus) && focus.is_ancestor(&list)) - } - - fn is_bound(&self) -> bool { - self.imp().binding.borrow().is_some() - } - - pub(super) fn take_popover(&self) -> Option { - self.imp().popover.take() - } - - /// Forget `popover` if it is still this cell's, leaving a newer one - /// in place. `true` when it was. - fn release_popover(&self, popover: >k::Popover) -> bool { - let mut slot = self.imp().popover.borrow_mut(); - if slot.as_ref() == Some(popover) { - slot.take(); - return true; - } - false - } - - pub(super) fn toggle_suppressed(&self) -> bool { - self.imp().suppress_toggle.get() - } - - pub(super) fn suppress_toggle(&self, suppressed: bool) { - self.imp().suppress_toggle.set(suppressed); - } -} - -fn holds_focus(widget: &impl IsA) -> bool { - widget - .root() - .and_downcast::() - .and_then(|window| gtk::prelude::GtkWindowExt::focus(&window)) - .is_some_and(|focus| focus.is_ancestor(widget)) -} - -#[cfg(test)] -mod tests { - use gtk4 as gtk; - use gtk4::prelude::*; - use tablepro_core::{ColumnInfo, Value}; - - use super::GridCell; - use crate::services::tracker_edit::TrackerEdit; - use crate::test_support::{FocusThief, after_next_frame, descendants, drain_main_context, wait_until}; - use crate::ui::grid::test_grid::{ - Grid, WAIT, edited_rows, flag_rows, focus_in_grid, key, named, page, recorder, text, two_text_rows, type_into, - }; - use crate::ui::grid::{RowObject, build_result_grid}; - - fn described(widget: &impl IsA) -> bool { - gtk::test_accessible_has_property(widget, gtk::AccessibleProperty::Description) - } - - #[gtk4::test] - fn pending_edit_sets_description_on_grid_cell() { - let grid = two_text_rows(); - let cell = grid.cell_of(1, 1); - assert!(!described(&cell)); - - grid.redraw(&grid.tracker.apply(TrackerEdit::Cell { - row_key: key(1), - col: 1, - original: Value::Text("A".into()), - value: Value::Text("edited".into()), - })); - assert!(described(&cell), "an edited cell said nothing"); - assert!(!described(&grid.cell_of(2, 1))); - - grid.redraw(&grid.tracker.undo().expect("an edit to undo")); - assert!(!described(&cell), "an undone edit still read as modified"); - } - - fn has_attribute(cell: &GridCell, kind: gtk::pango::AttrType) -> bool { - cell.display() - .and_then(|display| display.attributes()) - .is_some_and(|list| list.attributes().iter().any(|attr| attr.type_() == kind)) - } - - #[gtk4::test] - fn modified_cell_renders_bold() { - let grid = two_text_rows(); - let cell = grid.cell_of(1, 1); - assert!(!has_attribute(&cell, gtk::pango::AttrType::Weight)); - - grid.redraw(&grid.tracker.apply(TrackerEdit::Cell { - row_key: key(1), - col: 1, - original: Value::Text("A".into()), - value: Value::Text("edited".into()), - })); - assert!( - has_attribute(&cell, gtk::pango::AttrType::Weight), - "an edit showed only by its tint" - ); - assert!(!has_attribute(&grid.cell_of(1, 0), gtk::pango::AttrType::Weight)); - - grid.redraw(&grid.tracker.apply(TrackerEdit::DeleteRows { - persisted: vec![(key(1), vec![Value::Int(1), Value::Text("A".into())].into())], - drafts: Vec::new(), - })); - assert!(has_attribute(&cell, gtk::pango::AttrType::Strikethrough)); - assert!( - !has_attribute(&cell, gtk::pango::AttrType::Weight), - "a cell going away with its row read as an edit" - ); - } - - #[gtk4::test] - fn null_cell_has_description_text_null_does_not() { - let columns = [named("id", "integer", false), named("name", "text", false)]; - let rows = vec![RowObject::result(vec![Value::Null, Value::Text("NULL".into())].into())]; - let (sink, _messages) = recorder(); - let (view, _selection) = build_result_grid( - columns.iter().map(ColumnInfo::result_column).collect(), - rows, - sink, - true, - ); - let window = gtk::Window::new(); - window.set_child(Some(&view)); - window.present(); - let cells = || -> Vec { - descendants(&view) - .into_iter() - .filter_map(|widget| widget.downcast::().ok()) - .filter(|cell| cell.row().is_some()) - .collect() - }; - wait_until(WAIT, || cells().len() == 2).expect("the cells are drawn"); - let cell = |column| { - cells() - .into_iter() - .find(|cell| cell.column() == column) - .expect("the cell") - }; - - assert!(described(&cell(0)), "a NULL read the same as the text NULL"); - assert!(!described(&cell(1)), "the text NULL read as a NULL"); - window.destroy(); - - let editable = Grid::open( - vec![named("id", "integer", true), named("name", "text", false)], - vec![ - vec![Value::Int(1), Value::Null], - vec![Value::Int(2), Value::Text("NULL".into())], - ], - ); - assert!(described(&editable.cell_of(1, 1))); - assert!(!described(&editable.cell_of(2, 1))); - } - - #[gtk4::test] - fn bool_cell_is_labelled() { - let grid = flag_rows(); - let check = grid.cell_of(1, 1).check_button().expect("a check button"); - - assert!(gtk::test_accessible_has_property( - &check, - gtk::AccessibleProperty::Label - )); - } - - #[gtk4::test] - fn from_focus_finds_the_cell_below_a_focused_cell_widget_or_above_its_editor() { - let cell_widget = gtk::Box::new(gtk::Orientation::Horizontal, 0); - let cell = GridCell::new(); - let editor = gtk::Text::new(); - editor.set_parent(&cell); - cell_widget.append(&cell); - - let below = GridCell::from_focus(cell_widget.upcast_ref()); - let above = GridCell::from_focus(editor.upcast_ref()); - - assert_eq!(below.as_ref(), Some(&cell)); - assert_eq!(above.as_ref(), Some(&cell)); - assert!(GridCell::from_focus(gtk::Label::new(None).upcast_ref()).is_none()); - editor.unparent(); - } - - /// The keyboard sits on the list's own cell widget, above the cell. - /// GTK moves a focus left in a removed row only after the next frame, - /// and a window focus-in before then crashed GTK 4.22 on the row. - #[gtk4::test] - fn a_focus_in_after_a_page_took_the_focused_row_keeps_the_focus_in_the_grid() { - let grid = two_text_rows(); - grid.focus(0, 1); - let thief = FocusThief::take_from(&grid.window).expect("another window takes the focus"); - - grid.store.splice(0, grid.store.n_items(), &page(3..5)); - thief.give_back(&grid.window); - drain_main_context(); - after_next_frame(&grid.window); - - assert!(focus_in_grid(&grid), "the focus left the grid"); - } - - /// The focus leaving commits an edit whose row a page takes out. A - /// field whose window lost the focus to another has already left, - /// and its edit is committed all the same. - #[gtk4::test] - fn a_page_that_takes_an_edited_row_from_an_inactive_window_commits_the_edit() { - let grid = two_text_rows(); - let cell = grid.focus(0, 1); - type_into(&cell, "typed"); - let _thief = FocusThief::take_from(&grid.window).expect("another window takes the focus"); - - grid.store.splice(0, grid.store.n_items(), &page(3..5)); - drain_main_context(); - - assert!(!cell.is_editing(), "the edit outlived its row"); - assert_eq!( - edited_rows(&grid.take_messages()), - vec![(Some(key(1)), 1, text("typed"))] - ); - } - - #[gtk4::test] - fn an_unattached_cell_has_no_row() { - let cell = GridCell::new(); - - assert!(cell.row().is_none()); - assert!(cell.position().is_none()); - assert!(!cell.is_editing()); - } -} diff --git a/linux/crates/app/src/ui/grid/grid_columns.rs b/linux/crates/app/src/ui/grid/grid_columns.rs deleted file mode 100644 index ec3085bdd0..0000000000 --- a/linux/crates/app/src/ui/grid/grid_columns.rs +++ /dev/null @@ -1,107 +0,0 @@ -use std::cell::RefCell; -use std::rc::Rc; - -use gtk4::prelude::*; -use gtk4::{self as gtk, gio, glib}; - -use super::RowObject; -use super::cell_editor_kind::CellEditorKind; -use super::column_drawing::ColumnDrawing; - -/// The grid's columns, for finding a cell's neighbours. The user can -/// drag the columns into another order, so the order is read from the -/// view each time. -#[derive(Default)] -pub(super) struct GridColumns { - columns: RefCell>, -} - -struct GridColumn { - index: usize, - /// The view owns its columns, and their cells hold this. - view_column: glib::WeakRef, - drawing: Rc, - default_width: Option, -} - -#[derive(Clone)] -pub(super) struct ShownColumn { - pub(super) view_column: gtk::ColumnViewColumn, - /// The column's place in the result, which is what its cells hold. - pub(super) index: usize, - drawing: Rc, - /// The width it opens at with none saved, `None` for its natural one. - pub(super) default_width: Option, -} - -impl ShownColumn { - /// Whether the column's cell in `row` opens an editor. A check box - /// is edited where it stands, so it opens none. - pub(super) fn opens_editor(&self, row: &RowObject) -> bool { - let Some(column) = self.drawing.catalog.as_ref() else { - return false; - }; - let editability = self.drawing.lock(row.id()).map_or(Ok(()), Err); - matches!( - CellEditorKind::for_cell(column, editability), - CellEditorKind::Inline { .. } | CellEditorKind::Multiline { .. } | CellEditorKind::Calendar - ) - } -} - -impl GridColumns { - pub(super) fn push( - &self, - index: usize, - view_column: >k::ColumnViewColumn, - drawing: Rc, - default_width: Option, - ) { - self.columns.borrow_mut().push(GridColumn { - index, - view_column: view_column.downgrade(), - drawing, - default_width, - }); - } - - /// Measure the rows a change to `store` brought in. A change that - /// replaced every row, a new page, measures from scratch; adding or - /// removing a draft, which has no cells, measures nothing. - pub(super) fn measure_change(&self, store: &gio::ListStore, position: u32, added: u32) { - let replaced_all = position == 0 && store.n_items() == added; - if !replaced_all && added == 0 { - return; - } - let rows: Vec = (position..position.saturating_add(added)) - .filter_map(|position| store.item(position).and_downcast()) - .collect(); - for column in self.columns.borrow().iter() { - let drawing = &column.drawing; - match replaced_all { - true => drawing.figures.measure(&drawing.column_type, column.index, &rows), - false => drawing.figures.extend(&drawing.column_type, column.index, &rows), - } - } - } - - pub(super) fn shown(&self, view: >k::ColumnView) -> Vec { - let columns = self.columns.borrow(); - let model = view.columns(); - (0..model.n_items()) - .filter_map(|position| model.item(position).and_downcast::()) - .filter(gtk::ColumnViewColumn::is_visible) - .filter_map(|view_column| { - let column = columns - .iter() - .find(|column| column.view_column.upgrade().as_ref() == Some(&view_column))?; - Some(ShownColumn { - view_column, - index: column.index, - drawing: column.drawing.clone(), - default_width: column.default_width, - }) - }) - .collect() - } -} diff --git a/linux/crates/app/src/ui/grid/grid_content.rs b/linux/crates/app/src/ui/grid/grid_content.rs deleted file mode 100644 index 4d1f0ccf2a..0000000000 --- a/linux/crates/app/src/ui/grid/grid_content.rs +++ /dev/null @@ -1,13 +0,0 @@ -use std::sync::Arc; - -use tablepro_core::column::ResultColumn; - -use super::RowObject; - -/// What a grid is built to show: the result's columns and its rows, -/// which the grid's store then owns. -#[derive(Debug, Clone)] -pub struct GridContent { - pub columns: Arc<[ResultColumn]>, - pub rows: Vec, -} diff --git a/linux/crates/app/src/ui/grid/grid_keyboard.rs b/linux/crates/app/src/ui/grid/grid_keyboard.rs deleted file mode 100644 index b9b321a28d..0000000000 --- a/linux/crates/app/src/ui/grid/grid_keyboard.rs +++ /dev/null @@ -1,593 +0,0 @@ -use gtk4::prelude::*; -use gtk4::{self as gtk, gdk, glib}; - -use super::cell_edit::begin_edit; -use super::cell_focus::focused_cell; -use super::cell_preset::cell_presets; -use super::column_resize::{ColumnResize, resize_column}; -use super::context_menu::prepare_menu; -use super::grid_menus::GridMenus; -use super::reference_menu::OPEN_ONE; -use super::{CellPreset, GridCell, GridMsg, GridSink}; -use crate::application::{ShortcutOwner, component_trigger, controller_name}; - -pub(super) struct GridKeyboard; - -impl GridKeyboard { - /// One controller on the column view, run in the capture phase. - /// GtkListFactoryWidget class-binds Return, ISO_Enter and KP_Enter - /// to activate-keybinding and space to listitem.select, and a - /// G_TYPE_NONE signal action counts as handled, so on a focused cell - /// those keys never reach a bubble-phase ancestor. - /// - /// Each action proceeds while the cell is editing. GtkShortcutController - /// then tries its next shortcut and lets the key go on, so an open - /// editor keeps Return, space, Menu and Ctrl+Backspace (delete word). - pub(super) fn install(column_view: >k::ColumnView, menus: GridMenus, sink: GridSink) { - let controller = gtk::ShortcutController::new(); - controller.set_scope(gtk::ShortcutScope::Local); - controller.set_propagation_phase(gtk::PropagationPhase::Capture); - controller.set_name(Some(controller_name(ShortcutOwner::GridCell))); - let add = |action: &str, act: gtk::CallbackAction| { - controller.add_shortcut(gtk::Shortcut::new(component_trigger(action), Some(act))); - }; - add("cell.edit", on_focused_cell(|_, cell| edit_or_toggle(cell))); - add("cell.toggle", on_focused_cell(|_, cell| toggle(cell))); - let reference_menus = menus.clone(); - add( - "cell.menu", - on_focused_cell(move |widget, cell| show_menu(widget, cell, &menus)), - ); - add("cell.set-null", on_focused_cell(move |_, cell| set_null(cell, &sink))); - add( - "cell.open-referenced-row", - on_focused_cell(move |widget, cell| open_referenced_row(widget, cell, &reference_menus)), - ); - add( - "cell.widen-column", - on_focused_cell(|widget, cell| resize(widget, cell, ColumnResize::Wider)), - ); - add( - "cell.narrow-column", - on_focused_cell(|widget, cell| resize(widget, cell, ColumnResize::Narrower)), - ); - column_view.add_controller(controller); - } -} - -fn on_focused_cell(act: impl Fn(>k::Widget, &GridCell) -> glib::Propagation + 'static) -> gtk::CallbackAction { - gtk::CallbackAction::new(move |widget, _| match focused_cell(widget) { - Some(cell) if !cell.is_editing() => act(widget, &cell), - _ => glib::Propagation::Proceed, - }) -} - -/// A locked cell lets the key go on, so the list's own activation runs. -fn edit_or_toggle(cell: &GridCell) -> glib::Propagation { - if cell.check_button().is_some() { - return toggle(cell); - } - if cell.lock().is_some() { - return glib::Propagation::Proceed; - } - begin_edit(cell); - glib::Propagation::Stop -} - -/// Space on any other cell goes on to select the row. -fn toggle(cell: &GridCell) -> glib::Propagation { - let (None, Some(check)) = (cell.lock(), cell.check_button()) else { - return glib::Propagation::Proceed; - }; - // The check button's toggled handler sends the edit. - check.set_active(!check.is_active()); - glib::Propagation::Stop -} - -fn show_menu(widget: >k::Widget, cell: &GridCell, menus: &GridMenus) -> glib::Propagation { - let Some(column_view) = widget.downcast_ref::() else { - return glib::Propagation::Proceed; - }; - let popover = prepare_menu(menus, cell, column_view); - point_at_cell(&popover, cell, column_view); - popover.popup(); - glib::Propagation::Stop -} - -/// A key has no pointer position, so a menu points at the whole cell, -/// in the coordinates of the view the menu is parented to. -fn point_at_cell(popover: >k::PopoverMenu, cell: &GridCell, column_view: >k::ColumnView) { - let bounds = cell.compute_bounds(column_view).map(|bounds| { - gdk::Rectangle::new( - bounds.x() as i32, - bounds.y() as i32, - bounds.width() as i32, - bounds.height() as i32, - ) - }); - popover.set_pointing_to(bounds.as_ref()); -} - -/// Follow the one key the cell's column is in, or offer the keys to -/// choose from when it is in several. A column in none lets the key go -/// on. -fn open_referenced_row(widget: >k::Widget, cell: &GridCell, menus: &GridMenus) -> glib::Propagation { - let Some(column_view) = widget.downcast_ref::() else { - return glib::Propagation::Proceed; - }; - if menus.references.offers_for(cell).open.is_empty() { - return glib::Propagation::Proceed; - } - prepare_menu(menus, cell, column_view); - match menus.references.only_choice() { - Some(_) => { - if let Err(error) = column_view.activate_action(&format!("cell.{OPEN_ONE}"), None) { - tracing::debug!(%error, "the grid has no action to open a referenced row"); - } - } - None => { - point_at_cell(&menus.reference_chooser, cell, column_view); - menus.reference_chooser.popup(); - } - } - glib::Propagation::Stop -} - -fn resize(widget: >k::Widget, cell: &GridCell, change: ColumnResize) -> glib::Propagation { - let Some(column_view) = widget.downcast_ref::() else { - return glib::Propagation::Proceed; - }; - resize_column(column_view, cell, change); - glib::Propagation::Stop -} - -fn set_null(cell: &GridCell, sink: &GridSink) -> glib::Propagation { - let (None, Some(row), Some(drawing)) = (cell.lock(), cell.row(), cell.drawing()) else { - return glib::Propagation::Proceed; - }; - let Some(column) = drawing.catalog.as_ref() else { - return glib::Propagation::Proceed; - }; - if !cell_presets(column, row.id()).null { - return glib::Propagation::Proceed; - } - sink(GridMsg::SetCellValue { - row, - column: cell.column(), - preset: CellPreset::Null, - }); - glib::Propagation::Stop -} - -#[cfg(test)] -mod tests { - use std::collections::HashSet; - - use gtk4::prelude::*; - use gtk4::{self as gtk, gdk}; - use tablepro_core::Value; - - use crate::application::{ACCELERATORS, AcceleratorScope, ShortcutOwner}; - use crate::test_support::shortcut_keys::{ - Keystroke, class_binding_keys, class_keys_on_focus_paths, keystroke, owner_controller, shortcuts, table_keys, - trigger_keys, - }; - use crate::test_support::{descendants, wait_until}; - use crate::ui::grid::reference_menu::tests::{filter, followed, key as foreign_key, orders_grid, public}; - use crate::ui::grid::test_grid::{Grid, WAIT, content, edited_rows, key, named, recorder, text, two_text_rows}; - use crate::ui::grid::{ - CellPreset, GridCell, GridMsg, GridSorting, RowObject, TabGridContext, begin_edit, build_column_view, - }; - - fn accels(shortcut: >k::Shortcut) -> Vec { - shortcut - .trigger() - .map(|trigger| trigger_keys(&trigger)) - .unwrap_or_default() - } - - fn keyboard(view: >k::ColumnView) -> gtk::ShortcutController { - owner_controller(view, ShortcutOwner::GridCell).expect("the grid's keyboard controller") - } - - /// Run what `key` does on the focused cell, as a key press reaching - /// the controller would. `true` when the grid took the key. - fn press(view: >k::ColumnView, key: gdk::Key, modifiers: gdk::ModifierType) -> bool { - let action = shortcuts(&keyboard(view)) - .into_iter() - .find(|shortcut| accels(shortcut).contains(&(key.to_lower(), modifiers))) - .and_then(|shortcut| shortcut.action()) - .expect("the grid binds the key"); - action.activate(gtk::ShortcutActionFlags::empty(), view, None) - } - - fn none() -> gdk::ModifierType { - gdk::ModifierType::empty() - } - - #[gtk4::test] - fn grid_keyboard_matches_component_entries() { - let grid = two_text_rows(); - let bound: HashSet = shortcuts(&keyboard(&grid.view)).iter().flat_map(accels).collect(); - - assert_eq!(bound, table_keys(ShortcutOwner::GridCell)); - assert!(bound.contains(&keystroke("BackSpace"))); - assert!( - bound.contains(&(gdk::Key::KP_Enter, none())), - "the keypad Enter was lost" - ); - } - - /// A bubble-phase key on the column view reaches it only when no - /// class on the way up from the focused cell binds it first. - #[gtk4::test] - fn browse_grid_keys_are_not_class_bindings_inside_the_grid() { - let grid = two_text_rows(); - grid.focus(0, 1); - let class_keys = class_keys_on_focus_paths(&grid.view, |_| false); - assert!( - class_keys.contains(&keystroke("a")), - "the list view's own bindings were not found, so the check reads nothing" - ); - - for entry in ACCELERATORS { - let AcceleratorScope::Local { - owner: ShortcutOwner::BrowseGrid, - phase, - } = entry.scope - else { - continue; - }; - assert_eq!(phase, gtk::PropagationPhase::Bubble, "{}", entry.action); - for accel in entry.triggers { - assert!( - !class_keys.contains(&keystroke(accel)), - "{accel} of {} never reaches the column view", - entry.action - ); - } - } - } - - /// The table lists these as GtkListBase's own, so the list must bind - /// them. - #[gtk4::test] - fn stock_list_keys_are_list_view_bindings() { - let grid = two_text_rows(); - let list_view = descendants(&grid.view) - .into_iter() - .find(|widget| widget.is::()) - .expect("the column view's list"); - let bound = class_binding_keys(&list_view); - - for entry in ACCELERATORS.iter().filter(|entry| { - entry.scope == AcceleratorScope::Stock - && entry.action.starts_with("list.") - && entry.triggers.iter().all(|accel| !accel.contains("Pointer")) - }) { - for accel in entry.triggers { - assert!(bound.contains(&keystroke(accel)), "the list does not bind {accel}"); - } - } - } - - #[gtk4::test] - fn grid_shortcut_controller_runs_in_capture_phase_above_focus() { - let grid = two_text_rows(); - let controller = keyboard(&grid.view); - - grid.focus(1, 1); - - assert_eq!(controller.propagation_phase(), gtk::PropagationPhase::Capture); - assert_eq!(controller.scope(), gtk::ShortcutScope::Local); - let focus = gtk::prelude::GtkWindowExt::focus(&grid.window).expect("a focused widget"); - let owner = controller.widget().expect("the controller is on a widget"); - assert!( - focus.is_ancestor(&owner), - "a key on the focused cell does not pass the controller" - ); - assert_eq!(focus.css_name(), "cell", "the focus is not on a cell"); - } - - #[gtk4::test] - fn grid_shortcuts_proceed_while_editing() { - let grid = two_text_rows(); - let cell = grid.focus(0, 1); - begin_edit(&cell); - assert!(cell.inline_text().is_some()); - - assert!(!press(&grid.view, gdk::Key::Return, none())); - assert!(!press(&grid.view, gdk::Key::space, none())); - assert!(!press(&grid.view, gdk::Key::BackSpace, gdk::ModifierType::CONTROL_MASK)); - assert!(!press(&grid.view, gdk::Key::Menu, none())); - - assert!(cell.inline_text().is_some(), "a grid key closed the open editor"); - assert!(grid.take_messages().is_empty()); - } - - #[gtk4::test] - fn f2_starts_editing_focused_text_cell() { - let grid = two_text_rows(); - let cell = grid.focus(1, 1); - - assert!(press(&grid.view, gdk::Key::F2, none())); - - let editor = cell.inline_text().expect("F2 opened no editor"); - assert_eq!(editor.text(), "B"); - } - - #[gtk4::test] - fn kp_enter_starts_editing_focused_int_cell() { - let grid = Grid::open( - vec![named("id", "integer", true), named("n", "integer", false)], - vec![vec![Value::Int(1), Value::Int(42)]], - ); - let cell = grid.focus(0, 1); - - assert!(press(&grid.view, gdk::Key::KP_Enter, none())); - - let editor = cell.inline_text().expect("KP_Enter opened no editor"); - assert_eq!(editor.text(), "42"); - assert_eq!(editor.input_purpose(), gtk::InputPurpose::Number); - } - - #[gtk4::test] - fn return_toggles_a_focused_boolean_cell() { - let grid = Grid::open( - vec![named("id", "integer", true), named("flag", "boolean", false)], - vec![vec![Value::Int(1), Value::Bool(false)]], - ); - let cell = grid.focus(0, 1); - - assert!(press(&grid.view, gdk::Key::Return, none())); - - assert_eq!( - edited_rows(&grid.take_messages()), - vec![( - Some(key(1)), - 1, - tablepro_core::edit::CellInput::Value(Value::Bool(true)) - )] - ); - assert!(!cell.is_editing()); - } - - #[gtk4::test] - fn space_toggles_a_focused_check_box_and_selects_from_any_other_cell() { - let grid = Grid::open( - vec![ - named("id", "integer", true), - named("name", "text", false), - named("flag", "boolean", false), - ], - vec![vec![Value::Int(1), Value::Text("a".into()), Value::Bool(false)]], - ); - - grid.focus(0, 2); - assert!(press(&grid.view, gdk::Key::space, none())); - grid.focus(0, 1); - assert!( - !press(&grid.view, gdk::Key::space, none()), - "Space on a text cell did not go on to select the row" - ); - - assert_eq!( - edited_rows(&grid.take_messages()), - vec![( - Some(key(1)), - 2, - tablepro_core::edit::CellInput::Value(Value::Bool(true)) - )] - ); - } - - #[gtk4::test] - fn return_commits_and_returns_focus_to_cell() { - let grid = two_text_rows(); - let cell = grid.focus(1, 1); - assert!(press(&grid.view, gdk::Key::Return, none())); - let editor = cell.inline_text().expect("Return opened no editor"); - editor.set_text("renamed"); - - editor.emit_activate(); - - assert_eq!( - edited_rows(&grid.take_messages()), - vec![(Some(key(2)), 1, text("renamed"))] - ); - assert!(!cell.is_editing()); - assert_eq!(grid.focused_cell().as_ref(), Some(&cell), "the focus did not come back"); - } - - #[gtk4::test] - fn shift_f10_opens_menu_anchored_to_focused_cell() { - let grid = two_text_rows(); - let cell = grid.focus(1, 1); - wait_until(WAIT, || { - cell.compute_bounds(&grid.view) - .is_some_and(|bounds| bounds.width() > 0.0 && bounds.y() > 0.0) - }) - .expect("the cell is laid out"); - - assert!(press(&grid.view, gdk::Key::F10, gdk::ModifierType::SHIFT_MASK)); - - let mut popover = None; - wait_until(WAIT, || { - popover = descendants(&grid.view) - .into_iter() - .filter_map(|widget| widget.downcast::().ok()) - .find(|popover| popover.is_visible()); - popover.is_some() - }) - .expect("the cell menu opens"); - let popover = popover.expect("an open menu"); - let bounds = cell.compute_bounds(&grid.view).expect("the cell's bounds"); - let (pointed, rect) = popover.pointing_to(); - assert!(pointed); - assert_eq!( - (rect.x(), rect.y(), rect.width(), rect.height()), - ( - bounds.x() as i32, - bounds.y() as i32, - bounds.width() as i32, - bounds.height() as i32 - ) - ); - grid.view - .activate_action("cell.set-null", None) - .expect("the menu's Set to NULL"); - popover.popdown(); - let set: Vec<_> = grid - .take_messages() - .into_iter() - .filter_map(|msg| match msg { - GridMsg::SetCellValue { row, column, preset } => Some((row.key().cloned(), column, preset)), - _ => None, - }) - .collect(); - assert_eq!( - set, - vec![(Some(key(2)), 1, CellPreset::Null)], - "the menu acts on another cell" - ); - } - - #[gtk4::test] - fn ctrl_backspace_sets_focused_nullable_cell_null() { - let mut required = named("code", "text", false); - required.nullable = false; - let grid = Grid::open( - vec![named("id", "integer", true), named("name", "text", false), required], - vec![vec![Value::Int(1), Value::Text("a".into()), Value::Text("b".into())]], - ); - let ctrl = gdk::ModifierType::CONTROL_MASK; - - grid.focus(0, 1); - assert!(press(&grid.view, gdk::Key::BackSpace, ctrl)); - grid.focus(0, 2); - assert!( - !press(&grid.view, gdk::Key::BackSpace, ctrl), - "a NOT NULL column took NULL" - ); - grid.focus(0, 0); - assert!( - !press(&grid.view, gdk::Key::BackSpace, ctrl), - "a saved row's key took NULL" - ); - - let set: Vec<_> = grid - .take_messages() - .into_iter() - .filter_map(|msg| match msg { - GridMsg::SetCellValue { row, column, preset } => Some((row.key().cloned(), column, preset)), - _ => None, - }) - .collect(); - assert_eq!(set, vec![(Some(key(1)), 1, CellPreset::Null)]); - } - - #[gtk4::test] - fn focused_read_only_cell_resolves_grid_cell() { - let grid = two_text_rows(); - - let cell = grid.focus(0, 0); - - assert!(cell.lock().is_some(), "a saved row's key was editable"); - assert_eq!(cell.row().and_then(|row| row.key().cloned()), Some(key(1))); - assert!(!press(&grid.view, gdk::Key::F2, none()), "F2 took a locked cell"); - assert!(!cell.is_editing()); - assert!(press(&grid.view, gdk::Key::Menu, none()), "a locked cell has no menu"); - } - - #[gtk4::test] - fn a_result_grid_opens_its_read_only_menu_from_the_keyboard() { - let columns = [named("id", "integer", true)]; - let (sink, _messages) = recorder(); - let (view, _selection) = build_column_view( - content(&columns, vec![RowObject::result(vec![Value::Int(7)].into())]), - &[], - sink, - GridSorting::Server { initial: None }, - TabGridContext::default(), - ); - let window = gtk::Window::new(); - window.set_child(Some(&view)); - window.present(); - view.grab_focus(); - let mut focused = None; - wait_until(WAIT, || { - focused = gtk::prelude::GtkWindowExt::focus(&window).and_then(|focus| GridCell::from_focus(&focus)); - focused.is_some() - }) - .expect("a result cell takes the focus"); - - assert!(!press(&view, gdk::Key::Return, none()), "Return edited a result cell"); - assert!(press(&view, gdk::Key::Menu, none())); - let open: Vec = descendants(&view) - .into_iter() - .filter_map(|widget| widget.downcast::().ok()) - .filter(|popover| popover.is_visible()) - .collect(); - assert_eq!(open.len(), 1); - assert!( - view.activate_action("cell.set-null", None).is_err(), - "a result grid offers Set to NULL" - ); - for popover in open { - popover.popdown(); - } - window.destroy(); - } - - #[gtk4::test] - fn ctrl_j_follows_the_one_key_of_the_focused_cell_and_lets_the_key_go_on_elsewhere() { - let grid = orders_grid(vec![foreign_key(&["customer_id"], public("customers"), &["id"])]); - let ctrl = gdk::ModifierType::CONTROL_MASK; - - grid.focus(0, 1); - assert!(press(&grid.view, gdk::Key::j, ctrl)); - grid.focus(0, 2); - assert!(!press(&grid.view, gdk::Key::j, ctrl), "a column in no key took Ctrl+J"); - grid.focus(1, 1); - assert!(!press(&grid.view, gdk::Key::j, ctrl), "a NULL key took Ctrl+J"); - - assert_eq!(followed(&grid), [(public("customers"), filter(&[("id", "42")]))]); - } - - #[gtk4::test] - fn ctrl_j_on_a_column_in_two_keys_offers_them_at_the_cell() { - let crm = tablepro_core::meta::TableRef::new(Some("crm".to_owned()), "customers"); - let grid = orders_grid(vec![ - foreign_key(&["tenant_id"], public("tenants"), &["id"]), - foreign_key(&["tenant_id", "customer_id"], crm, &["tenant_id", "id"]), - ]); - grid.focus(0, 2); - - assert!(press(&grid.view, gdk::Key::j, gdk::ModifierType::CONTROL_MASK)); - - let mut chooser = None; - wait_until(WAIT, || { - chooser = descendants(&grid.view) - .into_iter() - .filter_map(|widget| widget.downcast::().ok()) - .find(|popover| popover.is_visible()); - chooser.is_some() - }) - .expect("the keys are offered"); - let chooser = chooser.expect("an open chooser"); - let model = chooser.menu_model().expect("the chooser's keys"); - assert_eq!(model.n_items(), 2); - assert!(followed(&grid).is_empty(), "a key was followed before one was chosen"); - chooser.popdown(); - } - - #[gtk4::test] - fn grid_keys_proceed_with_no_focused_cell() { - let grid = two_text_rows(); - gtk::prelude::GtkWindowExt::set_focus(&grid.window, None::<>k::Widget>); - - assert!(!press(&grid.view, gdk::Key::F2, none())); - assert!(!press(&grid.view, gdk::Key::BackSpace, gdk::ModifierType::CONTROL_MASK)); - assert!(grid.take_messages().is_empty()); - } -} diff --git a/linux/crates/app/src/ui/grid/grid_menus.rs b/linux/crates/app/src/ui/grid/grid_menus.rs deleted file mode 100644 index d541b5788f..0000000000 --- a/linux/crates/app/src/ui/grid/grid_menus.rs +++ /dev/null @@ -1,707 +0,0 @@ -use std::rc::Rc; -use std::sync::Arc; - -use gtk4::prelude::*; -use gtk4::{self as gtk, gio, glib}; - -use tablepro_core::column::ResultColumn; - -use super::cell_edit::begin_edit; -use super::cell_editor_kind::CellEditorKind; -use super::cell_preset::cell_presets; -use super::column_resize::{ColumnResize, resize_column}; -use super::context_menu::{CellContext, MenuTarget}; -use super::grid_columns::ShownColumn; -use super::reference_menu::{OPEN_CHOICE, OPEN_ONE, REFERENCING, ReferenceMenu, chosen, follow}; -use super::row_json_dialog::RowJsonRequest; -use super::selection::export_snapshot; -use super::{ - CellPreset, DeleteAvailability, GridCell, GridMsg, GridSink, RowObject, RowSnapshot, TabGridContext, - delete_availability, -}; -use crate::ui::clipboard::{CopyKind, CopyRequest, InClauseRows}; - -/// Enabled per right-clicked cell. `None` on a read-only grid, which -/// registers none of them. -#[derive(Clone, Default)] -pub(super) struct CellActions { - /// Hidden while disabled, so a cell they do not apply to shows no - /// dead entry. - edit: Option, - set_empty: Option, - set_null: Option, - set_default: Option, - /// Shown insensitive while disabled, since a menu item cannot carry - /// a tooltip to say why. - delete_row: Option, - /// On while the rows are sorted. - clear_sort: Option, -} - -impl CellActions { - /// Set for `cell` and `rows`, what the menu acts on, as it opens. - pub(super) fn arm(&self, cell: &GridCell, rows: &[RowObject]) { - let column = cell.drawing().and_then(|drawing| drawing.catalog.clone()); - let row = cell.row(); - let editor = column - .as_ref() - .map(|column| CellEditorKind::for_cell(column, cell.lock().map_or(Ok(()), Err))); - let presets = column - .as_ref() - .zip(row.as_ref()) - .map(|(column, row)| cell_presets(column, row.id())); - let enable = |action: &Option, enabled: bool| { - if let Some(action) = action { - action.set_enabled(enabled); - } - }; - enable( - &self.edit, - matches!( - editor, - Some(CellEditorKind::Inline { .. } | CellEditorKind::Multiline { .. } | CellEditorKind::Calendar) - ), - ); - enable(&self.set_empty, presets.is_some_and(|presets| presets.empty)); - enable(&self.set_null, presets.is_some_and(|presets| presets.null)); - enable(&self.set_default, presets.is_some_and(|presets| presets.default)); - if let Some(delete_row) = &self.delete_row { - let availability = delete_availability(rows.iter().map(RowObject::id), false); - delete_row.set_enabled(availability == DeleteAvailability::Available); - } - let sorted = cell - .column_view() - .and_then(|view| view.sorter()) - .and_downcast::() - .is_some_and(|sorter| sorter.primary_sort_column().is_some()); - enable(&self.clear_sort, sorted); - } -} - -#[derive(Clone)] -pub(super) struct GridMenus { - pub(super) context: Rc, - pub(super) editable_popover: gtk::PopoverMenu, - pub(super) readonly_popover: gtk::PopoverMenu, - pub(super) actions: CellActions, - pub(super) references: ReferenceMenu, - /// The keys a column is in, when it is in more than one, for the - /// keyboard to choose from. - pub(super) reference_chooser: gtk::PopoverMenu, -} - -#[derive(Clone, Copy)] -struct MenuShape { - edit_cell: bool, - set_value: bool, - row_ops: bool, -} - -impl MenuShape { - const READ_ONLY: MenuShape = MenuShape { - edit_cell: false, - set_value: false, - row_ops: false, - }; - const ROW_OPS: MenuShape = MenuShape { - edit_cell: false, - set_value: false, - row_ops: true, - }; - const FULL: MenuShape = MenuShape { - edit_cell: true, - set_value: true, - row_ops: true, - }; -} - -/// `sorts` is off for a grid the engine reads in its own order, which -/// no sort reaches. -fn build_cell_menu(shape: MenuShape, references: &gio::Menu, sorts: bool) -> gio::Menu { - let menu = gio::Menu::new(); - let edit_section = gio::Menu::new(); - let hidden_while_disabled = |label: String, action: &str| { - let item = crate::application::menu_item(&label, action); - item.set_attribute_value("hidden-when", Some(&"action-disabled".to_variant())); - edit_section.append_item(&item); - }; - if shape.edit_cell { - hidden_while_disabled(crate::i18n::gettext("Edit Cell"), "cell.edit"); - } - if shape.set_value { - // Each is offered only where the column can hold it, so a menu - // never offers a value the save would refuse. - hidden_while_disabled(crate::i18n::gettext("Set to Empty"), "cell.set-empty"); - hidden_while_disabled(crate::i18n::gettext("Set to NULL"), "cell.set-null"); - hidden_while_disabled(crate::i18n::gettext("Set to DEFAULT"), "cell.set-default"); - } - if shape.edit_cell || shape.set_value { - menu.append_section(None, &edit_section); - } - - let copy_as = gio::Menu::new(); - copy_as.append(Some(&crate::i18n::gettext("Rows")), Some("cell.copy-rows")); - copy_as.append( - Some(&crate::i18n::gettext("With Headers")), - Some("cell.copy-rows-headers"), - ); - copy_as.append(Some(&crate::i18n::gettext("JSON")), Some("cell.copy-json")); - copy_as.append(Some(&crate::i18n::gettext("CSV")), Some("cell.copy-csv")); - copy_as.append( - Some(&crate::i18n::gettext("CSV With Headers")), - Some("cell.copy-csv-headers"), - ); - copy_as.append(Some(&crate::i18n::gettext("Markdown")), Some("cell.copy-markdown")); - let in_clause = gio::MenuItem::new(Some(&crate::i18n::gettext("IN Clause")), Some("cell.copy-in-clause")); - in_clause.set_attribute_value("hidden-when", Some(&"action-missing".to_variant())); - copy_as.append_item(&in_clause); - if shape.row_ops { - let sql_section = gio::Menu::new(); - sql_section.append( - Some(&crate::i18n::gettext("INSERT Statement")), - Some("cell.copy-row-insert"), - ); - copy_as.append_section(None, &sql_section); - } - let copy_section = gio::Menu::new(); - copy_section.append(Some(&crate::i18n::gettext("Copy")), Some("cell.copy")); - copy_section.append_submenu(Some(&crate::i18n::gettext("Copy As")), ©_as); - copy_section.append( - Some(&crate::i18n::gettext("Copy Column Name")), - Some("cell.copy-column-name"), - ); - if shape.row_ops { - copy_section.append(Some(&crate::i18n::gettext("Paste")), Some("cell.paste")); - } - menu.append_section(None, ©_section); - - let json_section = gio::Menu::new(); - json_section.append( - Some(&crate::i18n::gettext("Show Row as JSON")), - Some("cell.show-row-json"), - ); - menu.append_section(None, &json_section); - menu.append_section(None, references); - - // A header's click and drag, which no key reaches. - let column_section = gio::Menu::new(); - if sorts { - column_section.append( - Some(&crate::i18n::gettext("Sort Ascending")), - Some("cell.sort-ascending"), - ); - column_section.append( - Some(&crate::i18n::gettext("Sort Descending")), - Some("cell.sort-descending"), - ); - column_section.append(Some(&crate::i18n::gettext("Clear Sort")), Some("cell.clear-sort")); - } - column_section.append_item(&crate::application::menu_item( - &crate::i18n::gettext("Widen Column"), - "cell.widen-column", - )); - column_section.append_item(&crate::application::menu_item( - &crate::i18n::gettext("Narrow Column"), - "cell.narrow-column", - )); - column_section.append( - Some(&crate::i18n::gettext("Reset Column Width")), - Some("cell.reset-width"), - ); - menu.append_section(None, &column_section); - - let action_section = gio::Menu::new(); - action_section.append(Some(&crate::i18n::gettext("Export…")), Some("cell.export")); - if shape.row_ops { - action_section.append(Some(&crate::i18n::gettext("Insert Row")), Some("cell.insert-row")); - action_section.append(Some(&crate::i18n::gettext("Duplicate")), Some("cell.duplicate-row")); - action_section.append(Some(&crate::i18n::gettext("Delete")), Some("cell.delete-row")); - } - menu.append_section(None, &action_section); - menu -} - -pub(super) struct GridMenuInit<'a> { - pub(super) column_view: &'a gtk::ColumnView, - pub(super) sink: GridSink, - pub(super) columns: Arc<[ResultColumn]>, - pub(super) tab_ctx: TabGridContext, - pub(super) sorts: bool, -} - -pub(super) fn install_grid_context_menus(init: GridMenuInit<'_>) -> GridMenus { - let GridMenuInit { - column_view, - sink, - columns, - tab_ctx, - sorts, - } = init; - let editable = tab_ctx.editable; - let tab = Rc::new(tab_ctx); - let kit = CellActionKit { - context: Rc::new(CellContext::default()), - view: column_view.downgrade(), - sink, - references: ReferenceMenu::new(tab.clone(), columns.clone()), - columns, - tab, - }; - - let group = gio::SimpleActionGroup::new(); - let mut entries = kit.reading_entries(); - // A read-only grid registers none of the mutating actions: it - // shows no menu item for them, and the editor throws their - // messages away at the far end. - if editable { - entries.extend(kit.editing_entries()); - } - group.add_action_entries(entries); - column_view.insert_action_group("cell", Some(&group)); - - let actions = CellActions { - edit: editable.then(|| simple_action(&group, "edit")).flatten(), - set_empty: editable.then(|| simple_action(&group, "set-empty")).flatten(), - set_null: editable.then(|| simple_action(&group, "set-null")).flatten(), - set_default: editable.then(|| simple_action(&group, "set-default")).flatten(), - delete_row: editable.then(|| simple_action(&group, "delete-row")).flatten(), - clear_sort: simple_action(&group, "clear-sort"), - }; - - let (editable_popover, readonly_popover) = cell_popovers(column_view, editable, kit.references.section(), sorts); - let reference_chooser = eager_popover(column_view, kit.references.choices()); - let mut popovers_for_destroy = vec![editable_popover.clone(), reference_chooser.clone()]; - if readonly_popover != editable_popover { - popovers_for_destroy.push(readonly_popover.clone()); - } - if editable { - popovers_for_destroy.push(install_empty_space_menu(column_view)); - } - column_view.connect_destroy(move |_| { - for popover in &popovers_for_destroy { - popover.unparent(); - } - }); - - GridMenus { - context: kit.context, - editable_popover, - readonly_popover, - actions, - references: kit.references, - reference_chooser, - } -} - -type CellEntry = gio::ActionEntry; - -struct CellActionKit { - context: Rc, - /// Every action closure holds the view weakly. The action group - /// belongs to the ColumnView, so a strong clone in a closure is a - /// cycle: the grid, its selection model, its store and every row it - /// holds would outlive the page that built them. - view: glib::WeakRef, - sink: GridSink, - columns: Arc<[ResultColumn]>, - tab: Rc, - references: ReferenceMenu, -} - -impl CellActionKit { - /// An action on the cell the menu opened on, its row and the rows - /// the menu acts on. - fn on_target(&self, name: &str, act: impl Fn(&GridCell, RowObject, Vec) + 'static) -> CellEntry { - let ctx = self.context.clone(); - let view = self.view.clone(); - gio::ActionEntry::builder(name) - .activate(move |_, _, _| { - let Some(cv) = view.upgrade() else { return }; - let Some(MenuTarget { cell, row, rows }) = ctx.target(&cv) else { - return; - }; - act(&cell, row, rows); - }) - .build() - } - - /// The rows are taken as the grid shows them, sharing its cells, and - /// the owner renders the copy off the GTK thread. - fn copy(&self, name: &str, kind: impl Fn(&GridCell, &[RowObject]) -> CopyKind + 'static) -> CellEntry { - let s = self.sink.clone(); - let tab = self.tab.clone(); - let columns = self.columns.clone(); - self.on_target(name, move |cell, _row, rows| { - s(GridMsg::Copy(CopyRequest::Rows { - columns: columns.clone(), - rows: snapshots(&tab, &rows), - kind: kind(cell, &rows), - })); - }) - } - - /// An action on the row the menu opened on that names one of the - /// items it offered. - fn on_choice(&self, name: &str, act: impl Fn(RowObject, usize) + 'static) -> CellEntry { - let ctx = self.context.clone(); - let view = self.view.clone(); - gio::ActionEntry::builder(name) - .parameter_type(Some(&u32::static_variant_type())) - .activate(move |_, _, parameter| { - let Some(cv) = view.upgrade() else { return }; - let (Some(MenuTarget { row, .. }), Some(index)) = (ctx.target(&cv), chosen(parameter)) else { - return; - }; - act(row, index); - }) - .build() - } - - fn send(&self, name: &str, msg: impl Fn(&GridCell, RowObject, Vec) -> GridMsg + 'static) -> CellEntry { - let s = self.sink.clone(); - self.on_target(name, move |cell, row, rows| s(msg(cell, row, rows))) - } - - /// The actions a read-only grid offers too. - fn reading_entries(&self) -> Vec { - let mut entries = self.copy_entries(); - entries.extend([self.copy_column_name(), self.show_row_json(), self.export()]); - entries.extend(self.reference_entries()); - entries.extend(self.column_entries()); - entries - } - - /// Follow a foreign key from the row, either way. Reading another - /// table changes nothing, so a read-only grid offers them too. - fn reference_entries(&self) -> Vec { - let open_one = { - let (menu, sink) = (self.references.clone(), self.sink.clone()); - self.on_target(OPEN_ONE, move |_cell, row, _rows| { - if let Some(reference) = menu.only_choice().and_then(|index| menu.referenced(index, &row)) { - follow(&sink, reference); - } - }) - }; - let open_choice = { - let (menu, sink) = (self.references.clone(), self.sink.clone()); - self.on_choice(OPEN_CHOICE, move |row, index| { - if let Some(reference) = menu.referenced(index, &row) { - follow(&sink, reference); - } - }) - }; - let referencing = { - let (menu, sink) = (self.references.clone(), self.sink.clone()); - self.on_choice(REFERENCING, move |row, index| { - if let Some(reference) = menu.referencing(index, &row) { - follow(&sink, reference); - } - }) - }; - vec![open_one, open_choice, referencing] - } - - /// Sort by the cell's column, as a header click does, or change the - /// width a header drag sets. - fn column_entries(&self) -> Vec { - let sort = |name: &str, order: gtk::SortType| { - let view = self.view.clone(); - self.on_target(name, move |cell, _row, _rows| { - let Some(cv) = view.upgrade() else { return }; - if let Some(column) = shown_column(&cv, cell) { - cv.sort_by_column(Some(&column.view_column), order); - } - }) - }; - let clear_sort = { - let view = self.view.clone(); - let sink = self.sink.clone(); - self.on_target("clear-sort", move |_cell, _row, _rows| { - let Some(cv) = view.upgrade() else { return }; - cv.sort_by_column(None, gtk::SortType::Ascending); - // The server sorts a browse page, and an unsorted view - // tells it nothing by itself. - sink(GridMsg::SortCleared); - }) - }; - let resize = |name: &str, change: ColumnResize| { - let view = self.view.clone(); - self.on_target(name, move |cell, _row, _rows| { - if let Some(cv) = view.upgrade() { - resize_column(&cv, cell, change); - } - }) - }; - let reset_width = { - let view = self.view.clone(); - let tab = self.tab.clone(); - let columns = self.columns.clone(); - self.on_target("reset-width", move |cell, _row, _rows| { - let Some(column) = view.upgrade().and_then(|cv| shown_column(&cv, cell)) else { - return; - }; - column.view_column.set_fixed_width(column.default_width.unwrap_or(-1)); - column.view_column.set_expand(true); - if let (Some(widths), Some(info)) = (&tab.column_widths, columns.get(column.index)) { - widths.forget(&info.name); - } - }) - }; - vec![ - sort("sort-ascending", gtk::SortType::Ascending), - sort("sort-descending", gtk::SortType::Descending), - clear_sort, - resize("widen-column", ColumnResize::Wider), - resize("narrow-column", ColumnResize::Narrower), - reset_width, - ] - } - - fn copy_entries(&self) -> Vec { - let mut entries = vec![ - // Plain Copy reads the cell's value, never the widget's text: - // the widget carries the display form, which is the `` - // sentinel, the `(auto)` placeholder, `` for a blob and - // a value cut at the 10k display cap. - self.copy("copy", |cell, rows| match rows.len() { - 1 => CopyKind::Value(cell.column()), - _ => CopyKind::Rows, - }), - self.copy("copy-rows", |_, _| CopyKind::Rows), - self.copy("copy-rows-headers", |_, _| CopyKind::RowsWithHeaders), - self.copy("copy-json", |_, _| CopyKind::Json), - self.copy("copy-csv", |_, _| CopyKind::Csv), - self.copy("copy-csv-headers", |_, _| CopyKind::CsvWithHeaders), - self.copy("copy-markdown", |_, _| CopyKind::Markdown), - ]; - if self.tab.in_clause { - entries.push(self.copy_in_clause()); - } - entries - } - - fn copy_in_clause(&self) -> CellEntry { - let tab = self.tab.clone(); - let columns = self.columns.clone(); - self.send("copy-in-clause", move |cell, _row, rows| { - GridMsg::CopyInClause(InClauseRows { - columns: columns.clone(), - rows: snapshots(&tab, &rows), - column: cell.column(), - }) - }) - } - - fn copy_column_name(&self) -> CellEntry { - let s = self.sink.clone(); - let cols = self.columns.clone(); - let view = self.view.clone(); - self.on_target("copy-column-name", move |cell, _row, _rows| { - let Some(cv) = view.upgrade() else { return }; - let name = cols - .get(cell.column()) - .map(|column| column.name.clone()) - .unwrap_or_default(); - crate::ui::clipboard::copy_text(&cv, name, |message| s(GridMsg::ShowToast(message.to_owned()))); - }) - } - - fn show_row_json(&self) -> CellEntry { - let cols = self.columns.clone(); - let tab = self.tab.clone(); - self.send("show-row-json", move |_cell, row, _rows| { - GridMsg::ShowRowJson(RowJsonRequest { - columns: cols.clone(), - row: tab.snapshot(&row), - }) - }) - } - - fn export(&self) -> CellEntry { - let s = self.sink.clone(); - let view = self.view.clone(); - let cols = self.columns.clone(); - let tab = self.tab.clone(); - gio::ActionEntry::builder("export") - .activate(move |_, _, _| { - let Some(cv) = view.upgrade() else { return }; - s(GridMsg::ExportResults(crate::ui::export::ExportSource::Rows { - columns: cols.clone(), - rows: export_snapshot(&cv, &tab), - })); - }) - .build() - } - - /// The actions that change rows, which only an editable grid has. - fn editing_entries(&self) -> Vec { - let set_value = |name: &str, preset: CellPreset| { - self.send(name, move |cell, row, _rows| GridMsg::SetCellValue { - row, - column: cell.column(), - preset, - }) - }; - let insert_row = { - let s = self.sink.clone(); - gio::ActionEntry::builder("insert-row") - .activate(move |_, _, _| s(GridMsg::InsertRow)) - .build() - }; - // Below the rows there is no cell to paste at. - let paste_rows = { - let s = self.sink.clone(); - gio::ActionEntry::builder("paste-rows") - .activate(move |_, _, _| s(GridMsg::Paste { at: None })) - .build() - }; - vec![ - // Editing opens on the cell, which must still show the row. - self.on_target("edit", |cell, row, _rows| { - if cell.row().as_ref() == Some(&row) { - begin_edit(cell); - } - }), - set_value("set-empty", CellPreset::Empty), - set_value("set-null", CellPreset::Null), - set_value("set-default", CellPreset::Default), - self.send("copy-row-insert", |_cell, _row, rows| GridMsg::CopyRowsAsInsert { - rows, - }), - insert_row, - self.send("delete-row", |_cell, _row, rows| GridMsg::DeleteRows { rows }), - self.send("duplicate-row", |_cell, _row, rows| GridMsg::DuplicateRows { rows }), - self.send("paste", |cell, row, _rows| GridMsg::Paste { - at: Some((row, cell.column())), - }), - paste_rows, - ] - } -} - -/// The menus a right click on a cell opens: one for a row that can be -/// edited and one for a row that can't. A read-only grid has a single -/// shape and both name it. -fn cell_popovers( - column_view: >k::ColumnView, - editable: bool, - references: &gio::Menu, - sorts: bool, -) -> (gtk::PopoverMenu, gtk::PopoverMenu) { - let make_popover = |shape: MenuShape| eager_popover(column_view, &build_cell_menu(shape, references, sorts)); - if editable { - (make_popover(MenuShape::FULL), make_popover(MenuShape::ROW_OPS)) - } else { - let readonly = make_popover(MenuShape::READ_ONLY); - (readonly.clone(), readonly) - } -} - -/// Popovers are parented eagerly so each PopoverMenu's action muxer -/// snapshots the ColumnView's `cell` group at set_parent() time. Lazy -/// parenting in the gesture handler drops every activation silently. -fn eager_popover(column_view: >k::ColumnView, model: &gio::Menu) -> gtk::PopoverMenu { - let popover = gtk::PopoverMenu::from_model_full(model, gtk::PopoverMenuFlags::NESTED); - popover.set_has_arrow(true); - popover.set_parent(column_view); - popover -} - -/// Insert Row on a right click below the last row. -fn install_empty_space_menu(column_view: >k::ColumnView) -> gtk::PopoverMenu { - let empty_menu = gio::Menu::new(); - empty_menu.append(Some(&crate::i18n::gettext("Insert Row")), Some("cell.insert-row")); - empty_menu.append(Some(&crate::i18n::gettext("Paste")), Some("cell.paste-rows")); - let empty_popover = gtk::PopoverMenu::from_model_full(&empty_menu, gtk::PopoverMenuFlags::NESTED); - empty_popover.set_has_arrow(true); - empty_popover.set_parent(column_view); - - let view = column_view.downgrade(); - let popover = empty_popover.clone(); - let empty_gesture = gtk::GestureClick::builder().button(3).build(); - empty_gesture.connect_pressed(move |g, _, x, y| { - let Some(cv) = view.upgrade() else { return }; - let cv_widget: gtk::Widget = cv.clone().upcast(); - if let Some(picked) = cv.pick(x, y, gtk::PickFlags::DEFAULT) - && picked != cv_widget - { - return; - } - g.set_state(gtk::EventSequenceState::Claimed); - popover.set_pointing_to(Some(>k::gdk::Rectangle::new(x as i32, y as i32, 1, 1))); - popover.popup(); - }); - column_view.add_controller(empty_gesture); - empty_popover -} - -/// The view's column that `cell` sits in. -pub(super) fn shown_column(view: >k::ColumnView, cell: &GridCell) -> Option { - cell.columns()? - .shown(view) - .into_iter() - .find(|column| column.index == cell.column()) -} - -/// The rows a menu item copies, as the grid shows them. -fn snapshots(tab: &TabGridContext, rows: &[RowObject]) -> Vec { - rows.iter().map(|row| tab.snapshot(row)).collect() -} - -fn simple_action(group: &gio::SimpleActionGroup, name: &str) -> Option { - group - .lookup_action(name) - .and_then(|a| a.downcast::().ok()) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn accel_of(menu: &gio::MenuModel, action: &str) -> Option> { - for position in 0..menu.n_items() { - let named = menu - .item_attribute_value(position, "action", None) - .and_then(|value| value.get::()); - if named.as_deref() == Some(action) { - return Some( - menu.item_attribute_value(position, "accel", None) - .and_then(|value| value.get::()), - ); - } - for link in ["section", "submenu"] { - if let Some(found) = menu - .item_link(position, link) - .and_then(|inner| accel_of(&inner, action)) - { - return Some(found); - } - } - } - None - } - - #[gtk4::test] - fn a_grid_in_the_engines_order_offers_no_sort() { - let sorted = build_cell_menu(MenuShape::FULL, &gio::Menu::new(), true).upcast::(); - let unsorted = build_cell_menu(MenuShape::FULL, &gio::Menu::new(), false).upcast::(); - - for action in ["cell.sort-ascending", "cell.sort-descending", "cell.clear-sort"] { - assert!(accel_of(&sorted, action).is_some(), "{action}"); - assert_eq!(accel_of(&unsorted, action), None, "{action}"); - } - assert!(accel_of(&unsorted, "cell.edit").is_some()); - } - - #[gtk4::test] - fn the_cell_menu_shows_the_cell_keys() { - let menu = build_cell_menu(MenuShape::FULL, &gio::Menu::new(), true).upcast::(); - - assert_eq!( - accel_of(&menu, "cell.set-null"), - Some(Some("BackSpace".to_owned())) - ); - assert_eq!(accel_of(&menu, "cell.edit"), Some(Some("F2".to_owned()))); - assert_eq!(accel_of(&menu, "cell.set-empty"), Some(None)); - } -} diff --git a/linux/crates/app/src/ui/grid/grid_msg.rs b/linux/crates/app/src/ui/grid/grid_msg.rs deleted file mode 100644 index 50e9343e6a..0000000000 --- a/linux/crates/app/src/ui/grid/grid_msg.rs +++ /dev/null @@ -1,58 +0,0 @@ -use std::rc::Rc; - -use tablepro_core::browse::RowReference; - -use super::{CellPreset, RowJsonRequest, RowObject}; -use crate::services::tracker_edit::TrackerEdit; -use crate::ui::clipboard::{CopyRequest, InClauseRows}; -use crate::ui::export::ExportSource; - -/// Where a grid sends its events. The owner holds the grid, so the -/// closure holds the owner weakly. -pub type GridSink = Rc; - -#[derive(Debug)] -pub enum GridMsg { - /// The sorter's state after the click, not a toggle hint: a click on - /// another column fires both sort notifies, and a hint would flip twice. - SortChanged(usize, bool), - /// Clear Sort: the rows go back to the order the server sends. - SortCleared, - /// An edit of `column` in `row`, already parsed for the column. - Edit { - row: RowObject, - column: usize, - edit: TrackerEdit, - }, - /// Rows to copy, which the owner renders off the GTK thread. - Copy(CopyRequest), - ShowRowJson(RowJsonRequest), - ShowToast(String), - CopyRowsAsInsert { - rows: Vec, - }, - /// The owner knows the engine whose literals the list is written in. - CopyInClause(InClauseRows), - /// An intent the tab resolves against the column, so the keyboard - /// path and the menu reach the same value. - SetCellValue { - row: RowObject, - column: usize, - preset: CellPreset, - }, - ExportResults(ExportSource), - DeleteRows { - rows: Vec, - }, - InsertRow, - DuplicateRows { - rows: Vec, - }, - /// Paste at the cell `at` names, a row and a column, or as new rows - /// from the first column with no cell. - Paste { - at: Option<(RowObject, usize)>, - }, - /// Open the rows a foreign key leads to from the row. - FollowReference(RowReference), -} diff --git a/linux/crates/app/src/ui/grid/grid_row_index.rs b/linux/crates/app/src/ui/grid/grid_row_index.rs deleted file mode 100644 index 120421267b..0000000000 --- a/linux/crates/app/src/ui/grid/grid_row_index.rs +++ /dev/null @@ -1,112 +0,0 @@ -use std::collections::HashMap; - -use gtk4::gio; -use gtk4::glib; -use gtk4::prelude::*; - -use super::RowObject; -use crate::services::change_tracker::RowKey; - -/// The grid's rows by the key the tracker files their changes under, -/// drafts included. The store owns the rows, so a row it drops is -/// forgotten here on the next lookup. -#[derive(Debug, Default)] -pub struct GridRowIndex(HashMap>); - -impl GridRowIndex { - pub fn rebuild(&mut self, store: &gio::ListStore) { - self.0.clear(); - for position in 0..store.n_items() { - if let Some(row) = store.item(position).and_downcast::() { - self.insert(&row); - } - } - } - - pub fn insert(&mut self, row: &RowObject) { - if let Some(key) = row.id().tracker_key() { - self.0.insert(key, row.downgrade()); - } - } - - pub fn remove(&mut self, key: &RowKey) { - self.0.remove(key); - } - - pub fn get(&mut self, key: &RowKey) -> Option { - let row = self.0.get(key)?.upgrade(); - if row.is_none() { - self.0.remove(key); - } - row - } - - #[cfg(test)] - fn position_in(&mut self, store: &gio::ListStore, key: &RowKey) -> Option { - store.find(&self.get(key)?) - } - - #[cfg(test)] - fn len(&self) -> usize { - self.0.len() - } -} - -#[cfg(test)] -mod tests { - use gtk4::gio; - use tablepro_core::Value; - - use super::GridRowIndex; - use crate::services::change_tracker::RowKey; - use crate::ui::grid::RowObject; - - fn key(id: i64) -> RowKey { - RowKey::from_pk_values(&[Value::Int(id)]).expect("a key") - } - - fn saved(id: i64) -> RowObject { - RowObject::persisted(Some(key(id)), vec![Value::Int(id)].into()) - } - - #[test] - fn grid_row_index_drops_dead_rows_on_get() { - let mut index = GridRowIndex::default(); - let kept = saved(1); - index.insert(&kept); - index.insert(&saved(2)); - - assert_eq!(index.get(&key(1)), Some(kept)); - assert_eq!(index.get(&key(2)), None, "a row nothing holds came back"); - assert_eq!(index.len(), 1, "the dead row stayed in the index"); - } - - #[test] - fn drafts_are_found_by_their_draft_key_and_unkeyed_rows_not_at_all() { - let store = gio::ListStore::new::(); - store.append(&RowObject::draft(7)); - store.append(&saved(1)); - store.append(&RowObject::persisted(None, vec![Value::Int(3)].into())); - let mut index = GridRowIndex::default(); - - index.rebuild(&store); - - assert_eq!(index.position_in(&store, &RowKey::Draft(7)), Some(0)); - assert_eq!(index.position_in(&store, &key(1)), Some(1)); - assert_eq!(index.len(), 2); - index.remove(&RowKey::Draft(7)); - assert_eq!(index.get(&RowKey::Draft(7)), None); - } - - #[test] - fn a_draft_is_never_taken_for_a_saved_row_with_the_same_number() { - let mut index = GridRowIndex::default(); - let draft = RowObject::draft(3); - let row = saved(3); - index.insert(&draft); - index.insert(&row); - - assert_eq!(index.get(&RowKey::Draft(3)), Some(draft)); - assert_eq!(index.get(&key(3)), Some(row)); - } -} diff --git a/linux/crates/app/src/ui/grid/inline_editor.rs b/linux/crates/app/src/ui/grid/inline_editor.rs deleted file mode 100644 index 911d4b5faa..0000000000 --- a/linux/crates/app/src/ui/grid/inline_editor.rs +++ /dev/null @@ -1,260 +0,0 @@ -use std::cell::Cell; -use std::rc::Rc; - -use gtk4::prelude::*; -use gtk4::{self as gtk, glib}; - -use super::cell_accessibility::name_editor; -use super::cell_focus::{edit_next, return_focus}; -use super::edit_commit::{commit_edit, send_commit}; -use super::edit_navigation::EditDirection; -use super::{EditSnapshot, GridCell}; - -/// How the one-line editor treats what is typed into it. -#[derive(Debug, Clone, PartialEq)] -pub struct InlineOptions { - /// Off for a column a line break means nothing in: a pasted break - /// keeps the first line, as a one-line field does elsewhere. - pub accepts_line_breaks: bool, - pub purpose: gtk::InputPurpose, - /// What an empty commit means, shown while the field is empty. - pub placeholder: Option, -} - -/// A `gtk::Text` laid over the cell's display while an edit is open, and -/// removed when it commits or is cancelled. -#[derive(Debug)] -pub struct InlineEditor { - text: gtk::Text, -} - -impl InlineEditor { - pub(super) fn begin(cell: &GridCell, snapshot: EditSnapshot, initial: &str, options: InlineOptions) { - cell.cancel_edit(); - cell.remove_closed_field(); - let text = gtk::Text::new(); - text.set_hexpand(true); - text.set_margin_start(8); - text.set_margin_end(8); - text.add_css_class("tp-cell-text"); - text.set_truncate_multiline(!options.accepts_line_breaks); - text.set_input_purpose(options.purpose); - text.set_placeholder_text(options.placeholder.as_deref()); - text.set_text(initial); - name_editor(&text, cell); - connect_commit(cell, &text); - if let Some(display) = cell.content() { - display.set_visible(false); - } - text.set_parent(cell); - cell.begin_edit(snapshot); - cell.set_inline(Self { text: text.clone() }); - text.grab_focus(); - // The first keystroke replaces the value, as in a Files rename. - text.select_region(0, -1); - } - - pub(super) fn widget(&self) -> >k::Text { - &self.text - } - - /// Show the display again, with the text the field held. GTK ends a - /// focus change by walking up from the widget that lost the focus, and - /// GTK 4.22 loops forever when that widget has no parent by then, so a - /// field that lost it stays in the cell, hidden, until the next edit. - pub(super) fn close(self, cell: &GridCell) -> String { - let typed = self.text.text().to_string(); - if holds_window_focus(self.text.upcast_ref()) { - self.text.unparent(); - } else { - self.text.set_visible(false); - cell.keep_closed_field(self.text); - } - if let Some(display) = cell.content() { - display.set_visible(true); - } - typed - } -} - -/// Commit the cell's one-line edit, as its field losing the focus does. -pub(super) fn commit_inline(cell: &GridCell) { - finish_inline(cell, true); -} - -/// End the cell's one-line edit. The field closes before the commit is -/// sent, so the redraw the commit causes meets no open editor. -fn finish_inline(cell: &GridCell, commit: bool) { - let Some(editor) = cell.take_inline() else { - return; - }; - let snapshot = cell.take_edit(); - let typed = editor.close(cell); - if !commit { - return; - } - let (Some(snapshot), Some(drawing)) = (snapshot, cell.drawing()) else { - return; - }; - let Some(column) = drawing.catalog.as_ref() else { - return; - }; - let result = commit_edit(column, &snapshot, &typed); - send_commit(cell, snapshot, result); -} - -/// The field is the cell's child, so its handlers hold the cell weakly. -fn connect_commit(cell: &GridCell, text: >k::Text) { - let weak = cell.downgrade(); - text.connect_activate(move |_| { - if let Some(cell) = weak.upgrade() { - finish_inline(&cell, true); - return_focus(&cell); - } - }); - - // Focus moving to an IME popover ends the edit mid-composition, and - // committing then would send the text from before it, so the commit - // waits for the preedit to clear. - let composing = Rc::new(Cell::new(false)); - let commit_when_composed = Rc::new(Cell::new(false)); - - // Capture phase, so Escape cancels before the GtkText can take it to - // clear its own buffer, and Tab commits before the window moves the - // focus. While a preedit is open both keys are the input method's. - let keys = gtk::EventControllerKey::new(); - keys.set_propagation_phase(gtk::PropagationPhase::Capture); - let weak = cell.downgrade(); - let keys_composing = composing.clone(); - keys.connect_key_pressed(move |_, keyval, _, modifiers| { - let Some(cell) = weak.upgrade() else { - return glib::Propagation::Proceed; - }; - if keys_composing.get() { - return glib::Propagation::Proceed; - } - match edit_key(keyval, modifiers) { - Some(EditKey::Cancel) => { - finish_inline(&cell, false); - return_focus(&cell); - } - Some(EditKey::Move(direction)) => { - finish_inline(&cell, true); - edit_next(&cell, direction); - } - None => return glib::Propagation::Proceed, - } - glib::Propagation::Stop - }); - text.add_controller(keys); - - let weak = cell.downgrade(); - let preedit_composing = composing.clone(); - let preedit_commit = commit_when_composed.clone(); - text.connect_preedit_changed(move |_, preedit| { - preedit_composing.set(!preedit.is_empty()); - if preedit.is_empty() - && preedit_commit.replace(false) - && let Some(cell) = weak.upgrade() - { - finish_inline(&cell, true); - } - }); - - let focus = gtk::EventControllerFocus::new(); - let weak = cell.downgrade(); - focus.connect_leave(move |focus| { - let Some(cell) = weak.upgrade() else { - return; - }; - // Another window taking the focus leaves it in the field, so the - // edit stays open for when the window is back. A focus moving - // within the window is unset while the leave runs. - if focus.widget().is_some_and(|field| holds_window_focus(&field)) { - return; - } - if composing.get() { - commit_when_composed.set(true); - return; - } - finish_inline(&cell, true); - }); - text.add_controller(focus); -} - -fn holds_window_focus(widget: >k::Widget) -> bool { - widget - .root() - .and_downcast::() - .and_then(|window| gtk::prelude::GtkWindowExt::focus(&window)) - .is_some_and(|focus| focus == *widget || focus.is_ancestor(widget)) -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum EditKey { - Cancel, - /// Commit, then edit the next cell that way. - Move(EditDirection), -} - -/// Ctrl+Tab and Alt+Tab belong to the window's tabs and switcher. -fn edit_key(keyval: gtk::gdk::Key, modifiers: gtk::gdk::ModifierType) -> Option { - use gtk::gdk::{Key, ModifierType}; - if modifiers.intersects(ModifierType::CONTROL_MASK | ModifierType::ALT_MASK) { - return None; - } - match keyval { - Key::Escape => Some(EditKey::Cancel), - Key::ISO_Left_Tab => Some(EditKey::Move(EditDirection::Backward)), - Key::Tab | Key::KP_Tab if modifiers.contains(ModifierType::SHIFT_MASK) => { - Some(EditKey::Move(EditDirection::Backward)) - } - Key::Tab | Key::KP_Tab => Some(EditKey::Move(EditDirection::Forward)), - _ => None, - } -} - -#[cfg(test)] -mod tests { - use gtk4::gdk::{Key, ModifierType}; - - use super::{EditKey, edit_key}; - use crate::test_support::shortcut_keys::keystroke; - use crate::ui::grid::edit_navigation::EditDirection; - - #[test] - fn tab_moves_escape_cancels_and_window_keys_pass() { - let none = ModifierType::empty(); - let shift = ModifierType::SHIFT_MASK; - - assert_eq!(edit_key(Key::Escape, none), Some(EditKey::Cancel)); - assert_eq!(edit_key(Key::Tab, none), Some(EditKey::Move(EditDirection::Forward))); - assert_eq!( - edit_key(Key::ISO_Left_Tab, shift), - Some(EditKey::Move(EditDirection::Backward)) - ); - assert_eq!(edit_key(Key::Tab, shift), Some(EditKey::Move(EditDirection::Backward))); - assert_eq!(edit_key(Key::Tab, ModifierType::CONTROL_MASK), None); - assert_eq!(edit_key(Key::Tab, ModifierType::ALT_MASK), None); - assert_eq!(edit_key(Key::Return, none), None, "Return is the field's activate"); - } - - /// The editor reads keys itself, to leave them to an open preedit, so - /// the table's entries are checked against what it does. - #[gtk4::test] - fn edit_keys_match_cell_editor_entries() { - let expected = [ - ("cell-editor.cancel", EditKey::Cancel), - ("cell-editor.next", EditKey::Move(EditDirection::Forward)), - ("cell-editor.previous", EditKey::Move(EditDirection::Backward)), - ]; - for (action, outcome) in expected { - let triggers = crate::application::triggers_for(action); - assert!(!triggers.is_empty(), "{action} is not in the table"); - for accel in triggers { - let (key, modifiers) = keystroke(accel); - assert_eq!(edit_key(key, modifiers), Some(outcome), "{accel}"); - } - } - } -} diff --git a/linux/crates/app/src/ui/grid/menu_rows.rs b/linux/crates/app/src/ui/grid/menu_rows.rs deleted file mode 100644 index 8175e8bd3b..0000000000 --- a/linux/crates/app/src/ui/grid/menu_rows.rs +++ /dev/null @@ -1,80 +0,0 @@ -use gtk4 as gtk; -use gtk4::prelude::*; - -use super::selection::{selected_positions, selection_of}; -use super::{GridCell, RowObject}; - -/// The rows a context menu item acts on: the whole selection when the -/// clicked row is in it, else the clicked row alone, so rows that moved -/// since the click never stand in for it. -pub fn menu_rows(selection: >k::MultiSelection, clicked: &RowObject, clicked_position: u32) -> Vec { - let clicked_is_selected = selection.is_selected(clicked_position) - && selection.item(clicked_position).and_downcast_ref::() == Some(clicked); - if !clicked_is_selected { - return vec![clicked.clone()]; - } - selected_positions(selection) - .into_iter() - .filter_map(|position| selection.item(position).and_downcast::()) - .collect() -} - -/// The rows a menu opened on `cell` acts on, read as it opens. -pub(super) fn menu_rows_of(column_view: >k::ColumnView, cell: &GridCell) -> Vec { - let Some(row) = cell.row() else { - return Vec::new(); - }; - match (selection_of(column_view), cell.position()) { - (Some(selection), Some(position)) => menu_rows(&selection, &row, position), - _ => vec![row], - } -} - -#[cfg(test)] -mod tests { - use gtk4 as gtk; - use gtk4::gio; - use gtk4::prelude::*; - use tablepro_core::Value; - - use super::menu_rows; - use crate::services::change_tracker::RowKey; - use crate::ui::grid::RowObject; - - fn rows(n: i64) -> (gtk::MultiSelection, Vec) { - let store = gio::ListStore::new::(); - let rows: Vec = (0..n) - .map(|id| RowObject::persisted(RowKey::from_pk_values(&[Value::Int(id)]), vec![Value::Int(id)].into())) - .collect(); - for row in &rows { - store.append(row); - } - (gtk::MultiSelection::new(Some(store)), rows) - } - - #[gtk4::test] - fn menu_rows_uses_selection_when_clicked_row_is_selected() { - let (selection, rows) = rows(5); - selection.select_item(3, false); - selection.select_item(1, false); - - assert_eq!( - menu_rows(&selection, &rows[3], 3), - vec![rows[1].clone(), rows[3].clone()], - "the rows are not the selection in position order" - ); - } - - #[gtk4::test] - fn menu_rows_uses_only_clicked_row_outside_selection() { - let (selection, rows) = rows(5); - selection.select_range(0, 2, true); - - assert_eq!(menu_rows(&selection, &rows[4], 4), vec![rows[4].clone()]); - assert_eq!( - menu_rows(&selection, &rows[4], 1), - vec![rows[4].clone()], - "a selected row at the clicked position stood in for the clicked one" - ); - } -} diff --git a/linux/crates/app/src/ui/grid/mod.rs b/linux/crates/app/src/ui/grid/mod.rs deleted file mode 100644 index 7dbbccf8d8..0000000000 --- a/linux/crates/app/src/ui/grid/mod.rs +++ /dev/null @@ -1,77 +0,0 @@ -mod calendar_editor; -mod cell_accessibility; -mod cell_binding; -mod cell_edit; -mod cell_editability; -mod cell_editor_kind; -mod cell_focus; -mod cell_lock; -mod cell_preset; -mod cell_tooltip; -mod column_drawing; -mod column_factory; -mod column_figures; -mod column_resize; -mod column_width_binding; -mod context_menu; -mod delete_availability; -mod display_text; -mod edit_commit; -mod edit_navigation; -mod edit_snapshot; -mod grid_attributes; -mod grid_cell; -mod grid_columns; -mod grid_content; -mod grid_keyboard; -mod grid_menus; -mod grid_msg; -mod grid_row_id; -mod grid_row_index; -mod inline_editor; -mod menu_rows; -mod multiline_editor; -mod reference_menu; -mod result_grid; -mod result_sorters; -mod row_factory; -mod row_json_dialog; -mod row_key_builder; -mod row_object; -mod row_snapshot; -mod row_state_column; -mod row_state_icon; -mod row_watches; -mod selection; -mod tab_grid_context; -mod table_keys; -#[cfg(test)] -mod test_grid; -mod toggle_cell; -mod value_order; - -#[cfg(test)] -pub(crate) use self::cell_edit::begin_edit; -pub(crate) use self::cell_editability::cell_editability; -pub(crate) use self::cell_focus::{commit_open_edit, edit_row, select_top_rows}; -pub(crate) use self::cell_lock::CellLock; -pub(crate) use self::cell_preset::{CellPreset, cell_presets}; -pub(crate) use self::column_factory::{GridSorting, build_column_view}; -pub(crate) use self::column_width_binding::ColumnWidthBinding; -pub(crate) use self::delete_availability::{DeleteAvailability, delete_availability}; -pub(crate) use self::edit_commit::{CommitResult, commit_input, edit_context}; -pub(crate) use self::edit_snapshot::EditSnapshot; -pub(crate) use self::grid_cell::GridCell; -pub(crate) use self::grid_content::GridContent; -pub(crate) use self::grid_msg::{GridMsg, GridSink}; -pub(crate) use self::grid_row_id::GridRowId; -pub(crate) use self::grid_row_index::GridRowIndex; -pub(crate) use self::result_grid::build_result_grid; -pub(crate) use self::row_json_dialog::{RowJsonRequest, present_row_json}; -pub(crate) use self::row_key_builder::RowKeyBuilder; -pub(crate) use self::row_object::RowObject; -pub(crate) use self::row_snapshot::RowSnapshot; -pub(crate) use self::row_state_column::data_columns; -pub(crate) use self::selection::{export_snapshot, selected_positions}; -pub(crate) use self::tab_grid_context::TabGridContext; -pub(crate) use self::table_keys::{KeysHandle, TableKeys}; diff --git a/linux/crates/app/src/ui/grid/multiline_editor.rs b/linux/crates/app/src/ui/grid/multiline_editor.rs deleted file mode 100644 index c6128de9b1..0000000000 --- a/linux/crates/app/src/ui/grid/multiline_editor.rs +++ /dev/null @@ -1,109 +0,0 @@ -use gtk4::prelude::*; -use gtk4::{self as gtk, glib}; -use sourceview5::prelude::*; - -use super::cell_accessibility::name_editor; -use super::edit_commit::{commit_edit, send_commit}; -use super::{EditSnapshot, GridCell}; -use crate::application::{ShortcutOwner, component_trigger, controller_name}; - -/// A popover with a source view, for text that spans lines or is too -/// long for a one-line field. Return adds a line, so Save is a button -/// and Ctrl+Return. -pub struct MultilineEditor; - -impl MultilineEditor { - pub(super) fn open(cell: &GridCell, snapshot: EditSnapshot, text: &str, language: Option<&'static str>) { - cell.cancel_edit(); - let buffer = sourceview5::Buffer::new(None); - if let Some(language) = language.and_then(|id| sourceview5::LanguageManager::default().language(id)) { - buffer.set_language(Some(&language)); - } - buffer.set_text(text); - let view = sourceview5::View::with_buffer(&buffer); - view.set_monospace(true); - view.set_wrap_mode(gtk::WrapMode::WordChar); - view.set_auto_indent(true); - crate::ui::style_scheme_resolver::apply(&view, "Adwaita"); - name_editor(&view, cell); - - let scrolled = gtk::ScrolledWindow::builder() - .child(&view) - .min_content_width(420) - .min_content_height(280) - .has_frame(true) - .build(); - let save = gtk::Button::with_label(&crate::i18n::gettext("Save")); - save.add_css_class("suggested-action"); - crate::ui::a11y::announce_key_shortcuts(&save, "cell-editor.save-text"); - let cancel = gtk::Button::with_label(&crate::i18n::gettext("Cancel")); - let buttons = gtk::Box::builder() - .orientation(gtk::Orientation::Horizontal) - .spacing(8) - .halign(gtk::Align::End) - .build(); - buttons.append(&cancel); - buttons.append(&save); - let content = gtk::Box::builder() - .orientation(gtk::Orientation::Vertical) - .spacing(8) - .margin_top(8) - .margin_bottom(8) - .margin_start(8) - .margin_end(8) - .build(); - content.append(&scrolled); - content.append(&buttons); - let popover = gtk::Popover::builder().child(&content).build(); - - let weak_popover = popover.downgrade(); - cancel.connect_clicked(move |_| { - if let Some(popover) = weak_popover.upgrade() { - popover.popdown(); - } - }); - let weak_cell = cell.downgrade(); - save.connect_clicked(move |_| { - if let Some(cell) = weak_cell.upgrade() { - save_text(&cell, &buffer); - } - }); - let save_shortcut = gtk::ShortcutController::new(); - save_shortcut.set_name(Some(controller_name(ShortcutOwner::CellEditor))); - let weak_save = save.downgrade(); - save_shortcut.add_shortcut(gtk::Shortcut::new( - component_trigger("cell-editor.save-text"), - Some(gtk::CallbackAction::new(move |_, _| { - if let Some(save) = weak_save.upgrade() { - save.emit_clicked(); - } - glib::Propagation::Stop - })), - )); - view.add_controller(save_shortcut); - - cell.begin_edit(snapshot); - cell.present_popover(&popover); - view.grab_focus(); - } -} - -/// The popover closes before the tab hears of the value, so an edit the -/// tab redraws the row for never meets an open popover. -fn save_text(cell: &GridCell, buffer: &sourceview5::Buffer) { - let Some(snapshot) = cell.take_edit() else { - return; - }; - let text = buffer.text(&buffer.start_iter(), &buffer.end_iter(), true).to_string(); - if let Some(popover) = cell.take_popover() { - popover.popdown(); - } - let Some(drawing) = cell.drawing() else { - return; - }; - let Some(column) = drawing.catalog.as_ref() else { - return; - }; - let result = commit_edit(column, &snapshot, &text); - send_commit(cell, snapshot, result); -} diff --git a/linux/crates/app/src/ui/grid/reference_menu.rs b/linux/crates/app/src/ui/grid/reference_menu.rs deleted file mode 100644 index 33c2179394..0000000000 --- a/linux/crates/app/src/ui/grid/reference_menu.rs +++ /dev/null @@ -1,581 +0,0 @@ -use std::cell::RefCell; -use std::rc::Rc; -use std::sync::Arc; - -use gtk4::prelude::*; -use gtk4::{gio, glib}; -use tablepro_core::browse::{ReferenceError, RowReference}; -use tablepro_core::column::ResultColumn; -use tablepro_core::ddl::{InboundForeignKey, OutboundForeignKey}; -use tablepro_core::meta::TableRef; - -use super::{GridCell, GridMsg, GridSink, KeysHandle, RowObject, TabGridContext, TableKeys}; -use crate::ui::menu_label; - -pub(super) const OPEN_ONE: &str = "open-referenced-row"; -pub(super) const OPEN_CHOICE: &str = "open-reference"; -pub(super) const REFERENCING: &str = "show-referencing-rows"; - -/// The keys a row leads somewhere by, as a menu opened on one of its -/// cells offers them. -#[derive(Debug, Clone, Default)] -pub(super) struct Offers { - keys: Option>, - /// The table's keys that hold the cell's column, by position. - pub(super) open: Vec, - /// The other tables' keys that point at the row, by position. - pub(super) referencing: Vec, -} - -/// The cell menu's foreign key items: one section both cell menus show, -/// filled as a menu opens, since a submenu cannot hide itself. -#[derive(Clone)] -pub(super) struct ReferenceMenu { - tab: Rc, - columns: Arc<[ResultColumn]>, - section: gio::Menu, - /// The choices a column in more than one key offers, which the - /// keyboard shows on its own. - choices: gio::Menu, - armed: Rc>, -} - -impl ReferenceMenu { - pub(super) fn new(tab: Rc, columns: Arc<[ResultColumn]>) -> Self { - Self { - tab, - columns, - section: gio::Menu::new(), - choices: gio::Menu::new(), - armed: Rc::default(), - } - } - - pub(super) fn section(&self) -> &gio::Menu { - &self.section - } - - pub(super) fn choices(&self) -> &gio::Menu { - &self.choices - } - - /// What a menu opened on `cell` would offer, under the keys read so - /// far. A grid of query results has no keys and offers nothing. - pub(super) fn offers_for(&self, cell: &GridCell) -> Offers { - let (Some(keys), Some(row)) = (self.tab.keys.as_ref().map(KeysHandle::get), cell.row()) else { - return Offers::default(); - }; - let Some(column) = self.columns.get(cell.column()) else { - return Offers::default(); - }; - let open = keys - .outbound - .iter() - .enumerate() - .filter(|(_, outbound)| outbound.key.columns.contains(&column.name)) - .filter(|(_, key)| leads_somewhere(self.referenced_by(&keys.table, key, &row))) - .map(|(index, _)| index) - .collect(); - let referencing = keys - .inbound - .iter() - .enumerate() - .filter(|(_, key)| self.referencing_by(&keys.table, key, &row).is_some_and(leads_somewhere)) - .map(|(index, _)| index) - .collect(); - Offers { - keys: Some(keys), - open, - referencing, - } - } - - /// Fill the section for `cell` as its menu opens. - pub(super) fn arm(&self, cell: &GridCell) { - let offers = self.offers_for(cell); - self.section.remove_all(); - self.choices.remove_all(); - if let Some(keys) = offers.keys.as_deref() { - let open: Vec<(usize, KeyName)> = offers - .open - .iter() - .filter_map(|&index| { - let key = &keys.outbound.get(index)?.key; - let target = TableRef::new(key.ref_schema.clone(), key.ref_table.clone()); - Some((index, KeyName::new(&keys.table, &target, &key.columns, &key.name))) - }) - .collect(); - for (index, label) in labelled(open) { - self.choices.append_item(&choice(&label, OPEN_CHOICE, index)); - } - match offers.open.len() { - 0 => {} - 1 => self.section.append_item(&crate::application::menu_item( - &crate::i18n::gettext("Open Referenced Row"), - &format!("cell.{OPEN_ONE}"), - )), - _ => self - .section - .append_submenu(Some(&crate::i18n::gettext("Open Referenced Row")), &self.choices), - } - let referencing = gio::Menu::new(); - let inbound: Vec<(usize, KeyName)> = offers - .referencing - .iter() - .filter_map(|&index| { - let key = keys.inbound.get(index)?; - Some((index, KeyName::new(&keys.table, &key.table, &key.columns, &key.name))) - }) - .collect(); - for (index, label) in labelled(inbound) { - referencing.append_item(&choice(&label, REFERENCING, index)); - } - if referencing.n_items() > 0 { - self.section - .append_submenu(Some(&crate::i18n::gettext("Show Referencing Rows")), &referencing); - } - } - self.armed.replace(offers); - } - - /// The row the outbound key at `index` points at from `row`, under - /// the keys the menu opened with. - pub(super) fn referenced(&self, index: usize, row: &RowObject) -> Option> { - let armed = self.armed.borrow(); - let keys = armed.keys.as_ref()?; - let key = keys.outbound.get(index)?; - Some(self.referenced_by(&keys.table, key, row)) - } - - /// The rows the inbound key at `index` holds that point at `row`. - pub(super) fn referencing(&self, index: usize, row: &RowObject) -> Option> { - let armed = self.armed.borrow(); - let keys = armed.keys.as_ref()?; - let key = keys.inbound.get(index)?; - self.referencing_by(&keys.table, key, row) - } - - /// The row `key` points at from `row` as the grid shows it, pending - /// edits included. - fn referenced_by( - &self, - source: &TableRef, - key: &OutboundForeignKey, - row: &RowObject, - ) -> Result { - let (columns, values) = self.key_cells(&key.key.columns, |index| { - Some(self.tab.with_effective_value(row, index, |value, _| value.clone())) - }); - RowReference::referenced(source, key, &columns, &values) - } - - /// The rows of `key` that point at `row` as it was saved, since that - /// is what they reference. A draft is not saved, so `None`. - fn referencing_by( - &self, - source: &TableRef, - key: &InboundForeignKey, - row: &RowObject, - ) -> Option> { - let saved = row.cells()?; - let (columns, values) = self.key_cells(&key.referenced_columns, |index| saved.get(index).cloned()); - Some(RowReference::referencing(source, key, &columns, &values)) - } - - /// The row's columns named `names` with their values, read through - /// `value`, so a row of large cells copies only its key's. - fn key_cells( - &self, - names: &[String], - value: impl Fn(usize) -> Option, - ) -> (Vec, Vec) { - names - .iter() - .filter_map(|name| { - let index = self.columns.iter().position(|column| column.name == *name)?; - Some((self.columns.get(index)?.clone(), value(index)?)) - }) - .unzip() - } - - /// The one outbound key the armed menu offers, for its plain item. - pub(super) fn only_choice(&self) -> Option { - match self.armed.borrow().open.as_slice() { - [index] => Some(*index), - _ => None, - } - } -} - -/// Open what a key leads to, or say why it cannot. -pub(super) fn follow(sink: &GridSink, reference: Result) { - match reference { - Ok(reference) => sink(GridMsg::FollowReference(reference)), - Err(error) => sink(GridMsg::ShowToast(crate::ui::error_text::reference_message(&error))), - } -} - -/// A key that cannot be followed is still offered, so the toast it -/// raises says why; one with nothing to follow is not. -fn leads_somewhere(reference: Result) -> bool { - match reference { - Ok(_) => true, - Err(error) => !error.leaves_nothing_to_follow(), - } -} - -fn choice(label: &str, action: &str, index: usize) -> gio::MenuItem { - let item = gio::MenuItem::new(Some(label), None); - let target = u32::try_from(index).unwrap_or(u32::MAX); - item.set_action_and_target_value(Some(&format!("cell.{action}")), Some(&target.to_variant())); - item -} - -/// How an item names its key: the other table, with its schema where -/// that is not the source's, and the key's columns on the side that -/// holds it, which tell two keys into one table apart. -struct KeyName { - short: String, - constraint: String, -} - -impl KeyName { - fn new(source: &TableRef, table: &TableRef, columns: &[String], constraint: &str) -> Self { - let name = match (&table.schema, table.schema == source.schema) { - (Some(schema), false) => format!("{schema}.{}", table.name), - _ => table.name.clone(), - }; - Self { - short: format!("{name} ({})", columns.join(", ")), - constraint: constraint.to_owned(), - } - } -} - -/// Each key's label. Two keys from the same columns into the same table -/// also name their constraint, since they point at different columns. -fn labelled(names: Vec<(usize, KeyName)>) -> Vec<(usize, String)> { - let shared = |short: &str| names.iter().filter(|(_, name)| name.short == short).count() > 1; - names - .iter() - .map(|(index, name)| { - let label = match shared(&name.short) { - true => crate::i18n::gettext_f( - "{key} through {constraint}", - &[("key", &name.short), ("constraint", &name.constraint)], - ), - false => name.short.clone(), - }; - (*index, menu_label(&label)) - }) - .collect() -} - -/// The index a parameterized item carries. -pub(super) fn chosen(parameter: Option<&glib::Variant>) -> Option { - parameter - .and_then(glib::Variant::get::) - .and_then(|index| usize::try_from(index).ok()) -} - -#[cfg(test)] -pub(super) mod tests { - use gtk4 as gtk; - use gtk4::gio; - use gtk4::prelude::*; - use tablepro_core::ddl::{InboundForeignKey, OutboundForeignKey}; - use tablepro_core::filter::{Combinator, FilterOp, FilterRule, FilterSet, FilterValue}; - use tablepro_core::meta::TableRef; - use tablepro_core::{ForeignKeyInfo, Value}; - - use crate::test_support::descendants; - use crate::ui::grid::test_grid::{Grid, named, open_menu}; - use crate::ui::grid::{GridMsg, TableKeys}; - - pub(in crate::ui::grid) fn public(name: &str) -> TableRef { - TableRef::new(Some("public".to_owned()), name) - } - - pub(in crate::ui::grid) fn key(columns: &[&str], to: TableRef, ref_columns: &[&str]) -> ForeignKeyInfo { - ForeignKeyInfo { - name: format!("{}_fkey", columns.join("_")), - columns: columns.iter().map(|name| (*name).to_owned()).collect(), - ref_schema: to.schema, - ref_table: to.name, - ref_columns: ref_columns.iter().map(|name| (*name).to_owned()).collect(), - on_delete: None, - on_update: None, - } - } - - pub(in crate::ui::grid) fn filter(rules: &[(&str, &str)]) -> FilterSet { - FilterSet { - combinator: Combinator::And, - rules: rules - .iter() - .map(|(column, text)| FilterRule { - column: (*column).to_owned(), - op: FilterOp::Eq, - value: Some(FilterValue::Single((*text).to_owned())), - }) - .collect(), - extra_sql: None, - } - } - - /// `orders` keyed by `id`, whose `customer_id` points at customers. - pub(in crate::ui::grid) fn orders_grid(outbound: Vec) -> Grid { - Grid::open_with_keys( - vec![ - named("id", "integer", true), - named("customer_id", "integer", false), - named("tenant_id", "integer", false), - ], - vec![ - vec![Value::Int(1), Value::Int(42), Value::Int(7)], - vec![Value::Int(2), Value::Null, Value::Int(7)], - ], - TableKeys { - table: public("orders"), - outbound: outbound - .into_iter() - .map(|key| OutboundForeignKey { - key, - parent_compares_alike: true, - }) - .collect(), - inbound: Vec::new(), - }, - ) - } - - /// The foreign key items the open cell menu holds, submenus opened, - /// as each one's label, action and the index it carries. - pub(in crate::ui::grid) fn reference_items(grid: &Grid) -> Vec<(String, Option, Option)> { - let menu = descendants(&grid.view) - .into_iter() - .filter_map(|widget| widget.downcast::().ok()) - .filter_map(|popover| popover.menu_model()) - .find(|model| { - items(model) - .iter() - .any(|(_, action, _)| action.as_deref() == Some("cell.show-row-json")) - }) - .expect("the cell menu"); - items(&menu) - .into_iter() - .filter(|(label, action, _)| { - label.contains("Referenc") || action.as_ref().is_some_and(|a| a.contains("referenc")) - }) - .collect() - } - - fn items(menu: &gio::MenuModel) -> Vec<(String, Option, Option)> { - let mut found = Vec::new(); - for position in 0..menu.n_items() { - let attribute = |name: &str| menu.item_attribute_value(position, name, None); - if let Some(label) = attribute("label").and_then(|value| value.get::()) { - found.push(( - label, - attribute("action").and_then(|value| value.get::()), - attribute("target").and_then(|value| value.get::()), - )); - } - for link in ["section", "submenu"] { - if let Some(inner) = menu.item_link(position, link) { - found.extend(items(&inner)); - } - } - } - found - } - - pub(in crate::ui::grid) fn followed(grid: &Grid) -> Vec<(TableRef, FilterSet)> { - grid.take_messages() - .into_iter() - .filter_map(|msg| match msg { - GridMsg::FollowReference(reference) => Some((reference.table, reference.filter)), - _ => None, - }) - .collect() - } - - fn item(label: &str, action: Option<&str>, target: Option) -> (String, Option, Option) { - (label.to_owned(), action.map(str::to_owned), target) - } - - #[gtk4::test] - fn a_key_cell_offers_open_referenced_row_and_sends_the_rows_it_names() { - let grid = orders_grid(vec![key(&["customer_id"], public("customers"), &["id"])]); - - open_menu(&grid.cell_of(1, 1)); - let offered = reference_items(&grid); - grid.view - .activate_action("cell.open-referenced-row", None) - .expect("the menu's Open Referenced Row"); - - assert_eq!( - offered, - [item("Open Referenced Row", Some("cell.open-referenced-row"), None)] - ); - assert_eq!(followed(&grid), [(public("customers"), filter(&[("id", "42")]))]); - } - - #[gtk4::test] - fn a_null_key_and_a_column_in_no_key_offer_nothing() { - let grid = orders_grid(vec![key(&["customer_id"], public("customers"), &["id"])]); - - open_menu(&grid.cell_of(2, 1)); - let on_null = reference_items(&grid); - open_menu(&grid.cell_of(1, 2)); - let on_other = reference_items(&grid); - - assert_eq!(on_null, []); - assert_eq!(on_other, []); - } - - #[gtk4::test] - fn a_column_in_two_keys_offers_each_and_follows_the_one_chosen() { - let crm = TableRef::new(Some("crm".to_owned()), "customers"); - let grid = orders_grid(vec![ - key(&["tenant_id"], public("tenants"), &["id"]), - key(&["tenant_id", "customer_id"], crm.clone(), &["tenant_id", "id"]), - ]); - - open_menu(&grid.cell_of(1, 2)); - let offered = reference_items(&grid); - grid.view - .activate_action("cell.open-reference", Some(&1u32.to_variant())) - .expect("the second key"); - - assert_eq!( - offered, - [ - item("Open Referenced Row", None, None), - item("tenants (tenant__id)", Some("cell.open-reference"), Some(0)), - item( - "crm.customers (tenant__id, customer__id)", - Some("cell.open-reference"), - Some(1) - ), - ] - ); - assert_eq!(followed(&grid), [(crm, filter(&[("tenant_id", "7"), ("id", "42")]))]); - } - - /// One column pointing at two columns of one table gives the two - /// keys the same columns, so their constraints tell them apart. - #[gtk4::test] - fn keys_from_the_same_columns_into_one_table_name_their_constraints() { - let mut by_id = key(&["customer_id"], public("customers"), &["id"]); - by_id.name = "by_id".to_owned(); - let mut by_code = key(&["customer_id"], public("customers"), &["code"]); - by_code.name = "by_code".to_owned(); - let grid = orders_grid(vec![by_id, by_code]); - - open_menu(&grid.cell_of(1, 1)); - - assert_eq!( - reference_items(&grid), - [ - item("Open Referenced Row", None, None), - item( - "customers (customer__id) through by__id", - Some("cell.open-reference"), - Some(0) - ), - item( - "customers (customer__id) through by__code", - Some("cell.open-reference"), - Some(1) - ), - ] - ); - } - - #[gtk4::test] - fn a_key_the_catalogue_could_not_pair_is_offered_and_says_why_it_cannot_open() { - let grid = orders_grid(vec![key(&["customer_id"], public("ghost"), &[])]); - - open_menu(&grid.cell_of(1, 1)); - let offered = reference_items(&grid).len(); - grid.view - .activate_action("cell.open-referenced-row", None) - .expect("the menu's Open Referenced Row"); - - assert_eq!(offered, 1); - let toasts: Vec = grid - .take_messages() - .into_iter() - .filter_map(|msg| match msg { - GridMsg::ShowToast(text) => Some(text), - GridMsg::FollowReference(reference) => panic!("opened {reference:?}"), - _ => None, - }) - .collect(); - assert_eq!(toasts.len(), 1); - assert!(toasts[0].contains("customer_id_fkey"), "{toasts:?}"); - } - - #[gtk4::test] - fn referencing_rows_are_offered_for_a_saved_row_and_not_for_a_draft() { - let orders = TableRef::new(Some("sales".to_owned()), "orders"); - let grid = Grid::open_with_keys( - vec![named("id", "integer", true), named("name", "text", false)], - vec![vec![Value::Int(1), Value::Text("Ada".into())]], - TableKeys { - table: public("customers"), - outbound: Vec::new(), - inbound: vec![InboundForeignKey { - child_compares_alike: true, - name: "orders_customer_id_fkey".to_owned(), - table: orders.clone(), - columns: vec!["customer_id".to_owned()], - referenced_columns: vec!["id".to_owned()], - }], - }, - ); - - open_menu(&grid.cell_of(1, 1)); - let offered = reference_items(&grid); - grid.view - .activate_action("cell.show-referencing-rows", Some(&0u32.to_variant())) - .expect("the menu's Show Referencing Rows"); - let opened = followed(&grid); - grid.insert_draft(); - let draft = grid - .cells() - .into_iter() - .find(|cell| cell.row().is_some_and(|row| row.draft_id().is_some())) - .expect("the draft's cell"); - open_menu(&draft); - - assert_eq!( - offered, - [ - item("Show Referencing Rows", None, None), - item( - "sales.orders (customer__id)", - Some("cell.show-referencing-rows"), - Some(0) - ), - ] - ); - assert_eq!(opened, [(orders, filter(&[("customer_id", "1")]))]); - assert_eq!(reference_items(&grid), [], "a draft is offered rows that point at it"); - } - - #[gtk4::test] - fn a_result_grid_offers_no_reference_items() { - let grid = Grid::open( - vec![named("id", "integer", true), named("customer_id", "integer", false)], - vec![vec![Value::Int(1), Value::Int(42)]], - ); - - open_menu(&grid.cell_of(1, 1)); - - assert_eq!(reference_items(&grid), []); - assert!(grid.view.activate_action("cell.open-referenced-row", None).is_ok()); - assert_eq!(followed(&grid), []); - } -} diff --git a/linux/crates/app/src/ui/grid/result_grid.rs b/linux/crates/app/src/ui/grid/result_grid.rs deleted file mode 100644 index 08d41bb003..0000000000 --- a/linux/crates/app/src/ui/grid/result_grid.rs +++ /dev/null @@ -1,381 +0,0 @@ -use std::sync::Arc; - -use gtk4 as gtk; -use tablepro_core::column::ResultColumn; - -use super::{GridContent, GridSink, GridSorting, RowObject, TabGridContext, build_column_view}; - -/// A grid of an editor result, which sorts the rows it holds when a -/// header is clicked. -/// -/// The rows reach the view through a `GtkSortListModel` on the view's -/// own sorter, so every selection, copy, menu and export reads the row -/// at its place on screen. -/// -/// `in_clause` is false for an engine without SQL, whose rows have no -/// `IN` list to copy as. -pub(crate) fn build_result_grid( - columns: Arc<[ResultColumn]>, - rows: Vec, - sink: GridSink, - in_clause: bool, -) -> (gtk::ColumnView, gtk::MultiSelection) { - build_column_view( - GridContent { columns, rows }, - &[], - sink, - GridSorting::Client, - TabGridContext { - in_clause, - ..TabGridContext::default() - }, - ) -} - -#[cfg(test)] -mod tests { - use std::time::{Duration, Instant}; - - use gtk4::glib; - use gtk4::prelude::*; - use tablepro_core::Value; - use tablepro_core::column::ColumnInfo; - - use super::*; - use crate::test_support::{descendants, drain_main_context, wait_until}; - use crate::ui::clipboard::{CopyKind, CopyRequest}; - use crate::ui::grid::test_grid::{Grid, named, recorder}; - use crate::ui::grid::{GridCell, GridMsg}; - - const WAIT: Duration = Duration::from_secs(10); - - fn grid(columns: &[ColumnInfo], rows: Vec>, sink: GridSink) -> (gtk::ColumnView, gtk::MultiSelection) { - let rows = rows.into_iter().map(|cells| RowObject::result(cells.into())).collect(); - build_result_grid( - columns.iter().map(ColumnInfo::result_column).collect(), - rows, - sink, - true, - ) - } - - fn sort(view: >k::ColumnView, column: u32, order: gtk::SortType) { - let column = view - .columns() - .item(column) - .and_downcast::() - .expect("the column"); - view.sort_by_column(Some(&column), order); - } - - /// Column `column` of each row, in the order the view shows them. - fn shown(selection: >k::MultiSelection, column: usize) -> Vec { - (0..selection.n_items()) - .filter_map(|position| selection.item(position).and_downcast::()) - .map(|row| row.cell(column).cloned().unwrap_or(Value::Null)) - .collect() - } - - fn text(value: &str) -> Value { - Value::Text(value.to_owned()) - } - - #[gtk4::test] - fn sort_by_column_descending_orders_result_rows() { - let columns = [named("id", "integer", false), named("name", "text", false)]; - let (view, selection) = grid( - &columns, - vec![ - vec![Value::Int(2), text("b")], - vec![Value::Int(10), text("j")], - vec![Value::Null, text("none")], - vec![Value::Int(-1), text("a")], - ], - recorder().0, - ); - - sort(&view, 0, gtk::SortType::Descending); - - assert_eq!( - shown(&selection, 0), - [Value::Int(10), Value::Int(2), Value::Int(-1), Value::Null] - ); - assert_eq!(shown(&selection, 1), [text("j"), text("b"), text("a"), text("none")]); - sort(&view, 0, gtk::SortType::Ascending); - assert_eq!( - shown(&selection, 0), - [Value::Null, Value::Int(-1), Value::Int(2), Value::Int(10)] - ); - } - - #[gtk4::test] - fn a_sort_keeps_the_selected_rows_selected() { - let columns = [named("id", "integer", false)]; - let (view, selection) = grid( - &columns, - vec![vec![Value::Int(3)], vec![Value::Int(1)], vec![Value::Int(2)]], - recorder().0, - ); - selection.select_item(0, true); - selection.select_item(1, false); - - sort(&view, 0, gtk::SortType::Ascending); - - let selected: Vec = (0..selection.n_items()) - .filter(|position| selection.is_selected(*position)) - .filter_map(|position| selection.item(position).and_downcast::()) - .map(|row| row.cell(0).cloned().unwrap_or(Value::Null)) - .collect(); - assert_eq!(selected, [Value::Int(1), Value::Int(3)]); - } - - #[gtk4::test] - fn text_column_sorts_with_unicode_collation() { - let columns = [named("name", "text", false)]; - let names = ["f", "é", "e", "E"]; - let mut rows: Vec> = names.iter().map(|name| vec![text(name)]).collect(); - rows.insert(2, vec![Value::Null]); - let (view, selection) = grid(&columns, rows, recorder().0); - - sort(&view, 0, gtk::SortType::Ascending); - - // The order is the locale's: whatever it is, it is the order of - // the collation keys GLib builds for it, with NULL first. The - // sorter ignores case, as GtkStringSorter does by default, so - // `e` and `E` tie and keep their row order. - let mut collated = names.to_vec(); - collated.sort_by_key(|name| glib::CollationKey::from(glib::casefold(name).as_str())); - let mut expected = vec![Value::Null]; - expected.extend(collated.iter().map(|name| text(name))); - assert_eq!(shown(&selection, 0), expected); - let key = |name: &str| glib::CollationKey::from(glib::casefold(name).as_str()); - if key("e") < key("é") && key("é") < key("f") { - let order: Vec = shown(&selection, 0) - .into_iter() - .filter(|value| *value != text("E")) - .collect(); - assert_eq!(order, [Value::Null, text("e"), text("é"), text("f")]); - } - sort(&view, 0, gtk::SortType::Descending); - assert_eq!( - shown(&selection, 0).last(), - Some(&Value::Null), - "NULL is not last descending" - ); - } - - #[gtk4::test] - fn a_text_column_orders_its_numbers_before_its_text() { - let mut column = named("tag", "nvarchar(20)", false); - column.column_type.dynamic_storage = false; - let (view, selection) = grid( - &[column], - vec![ - vec![Value::Int(10)], - vec![text("b")], - vec![Value::Int(9)], - vec![Value::Null], - vec![text("a")], - ], - recorder().0, - ); - - sort(&view, 0, gtk::SortType::Ascending); - - assert_eq!( - shown(&selection, 0), - [Value::Null, Value::Int(9), Value::Int(10), text("a"), text("b")] - ); - } - - #[gtk4::test] - fn text_column_sorter_is_string_sorter_with_unicode_collation() { - let (view, _selection) = grid(&[named("name", "varchar(20)", false)], Vec::new(), recorder().0); - let column = view - .columns() - .item(0) - .and_downcast::() - .expect("the column"); - - let sorter = column - .sorter() - .and_downcast::() - .expect("a text column sorts in steps"); - let collating = sorter - .item(sorter.n_items() - 1) - .and_downcast::() - .expect("the last step collates the text"); - assert_eq!(collating.collation(), gtk::Collation::Unicode); - assert!( - sorter.item(0).and_downcast::().is_some(), - "NULL is not placed first" - ); - } - - #[gtk4::test] - fn sorting_10k_text_rows_finishes_within_one_second() { - let rows = (0..10_000_u64) - .map(|row| vec![text(&format!("name {:05}", row * 7_919 % 10_000))]) - .collect(); - let (view, selection) = grid(&[named("name", "text", false)], rows, recorder().0); - - let started = Instant::now(); - sort(&view, 0, gtk::SortType::Ascending); - let count = selection.n_items(); - let elapsed = started.elapsed(); - - assert_eq!(count, 10_000); - assert!(elapsed < Duration::from_secs(1), "sorting took {elapsed:?}"); - assert_eq!(shown(&selection, 0)[0], text("name 00000")); - assert_eq!(shown(&selection, 0)[9_999], text("name 09999")); - } - - /// PostgreSQL's editor reads columns with no type, holding the - /// server's text. - #[gtk4::test] - fn untyped_numbers_line_up_as_the_browse_grid_lines_them_up() { - let untyped = |name: &str| ResultColumn::new(name, tablepro_core::column::ColumnType::untyped_text("text")); - let columns: Arc<[ResultColumn]> = ["id", "category", "products", "avg_price"].map(untyped).into(); - let rows = vec![ - RowObject::result(vec![text("1"), text("Coffee"), text("7"), text("23.79")].into()), - RowObject::result(vec![text("2"), text("Tea"), Value::Null, text("10.58")].into()), - ]; - let (view, _selection) = build_result_grid(columns, rows, recorder().0, true); - let window = gtk::Window::new(); - window.set_child(Some(&view)); - window.present(); - let result = |column: usize| { - descendants(&view) - .into_iter() - .filter_map(|widget| widget.downcast::().ok()) - .find(|cell| cell.position() == Some(0) && cell.column() == column && cell.row().is_some()) - .and_then(|cell| cell.display()) - }; - wait_until(WAIT, || (0..4).all(|column| result(column).is_some())).expect("the result is drawn"); - let browse = Grid::open( - vec![ - named("id", "integer", true), - named("category", "text", false), - named("products", "bigint", false), - named("avg_price", "numeric", false), - ], - vec![vec![ - Value::Int(1), - text("Coffee"), - Value::Int(7), - Value::Decimal("23.79".parse().expect("a decimal")), - ]], - ); - - for column in 0..4 { - let shown = result(column).expect("a result cell"); - let typed = browse.cell_of(1, column).display().expect("a browse cell"); - assert_eq!(shown.xalign(), typed.xalign(), "column {column}"); - assert_eq!( - shown.has_css_class("numeric"), - typed.has_css_class("numeric"), - "column {column}" - ); - } - assert_eq!(result(2).map(|display| display.xalign()), Some(1.0)); - assert_eq!(result(1).map(|display| display.xalign()), Some(0.0)); - window.close(); - } - - #[gtk4::test] - fn the_cell_menu_sorts_the_results_and_restores_their_order() { - let columns = [named("id", "integer", false), named("name", "text", false)]; - let (view, selection) = grid( - &columns, - vec![ - vec![Value::Int(2), text("b")], - vec![Value::Int(3), text("c")], - vec![Value::Int(1), text("a")], - ], - recorder().0, - ); - let window = gtk::Window::new(); - window.set_default_size(600, 400); - window.set_child(Some(&view)); - window.present(); - let cell = || { - descendants(&view) - .into_iter() - .filter_map(|widget| widget.downcast::().ok()) - .find(|cell| cell.position() == Some(0) && cell.column() == 0 && cell.row().is_some()) - }; - wait_until(WAIT, || cell().is_some()).expect("the cells are drawn"); - let open_menu = || { - let cell = cell().expect("the first row's cell"); - let controllers = cell.observe_controllers(); - let gesture = (0..controllers.n_items()) - .filter_map(|index| controllers.item(index).and_downcast::()) - .find(|gesture| gesture.button() == 3) - .expect("a cell answers a right-click"); - gesture.emit_by_name::<()>("pressed", &[&1i32, &1.0f64, &1.0f64]); - }; - - open_menu(); - view.activate_action("cell.sort-descending", None) - .expect("the menu's Sort Descending"); - assert_eq!(shown(&selection, 0), [Value::Int(3), Value::Int(2), Value::Int(1)]); - - open_menu(); - view.activate_action("cell.clear-sort", None) - .expect("the menu's Clear Sort"); - assert_eq!(shown(&selection, 0), [Value::Int(2), Value::Int(3), Value::Int(1)]); - window.close(); - } - - #[gtk4::test] - fn copy_after_sort_copies_displayed_row() { - let columns = [named("id", "integer", false), named("name", "text", false)]; - let (sink, messages) = recorder(); - let (view, _selection) = grid( - &columns, - vec![ - vec![Value::Int(3), text("c")], - vec![Value::Int(1), text("a")], - vec![Value::Int(2), text("b")], - ], - sink, - ); - let window = gtk::Window::new(); - window.set_default_size(600, 400); - window.set_child(Some(&view)); - window.present(); - sort(&view, 0, gtk::SortType::Ascending); - let first_cell = || { - descendants(&view) - .into_iter() - .filter_map(|widget| widget.downcast::().ok()) - .find(|cell| cell.position() == Some(0) && cell.column() == 1) - }; - wait_until(WAIT, || first_cell().is_some_and(|cell| cell.row().is_some())).expect("the cells are drawn"); - let cell = first_cell().expect("the first row's cell"); - let gesture = { - let controllers = cell.observe_controllers(); - (0..controllers.n_items()) - .filter_map(|index| controllers.item(index).and_downcast::()) - .find(|gesture| gesture.button() == 3) - .expect("a cell answers a right-click") - }; - - gesture.emit_by_name::<()>("pressed", &[&1i32, &1.0f64, &1.0f64]); - view.activate_action("cell.copy", None).expect("the menu's Copy"); - drain_main_context(); - - let copied: Vec<(Vec, CopyKind)> = messages - .borrow() - .iter() - .filter_map(|msg| match msg { - GridMsg::Copy(CopyRequest::Rows { rows, kind, .. }) => { - Some((rows.iter().flat_map(|row| row.to_values()).collect(), *kind)) - } - _ => None, - }) - .collect(); - assert_eq!(copied, [(vec![Value::Int(1), text("a")], CopyKind::Value(1))]); - window.close(); - } -} diff --git a/linux/crates/app/src/ui/grid/result_sorters.rs b/linux/crates/app/src/ui/grid/result_sorters.rs deleted file mode 100644 index 8553ce35a0..0000000000 --- a/linux/crates/app/src/ui/grid/result_sorters.rs +++ /dev/null @@ -1,63 +0,0 @@ -use std::cmp::Ordering; - -use gtk4::prelude::*; -use gtk4::{self as gtk, glib}; -use tablepro_core::Value; -use tablepro_core::column::{ColumnKind, ResultColumn}; - -use super::RowObject; -use super::value_order::{compare, sort_text, storage_class_order}; - -/// What a header click on column `index` sorts the rows by. -/// -/// Text is collated in the user's locale by `GtkStringSorter`, which -/// builds a `g_utf8_collate_key` once per row and compares the keys -/// with `strcmp`, so a sort of thousands of rows does not normalise a -/// string for every comparison. The sorter ahead of it places every -/// value that is not text, so the pair serves any column that can hold -/// text: a text kind, or one with `dynamic_storage`, which a column of -/// an ordinary SQLite table has because its rows choose their own -/// storage whatever the declared kind. -pub(crate) fn column_sorter(index: usize, column: &ResultColumn) -> gtk::Sorter { - let column_type = &column.column_type; - if column_type.dynamic_storage || holds_text(&column_type.kind) { - return collating(cells_by(index, storage_class_order), index); - } - cells_by(index, compare).upcast() -} - -fn holds_text(kind: &ColumnKind) -> bool { - kind.is_textual() || matches!(kind, ColumnKind::Json { .. } | ColumnKind::Network(_)) -} - -/// `first`, then the locale's collation of the column's text. -fn collating(first: gtk::CustomSorter, index: usize) -> gtk::Sorter { - let sorter = gtk::MultiSorter::new(); - sorter.append(first); - sorter.append(text_sorter(index)); - sorter.upcast() -} - -fn text_sorter(index: usize) -> gtk::StringSorter { - let text = gtk::ClosureExpression::with_callback(&[] as &[gtk::Expression], move |values: &[glib::Value]| { - let row = values.first()?.get::().ok()?; - row.cell(index).and_then(sort_text).map(str::to_owned) - }); - let sorter = gtk::StringSorter::new(Some(text)); - sorter.set_collation(gtk::Collation::Unicode); - sorter -} - -/// A sorter that orders two rows by their cells in column `index`. -fn cells_by(index: usize, order: fn(&Value, &Value) -> Ordering) -> gtk::CustomSorter { - gtk::CustomSorter::new(move |a, b| { - let (Some(a), Some(b)) = (a.downcast_ref::(), b.downcast_ref::()) else { - return gtk::Ordering::Equal; - }; - order( - a.cell(index).unwrap_or(&Value::Null), - b.cell(index).unwrap_or(&Value::Null), - ) - .into() - }) -} diff --git a/linux/crates/app/src/ui/grid/row_factory.rs b/linux/crates/app/src/ui/grid/row_factory.rs deleted file mode 100644 index 61095da789..0000000000 --- a/linux/crates/app/src/ui/grid/row_factory.rs +++ /dev/null @@ -1,117 +0,0 @@ -use std::rc::Rc; - -use gtk4 as gtk; -use gtk4::prelude::*; - -use super::cell_accessibility::row_description; -use super::row_watches::RowWatches; -use super::{RowObject, TabGridContext}; - -/// Rows never take the focus, so it always lands on a cell and the -/// arrow keys move between cells. Each row tells a screen reader when -/// it is new or marked for deletion. -pub(super) fn install_row_factory(column_view: >k::ColumnView, tab_ctx: TabGridContext) { - let factory = gtk::SignalListItemFactory::new(); - factory.connect_setup(|_, item| { - if let Some(list_row) = item.downcast_ref::() { - list_row.set_focusable(false); - } - }); - - let watches: Rc> = Rc::default(); - let tab_ctx = Rc::new(tab_ctx); - { - let watches = watches.clone(); - factory.connect_bind(move |_, item| { - let Some(list_row) = item.downcast_ref::() else { - return; - }; - let Some(row) = list_row.item().and_downcast::() else { - return; - }; - let tab_ctx = tab_ctx.clone(); - watches.bind(list_row, row, move |list_row, row| describe(list_row, &tab_ctx, row)); - }); - } - factory.connect_unbind(move |_, item| { - let Some(list_row) = item.downcast_ref::() else { - return; - }; - watches.unbind(list_row); - list_row.set_property("accessible-description", None::); - }); - column_view.set_row_factory(Some(&factory)); -} - -/// Through the property, since the setter takes no NULL to clear it. -fn describe(list_row: >k::ColumnViewRow, tab_ctx: &TabGridContext, row: &RowObject) { - list_row.set_property( - "accessible-description", - row_description(tab_ctx.row_pending_state(row)), - ); -} - -#[cfg(test)] -mod tests { - use gtk4 as gtk; - use gtk4::prelude::*; - use tablepro_core::Value; - use tablepro_core::edit::CellInput; - - use crate::services::tracker_edit::TrackerEdit; - use crate::test_support::wait_until; - use crate::ui::grid::test_grid::{WAIT, key, two_text_rows}; - - #[gtk4::test] - fn pending_delete_sets_row_widget_description() { - let grid = two_text_rows(); - let cell = grid.cell_of(1, 1); - let row = cell.parent().and_then(|cell| cell.parent()).expect("the row widget"); - let described = || gtk::test_accessible_has_property(&row, gtk::AccessibleProperty::Description); - assert!(gtk::test_accessible_has_role(&row, gtk::AccessibleRole::Row)); - assert!(!described()); - assert!(!row.is_focusable(), "a row took the focus a cell should"); - - grid.redraw(&grid.tracker.apply(TrackerEdit::DeleteRows { - persisted: vec![(key(1), vec![Value::Int(1), Value::Text("A".into())].into())], - drafts: Vec::new(), - })); - assert!(described(), "a row marked for deletion said nothing"); - - grid.redraw(&grid.tracker.undo().expect("a delete to undo")); - assert!(!described(), "a restored row still read as marked"); - } - - #[gtk4::test] - fn a_new_row_is_described_and_its_cells_are_not() { - let grid = two_text_rows(); - grid.insert_draft(); - let mut draft = None; - wait_until(WAIT, || { - draft = grid - .cells() - .into_iter() - .find(|cell| cell.row().is_some_and(|row| row.draft_id().is_some())); - draft.is_some() - }) - .expect("the draft is drawn"); - let draft = draft.expect("the draft's cell"); - let row = draft.parent().and_then(|cell| cell.parent()).expect("the row widget"); - // A NULL is named whatever the row, so the cell holds a value. - let draft_id = draft.row().and_then(|row| row.draft_id()).expect("a draft"); - grid.redraw(&grid.tracker.apply(TrackerEdit::DraftCell { - draft_id, - col: draft.column(), - input: CellInput::Value(Value::Text("typed".into())), - })); - - assert!(gtk::test_accessible_has_property( - &row, - gtk::AccessibleProperty::Description - )); - assert!(!gtk::test_accessible_has_property( - &draft, - gtk::AccessibleProperty::Description - )); - } -} diff --git a/linux/crates/app/src/ui/grid/row_object.rs b/linux/crates/app/src/ui/grid/row_object.rs deleted file mode 100644 index 8d89294aff..0000000000 --- a/linux/crates/app/src/ui/grid/row_object.rs +++ /dev/null @@ -1,145 +0,0 @@ -use std::cell::{Cell, OnceCell}; -use std::sync::Arc; - -use gtk4::glib; -use gtk4::prelude::*; -use gtk4::subclass::prelude::*; - -use tablepro_core::Value; - -use super::GridRowId; -use crate::services::change_tracker::RowKey; - -mod imp { - use super::*; - - #[derive(Default, glib::Properties)] - #[properties(wrapper_type = super::RowObject)] - pub struct RowObject { - pub id: OnceCell, - /// The row as it was read. A draft has none: its cells are the - /// tracker's. - pub cells: OnceCell>, - /// Moves on when the row's pending state changes, so the cells - /// bound to it draw again without the store swapping the row. - #[property(get)] - pub revision: Cell, - } - - #[glib::object_subclass] - impl ObjectSubclass for RowObject { - const NAME: &'static str = "TableProRowObject"; - type Type = super::RowObject; - } - - #[glib::derived_properties] - impl ObjectImpl for RowObject {} -} - -glib::wrapper! { - pub struct RowObject(ObjectSubclass); -} - -/// What a row made by `glib::Object::new` rather than a constructor -/// here reads as: nothing to edit. -static UNNAMED: GridRowId = GridRowId::Result; - -impl RowObject { - fn with(id: GridRowId, cells: Option>) -> Self { - let row: Self = glib::Object::new(); - let _ = row.imp().id.set(id); - if let Some(cells) = cells { - let _ = row.imp().cells.set(cells); - } - row - } - - /// A row read from a table. `None` for a row with no usable key. - pub fn persisted(key: Option, cells: Arc<[Value]>) -> Self { - let id = match key { - Some(key) => GridRowId::Persisted(key), - None => GridRowId::Unkeyed, - }; - Self::with(id, Some(cells)) - } - - pub fn draft(draft_id: u64) -> Self { - Self::with(GridRowId::Draft(draft_id), None) - } - - pub fn result(cells: Arc<[Value]>) -> Self { - Self::with(GridRowId::Result, Some(cells)) - } - - pub fn id(&self) -> &GridRowId { - self.imp().id.get().unwrap_or(&UNNAMED) - } - - pub fn key(&self) -> Option<&RowKey> { - self.id().key() - } - - pub fn draft_id(&self) -> Option { - self.id().draft_id() - } - - pub fn cells(&self) -> Option> { - self.imp().cells.get().cloned() - } - - pub fn cell(&self, column: usize) -> Option<&Value> { - self.imp().cells.get()?.get(column) - } - - /// The row's own hold on its cells, to count who else holds them. - #[cfg(test)] - pub(crate) fn shared_cells(&self) -> Option<&Arc<[Value]>> { - self.imp().cells.get() - } - - pub(crate) fn bump_revision(&self) { - let revision = &self.imp().revision; - revision.set(revision.get().wrapping_add(1)); - self.notify_revision(); - } -} - -#[cfg(test)] -mod tests { - use std::cell::Cell; - use std::rc::Rc; - - use tablepro_core::Value; - - use super::RowObject; - use crate::services::change_tracker::RowKey; - use crate::ui::grid::GridRowId; - - #[test] - fn a_row_keeps_the_identity_it_was_made_with() { - let key = RowKey::from_pk_values(&[Value::Int(2)]).expect("a key"); - let persisted = RowObject::persisted(Some(key.clone()), vec![Value::Int(2)].into()); - let unkeyed = RowObject::persisted(None, vec![Value::Int(2)].into()); - let draft = RowObject::draft(5); - - assert_eq!(persisted.key(), Some(&key)); - assert_eq!(persisted.cell(0), Some(&Value::Int(2))); - assert_eq!(unkeyed.id(), &GridRowId::Unkeyed); - assert_eq!(draft.draft_id(), Some(5)); - assert!(draft.cells().is_none(), "a draft's cells are the tracker's"); - } - - #[test] - fn bump_revision_notifies_each_listener_once() { - let row = RowObject::draft(1); - let heard = Rc::new(Cell::new(0)); - let counter = heard.clone(); - row.connect_revision_notify(move |_| counter.set(counter.get() + 1)); - - row.bump_revision(); - row.bump_revision(); - - assert_eq!(row.revision(), 2); - assert_eq!(heard.get(), 2); - } -} diff --git a/linux/crates/app/src/ui/grid/row_snapshot.rs b/linux/crates/app/src/ui/grid/row_snapshot.rs deleted file mode 100644 index fbe6092d86..0000000000 --- a/linux/crates/app/src/ui/grid/row_snapshot.rs +++ /dev/null @@ -1,119 +0,0 @@ -use std::sync::Arc; - -use tablepro_core::Value; - -static NULL: Value = Value::Null; - -/// A row as the grid shows it, pending edits included, for a copy or an -/// export. It shares the cells the page read and holds its own copy of -/// only the cells the user changed, and it can leave the GTK thread. -#[derive(Debug, Clone)] -pub struct RowSnapshot { - cells: Arc<[Value]>, - /// Pending values by column, in column order. - overrides: Box<[(usize, Value)]>, -} - -impl RowSnapshot { - pub fn new(cells: Arc<[Value]>, overrides: Vec<(usize, Value)>) -> Self { - Self { - cells, - overrides: overrides.into_boxed_slice(), - } - } - - /// NULL for a column past the row's end. - pub fn value(&self, column: usize) -> &Value { - match self.overrides.binary_search_by_key(&column, |(col, _)| *col) { - Ok(index) => &self.overrides[index].1, - Err(_) => self.cells.get(column).unwrap_or(&NULL), - } - } - - pub fn len(&self) -> usize { - self.cells.len() - } - - /// Each cell as the grid shows it, borrowed, for an encoder that - /// takes a row at a time. - pub fn values(&self) -> impl Iterator { - (0..self.len()).map(|column| self.value(column)) - } - - /// The row as owned cells, for a renderer that takes whole rows. - pub fn to_values(&self) -> Vec { - self.values().cloned().collect() - } - - #[cfg(test)] - pub(crate) fn shares_cells_with(&self, cells: &Arc<[Value]>) -> bool { - Arc::ptr_eq(&self.cells, cells) - } -} - -#[cfg(test)] -mod tests { - use std::sync::Arc; - - use tablepro_core::Value; - - use super::RowSnapshot; - use crate::services::change_tracker::RowKey; - use crate::services::tab_tracker_handle::TabTrackerHandle; - use crate::services::tracker_edit::TrackerEdit; - use crate::ui::grid::{RowObject, TabGridContext}; - - #[test] - fn row_snapshot_shares_unmodified_cells() { - let tracker = TabTrackerHandle::default(); - let key = RowKey::from_pk_values(&[Value::Int(1)]).expect("a key"); - let _ = tracker.apply(TrackerEdit::Cell { - row_key: key.clone(), - col: 2, - original: Value::Text("old".into()), - value: Value::Text("new".into()), - }); - let cells: Arc<[Value]> = vec![Value::Int(1), Value::Text("Ada".into()), Value::Text("old".into())].into(); - let row = RowObject::persisted(Some(key), cells.clone()); - let context = TabGridContext { - tracker: Some(tracker), - ..TabGridContext::default() - }; - - let snapshot = context.snapshot(&row); - - assert!(Arc::ptr_eq(&snapshot.cells, &cells), "the row's cells were copied"); - assert_eq!(snapshot.overrides.len(), 1, "an unedited cell was copied"); - assert_eq!(snapshot.overrides[0].0, 2); - assert_eq!( - snapshot.to_values(), - vec![Value::Int(1), Value::Text("Ada".into()), Value::Text("new".into())] - ); - assert_eq!(snapshot.value(9), &Value::Null); - } - - #[test] - fn a_draft_snapshot_reads_a_default_as_null() { - let tracker = TabTrackerHandle::default(); - let delta = tracker.apply(TrackerEdit::InsertDrafts { - rows: vec![vec![ - tablepro_core::edit::CellInput::Default, - tablepro_core::edit::CellInput::Value(Value::Int(3)), - ]], - }); - let draft = RowObject::draft(delta.inserted_drafts[0]); - let context = TabGridContext { - tracker: Some(tracker), - ..TabGridContext::default() - }; - - assert_eq!(context.snapshot(&draft).to_values(), vec![Value::Null, Value::Int(3)]); - } - - #[test] - fn a_snapshot_can_leave_the_gtk_thread() { - fn sendable() {} - - sendable::(); - } -} diff --git a/linux/crates/app/src/ui/grid/row_state_column.rs b/linux/crates/app/src/ui/grid/row_state_column.rs deleted file mode 100644 index 0fa81768a5..0000000000 --- a/linux/crates/app/src/ui/grid/row_state_column.rs +++ /dev/null @@ -1,263 +0,0 @@ -use std::rc::Rc; - -use gtk4 as gtk; -use gtk4::prelude::*; - -use super::row_state_icon::row_state_icon; -use super::row_watches::RowWatches; -use super::{RowObject, TabGridContext}; - -/// Not a result column, so it has no name in the table, no saved width -/// and no place in a copy or an export. -pub(crate) const ROW_STATE_COLUMN_ID: &str = "tp-row-state"; -/// One symbolic icon and its padding. -const ROW_STATE_COLUMN_WIDTH: i32 = 28; - -/// A narrow first column that shows each row's pending change as an -/// icon, so the change reads without its tint, as a diff gutter marks a -/// line. -pub(super) fn row_state_column(tab_ctx: TabGridContext) -> gtk::ColumnViewColumn { - let factory = gtk::SignalListItemFactory::new(); - factory.connect_setup(|_, item| { - let Some(cell) = item.downcast_ref::() else { - return; - }; - // The row's own description names the state, so the icon would - // say it twice. - let image = gtk::Image::builder() - .accessible_role(gtk::AccessibleRole::Presentation) - .build(); - cell.set_property("focusable", false); - cell.set_child(Some(&image)); - }); - - let watches: Rc> = Rc::default(); - let tab_ctx = Rc::new(tab_ctx); - { - let watches = watches.clone(); - factory.connect_bind(move |_, item| { - let Some(cell) = item.downcast_ref::() else { - return; - }; - let Some(row) = cell.item().and_downcast::() else { - return; - }; - let tab_ctx = tab_ctx.clone(); - watches.bind(cell, row, move |cell, row| { - if let Some(image) = cell.child().and_downcast::() { - show_state(&image, &tab_ctx, row); - } - }); - }); - } - factory.connect_unbind(move |_, item| { - if let Some(cell) = item.downcast_ref::() { - watches.unbind(cell); - } - }); - - gtk::ColumnViewColumn::builder() - .id(ROW_STATE_COLUMN_ID) - .factory(&factory) - .fixed_width(ROW_STATE_COLUMN_WIDTH) - .resizable(false) - .build() -} - -fn show_state(image: >k::Image, tab_ctx: &TabGridContext, row: &RowObject) { - let shown = row_state_icon(tab_ctx.row_pending_state(row)); - image.set_icon_name(shown.as_ref().map(|(icon, _)| *icon)); - image.set_tooltip_text(shown.as_ref().map(|(_, text)| text.as_str())); -} - -/// The view's columns that show the result's, in the view's order. That -/// is the order of a row's cells only until a header drag moves one, so -/// read it as the view is built. -pub(crate) fn data_columns(view: >k::ColumnView) -> Vec { - let columns = view.columns(); - (0..columns.n_items()) - .filter_map(|position| columns.item(position).and_downcast::()) - .filter(|column| column.id().is_none_or(|id| id.as_str() != ROW_STATE_COLUMN_ID)) - .collect() -} - -#[cfg(test)] -mod tests { - use gtk4 as gtk; - use gtk4::prelude::*; - use tablepro_core::Value; - - use super::{ROW_STATE_COLUMN_ID, data_columns}; - use crate::services::tab_tracker_handle::TabTrackerHandle; - use crate::services::tracker_edit::TrackerEdit; - use crate::test_support::{first_descendant_of_type, wait_until}; - use crate::ui::grid::test_grid::{WAIT, content, key, named, persisted, recorder, tracked, two_text_rows}; - use crate::ui::grid::{ColumnWidthBinding, GridCell, GridMsg, GridSorting, RowObject, build_column_view}; - - /// The state icon in the row `cell` is in. - fn state_icon(cell: &GridCell) -> gtk::Image { - let row = cell.parent().and_then(|cell| cell.parent()).expect("the row widget"); - first_descendant_of_type::(&row).expect("the row's state icon") - } - - fn shown(image: >k::Image) -> (Option, Option) { - ( - image.icon_name().map(|name| name.to_string()), - image.tooltip_text().map(|text| text.to_string()), - ) - } - - fn state(icon: &str, text: &str) -> (Option, Option) { - (Some(icon.to_owned()), Some(text.to_owned())) - } - - #[gtk4::test] - fn row_state_column_reflects_each_state() { - let grid = two_text_rows(); - let first = state_icon(&grid.cell_of(1, 1)); - let first_row = grid.cell_of(1, 1).row().expect("a bound row"); - assert_eq!(shown(&first), (None, None)); - assert!(gtk::test_accessible_has_role(&first, gtk::AccessibleRole::Presentation)); - assert!( - !first.parent().is_some_and(|cell| cell.is_focusable()), - "the state cell took the focus a data cell should" - ); - - grid.redraw(&grid.tracker.apply(TrackerEdit::Cell { - row_key: key(1), - col: 1, - original: Value::Text("A".into()), - value: Value::Text("edited".into()), - })); - assert_eq!(shown(&first), state("document-edit-symbolic", "Modified")); - - let generation = grid.tracker.flash_error_row(key(1)).generation; - first_row.bump_revision(); - assert_eq!(shown(&first), state("dialog-error-symbolic", "Not saved")); - assert!(grid.tracker.clear_error_row_if(generation)); - first_row.bump_revision(); - assert_eq!(shown(&first), state("document-edit-symbolic", "Modified")); - - grid.redraw(&grid.tracker.apply(TrackerEdit::DeleteRows { - persisted: vec![(key(2), vec![Value::Int(2), Value::Text("B".into())].into())], - drafts: Vec::new(), - })); - assert_eq!( - shown(&state_icon(&grid.cell_of(2, 1))), - state("edit-delete-symbolic", "Marked for deletion") - ); - - grid.insert_draft(); - let mut draft = None; - wait_until(WAIT, || { - draft = grid - .cells() - .into_iter() - .find(|cell| cell.row().is_some_and(|row| row.draft_id().is_some())); - draft.is_some() - }) - .expect("the draft is drawn"); - let draft = draft.expect("the draft's cell"); - assert_eq!(shown(&state_icon(&draft)), state("list-add-symbolic", "New row")); - - grid.redraw(&grid.tracker.discard()); - assert_eq!(shown(&state_icon(&grid.cell_of(1, 1))), (None, None)); - } - - /// The second row, since the first holds the focus, and a focused - /// row taken out of the list trips GTK 4.22. - #[gtk4::test] - fn an_unbound_state_icon_stops_following_its_row() { - let grid = two_text_rows(); - let icon = state_icon(&grid.cell_of(2, 1)); - let row: RowObject = grid.cell_of(2, 1).row().expect("a bound row"); - - grid.store.remove(1); - crate::test_support::drain_main_context(); - let _ = grid.tracker.apply(TrackerEdit::Cell { - row_key: key(2), - col: 1, - original: Value::Text("B".into()), - value: Value::Text("edited".into()), - }); - row.bump_revision(); - - assert_eq!(icon.icon_name(), None, "a recycled icon drew a row it no longer shows"); - } - - #[gtk4::test] - fn row_state_column_is_excluded_from_widths_and_sorting() { - let runtime = crate::test_support::test_runtime(); - let root = tempfile::tempdir().expect("tempdir"); - let widths = ColumnWidthBinding { - store: crate::persistence::ColumnWidthStore::load(root.path().join("column-widths.json"), &runtime.tasks()), - connection_id: uuid::Uuid::new_v4(), - schema: None, - table: "t".to_owned(), - }; - let columns = vec![named("id", "integer", true), named("name", "text", false)]; - let rows = vec![vec![Value::Int(1), Value::Text("A".into())]]; - let (sink, messages) = recorder(); - let tracker = TabTrackerHandle::default(); - let (view, _selection) = build_column_view( - content(&columns, persisted(&rows)), - &columns, - sink, - GridSorting::Server { initial: None }, - crate::ui::grid::TabGridContext { - column_widths: Some(widths.clone()), - ..tracked(&tracker) - }, - ); - let state = view - .columns() - .item(0) - .and_downcast::() - .expect("the state column"); - let data = data_columns(&view); - - assert_eq!( - state.id().map(|id| id.to_string()).as_deref(), - Some(ROW_STATE_COLUMN_ID) - ); - assert!(!state.is_resizable()); - assert!(state.sorter().is_none()); - assert_eq!(state.fixed_width(), 28); - assert_eq!(state.title(), None); - let titles: Vec = data - .iter() - .filter_map(|column| column.title().map(|title| title.to_string())) - .collect(); - assert_eq!(titles, ["id", "name"]); - - state.set_fixed_width(60); - data[1].set_fixed_width(200); - assert_eq!(widths.width(ROW_STATE_COLUMN_ID), None); - assert_eq!(widths.width("name"), Some(200)); - - view.sort_by_column(Some(&data[0]), gtk::SortType::Descending); - assert!( - messages - .borrow() - .iter() - .any(|msg| matches!(msg, GridMsg::SortChanged(0, false))), - "a sort by the first data column named another" - ); - } - - #[gtk4::test] - fn a_grid_without_edits_has_no_state_column() { - let columns = vec![named("id", "integer", true)]; - let (sink, _messages) = recorder(); - let (view, _selection) = build_column_view( - content(&columns, persisted(&[vec![Value::Int(1)]])), - &columns, - sink, - GridSorting::Client, - crate::ui::grid::TabGridContext::default(), - ); - - assert_eq!(view.columns().n_items(), 1); - assert_eq!(data_columns(&view).len(), 1); - } -} diff --git a/linux/crates/app/src/ui/grid/row_state_icon.rs b/linux/crates/app/src/ui/grid/row_state_icon.rs deleted file mode 100644 index ca991d8a95..0000000000 --- a/linux/crates/app/src/ui/grid/row_state_icon.rs +++ /dev/null @@ -1,90 +0,0 @@ -use super::cell_accessibility::pending_state_text; -use crate::services::change_tracker::RowState; -use crate::ui::icons; - -/// A row's pending change, as the grid shows it. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum RowPendingState { - Unchanged, - Modified, - Inserted, - MarkedForDeletion, - /// The row produced the statement a save just failed on. - SaveFailed, -} - -impl RowPendingState { - pub fn new(state: RowState, save_failed: bool) -> Self { - if save_failed { - return Self::SaveFailed; - } - match state { - RowState::Clean => Self::Unchanged, - RowState::Modified => Self::Modified, - RowState::InsertDraft => Self::Inserted, - RowState::PendingDelete => Self::MarkedForDeletion, - } - } -} - -/// The icon and its tooltip, so the state reads without its tint. -pub fn row_state_icon(state: RowPendingState) -> Option<(&'static str, String)> { - let icon = match state { - RowPendingState::Unchanged => return None, - RowPendingState::Modified => icons::DOCUMENT_EDIT, - RowPendingState::Inserted => icons::LIST_ADD, - RowPendingState::MarkedForDeletion => icons::EDIT_DELETE, - RowPendingState::SaveFailed => icons::DIALOG_ERROR, - }; - Some((icon, pending_state_text(state)?)) -} - -#[cfg(test)] -mod tests { - use std::collections::HashSet; - - use super::{RowPendingState, row_state_icon}; - use crate::services::change_tracker::RowState; - - const PENDING: [RowPendingState; 4] = [ - RowPendingState::Modified, - RowPendingState::Inserted, - RowPendingState::MarkedForDeletion, - RowPendingState::SaveFailed, - ]; - - #[test] - fn row_state_icons_are_distinct_and_described() { - let shown: Vec<(&str, String)> = PENDING.into_iter().filter_map(row_state_icon).collect(); - - assert_eq!( - shown, - [ - ("document-edit-symbolic", "Modified".to_owned()), - ("list-add-symbolic", "New row".to_owned()), - ("edit-delete-symbolic", "Marked for deletion".to_owned()), - ("dialog-error-symbolic", "Not saved".to_owned()), - ] - ); - let icons: HashSet<&str> = shown.iter().map(|(icon, _)| *icon).collect(); - assert_eq!(icons.len(), PENDING.len()); - assert_eq!(row_state_icon(RowPendingState::Unchanged), None); - } - - #[test] - fn a_failed_save_outranks_the_rows_change() { - assert_eq!( - RowPendingState::new(RowState::Modified, true), - RowPendingState::SaveFailed - ); - assert_eq!( - RowPendingState::new(RowState::InsertDraft, false), - RowPendingState::Inserted - ); - assert_eq!( - RowPendingState::new(RowState::PendingDelete, false), - RowPendingState::MarkedForDeletion - ); - assert_eq!(RowPendingState::new(RowState::Clean, false), RowPendingState::Unchanged); - } -} diff --git a/linux/crates/app/src/ui/grid/row_watches.rs b/linux/crates/app/src/ui/grid/row_watches.rs deleted file mode 100644 index 4bbf75bceb..0000000000 --- a/linux/crates/app/src/ui/grid/row_watches.rs +++ /dev/null @@ -1,90 +0,0 @@ -use std::cell::RefCell; -use std::collections::HashMap; - -use gtk4::glib; -use gtk4::prelude::*; - -use super::RowObject; - -struct RowWatch { - row: RowObject, - handler: glib::SignalHandlerId, -} - -/// The rows a factory's bound items show. An item is redrawn each time -/// its row's revision moves, until it is unbound. -pub(super) struct RowWatches { - watches: RefCell>, -} - -impl Default for RowWatches { - fn default() -> Self { - Self { - watches: RefCell::default(), - } - } -} - -impl RowWatches -where - W: IsA, -{ - /// Show `row` on `item` now and after each of the row's changes. - pub fn bind(&self, item: &W, row: RowObject, show: impl Fn(&W, &RowObject) + 'static) { - show(item, &row); - // The row outlives the item it is bound to, so the handler holds - // the item weakly. - let weak = item.downgrade(); - let handler = row.connect_revision_notify(move |row| { - if let Some(item) = weak.upgrade() { - show(&item, row); - } - }); - if let Some(stale) = self - .watches - .borrow_mut() - .insert(item.clone(), RowWatch { row, handler }) - { - stale.row.disconnect(stale.handler); - } - } - - pub fn unbind(&self, item: &W) { - // `W` also borrows as a plain object, so the key type is named. - if let Some(watch) = self.watches.borrow_mut().remove::(item) { - watch.row.disconnect(watch.handler); - } - } -} - -#[cfg(test)] -mod tests { - use std::cell::RefCell; - use std::rc::Rc; - - use gtk4 as gtk; - - use super::RowWatches; - use crate::ui::grid::RowObject; - - #[gtk4::test] - fn an_item_follows_only_the_row_it_is_bound_to() { - let watches = RowWatches::default(); - let item = gtk::Label::new(None); - let (first, second) = (RowObject::draft(1), RowObject::draft(2)); - let shown = Rc::new(RefCell::new(Vec::new())); - let record = || { - let shown = shown.clone(); - move |_: >k::Label, row: &RowObject| shown.borrow_mut().push(row.draft_id()) - }; - - watches.bind(&item, first.clone(), record()); - watches.bind(&item, second.clone(), record()); - first.bump_revision(); - second.bump_revision(); - watches.unbind(&item); - second.bump_revision(); - - assert_eq!(*shown.borrow(), [Some(1), Some(2), Some(2)]); - } -} diff --git a/linux/crates/app/src/ui/grid/selection.rs b/linux/crates/app/src/ui/grid/selection.rs deleted file mode 100644 index d3fb70b014..0000000000 --- a/linux/crates/app/src/ui/grid/selection.rs +++ /dev/null @@ -1,47 +0,0 @@ -use gtk4 as gtk; -use gtk4::prelude::*; - -use super::{RowObject, RowSnapshot, TabGridContext}; - -pub(crate) fn selected_positions(selection: >k::MultiSelection) -> Vec { - let bitset = selection.selection(); - let mut out = Vec::with_capacity(bitset.size() as usize); - for i in 0..bitset.size() { - out.push(bitset.nth(i as u32)); - } - out.sort_unstable(); - out -} - -pub(super) fn row_at(column_view: >k::ColumnView, position: u32) -> Option { - column_view.model()?.item(position)?.downcast::().ok() -} - -pub(super) fn selection_of(column_view: >k::ColumnView) -> Option { - column_view.model()?.downcast::().ok() -} - -/// A right-click on a selected row keeps the selection, so a block -/// still copies whole; any other row becomes the selection. That keeps -/// Copy and Delete in one menu pointing at the same rows. -pub(super) fn select_row_for_menu(column_view: >k::ColumnView, position: u32) { - let Some(selection) = selection_of(column_view) else { - return; - }; - if position >= selection.n_items() || selection.is_selected(position) { - return; - } - selection.select_item(position, true); -} - -/// The paginator's Export button and the menu's Export both read the -/// rows here, so one label cannot mean two files. -pub(crate) fn export_snapshot(column_view: >k::ColumnView, ctx: &TabGridContext) -> Vec { - let Some(model) = column_view.model() else { - return Vec::new(); - }; - (0..model.n_items()) - .filter_map(|position| row_at(column_view, position)) - .map(|row| ctx.snapshot(&row)) - .collect() -} diff --git a/linux/crates/app/src/ui/grid/tab_grid_context.rs b/linux/crates/app/src/ui/grid/tab_grid_context.rs deleted file mode 100644 index 9b5b1d4b08..0000000000 --- a/linux/crates/app/src/ui/grid/tab_grid_context.rs +++ /dev/null @@ -1,259 +0,0 @@ -use std::sync::Arc; - -use tablepro_core::Value; -use tablepro_core::edit::CellInput; -use tablepro_core::meta::RowIdentity; - -use super::row_state_icon::RowPendingState; -use super::{ColumnWidthBinding, GridRowId, KeysHandle, RowObject, RowSnapshot}; -use crate::services::cell_overlay::CellMarks; -use crate::services::change_tracker::{CellState, RowKey}; -use crate::services::tab_tracker_handle::TabTrackerHandle; - -/// `tracker`, `column_widths` and `keys` are `None` for a grid nothing -/// tracks, such as editor results. -#[derive(Debug, Clone)] -pub struct TabGridContext { - pub tracker: Option, - /// Whether the grid offers edits at all. A read-only session's grid - /// does not. - pub editable: bool, - /// What names the grid's rows, which decides the columns a saved - /// row cannot change. - pub identity: RowIdentity, - pub column_widths: Option, - /// Whether the menu offers to copy rows as an SQL `IN` list, which an - /// engine without SQL has no spelling for. - pub in_clause: bool, - /// The foreign keys of the table the rows come from, which the cell - /// menu follows. - pub keys: Option, -} - -impl Default for TabGridContext { - fn default() -> Self { - Self { - tracker: None, - editable: false, - identity: RowIdentity::Unordered, - column_widths: None, - in_clause: true, - keys: None, - } - } -} - -impl TabGridContext { - /// `None` when nothing tracks the grid or the row has no key. - fn tracked_key(&self, row: &RowObject) -> Option<(&TabTrackerHandle, RowKey)> { - Some((self.tracker.as_ref()?, row.id().tracker_key()?)) - } - - /// `Unchanged` for a row nothing tracks. - pub(super) fn row_pending_state(&self, row: &RowObject) -> RowPendingState { - self.tracked_key(row) - .map_or(RowPendingState::Unchanged, |(tracker, key)| { - RowPendingState::new(tracker.row_state(&key), tracker.is_error_row(&key)) - }) - } - - /// Whether the grid's rows can carry pending changes, which is when - /// it gives them a state column. - pub(super) fn tracks_changes(&self) -> bool { - self.editable && self.tracker.is_some() - } - - /// The row as the grid shows it, for every copy and export path: - /// `RowObject::cells` holds a saved row as read and a draft not at - /// all. A draft's cell left to the server reads as NULL. - pub(crate) fn snapshot(&self, row: &RowObject) -> RowSnapshot { - if let (GridRowId::Draft(draft_id), Some(tracker)) = (row.id(), self.tracker.as_ref()) { - return RowSnapshot::new(tracker.draft_values(*draft_id).unwrap_or_default().into(), Vec::new()); - } - let cells = row.cells().unwrap_or_else(|| Arc::from([])); - let overrides = self - .tracked_key(row) - .map(|(tracker, key)| tracker.pending_values(&key, cells.len())) - .unwrap_or_default(); - RowSnapshot::new(cells, overrides) - } - - /// Like `snapshot`, except a draft's cell left to the server stays - /// `Default` rather than reading as NULL. - pub(crate) fn effective_inputs(&self, row: &RowObject) -> Vec { - if let (GridRowId::Draft(draft_id), Some(tracker)) = (row.id(), self.tracker.as_ref()) { - return tracker.draft_inputs(*draft_id).unwrap_or_default(); - } - self.snapshot(row) - .to_values() - .into_iter() - .map(CellInput::Value) - .collect() - } - - /// What an editor opens on: a draft's cell as the tracker holds it, - /// a saved row's pending value, else the value as it was read. - pub(crate) fn edit_input(&self, row: &RowObject, col: usize) -> CellInput { - self.with_effective_value(row, col, |value, default| match default { - true => CellInput::Default, - false => CellInput::Value(value.clone()), - }) - } - - /// Calls `f` with the value the cell shows, borrowed from the row - /// unless the tracker holds a pending one. `default` is set for a - /// draft's cell left to the server, whose value then reads as NULL. - pub(crate) fn with_effective_value(&self, row: &RowObject, col: usize, f: impl FnOnce(&Value, bool) -> R) -> R { - let pending = self - .tracked_key(row) - .and_then(|(tracker, key)| tracker.cell_overlay(&key, col).pending); - match (pending, row.cell(col)) { - (Some(CellInput::Value(value)), _) => f(&value, false), - (Some(CellInput::Default), _) => f(&Value::Null, true), - (None, Some(value)) => f(value, false), - (None, None) => f(&Value::Null, false), - } - } - - /// The classes that tint the cell for its row's pending change, and - /// the cell's own state. - pub(super) fn pending_marks(&self, row: &RowObject, col: usize) -> (Vec<&'static str>, CellState) { - let Some((tracker, key)) = self.tracked_key(row) else { - return (Vec::new(), CellState::Clean); - }; - let marks = tracker.cell_marks(&key, col); - (pending_classes(&marks, col), marks.state) - } -} - -fn pending_classes(marks: &CellMarks, col: usize) -> Vec<&'static str> { - let mut classes = Vec::new(); - match (marks.row_deleted, marks.state) { - (true, _) => classes.push("tp-row-pending-delete"), - (false, CellState::InsertDraft) => classes.push("tp-row-pending-insert"), - (false, CellState::Modified) => classes.push("tp-cell-modified"), - (false, CellState::Clean) => {} - } - if col == 0 && marks.error_flash { - classes.push("tp-row-leftmost-error-flash"); - } - classes -} - -#[cfg(test)] -mod tests { - use tablepro_core::Value; - use tablepro_core::edit::CellInput; - - use super::{RowObject, TabGridContext}; - use crate::services::change_tracker::RowKey; - use crate::services::tab_tracker_handle::TabTrackerHandle; - use crate::services::tracker_edit::TrackerEdit; - - fn context(tracker: &TabTrackerHandle) -> TabGridContext { - TabGridContext { - tracker: Some(tracker.clone()), - ..TabGridContext::default() - } - } - - fn insert_draft(tracker: &TabTrackerHandle, values: Vec) -> u64 { - let delta = tracker.apply(TrackerEdit::InsertDrafts { - rows: vec![values.into_iter().map(CellInput::Value).collect()], - }); - let Some(draft_id) = delta.inserted_drafts.into_iter().next() else { - panic!("an insert adds a draft row"); - }; - draft_id - } - - #[test] - fn a_tracked_draft_carries_its_insert_tint_and_error_flash() { - let tracker = TabTrackerHandle::default(); - let draft_id = insert_draft(&tracker, vec![Value::Int(4)]); - let _ = tracker.flash_error_row(RowKey::Draft(draft_id)); - let draft = RowObject::draft(draft_id); - - let (classes, _) = context(&tracker).pending_marks(&draft, 0); - - assert_eq!( - context(&tracker).edit_input(&draft, 0), - CellInput::Value(Value::Int(4)), - "a draft's cells are read from the tracker" - ); - assert_eq!(classes, vec!["tp-row-pending-insert", "tp-row-leftmost-error-flash"]); - } - - #[test] - fn an_untracked_grid_draws_the_fetched_value_plainly() { - let row = RowObject::result(vec![Value::Int(1)].into()); - - let (classes, _) = TabGridContext::default().pending_marks(&row, 0); - - assert_eq!( - TabGridContext::default().edit_input(&row, 0), - CellInput::Value(Value::Int(1)) - ); - assert!(classes.is_empty()); - } - - #[test] - fn a_clean_cell_is_drawn_from_the_row_itself() { - let tracker = TabTrackerHandle::default(); - let key = RowKey::from_pk_values(&[Value::Int(1)]).expect("a key"); - let _ = tracker.apply(TrackerEdit::Cell { - row_key: key.clone(), - col: 1, - original: Value::Text("old".into()), - value: Value::Text("new".into()), - }); - let row = RowObject::persisted(Some(key), vec![Value::Int(1), Value::Text("old".into())].into()); - let cells = row.cells().expect("a saved row's cells"); - - let borrowed = context(&tracker).with_effective_value(&row, 0, |value, _| std::ptr::eq(value, &cells[0])); - let pending = context(&tracker).with_effective_value(&row, 1, |value, default| (value.clone(), default)); - - assert!(borrowed, "an unedited cell was copied out of its row"); - assert_eq!(pending, (Value::Text("new".into()), false)); - } - - #[test] - fn snapshots_include_pending_edits_and_draft_cells() { - let tracker = TabTrackerHandle::default(); - let key = RowKey::from_pk_values(&[Value::Int(1)]).expect("a key"); - let _ = tracker.apply(TrackerEdit::Cell { - row_key: key.clone(), - col: 1, - original: Value::Text("old".into()), - value: Value::Text("new".into()), - }); - let draft_id = insert_draft(&tracker, vec![Value::Null, Value::Text("draft".into())]); - let saved = RowObject::persisted(Some(key), vec![Value::Int(1), Value::Text("old".into())].into()); - - assert_eq!( - context(&tracker).snapshot(&saved).to_values(), - vec![Value::Int(1), Value::Text("new".into())] - ); - assert_eq!( - context(&tracker).snapshot(&RowObject::draft(draft_id)).to_values(), - vec![Value::Null, Value::Text("draft".into())] - ); - } - - #[test] - fn a_draft_cell_left_to_the_server_opens_as_the_default() { - let tracker = TabTrackerHandle::default(); - let delta = tracker.apply(TrackerEdit::InsertDrafts { - rows: vec![vec![CellInput::Default, CellInput::Value(Value::Null)]], - }); - let draft = RowObject::draft(delta.inserted_drafts[0]); - - assert_eq!(context(&tracker).edit_input(&draft, 0), CellInput::Default); - assert_eq!(context(&tracker).edit_input(&draft, 1), CellInput::Value(Value::Null)); - assert_eq!( - context(&tracker).snapshot(&draft).to_values(), - vec![Value::Null, Value::Null], - "a copy has no value to give a default" - ); - } -} diff --git a/linux/crates/app/src/ui/grid/test_grid.rs b/linux/crates/app/src/ui/grid/test_grid.rs deleted file mode 100644 index 03cbd52b5d..0000000000 --- a/linux/crates/app/src/ui/grid/test_grid.rs +++ /dev/null @@ -1,337 +0,0 @@ -use std::cell::RefCell; -use std::rc::Rc; -use std::time::Duration; - -use gtk4::prelude::*; -use gtk4::{self as gtk, gio}; - -use tablepro_core::edit::CellInput; -use tablepro_core::meta::RowIdentity; -use tablepro_core::{ColumnInfo, Value}; - -use super::{ - ColumnWidthBinding, GridCell, GridContent, GridMsg, GridSink, GridSorting, KeysHandle, RowObject, TabGridContext, - TableKeys, begin_edit, build_column_view, data_columns, -}; -use crate::services::change_tracker::RowKey; -use crate::services::tab_tracker_handle::TabTrackerHandle; -use crate::services::tracker_delta::TrackerDelta; -use crate::services::tracker_edit::TrackerEdit; -use crate::test_support::{descendants, drain_main_context, wait_until}; - -pub(super) const WAIT: Duration = Duration::from_secs(5); - -#[expect( - clippy::disallowed_methods, - reason = "a test fixture stands in for the catalogue, which is where this text comes from in production" -)] -pub(super) fn col(data_type: &str, primary_key: bool) -> ColumnInfo { - let kind = tablepro_core::column::classify_type_name(data_type); - ColumnInfo { - name: "x".into(), - column_type: tablepro_core::column::ColumnType { - name: data_type.to_owned(), - dynamic_storage: tablepro_core::column::has_dynamic_storage(&kind), - kind, - catalog: Some(tablepro_core::column::CatalogType { - declaration: tablepro_core::column::SqlTypeExpr::from_catalog_text(data_type), - cast_target: tablepro_core::column::SqlTypeExpr::from_catalog_text(data_type), - }), - read_form: tablepro_core::column::ReadForm::Native, - }, - nullable: true, - primary_key, - is_auto_increment: false, - is_generated: false, - partition_scoped: false, - default: tablepro_core::column::ColumnDefault::None, - comment: None, - } -} - -pub(super) fn named(name: &str, data_type: &str, primary_key: bool) -> ColumnInfo { - let mut column = col(data_type, primary_key); - column.name = name.to_owned(); - column -} - -pub(super) fn key(id: i64) -> RowKey { - RowKey::from_pk_values(&[Value::Int(id)]).expect("a key") -} - -pub(super) fn keyed_by_id() -> RowIdentity { - RowIdentity::UniqueKey { - columns: vec!["id".to_owned()], - } -} - -pub(super) fn tracked(tracker: &TabTrackerHandle) -> TabGridContext { - TabGridContext { - tracker: Some(tracker.clone()), - editable: true, - identity: keyed_by_id(), - column_widths: None, - in_clause: true, - keys: None, - } -} - -/// Persisted rows keyed by their first cell, as a browse page is. -pub(super) fn persisted(rows: &[Vec]) -> Vec { - rows.iter() - .map(|cells| { - let id = cells.first().cloned().unwrap_or(Value::Null); - RowObject::persisted(RowKey::from_pk_values(&[id]), cells.clone().into()) - }) - .collect() -} - -/// `rows` under the result columns of `columns`. -pub(super) fn content(columns: &[ColumnInfo], rows: Vec) -> GridContent { - GridContent { - columns: columns.iter().map(ColumnInfo::result_column).collect(), - rows, - } -} - -pub(super) fn recorder() -> (GridSink, Rc>>) { - let messages = Rc::new(RefCell::new(Vec::new())); - let log = messages.clone(); - (Rc::new(move |msg| log.borrow_mut().push(msg)), messages) -} - -pub(super) struct Grid { - pub(super) window: gtk::Window, - pub(super) view: gtk::ColumnView, - pub(super) store: gio::ListStore, - pub(super) tracker: TabTrackerHandle, - messages: Rc>>, - width: usize, -} - -impl Grid { - /// `rows` keyed by their first cell, in a grid that edits them. - pub(super) fn open(columns: Vec, rows: Vec>) -> Self { - Self::open_in(columns, rows, None, None) - } - - /// As `open`, with the column widths saved through `widths`. - pub(super) fn open_with_widths( - columns: Vec, - rows: Vec>, - widths: ColumnWidthBinding, - ) -> Self { - Self::open_in(columns, rows, Some(widths), None) - } - - /// As `open`, for a table with `keys`. - pub(super) fn open_with_keys(columns: Vec, rows: Vec>, keys: TableKeys) -> Self { - let handle = KeysHandle::new(keys.table.clone()); - handle.set(keys); - Self::open_in(columns, rows, None, Some(handle)) - } - - fn open_in( - columns: Vec, - rows: Vec>, - widths: Option, - keys: Option, - ) -> Self { - let tracker = TabTrackerHandle::default(); - let (sink, messages) = recorder(); - let (view, selection) = build_column_view( - content(&columns, persisted(&rows)), - &columns, - sink, - GridSorting::Server { initial: None }, - TabGridContext { - column_widths: widths, - keys, - ..tracked(&tracker) - }, - ); - let store = selection - .model() - .and_downcast::() - .expect("a list store"); - let window = gtk::Window::new(); - window.set_default_size(600, 400); - window.set_child(Some(&view)); - window.present(); - let grid = Self { - window, - view, - store, - tracker, - messages, - width: columns.len(), - }; - wait_until(WAIT, || grid.cells().len() == columns.len() * rows.len()).expect("the cells are drawn"); - grid - } - - pub(super) fn cells(&self) -> Vec { - descendants(&self.view) - .into_iter() - .filter_map(|widget| widget.downcast::().ok()) - .filter(|cell| cell.row().is_some()) - .collect() - } - - /// The cell that shows `column` of the row named `id`. - pub(super) fn cell_of(&self, id: i64, column: usize) -> GridCell { - self.cells() - .into_iter() - .find(|cell| cell.column() == column && cell.row().is_some_and(|row| row.key() == Some(&key(id)))) - .expect("the row's cell is drawn") - } - - pub(super) fn insert_draft(&self) { - let delta = self.tracker.apply(TrackerEdit::InsertDrafts { - rows: vec![vec![CellInput::Value(Value::Null); self.width]], - }); - let Some(draft_id) = delta.inserted_drafts.into_iter().next() else { - panic!("an insert adds a draft row"); - }; - self.store.insert(0, &RowObject::draft(draft_id)); - drain_main_context(); - } - - /// The view's `index`th column of the result, past the state column. - pub(super) fn column(&self, index: u32) -> gtk::ColumnViewColumn { - let index = usize::try_from(index).expect("a column index"); - data_columns(&self.view).into_iter().nth(index).expect("a column") - } - - pub(super) fn focused_cell(&self) -> Option { - let focus = gtk::prelude::GtkWindowExt::focus(&self.window)?; - GridCell::from_focus(&focus) - } - - /// Focus the cell of the `column`th shown column at `position`, as - /// the arrow keys do. `scroll_to` moves the focus only within a - /// focused list, so the view's grab carries it there. The view shows - /// the columns in result order. - pub(super) fn focus(&self, position: u32, column: u32) -> GridCell { - self.view - .scroll_to(position, Some(&self.column(column)), gtk::ListScrollFlags::FOCUS, None); - self.view.grab_focus(); - let mut focused = None; - wait_until(WAIT, || { - focused = self - .focused_cell() - .filter(|cell| cell.position() == Some(position) && u32::try_from(cell.column()) == Ok(column)); - focused.is_some() - }) - .expect("the cell takes the focus"); - focused.expect("a focused cell") - } - - pub(super) fn take_messages(&self) -> Vec { - self.messages.take() - } - - /// What the tab does with a tracker change. - pub(super) fn redraw(&self, delta: &TrackerDelta) { - for position in 0..self.store.n_items() { - if let Some(row) = self.store.item(position).and_downcast::() - && row.id().tracker_key().is_some_and(|key| delta.changed.contains(&key)) - { - row.bump_revision(); - } - } - } -} - -impl Drop for Grid { - fn drop(&mut self) { - self.window.destroy(); - drain_main_context(); - } -} - -/// Saved rows keyed `ids`, as a page read from the table is. -pub(super) fn page(ids: std::ops::Range) -> Vec { - let rows: Vec> = ids - .map(|id| vec![Value::Int(id), Value::Text(format!("row {id}"))]) - .collect(); - persisted(&rows) -} - -/// Open the cell's menu as a right click does. -pub(super) fn open_menu(cell: &GridCell) { - let controllers = cell.observe_controllers(); - let gesture = (0..controllers.n_items()) - .filter_map(|index| controllers.item(index).and_downcast::()) - .find(|gesture| gesture.button() == 3) - .expect("a cell answers a right-click"); - gesture.emit_by_name::<()>("pressed", &[&1i32, &1.0f64, &1.0f64]); -} - -/// Whether the window's focus is the grid or inside it. -pub(super) fn focus_in_grid(grid: &Grid) -> bool { - gtk::prelude::GtkWindowExt::focus(&grid.window) - .is_some_and(|focus| focus == *grid.view.upcast_ref::() || focus.is_ancestor(&grid.view)) -} - -pub(super) fn two_text_rows() -> Grid { - Grid::open(two_text_columns(), two_text_values()) -} - -pub(super) fn two_text_columns() -> Vec { - vec![named("id", "integer", true), named("name", "text", false)] -} - -pub(super) fn two_text_values() -> Vec> { - vec![ - vec![Value::Int(1), Value::Text("A".into())], - vec![Value::Int(2), Value::Text("B".into())], - ] -} - -/// Open the cell's one-line editor and type `text` over its value. -pub(super) fn type_into(cell: &GridCell, text: &str) -> gtk::Text { - begin_edit(cell); - let editor = cell.inline_text().expect("the cell did not open for editing"); - editor.set_text(text); - editor -} - -pub(super) fn display_text(cell: &GridCell) -> String { - cell.display() - .and_then(|display| display.text()) - .map(|text| text.to_string()) - .unwrap_or_default() -} - -/// Each edit the grid sent, as the row, the column and the value. -pub(super) fn edited_rows(messages: &[GridMsg]) -> Vec<(Option, usize, CellInput)> { - messages - .iter() - .filter_map(|msg| match msg { - GridMsg::Edit { row, column, edit } => { - let input = match edit { - TrackerEdit::Cell { value, .. } => CellInput::Value(value.clone()), - TrackerEdit::DraftCell { input, .. } => input.clone(), - other => panic!("a cell commit made {other:?}"), - }; - Some((row.key().cloned(), *column, input)) - } - _ => None, - }) - .collect() -} - -pub(super) fn text(value: &str) -> CellInput { - CellInput::Value(Value::Text(value.to_owned())) -} - -pub(super) fn flag_rows() -> Grid { - Grid::open( - vec![named("id", "integer", true), named("flag", "boolean", false)], - vec![ - vec![Value::Int(1), Value::Bool(false)], - vec![Value::Int(2), Value::Bool(false)], - ], - ) -} diff --git a/linux/crates/app/src/ui/grid/toggle_cell.rs b/linux/crates/app/src/ui/grid/toggle_cell.rs deleted file mode 100644 index 6a975ddb3e..0000000000 --- a/linux/crates/app/src/ui/grid/toggle_cell.rs +++ /dev/null @@ -1,153 +0,0 @@ -use gtk4::prelude::*; -use gtk4::{self as gtk}; -use tablepro_core::Value; -use tablepro_core::column::{BoolStorage, ColumnKind, ColumnType}; -use tablepro_core::edit::CellInput; - -use super::display_text::display_text; -use super::edit_commit::{commit_input, send_commit}; -use super::{EditSnapshot, GridCell}; - -pub struct ToggleCell; - -/// How a check button shows a cell's value. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ToggleState { - pub active: bool, - /// A value that is neither true nor false shows as neither, with the - /// value itself in the tooltip, rather than as a confidently wrong - /// box. - pub inconsistent: bool, - pub tooltip: Option, -} - -impl ToggleCell { - /// `column` names the check box, as written in the table, since a - /// box with no label reads as nothing but its state. - pub(super) fn setup(cell: &GridCell, column: &str) { - let check = gtk::CheckButton::builder() - .halign(gtk::Align::Start) - .valign(gtk::Align::Center) - .margin_start(8) - .margin_end(8) - // The cell takes the focus, and toggles on F2, Return or Space, - // so Tab leaves the grid rather than stopping inside the cell. - .focusable(false) - .focus_on_click(false) - .build(); - check.update_property(&[gtk::accessible::Property::Label(column)]); - cell.set_content(&check); - // The cell owns the check button, so the handler finds the cell - // through its parent rather than holding it. - check.connect_toggled(|check| { - let Some(cell) = check.parent().and_downcast::() else { - return; - }; - if !cell.toggle_suppressed() && cell.lock().is_none() { - commit_toggle(&cell, check.is_active()); - } - }); - } - - pub(super) fn draw(cell: &GridCell, check: >k::CheckButton, state: &ToggleState) { - // Suppressed, so drawing the value does not echo back as an edit. - cell.suppress_toggle(true); - check.set_inconsistent(state.inconsistent); - check.set_active(state.active); - cell.suppress_toggle(false); - check.set_tooltip_text(state.tooltip.as_deref()); - } -} - -/// `default` is a draft's cell left to the server. -pub fn toggle_state(value: &Value, default: bool, column: &ColumnType, storage: BoolStorage) -> ToggleState { - let neither = |tooltip: &str| ToggleState { - active: false, - inconsistent: true, - tooltip: Some(tooltip.to_owned()), - }; - let shown = |active| ToggleState { - active, - inconsistent: false, - tooltip: None, - }; - match (default, value, storage) { - (true, ..) => neither("DEFAULT"), - (false, Value::Bool(flag), _) => shown(*flag), - (false, Value::Int(0), BoolStorage::Integer) => shown(false), - (false, Value::Int(1), BoolStorage::Integer) => shown(true), - (false, other, _) => neither(display_text(other, column, &mut String::new()).text), - } -} - -/// `tinyint(1)` keeps any integer, so a toggle writes 0 or 1 as one. -pub fn toggled_value(storage: BoolStorage, active: bool) -> Value { - match storage { - BoolStorage::Native => Value::Bool(active), - BoolStorage::Integer => Value::Int(i64::from(active)), - } -} - -fn commit_toggle(cell: &GridCell, active: bool) { - let (Some(row), Some(drawing)) = (cell.row(), cell.drawing()) else { - return; - }; - let Some(column) = drawing.catalog.as_ref() else { - return; - }; - let ColumnKind::Bool(storage) = column.column_type.kind else { - return; - }; - let snapshot = EditSnapshot { - original: drawing.tab_ctx.edit_input(&row, cell.column()), - row, - column: cell.column(), - }; - let result = commit_input(column, &snapshot, CellInput::Value(toggled_value(storage, active))); - send_commit(cell, snapshot, result); -} - -#[cfg(test)] -mod tests { - use tablepro_core::Value; - use tablepro_core::column::{BoolStorage, ColumnType}; - - use super::{ToggleState, toggle_state, toggled_value}; - - fn state(active: bool, inconsistent: bool, tooltip: Option<&str>) -> ToggleState { - ToggleState { - active, - inconsistent, - tooltip: tooltip.map(str::to_owned), - } - } - - #[test] - fn a_toggle_shows_only_true_and_false_as_checked_or_not() { - let column = ColumnType::untyped_text("boolean"); - let value = |value: Value, storage| toggle_state(&value, false, &column, storage); - - assert_eq!(value(Value::Bool(true), BoolStorage::Native), state(true, false, None)); - assert_eq!(value(Value::Int(1), BoolStorage::Integer), state(true, false, None)); - assert_eq!(value(Value::Int(0), BoolStorage::Integer), state(false, false, None)); - assert_eq!( - value(Value::Int(2), BoolStorage::Integer), - state(false, true, Some("2")) - ); - assert_eq!( - value(Value::Null, BoolStorage::Native), - state(false, true, Some("NULL")) - ); - assert_eq!( - toggle_state(&Value::Null, true, &column, BoolStorage::Integer), - state(false, true, Some("DEFAULT")) - ); - } - - #[test] - fn a_toggle_writes_the_columns_own_form() { - assert_eq!(toggled_value(BoolStorage::Native, true), Value::Bool(true)); - assert_eq!(toggled_value(BoolStorage::Integer, true), Value::Int(1)); - assert_eq!(toggled_value(BoolStorage::Integer, false), Value::Int(0)); - } -} diff --git a/linux/crates/app/src/ui/icons.rs b/linux/crates/app/src/ui/icons.rs index 27fc82b4fc..04274ea953 100644 --- a/linux/crates/app/src/ui/icons.rs +++ b/linux/crates/app/src/ui/icons.rs @@ -42,6 +42,7 @@ pub const SECURITY_HIGH: &str = "security-high-symbolic"; pub const STARRED: &str = "starred-symbolic"; pub const STATEMENT_PENDING: &str = "statement-pending-symbolic"; pub const SIDEBAR_SHOW: &str = "sidebar-show-symbolic"; +pub const SIDEBAR_SHOW_RIGHT: &str = "sidebar-show-right-symbolic"; /// Not in Adwaita; GtkSourceView installs it, for its own snippet /// completion. pub const SNIPPET: &str = "completion-snippet-symbolic"; @@ -66,6 +67,7 @@ pub const VIEW_SORT_ASCENDING: &str = "view-sort-ascending-symbolic"; pub const WEATHER_OVERCAST: &str = "weather-overcast-symbolic"; pub const WEB_BROWSER: &str = "web-browser-symbolic"; pub const WINDOW_CLOSE: &str = "window-close-symbolic"; +pub const X_OFFICE_CALENDAR: &str = "x-office-calendar-symbolic"; pub const ZOOM_FIT_BEST: &str = "zoom-fit-best-symbolic"; pub const ZOOM_IN: &str = "zoom-in-symbolic"; pub const ZOOM_OUT: &str = "zoom-out-symbolic"; @@ -115,6 +117,7 @@ pub const ALL: &[&str] = &[ PROCESS_STOP, SECURITY_HIGH, SIDEBAR_SHOW, + SIDEBAR_SHOW_RIGHT, SNIPPET, STARRED, STATEMENT_PENDING, @@ -139,6 +142,7 @@ pub const ALL: &[&str] = &[ WEATHER_OVERCAST, WEB_BROWSER, WINDOW_CLOSE, + X_OFFICE_CALENDAR, ZOOM_FIT_BEST, ZOOM_IN, ZOOM_OUT, diff --git a/linux/crates/app/src/ui/key_tab/mod.rs b/linux/crates/app/src/ui/key_tab/mod.rs index eaf4e9452c..c30341e809 100644 --- a/linux/crates/app/src/ui/key_tab/mod.rs +++ b/linux/crates/app/src/ui/key_tab/mod.rs @@ -12,18 +12,19 @@ use std::rc::{Rc, Weak}; use std::sync::Arc; use gtk4 as gtk; +use gtk4::glib; use gtk4::prelude::*; -use gtk4::{gio, glib}; use libadwaita as adw; -use libadwaita::prelude::*; use tablepro_core::column::ResultColumn; use tablepro_core::keyspace::{KeyCursor, KeyPage, KeyWindow}; use tablepro_core::meta::KeyKind; use tablepro_session::runtime::{Tasks, WatchForwarder}; use tablepro_session::{LinkGeneration, OperationError, OperationKind, Session}; +use tablepro_storage::AppSettings; +use crate::services::grid::grid_columns; +use crate::ui::data_grid::{DataGrid, GridConfig, GridRequest, GridSorting, RowData}; use crate::ui::export::{ExportOffer, ExportOrigin, ExportSource}; -use crate::ui::grid::{GridMsg, GridSink, RowObject, build_result_grid, present_row_json}; use crate::ui::tab_load::{ContentLoad, ContentPage, FirstLoad, LoadFailure, LoadPresentation, StaleBanner}; pub(crate) use self::key_summary::key_kind_name; @@ -31,6 +32,8 @@ pub(crate) use self::key_summary::key_kind_name; const LOADING: &str = "loading"; const CONTENT: &str = "content"; const ERROR: &str = "error"; +const ROWS: &str = "rows"; +const UNREADABLE: &str = "unreadable"; /// What a key tab asks of the workspace around it. #[derive(Debug)] @@ -48,6 +51,8 @@ pub(crate) struct KeyTabInit { pub key: String, pub session: Arc, pub tasks: Tasks, + /// Read by the grid's detail pane, for the editor colour scheme. + pub settings: Rc, pub first_load: FirstLoad, } @@ -66,7 +71,11 @@ struct Inner { banner: StaleBanner, type_label: gtk::Label, summary: gtk::Label, - body: adw::Bin, + /// The grid, or the page for a type the tab does not read. + body: gtk::Stack, + unreadable: adw::StatusPage, + /// One grid for the tab's life: a reread keeps its sort. + grid: DataGrid, footer: gtk::Label, more: gtk::Button, state: RefCell, @@ -96,7 +105,6 @@ struct State { struct Shown { kind: KeyKind, columns: Arc<[ResultColumn]>, - store: gio::ListStore, rows: u64, length: Option, next: Option, @@ -122,6 +130,7 @@ impl KeyTab { key, session, tasks, + settings, first_load, } = init; let root = gtk::Box::new(gtk::Orientation::Vertical, 0); @@ -148,7 +157,9 @@ impl KeyTab { retry.add_css_class("pill"); error.set_child(Some(&retry)); stack.add_named(&error, Some(ERROR)); - let (content, parts) = content_page(); + let grid = key_grid(&key, settings, tasks.clone()); + root.insert_action_group("grid", Some(&grid.actions())); + let (content, parts) = content_page(&grid); stack.add_named(&content, Some(CONTENT)); let inner = Rc::new_cyclic(|this: &Weak| { @@ -167,6 +178,8 @@ impl KeyTab { type_label: parts.type_label, summary: parts.summary, body: parts.body, + unreadable: parts.unreadable, + grid, footer: parts.footer, more: parts.more, state: RefCell::new(State::default()), @@ -190,6 +203,12 @@ impl KeyTab { ); inner.link_watch.replace(Some(watch)); let weak = Rc::downgrade(&inner); + inner.grid.connect_request(move |_, request| { + if let Some(inner) = weak.upgrade() { + inner.grid_request(request); + } + }); + let weak = Rc::downgrade(&inner); retry.connect_clicked(move |_| reload(&weak)); let weak = Rc::downgrade(&inner); parts.refresh.connect_clicked(move |_| reload(&weak)); @@ -231,18 +250,42 @@ impl KeyTab { } } +/// A key's entries are read a window at a time, so they sort and search +/// on the client; nothing in them is edited. +fn key_grid(key: &str, settings: Rc, tasks: Tasks) -> DataGrid { + let grid = DataGrid::new(GridConfig { + columns: Arc::from([]), + sorting: GridSorting::Client, + rules: None, + tracker: None, + settings, + layout: None, + references: None, + dialect: None, + table: None, + find: true, + filter_by_value: false, + tasks, + label: key.to_owned(), + }); + grid.set_hexpand(true); + grid.set_vexpand(true); + grid +} + struct ContentParts { type_label: gtk::Label, summary: gtk::Label, refresh: gtk::Button, - body: adw::Bin, + body: gtk::Stack, + unreadable: adw::StatusPage, footer: gtk::Label, more: gtk::Button, } /// The key's type and what it holds over its entries, and how many of /// them the tab holds under them. -fn content_page() -> (gtk::Box, ContentParts) { +fn content_page(grid: &DataGrid) -> (gtk::Box, ContentParts) { let page = gtk::Box::new(gtk::Orientation::Vertical, 0); let header = gtk::Box::builder() .orientation(gtk::Orientation::Horizontal) @@ -263,10 +306,27 @@ fn content_page() -> (gtk::Box, ContentParts) { let refresh = gtk::Button::builder().icon_name(crate::ui::icons::VIEW_REFRESH).build(); refresh.add_css_class("flat"); crate::ui::a11y::describe_icon_button(&refresh, &crate::i18n::gettext("Refresh"), None); + let details = gtk::ToggleButton::builder() + .icon_name(crate::ui::icons::SIDEBAR_SHOW_RIGHT) + .action_name("grid.show-details") + .build(); + details.add_css_class("flat"); + crate::ui::a11y::describe_icon_button( + &details, + &crate::i18n::gettext("Row Details"), + Some("grid.show-details"), + ); header.append(&type_label); header.append(&summary); + header.append(&details); header.append(&refresh); - let body = adw::Bin::builder().vexpand(true).build(); + let body = gtk::Stack::builder().vexpand(true).build(); + body.add_named(grid, Some(ROWS)); + let unreadable = adw::StatusPage::builder() + .icon_name(crate::ui::icons::TEXT_X_GENERIC) + .title(crate::i18n::gettext("No Viewer for This Type")) + .build(); + body.add_named(&unreadable, Some(UNREADABLE)); let footer_bar = gtk::Box::builder() .orientation(gtk::Orientation::Horizontal) .spacing(12) @@ -297,6 +357,7 @@ fn content_page() -> (gtk::Box, ContentParts) { summary, refresh, body, + unreadable, footer, more, }, @@ -462,17 +523,14 @@ impl Inner { presentation } - /// The key's first window, in a grid of its own: another type than - /// the last read has other columns. + /// The key's first window. Another type than the last read has other + /// columns; the same columns keep the grid's sort. fn show(self: &Rc, page: KeyPage) { let kind = key_kind_name(page.kind); self.type_label.set_label(&kind); self.show_summary(&page); if page.kind == KeyKind::Other { - self.body.set_child(Some(&unreadable_type(&page.type_name))); - self.footer.set_label(""); - self.more.set_visible(false); - self.state.borrow_mut().shown = None; + self.show_unreadable(&page.type_name); return; } let columns = page.rows.columns.clone(); @@ -481,22 +539,15 @@ impl Inner { let mut seen = matches!(page.kind, KeyKind::Hash | KeyKind::Set).then(HashSet::new); let rows = new_rows(page.rows.rows, seen.as_mut()); let count = rows.len() as u64; - let (view, selection) = build_result_grid(columns.clone(), rows, self.grid_sink(), false); - view.update_property(&[gtk::accessible::Property::Label(&self.key)]); - let store = store_of(&selection); - let scrolled = gtk::ScrolledWindow::builder() - .child(&view) - .hexpand(true) - .vexpand(true) - .build(); - self.body.set_child(Some(&scrolled)); - let Some(store) = store else { - return; - }; + if !shows_columns(&self.grid, &columns) { + self.grid + .set_columns(grid_columns(&columns, &[], |_| true), GridSorting::Client, None); + } + self.grid.replace_rows(rows, 0); + self.body.set_visible_child_name(ROWS); self.state.borrow_mut().shown = Some(Shown { kind: page.kind, columns, - store, rows: count, length: page.length, next: page.next, @@ -526,22 +577,39 @@ impl Inner { return false; } self.show_summary(&page); - { + let rows = { let mut state = self.state.borrow_mut(); let Some(shown) = state.shown.as_mut() else { return false; }; let rows = new_rows(page.rows.rows, shown.seen.as_mut()); shown.rows += rows.len() as u64; - shown.store.splice(shown.store.n_items(), 0, &rows); shown.length = page.length; shown.next = page.next; shown.appending = false; - } + rows + }; + self.grid.append_rows(rows); self.show_footer(kind); true } + /// A type the tab does not read: its name, and no rows. + fn show_unreadable(&self, type_name: &str) { + let description = crate::i18n::gettext_f( + "TablePro does not read values of type {type}. Read it with the module's own commands in a query tab.", + &[("type", type_name)], + ); + // The page reads its description as markup. + self.unreadable + .set_description(Some(&glib::markup_escape_text(&description))); + self.body.set_visible_child_name(UNREADABLE); + self.grid.clear_rows(); + self.footer.set_label(""); + self.more.set_visible(false); + self.state.borrow_mut().shown = None; + } + /// The key's length and expiry as the last window read them. fn show_summary(&self, page: &KeyPage) { let parts: Vec = [ @@ -578,36 +646,21 @@ impl Inner { self.more.set_sensitive(!shown.appending && !state.replacing); } - /// The grid's copies, its row as JSON and its export. - fn grid_sink(self: &Rc) -> GridSink { - let this = Rc::downgrade(self); - Rc::new(move |message| { - let Some(inner) = this.upgrade() else { - return; - }; - match message { - GridMsg::Copy(request) => { - let toasts = Rc::downgrade(&inner); - crate::ui::clipboard::copy_rows(&inner.root, &inner.tasks, request, move |text| { - if let Some(inner) = toasts.upgrade() { - inner.emit(KeyTabEvent::Toast(text.to_owned())); - } - }); - } - GridMsg::ShowRowJson(request) => { - let (parent, tasks) = (inner.root.clone().upcast(), inner.tasks.clone()); - glib::spawn_future_local(async move { present_row_json(parent, tasks, request).await }); - } - GridMsg::ShowToast(text) => inner.emit(KeyTabEvent::Toast(text)), - GridMsg::ExportResults(rows) => inner.export(rows), - _ => {} - } - }) + /// What the grid asks of the tab; copies and row JSON are the + /// grid's own. + fn grid_request(&self, request: GridRequest) { + match request { + GridRequest::Export(rows) => self.export(rows), + GridRequest::Toast(text) => self.emit(KeyTabEvent::Toast(text)), + GridRequest::Sort(_) + | GridRequest::FollowReference(_) + | GridRequest::FilterByValue(_) + | GridRequest::PendingChanged => {} + } } fn export(&self, rows: ExportSource) { - let columns = self.state.borrow().shown.as_ref().map(|shown| shown.columns.clone()); - if columns.is_none() { + if self.state.borrow().shown.is_none() { return; } self.emit(KeyTabEvent::Export(Box::new(ExportOffer { @@ -653,19 +706,28 @@ impl Inner { } } -/// The bytes a string's cell holds. /// The grid's rows for a window, leaving out an entry `seen` already /// holds. A list keeps every element, equal ones too. -fn new_rows(rows: Vec>, mut seen: Option<&mut HashSet>>) -> Vec { +fn new_rows(rows: Vec>, mut seen: Option<&mut HashSet>>) -> Vec { rows.into_iter() .filter(|cells| match (seen.as_deref_mut(), entry_name(cells.first())) { (Some(seen), Some(name)) => seen.insert(name), _ => true, }) - .map(|cells| RowObject::result(cells.into())) + .map(|cells| RowData::result(cells.into())) .collect() } +/// Whether the grid's columns are `columns`, so new rows can go under +/// them without a rebuild. +fn shows_columns(grid: &DataGrid, columns: &[ResultColumn]) -> bool { + let shown = grid.grid_columns(); + shown.len() == columns.len() + && shown.iter().zip(columns).all(|(grid_column, column)| { + grid_column.name == column.name && grid_column.column_type == column.column_type + }) +} + /// A hash's field or a set's member, as the bytes Redis holds. fn entry_name(cell: Option<&tablepro_core::Value>) -> Option> { match cell? { @@ -675,6 +737,7 @@ fn entry_name(cell: Option<&tablepro_core::Value>) -> Option> { } } +/// The bytes a string's cell holds. fn cell_bytes(cell: Option<&tablepro_core::Value>) -> u64 { match cell { Some(tablepro_core::Value::Text(text)) => text.len() as u64, @@ -683,33 +746,6 @@ fn cell_bytes(cell: Option<&tablepro_core::Value>) -> u64 { } } -/// The store a result grid's rows are in, under its sorter. -fn store_of(selection: >k::MultiSelection) -> Option { - let model = selection.model()?; - if let Ok(store) = model.clone().downcast::() { - return Some(store); - } - model - .downcast::() - .ok()? - .model()? - .downcast::() - .ok() -} - -/// A module's type, such as a JSON document, which the tab names and -/// leaves to the editor. -fn unreadable_type(type_name: &str) -> adw::StatusPage { - adw::StatusPage::builder() - .icon_name(crate::ui::icons::TEXT_X_GENERIC) - .title(crate::i18n::gettext("No Viewer for This Type")) - .description(glib::markup_escape_text(&crate::i18n::gettext_f( - "TablePro does not read values of type {type}. Read it with the module's own commands in a query tab.", - &[("type", type_name)], - ))) - .build() -} - #[cfg(test)] impl KeyTab { pub(crate) fn shown_page(&self) -> Option { @@ -734,12 +770,11 @@ impl KeyTab { /// The rows the grid holds. pub(crate) fn rows(&self) -> u32 { - self.inner - .state - .borrow() - .shown - .as_ref() - .map_or(0, |shown| shown.store.n_items()) + self.inner.grid.row_count() + } + + pub(crate) fn grid(&self) -> &DataGrid { + &self.inner.grid } pub(crate) fn offers_more(&self) -> bool { diff --git a/linux/crates/app/src/ui/key_tab/tests.rs b/linux/crates/app/src/ui/key_tab/tests.rs index 578ba06ec5..c6afe185a1 100644 --- a/linux/crates/app/src/ui/key_tab/tests.rs +++ b/linux/crates/app/src/ui/key_tab/tests.rs @@ -8,7 +8,7 @@ use tablepro_test_support::{CallCounters, ConnectionScript, FakeReply}; use tokio_util::sync::CancellationToken; use super::*; -use crate::test_support::{FakeSession, test_runtime, wait_until}; +use crate::test_support::{FakeSession, MemorySettings, drain_main_context, test_runtime, wait_until}; const WAIT: Duration = Duration::from_secs(10); @@ -30,7 +30,13 @@ fn list_page(from: i64, to: i64, next: Option<&str>) -> KeyPage { } } -fn opened(script: ConnectionScript) -> (KeyTab, FakeSession, crate::runtime::AppRuntime) { +/// What a test tab runs on, held for the test's length. +struct Held { + _runtime: crate::runtime::AppRuntime, + _settings: MemorySettings, +} + +fn opened(script: ConnectionScript) -> (KeyTab, FakeSession, Held) { reopened(script, []) } @@ -39,17 +45,36 @@ fn opened(script: ConnectionScript) -> (KeyTab, FakeSession, crate::runtime::App fn reopened( script: ConnectionScript, reconnects: impl IntoIterator, -) -> (KeyTab, FakeSession, crate::runtime::AppRuntime) { +) -> (KeyTab, FakeSession, Held) { adw::init().expect("libadwaita initialises under the test backend"); + crate::register_test_resources(); let runtime = test_runtime(); + let settings = MemorySettings::new(); let fake = FakeSession::start(script, reconnects, runtime.tasks()); let tab = KeyTab::new(KeyTabInit { key: "queue".to_owned(), session: fake.session.clone(), tasks: runtime.tasks(), + settings: settings.get().clone(), first_load: FirstLoad::Now, }); - (tab, fake, runtime) + let held = Held { + _runtime: runtime, + _settings: settings, + }; + (tab, fake, held) +} + +/// The second column of each row, in the order the grid shows them. +fn shown_elements(tab: &KeyTab) -> Vec { + tab.grid() + .shown_rows() + .iter() + .filter_map(|row| match row.cells()?.get(1)? { + Value::Text(text) => Some(text.clone()), + _ => None, + }) + .collect() } #[gtk4::test] @@ -153,6 +178,60 @@ fn a_key_written_over_with_another_type_is_read_again() { ); } +/// A reread lands in the grid the tab had, under the sort the user set. +#[gtk4::test] +fn a_refresh_keeps_the_grid_and_its_sort() { + let mut script = ConnectionScript::default(); + script.read_key.once(FakeReply::ok(Some(list_page(0, 2, Some("2"))))); + script.read_key.always(FakeReply::ok(Some(list_page(0, 3, None)))); + let (tab, _fake, _runtime) = opened(script); + wait_until(WAIT, || tab.rows() == 2).expect("the first window"); + let grid = tab.grid().clone(); + grid.set_sort(Some((1, false))); + assert_eq!(shown_elements(&tab), ["element1", "element0"]); + + tab.refresh(); + + wait_until(WAIT, || tab.rows() == 3).expect("the key read again"); + assert_eq!(tab.grid(), &grid, "the reread built another grid"); + assert!(grid.is_sorted(), "the reread dropped the sort"); + assert_eq!(shown_elements(&tab), ["element2", "element1", "element0"]); +} + +/// The header's Row Details toggle reaches the tab's grid. +#[gtk4::test] +fn row_details_reaches_the_key_s_grid() { + let mut script = ConnectionScript::default(); + script.read_key.always(FakeReply::ok(Some(list_page(0, 2, None)))); + let (tab, _fake, _runtime) = opened(script); + // Wide enough that the grid keeps its detail pane beside the rows. + let _window = crate::test_support::OwnedWindow::presented( + gtk::Window::builder() + .default_width(1200) + .default_height(700) + .child(tab.widget()) + .build(), + ); + wait_until(WAIT, || tab.rows() == 2 && tab.grid().width() > 0).expect("the first window, laid out"); + let toggle = crate::test_support::descendants(tab.widget()) + .into_iter() + .filter_map(|widget| widget.downcast::().ok()) + .find(|toggle| toggle.action_name().as_deref() == Some("grid.show-details")) + .expect("the Row Details toggle"); + wait_until(WAIT, || toggle.is_sensitive()).expect("the toggle reaches the grid"); + + toggle.emit_clicked(); + drain_main_context(); + + let shown = tab + .grid() + .actions() + .action_state("show-details") + .and_then(|state| state.get::()); + assert_eq!(shown, Some(true)); + assert!(toggle.is_active()); +} + #[gtk4::test] fn a_refresh_reads_the_key_from_its_start() { let mut script = ConnectionScript::default(); diff --git a/linux/crates/app/src/ui/mod.rs b/linux/crates/app/src/ui/mod.rs index 98c63a0b1d..1373b0c41e 100644 --- a/linux/crates/app/src/ui/mod.rs +++ b/linux/crates/app/src/ui/mod.rs @@ -8,6 +8,7 @@ mod connect_dialog; mod connection_appearance; mod connection_list_banner; pub(crate) mod credential_prompt; +pub(crate) mod data_grid; mod delete_connection_dialog; mod diagram; mod discard_alert; @@ -21,7 +22,6 @@ pub(crate) mod file_reveal; mod file_write; mod filter_strip; mod format; -mod grid; mod handler_guard; mod history_dialog; mod host_key_failure; diff --git a/linux/crates/app/src/ui/preferences_dialog/data_browser_group.rs b/linux/crates/app/src/ui/preferences_dialog/data_browser_group.rs index 866614ef16..d072f3431d 100644 --- a/linux/crates/app/src/ui/preferences_dialog/data_browser_group.rs +++ b/linux/crates/app/src/ui/preferences_dialog/data_browser_group.rs @@ -38,7 +38,7 @@ mod imp { impl ObjectImpl for DataBrowserGroup { fn constructed(&self) { self.parent_constructed(); - let threshold = crate::ui::browse_tab::BULK_DELETE_CONFIRM_THRESHOLD; + let threshold = crate::ui::data_grid::BULK_DELETE_CONFIRM_THRESHOLD; self.confirm_row.set_subtitle(&crate::i18n::ngettext_f( "Ask before deleting {n} or more rows at once", "Ask before deleting {n} or more rows at once", diff --git a/linux/crates/app/src/ui/result_pane/mod.rs b/linux/crates/app/src/ui/result_pane/mod.rs index ab83f2c55d..fa6a62603d 100644 --- a/linux/crates/app/src/ui/result_pane/mod.rs +++ b/linux/crates/app/src/ui/result_pane/mod.rs @@ -6,49 +6,74 @@ mod result_limit_banner; mod result_set_view; mod result_summary; +use std::rc::Rc; + use gtk4 as gtk; use gtk4::prelude::*; +use tablepro_session::runtime::Tasks; +use tablepro_storage::AppSettings; pub(crate) use self::result_limit_banner::ResultLimitBanner; pub(crate) use self::result_set_view::ResultSetView; pub(crate) use self::result_summary::ResultSummary; use crate::i18n::ngettext_f; +use crate::services::grid::grid_columns; +use crate::ui::data_grid::{DataGrid, DialectSource, GridConfig, GridSorting, RowData}; use crate::ui::export::ExportSource; use crate::ui::format::{format_thousands, plural_count}; -use crate::ui::grid::{GridSink, RowObject, TabGridContext, build_result_grid, export_snapshot}; -/// The page for `view`, its grid named `name` as the results list names -/// it. Export All on its banner hands `export_all` the rows the grid -/// shows, in the order it shows them, for the dialog's Shown Rows. -/// `in_clause` is false where the engine has no SQL to copy rows into. +/// What a result grid reads from the tab that shows it. +#[derive(Clone)] +pub(crate) struct ResultGridDeps { + pub settings: Rc, + pub tasks: Tasks, + /// `None` where the rows have no SQL spelling: no IN list to copy. + pub dialect: Option, +} + +/// The page for `view` and the grid on it, named `name` as the results +/// list names it. Export All on its banner hands `export_all` the rows +/// the grid shows, in the order it shows them, for the dialog's Shown +/// Rows. pub(crate) fn result_set_page( view: &ResultSetView, name: &str, - sink: GridSink, - in_clause: bool, + deps: &ResultGridDeps, export_all: impl Fn(ExportSource) + 'static, -) -> gtk::Widget { - let rows = view.rows.iter().cloned().map(RowObject::result).collect(); - let (column_view, _selection) = build_result_grid(view.columns.clone(), rows, sink, in_clause); - column_view.update_property(&[gtk::accessible::Property::Label(name)]); +) -> (gtk::Widget, DataGrid) { + let grid = DataGrid::new(GridConfig { + columns: grid_columns(&view.columns, &[], |_| true), + sorting: GridSorting::Client, + rules: None, + tracker: None, + settings: deps.settings.clone(), + layout: None, + references: None, + dialect: deps.dialect.clone(), + table: None, + find: true, + filter_by_value: false, + tasks: deps.tasks.clone(), + label: name.to_owned(), + }); + grid.set_hexpand(true); + grid.set_vexpand(true); + grid.replace_rows(view.rows.iter().cloned().map(RowData::result).collect(), 0); let banner = ResultLimitBanner::new(&view.summary); - let columns = view.columns.clone(); - let grid = column_view.downgrade(); + let shown = grid.downgrade(); banner.connect_export_all(move || { - let Some(column_view) = grid.upgrade() else { - return; - }; - export_all(ExportSource::Rows { - columns: columns.clone(), - rows: export_snapshot(&column_view, &TabGridContext::default()), - }); + if let Some(grid) = shown.upgrade() { + export_all(grid.export_source()); + } }); - let scrolled = gtk::ScrolledWindow::builder() - .child(&column_view) - .hexpand(true) - .vexpand(true) - .build(); - let shown = view.row_count() as u64; + let page = gtk::Box::builder().orientation(gtk::Orientation::Vertical).build(); + page.append(banner.widget()); + page.append(&grid); + page.append(&row_count_footer(view.row_count() as u64)); + (page.upcast(), grid) +} + +fn row_count_footer(shown: u64) -> gtk::Label { let footer = gtk::Label::builder() .label(ngettext_f( "{n} row", @@ -64,9 +89,5 @@ pub(crate) fn result_set_page( .build(); footer.add_css_class("dim-label"); footer.add_css_class("caption"); - let page = gtk::Box::builder().orientation(gtk::Orientation::Vertical).build(); - page.append(banner.widget()); - page.append(&scrolled); - page.append(&footer); - page.upcast() + footer } diff --git a/linux/crates/app/src/ui/workspace/saves.rs b/linux/crates/app/src/ui/workspace/saves.rs index e1cb119244..7bfae67844 100644 --- a/linux/crates/app/src/ui/workspace/saves.rs +++ b/linux/crates/app/src/ui/workspace/saves.rs @@ -98,10 +98,10 @@ impl WorkspaceModel { self.dispatch_to_tab(tab, browse_tab::SaveInput::SaveCommitted(origins)); } (WriteKind::RowChanges, SaveOutcome::RolledBack(cause)) => { - // The row behind the failing step is flashed before the + // The row behind the failing step is marked before the // error shows, so the user sees which one it was. if let Some(source) = failing { - self.dispatch_to_tab(tab, browse_tab::SaveInput::FlashErrorRow(source)); + self.dispatch_to_tab(tab, browse_tab::SaveInput::MarkErrorRow(source)); } self.dispatch_to_tab(tab, browse_tab::SaveInput::SaveFailed(cause.message())); } diff --git a/linux/crates/app/src/ui/workspace/shell.rs b/linux/crates/app/src/ui/workspace/shell.rs index 2d89d623a4..553bef1ebe 100644 --- a/linux/crates/app/src/ui/workspace/shell.rs +++ b/linux/crates/app/src/ui/workspace/shell.rs @@ -465,7 +465,7 @@ pub(super) mod tests { /// filter strip. #[gtk4::test] fn an_open_table_tab_fits_the_window_floor() { - use crate::ui::browse_tab::{BrowseTab, EditInput, PagingInput}; + use crate::ui::browse_tab::{BrowseTab, PagingInput}; let test = TestWindow::with_script( "TableFloor", Script { @@ -490,11 +490,9 @@ pub(super) mod tests { .expect("a table tab"); let tab = page.child().downcast::().expect("a browse tab"); let wait = Duration::from_secs(10); - wait_until(wait, || { - descendants(&tab).iter().any(|widget| widget.is::()) - }) - .expect("the tab's rows"); - tab.send(EditInput::InsertRow); + wait_until(wait, || tab.grid().row_count() > 0).expect("the tab's rows"); + tab.activate_action("grid.insert-row", None) + .expect("the tab inserts rows"); let content = crate::test_support::first_descendant_of_type::(&tab).expect("the tab's toolbar view"); let needs = || { diff --git a/linux/crates/app/src/ui/workspace/tab_factory.rs b/linux/crates/app/src/ui/workspace/tab_factory.rs index c3c36bdd97..631813f0dc 100644 --- a/linux/crates/app/src/ui/workspace/tab_factory.rs +++ b/linux/crates/app/src/ui/workspace/tab_factory.rs @@ -298,6 +298,7 @@ impl TabFactory { session, tasks: parts.context.tasks.clone(), first_load, + settings: parts.settings.clone(), }); let page = self.append(tab.widget(), id, None); let follow = follow_loading(&page, tab.is_loading()); diff --git a/linux/crates/app/src/ui/workspace/tests/close_tab.rs b/linux/crates/app/src/ui/workspace/tests/close_tab.rs index 238d1dcf21..805243637d 100644 --- a/linux/crates/app/src/ui/workspace/tests/close_tab.rs +++ b/linux/crates/app/src/ui/workspace/tests/close_tab.rs @@ -11,9 +11,9 @@ use uuid::Uuid; use super::super::{OpenMode, WorkspaceInput}; use crate::runtime::DeliverInput; -use crate::test_support::{after_next_frame, descendants, drain_main_context, wait_until}; +use crate::test_support::{after_next_frame, drain_main_context, wait_until}; use crate::ui::browse_tab::BrowseTab; -use crate::ui::grid::GridCell; +use crate::ui::data_grid::GridCell; use crate::ui::session_window::tests::{TestWindow, WAIT}; use crate::workspace::{answer, visible_alert}; @@ -80,29 +80,28 @@ fn open_table(test: &TestWindow, name: &str) -> (Uuid, BrowseTab) { } /// Give the keyboard focus to the name of the first row. -fn focus_name(test: &TestWindow, tab: &BrowseTab) -> GridCell { - let mut grid = None; +fn focus_name(tab: &BrowseTab) -> GridCell { + let grid = tab.grid(); + wait_until(WAIT, || grid.row_count() == 2).expect("the rows"); + let mut focused = None; wait_until(WAIT, || { - grid = descendants(tab) - .into_iter() - .find_map(|widget| widget.downcast::().ok()) - .filter(|view| view.model().is_some_and(|model| model.n_items() == 2)); - grid.is_some() - }) - .expect("the rows"); - let view = grid.expect("the grid"); - let name = crate::ui::grid::data_columns(&view) - .into_iter() - .nth(1) - .expect("the name column"); - view.scroll_to(0, Some(&name), gtk::ListScrollFlags::FOCUS, None); - view.grab_focus(); - let focused = || gtk::prelude::GtkWindowExt::focus(&test.window).and_then(|focus| GridCell::from_focus(&focus)); - wait_until(WAIT, || { - focused().is_some_and(|cell| cell.position() == Some(0) && cell.column() == 1) + focused = grid.focus_cell(0, 1); + focused.is_some() }) .expect("the cell takes the focus"); - focused().expect("the focused cell") + focused.expect("the focused cell") +} + +/// Open the name's one-line editor and type over it, leaving it open. +fn type_a_name(tab: &BrowseTab) -> GridCell { + let cell = focus_name(tab); + let grid = tab.grid(); + assert!(grid.edit().edit_cell(&cell), "the name took no edit"); + grid.edit() + .open_text() + .expect("a one-line field") + .set_text("Ada Lovelace"); + cell } /// What was typed is an unsaved change like any other, so the close asks @@ -112,9 +111,7 @@ fn focus_name(test: &TestWindow, tab: &BrowseTab) -> GridCell { fn closing_a_tab_while_a_cell_is_edited_asks_about_the_edit() { let test = TestWindow::connected_with("CloseWhileEditing", serving_tables); let (id, tab) = open_table(&test, "users"); - let cell = focus_name(&test, &tab); - crate::ui::grid::begin_edit(&cell); - cell.inline_text().expect("a one-line field").set_text("Ada Lovelace"); + let cell = type_a_name(&tab); let previous = glib::log_set_always_fatal(glib::LogLevels::LEVEL_CRITICAL); WidgetExt::activate_action(&test.window, "win.close-current", None).expect("win.close-current"); @@ -138,9 +135,12 @@ fn closing_a_tab_while_a_cell_is_edited_asks_about_the_edit() { fn closing_the_window_while_a_cell_is_edited_asks_about_the_edit() { let test = TestWindow::connected_with("CloseWindowWhileEditing", serving_tables); let (id, tab) = open_table(&test, "users"); - let cell = focus_name(&test, &tab); - crate::ui::grid::begin_edit(&cell); - cell.inline_text().expect("a one-line field").set_text("Ada Lovelace"); + let cell = type_a_name(&tab); + assert_eq!( + test.workspace().row_tracker(id).map(|tracker| tracker.pending_count()), + Some(0), + "the edit counted before the close committed it" + ); test.window.close(); @@ -150,6 +150,7 @@ fn closing_the_window_while_a_cell_is_edited_asks_about_the_edit() { assert!(answer(&test.window, "cancel")); drain_main_context(); assert_eq!(test.app.windows().len(), 1, "Cancel closed the window"); + assert!(!cell.is_editing()); } /// The focus leaves the tab before it goes. GTK 4.22 leaks a focus that @@ -161,7 +162,7 @@ fn a_tab_closed_with_the_focus_in_it_is_freed_when_the_next_takes_none() { let (_, next) = open_table(&test, "orders"); next.set_can_focus(false); let (_, tab) = open_table(&test, "users"); - drop(focus_name(&test, &tab)); + drop(focus_name(&tab)); let closed = tab.downgrade(); drop(tab); diff --git a/linux/crates/app/tests/accessibility/main.rs b/linux/crates/app/tests/accessibility/main.rs index f32bb02629..ae0db14de3 100644 --- a/linux/crates/app/tests/accessibility/main.rs +++ b/linux/crates/app/tests/accessibility/main.rs @@ -294,28 +294,21 @@ fn open_filter_strip(window: &adw::ApplicationWindow) -> Result<(), String> { }) } -/// The editor of the cell that shows `CELL`, opened from its menu the -/// way the Menu key opens it. +/// The editor of the cell that shows `CELL`, opened the way F2 opens it +/// on the focused cell. fn open_cell_editor(window: &adw::ApplicationWindow) -> Result<(), String> { let cell = widgets_of(window.upcast_ref(), "TableProGridCell") .into_iter() .find(|cell| shows_text(cell, CELL)) .ok_or("no cell shows the row")?; - let controllers = cell.observe_controllers(); - let menu = (0..controllers.n_items()) - .filter_map(|index| controllers.item(index).and_downcast::()) - .find(|gesture| gesture.button() == 3) - .ok_or("the cell has no menu")?; - menu.emit_by_name::<()>("pressed", &[&1i32, &1.0f64, &1.0f64]); - for popover in descendants(window.upcast_ref()) - .into_iter() - .filter_map(|widget| widget.downcast::().ok()) - { - popover.popdown(); + // The column view's own cell widget around ours is what takes focus. + let focusable = cell.parent().ok_or("the cell is not in the column view")?; + if !focusable.grab_focus() { + return Err("the cell takes no focus".to_owned()); } drain_main_context(); - cell.activate_action("cell.edit", None) - .map_err(|error| format!("cell.edit: {error}"))?; + cell.activate_action("grid.edit", None) + .map_err(|error| format!("grid.edit: {error}"))?; wait_until("the cell's editor", || { descendants(&cell) .iter() diff --git a/linux/crates/app/tests/kill_during_save.rs b/linux/crates/app/tests/kill_during_save.rs index 9d2142e4d7..e3e424e14e 100644 --- a/linux/crates/app/tests/kill_during_save.rs +++ b/linux/crates/app/tests/kill_during_save.rs @@ -141,11 +141,18 @@ fn table_row(window: >k4::Widget, name: &str) -> Option<(gtk4::ListView, u32)> }) } -/// Edit a cell the way a double-click, typing and Enter do. +/// Edit a cell the way Return on the focused cell, typing and Enter do. async fn edit_cell(window: >k4::Widget, from: &str, to: &str) -> Result<(), String> { let cell = until(&format!("the cell showing {from}"), || cell_showing(window, from)).await?; - let gesture = edit_gesture(&cell).ok_or_else(|| format!("the cell showing {from} takes no double-click"))?; - gesture.emit_by_name::<()>("pressed", &[&2i32, &1.0f64, &1.0f64]); + // The column view's own cell widget around ours is what takes focus. + let focusable = cell + .parent() + .ok_or_else(|| format!("the cell showing {from} is in no grid"))?; + if !focusable.grab_focus() { + return Err(format!("the cell showing {from} takes no focus")); + } + cell.activate_action("grid.edit", None) + .map_err(|error| format!("grid.edit: {error}"))?; let field = until("the cell's editor", || first_of::(&cell)).await?; field.set_text(to); field.emit_by_name::<()>("activate", &[]); @@ -154,16 +161,6 @@ async fn edit_cell(window: >k4::Widget, from: &str, to: &str) -> Result<(), St .map(drop) } -fn edit_gesture(cell: >k4::Widget) -> Option { - let controllers = cell.observe_controllers(); - (0..controllers.n_items()) - .filter_map(|index| controllers.item(index).and_downcast::()) - .find(|gesture| { - gesture.button() == gtk4::gdk::BUTTON_PRIMARY - && gesture.propagation_phase() == gtk4::PropagationPhase::Capture - }) -} - fn cell_showing(root: >k4::Widget, text: &str) -> Option { descendants(root) .into_iter() diff --git a/linux/data/resources/resources.gresource.xml b/linux/data/resources/resources.gresource.xml index 71c08cf47a..875e51f7f2 100644 --- a/linux/data/resources/resources.gresource.xml +++ b/linux/data/resources/resources.gresource.xml @@ -12,7 +12,9 @@ ui/assistant-pane.ui ui/browse-tab.ui ui/connect-dialog.ui + ui/data-grid.ui ui/connect-failed-page.ui + ui/column-chooser.ui ui/connecting-page.ui ui/diagram-tab.ui ui/export-operations-button.ui @@ -32,6 +34,7 @@ ui/preferences-ai-page.ui ui/preferences-mcp-page.ui ui/results-pane.ui + ui/row-detail-pane.ui ui/session-window.ui ui/sql-editor.ui ui/database-file-row.ui diff --git a/linux/data/resources/shortcuts-dialog.ui b/linux/data/resources/shortcuts-dialog.ui index dbbb4a9b96..63bef131a5 100644 --- a/linux/data/resources/shortcuts-dialog.ui +++ b/linux/data/resources/shortcuts-dialog.ui @@ -205,107 +205,131 @@ - - Edit a Cell or Toggle a Check Box + + Edit a Cell F2 Return - - Toggle a Check Box + + Toggle a Boolean Cell space - + Show the Cell Menu Menu <Shift>F10 - + Set Cell to NULL <Primary>BackSpace - + Open the Referenced Row <Primary>j - + Widen Column <Shift><Alt>Right - + Narrow Column <Shift><Alt>Left - - Finish Editing - Return + + Insert Row + <Primary>n - - Cancel Editing - Escape + + Mark Rows for Deletion + Delete - - Finish Editing and Edit the Next Cell - Tab + + Copy the Cell or the Selected Rows + <Primary>c - - Finish Editing and Edit the Previous Cell - <Shift>Tab + + Paste + <Primary>v - - Save a Multi-Line Value - <Primary>Return + + Clear the Multi-Row Selection + Escape - - Insert Row - <Primary>n + + Show or Hide Row Details + <Alt>Return - - Mark Rows for Deletion - Delete + + Find in Rows + <Primary>f - - Copy Selected Rows as TSV - <Primary>c + + Next Match + <Primary>g - - Paste Rows - <Primary>v + + Previous Match + <Primary><Shift>g - - Clear the Multi-Row Selection + + Finish Editing + Return + + + + + Cancel Editing Escape + + + Finish Editing and Edit the Next Cell + Tab + + + + + Finish Editing and Edit the Previous Cell + <Shift>Tab + + + + + Save the Value in Row Details + <Primary>Return + + diff --git a/linux/data/resources/style.css b/linux/data/resources/style.css index ca4166fcbf..4660c5ee3f 100644 --- a/linux/data/resources/style.css +++ b/linux/data/resources/style.css @@ -6,69 +6,18 @@ textview.sql-editor, textview.sql-editor text { font-size: var(--monospace-font-size); } -/* Pending-changeset cell and row states. The Adwaita classes - (.warning, .success, .error) repaint the whole row, so these tint the - libadwaita background colours instead, the way GNOME Builder marks a - diff. */ -.tp-cell-modified { - background: color-mix(in srgb, var(--warning-bg-color) 25%, transparent); -} - -.tp-row-pending-insert { - background: color-mix(in srgb, var(--success-bg-color) 22%, transparent); -} - -/* The full error colour: at 70% the text fell to about 3.5:1 on the - tint, under the 4.5:1 body text needs. */ -.tp-row-pending-delete { - background: color-mix(in srgb, var(--error-bg-color) 16%, transparent); - color: var(--error-color); - text-decoration: line-through; -} - -/* GtkColumnView's default cell focus is a 1px outline that vanishes - against the selected-row highlight. A 2px inset accent ring is the - spreadsheet-standard focused-cell signal, on the focused cell and on - the field of a cell being edited. */ +/* Adwaita 1.9 rings a focused row but draws nothing on a focused column + view cell, so with cell focus the grid's focused cell shows only through + this ring: on the cell, and on the inline editor laid into it. */ columnview > listview > row > cell:focus-visible > tp-grid-cell, -columnview > listview > row > cell > tp-grid-cell > text.tp-cell-text:focus-within { +columnview > listview > row > cell > tp-grid-cell > text:focus-within { box-shadow: inset 0 0 0 2px var(--accent-color); border-radius: 2px; } -/* One-shot flash on the row whose statement failed to commit. Each - redraw of the row keeps the class until the tab clears its error row. */ -@keyframes tp-flash-error { - 0% { - background: color-mix(in srgb, var(--error-bg-color) 55%, transparent); - } - 100% { - background: transparent; - } -} - -.tp-row-leftmost-error-flash { - animation: tp-flash-error 1.8s ease-out; -} - @media (prefers-contrast: more) { - .tp-cell-modified { - background: var(--warning-bg-color); - color: var(--warning-fg-color); - } - - .tp-row-pending-insert { - background: var(--success-bg-color); - color: var(--success-fg-color); - } - - .tp-row-pending-delete { - background: var(--error-bg-color); - color: var(--error-fg-color); - } - columnview > listview > row > cell:focus-visible > tp-grid-cell, - columnview > listview > row > cell > tp-grid-cell > text.tp-cell-text:focus-within { + columnview > listview > row > cell > tp-grid-cell > text:focus-within { box-shadow: inset 0 0 0 3px var(--accent-color); } } diff --git a/linux/data/resources/ui/browse-tab.ui b/linux/data/resources/ui/browse-tab.ui index 41c797fb85..a997e126c5 100644 --- a/linux/data/resources/ui/browse-tab.ui +++ b/linux/data/resources/ui/browse-tab.ui @@ -99,7 +99,7 @@ list-add-symbolic - browse.insert-row + grid.insert-row @@ -108,7 +108,7 @@ list-remove-symbolic - browse.delete-rows + grid.delete-rows @@ -129,6 +129,19 @@ + + + sidebar-show-right-symbolic + Row Details + grid.show-details + + + Row Details + + + view-more-symbolic diff --git a/linux/data/resources/ui/column-chooser.ui b/linux/data/resources/ui/column-chooser.ui new file mode 100644 index 0000000000..5c5c250a6a --- /dev/null +++ b/linux/data/resources/ui/column-chooser.ui @@ -0,0 +1,87 @@ + + + + + + diff --git a/linux/data/resources/ui/data-grid.ui b/linux/data/resources/ui/data-grid.ui new file mode 100644 index 0000000000..fa99f9aab2 --- /dev/null +++ b/linux/data/resources/ui/data-grid.ui @@ -0,0 +1,124 @@ + + + + + + diff --git a/linux/data/resources/ui/results-pane.ui b/linux/data/resources/ui/results-pane.ui index dd430dad58..303f881c23 100644 --- a/linux/data/resources/ui/results-pane.ui +++ b/linux/data/resources/ui/results-pane.ui @@ -80,6 +80,22 @@ + + + + sidebar-show-right-symbolic + Row Details + grid.show-details + false + + + Row Details + + + diff --git a/linux/data/resources/ui/row-detail-pane.ui b/linux/data/resources/ui/row-detail-pane.ui new file mode 100644 index 0000000000..a199ea7fbc --- /dev/null +++ b/linux/data/resources/ui/row-detail-pane.ui @@ -0,0 +1,268 @@ + + + + + + +
+ + Set to Empty + grid.set-empty + action-disabled + + + Set to NULL + grid.set-null + action-disabled + + + Set to DEFAULT + grid.set-default + action-disabled + +
+
+ + Copy Value + grid.copy + +
+
+ +
diff --git a/linux/docs/decisions/0027-one-data-grid.md b/linux/docs/decisions/0027-one-data-grid.md new file mode 100644 index 0000000000..dc83c5084e --- /dev/null +++ b/linux/docs/decisions/0027-one-data-grid.md @@ -0,0 +1,37 @@ +# 0027: One data grid on GtkColumnView, with rows and a focused cell + +- **Status**: Accepted +- **Date**: 2026-10-25 + +## Context + +The table tab, the SQL editor's results and the key tab showed rows through one builder, but what a grid did lived in four places: the grid's own actions, the table tab's actions and handlers, and each host's handling of the grid's messages. Copy, insert, delete and the edit lifecycle meant different things on each surface, each column was described twice, and a reload replaced every row object, which made GTK build every row and cell widget again. + +GTK 4.22 gives one data table, `GtkColumnView` with the `.data-table` style. It selects rows, not cells or ranges; it builds a widget for every visible column of about 205 rows, so wide results cost in proportion to their columns; it keeps its row widgets only for row objects that stay in the model; it has no pinned column; and its header takes no keyboard focus. + +## Decision + +Every surface shows rows in one `DataGrid` widget on `GtkColumnView`, with row selection plus a focused cell, rows held in stable slots whose data is replaced in place, a row number column, values as text, and a row detail pane for long values. + +## Rationale + +- One widget owns selection, keys, menus, editing, copy, find and the column layout, so an action means the same on every surface and has one enabled state that its key, its menu item and the host's button all read. +- Rows and a focused cell are what the toolkit selects. A cell range would mean turning row selection off and drawing and keeping a selection of the grid's own. +- Slots keep GTK's widgets across a reload: measured at 300 columns, a reload took 2,321 ms with new row objects and 328 ms with the same ones. Anything that acts later (a menu, an editor, a copy) holds the row pinned to the slot's generation, so it acts on the row the user chose or on nothing. +- The detail pane is the HIG's utility pane: every field of the focused row, and an editor for long text, JSON and dates, toggled by Alt+Return, the HIG's Properties key, since F9 shows the sidebar. +- Values as text, booleans as `true` and `false`, keep every row one line tall in every grid; a check button made rows taller, and GtkInscription draws ellipsized text from its top edge. +- The row number column is the toolkit's only way to show row numbers; it scrolls and can be dragged like any column. + +## Consequences + +- No cell range selection, unlike the macOS app; Copy copies the focused cell's value when one row is selected and the rows otherwise. +- Every column is shown; a result with hundreds of columns scrolls slower, since GTK builds them all. +- The header's actions are also in the cell menu, since the header takes no keyboard focus; sort changes are announced, since AT-SPI reads no sort state. +- Pending changes show as the row number column's icon and as text marks (bold in the warning colour for a changed value, struck through for a deleted row), not as row tints. +- Column order, visibility and widths are kept per table in the column layout file. + +## Alternatives considered + +- **A grid drawn on a `GtkDrawingArea`.** It scales in both directions, but rebuilds focus, selection, editing and accessibility by hand. +- **`GtkEditableLabel` in every cell.** GTK's own in-place editor starts editing on the first click, cannot ellipsize, takes the context menu, and at 50 columns used over 1 GB and took 8 s to reload. +- **A separate list beside the grid for row numbers.** It would pin them, but only by reading the column view's private layout to keep the two in step. diff --git a/linux/docs/decisions/README.md b/linux/docs/decisions/README.md index 7ad545f6bf..7a5952ffd1 100644 --- a/linux/docs/decisions/README.md +++ b/linux/docs/decisions/README.md @@ -74,6 +74,7 @@ A fact the repository keeps elsewhere, such as a workflow trigger, a branch, a v | [0024](0024-multi-cursor-on-text-marks.md) | Multiple cursors on text marks, in the app | Accepted | The SQL editor's view, a GtkSourceView subclass, keeps extra cursors as GtkTextMark pairs, runs GTK's own commands once per cursor and repeats committed text from `end-user-action`, so one undo takes back every cursor's edit. | | [0025](0025-bundled-duckdb.md) | DuckDB built from the duckdb crate's bundled sources | Accepted | No PPA or COPR target but Ubuntu 26.10 ships libduckdb, so DuckDB 1.5.6 is compiled from the crate on every channel, with no extension fetched or loaded on its own. | | [0026](0026-documents-in-the-table-tab.md) | Document collections open in the table tab, and their statements are shell calls | Accepted | A document engine states `DataModel::Document`, lists collections as tables, opens them in the table tab, and every builder writes a mongosh call with bound values through a document arm chosen by the grammar. | +| [0027](0027-one-data-grid.md) | One data grid on GtkColumnView, with rows and a focused cell | Accepted | Every surface shows rows in one `DataGrid`: row selection plus a focused cell, stable row slots, a row number column, values as text and a row detail pane. | 0011 is not used. It was reserved for the DDL decision, which was recorded as 0010, and docs cite ADRs by number, so the later ones keep theirs. diff --git a/linux/docs/regression-tests.md b/linux/docs/regression-tests.md index 2e95636e99..587294c209 100644 --- a/linux/docs/regression-tests.md +++ b/linux/docs/regression-tests.md @@ -42,12 +42,12 @@ Kinds: `unit` runs in the Meson unit suite, `gtk` in the gtk suite, `docker` in | F-002 | gtk | `f002_a_late_catalog_commit_after_the_watch_drops_is_not_delivered` | [crates/app/src/ui/workspace/sidebar/catalog_watch.rs](../crates/app/src/ui/workspace/sidebar/catalog_watch.rs) | | F-003 | unit | `f003_truncated_file_is_unavailable_and_upsert_leaves_bytes_identical` | [crates/storage/src/connections.rs](../crates/storage/src/connections.rs) | | F-003 | unit | `f003_a_newer_version_is_never_written` | [crates/storage/src/connections.rs](../crates/storage/src/connections.rs) | -| F-004 | gtk | `f004_edit_after_draft_insert_targets_edited_row` | [crates/app/src/ui/grid/column_factory.rs](../crates/app/src/ui/grid/column_factory.rs) | -| F-004 | gtk | `f004_menu_set_null_after_draft_removal_targets_clicked_row` | [crates/app/src/ui/grid/column_factory.rs](../crates/app/src/ui/grid/column_factory.rs) | -| F-004 | gtk | `f004_copy_row_as_insert_uses_row_below_draft` | [crates/app/src/ui/browse_tab/tests/mod.rs](../crates/app/src/ui/browse_tab/tests/mod.rs) | -| F-005 | gtk | `f005_unchanged_40kb_multiline_text_records_no_edit` | [crates/app/src/ui/browse_tab/tests/mod.rs](../crates/app/src/ui/browse_tab/tests/mod.rs) | -| F-005 | gtk | `f005_pasted_newlines_in_text_cell_are_kept` | [crates/app/src/ui/browse_tab/tests/mod.rs](../crates/app/src/ui/browse_tab/tests/mod.rs) | -| F-005 | gtk | `f005_pressing_enter_on_float_keeps_full_precision` | [crates/app/src/ui/browse_tab/tests/mod.rs](../crates/app/src/ui/browse_tab/tests/mod.rs) | +| F-004 | gtk | `f004_edit_after_draft_insert_targets_edited_row` | [crates/app/src/ui/data_grid/edit/tests/mod.rs](../crates/app/src/ui/data_grid/edit/tests/mod.rs) | +| F-004 | gtk | `f004_menu_set_null_after_draft_removal_targets_clicked_row` | [crates/app/src/ui/data_grid/edit/tests/mod.rs](../crates/app/src/ui/data_grid/edit/tests/mod.rs) | +| F-004 | gtk | `f004_copy_row_as_insert_uses_row_below_draft` | [crates/app/src/ui/browse_tab/tests/rows.rs](../crates/app/src/ui/browse_tab/tests/rows.rs) | +| F-005 | gtk | `f005_unchanged_40kb_multiline_text_records_no_edit` | [crates/app/src/ui/data_grid/row_detail/tests.rs](../crates/app/src/ui/data_grid/row_detail/tests.rs) | +| F-005 | gtk | `f005_pasted_newlines_in_text_cell_are_kept` | [crates/app/src/ui/data_grid/edit/tests/mod.rs](../crates/app/src/ui/data_grid/edit/tests/mod.rs) | +| F-005 | gtk | `f005_pressing_enter_on_float_keeps_full_precision` | [crates/app/src/ui/data_grid/edit/tests/mod.rs](../crates/app/src/ui/data_grid/edit/tests/mod.rs) | | F-006 | conformance | `f006_unsupported_types_never_decode_as_null` | [crates/test-support/src/conformance/cases/type_fallback.rs](../crates/test-support/src/conformance/cases/type_fallback.rs) | | F-006 | docker | `f006_round_trip_matrix` | [crates/drivers/postgres/tests/integration/decode.rs](../crates/drivers/postgres/tests/integration/decode.rs) | | F-006 | unit | `f006_count_sum_and_expression_columns_are_typed` | [crates/drivers/sqlite/tests/integration/decode.rs](../crates/drivers/sqlite/tests/integration/decode.rs) | diff --git a/linux/docs/storage.md b/linux/docs/storage.md index dc3484e216..34170f5dd2 100644 --- a/linux/docs/storage.md +++ b/linux/docs/storage.md @@ -9,7 +9,7 @@ The app's data goes to the store its shape calls for. The `storage` crate owns t | Preferences and window geometry | `gio::Settings` | `AppSettings` | | Query history and saved queries | SQLite, one database | `QueryHistory`, `SavedQueries` | | Editor tab text | one file per tab | `DraftStore` | -| Open tabs, open sessions, column widths, filters | JSON files the app crate writes | `WorkspaceStore`, `OpenSessions`, `crates/app/src/persistence/` | +| Open tabs, open sessions, column layouts, filters | JSON files the app crate writes | `WorkspaceStore`, `OpenSessions`, `crates/app/src/persistence/` | [Where files go](#where-files-go) has the paths. @@ -94,7 +94,7 @@ leaves nothing unreachable behind when a step fails: entry stays: an entry with no secrets only asks for them again, while a secret with no entry is orphaned in the keyring. 2. The entry in `connections.json`. -3. Everything kept for it: column widths, filters, the saved tabs with any +3. Everything kept for it: column layouts, filters, the saved tabs with any copy Reset moved aside, the editor drafts (`WorkspaceStore::purge`), the saved queries, and the query history when the user turned that on. A failure here is reported and does not bring the entry back. @@ -120,7 +120,7 @@ nothing reaches outside it. | Accessor | Path | |---|---| | `connections_file()` | `$XDG_CONFIG_HOME//connections.json` | -| `column_widths_file()` | `$XDG_CONFIG_HOME//column_widths.json` | +| `column_widths_file()` | `$XDG_CONFIG_HOME//column_widths.json`: each table's column layout (order, hidden columns, widths the user set), version 2; a version 1 file of widths alone is upgraded on read | | `filter_settings_file()` | `$XDG_CONFIG_HOME//filter_settings.json` | | `history_database()` | `$XDG_STATE_HOME//history.db` | | `workspaces_dir()` | `$XDG_STATE_HOME//workspaces`: one file per session, and `open-sessions.json` | @@ -328,7 +328,7 @@ Three points worth knowing: Preference rows bind straight to the schema with `settings.gio().bind(key, &row, prop)`, so a change reaches every open view through `changed::` without a save step. -High-churn keyed data stays in files: column widths, per-table filters and workspace tabs are unbounded maps, which is not what GSettings is for. +High-churn keyed data stays in files: column layouts, per-table filters and workspace tabs are unbounded maps, which is not what GSettings is for. ## Errors diff --git a/linux/docs/testing.md b/linux/docs/testing.md index 40ef3b7c08..fbdb149c0f 100644 --- a/linux/docs/testing.md +++ b/linux/docs/testing.md @@ -491,7 +491,7 @@ What a person checks on a desktop because no suite can, each with the date it la ### Display scales -The app at 100, 125, 150, 175 and 200% on GNOME Wayland, at 200% on X11, on two monitors at different scales with the window moved from one to the other while a table, an editor and an ER diagram tab are open, on KDE Plasma Wayland at 125 and 150% with the Adwaita style, and at 150% with Large Text. At each scale: grid text, row height, header, pager and saved column widths; the focused cell ring, the selection and the pending insert, edit and delete tints; the SQL editor's text, gutter and completion list; the sidebar, the connect dialog, Preferences, the save alert, the main menu and About; the app icon and the three symbolic icons in `data/resources/icons`. +The app at 100, 125, 150, 175 and 200% on GNOME Wayland, at 200% on X11, on two monitors at different scales with the window moved from one to the other while a table, an editor and an ER diagram tab are open, on KDE Plasma Wayland at 125 and 150% with the Adwaita style, and at 150% with Large Text. At each scale: grid text, row height, header, pager and saved column widths; the focused cell ring, the selection, the row number column with its pending icons, and the marks on changed and deleted values; the SQL editor's text, gutter and completion list; the sidebar, the connect dialog, Preferences, the save alert, the main menu and About; the app icon and the three symbolic icons in `data/resources/icons`. #### 2026-10-24 diff --git a/linux/po/POTFILES.in b/linux/po/POTFILES.in index 8409d75650..99f7eed284 100644 --- a/linux/po/POTFILES.in +++ b/linux/po/POTFILES.in @@ -30,6 +30,7 @@ crates/app/src/i18n.rs crates/app/src/lib.rs crates/app/src/logging.rs crates/app/src/main.rs +crates/app/src/persistence/column_layout_store.rs crates/app/src/persistence/column_width_store.rs crates/app/src/persistence/column_widths_document.rs crates/app/src/persistence/filter_settings_document.rs @@ -71,6 +72,27 @@ crates/app/src/services/duplicate_name.rs crates/app/src/services/file_location.rs crates/app/src/services/file_location_kind.rs crates/app/src/services/file_resolution.rs +crates/app/src/services/grid/cell_editability.rs +crates/app/src/services/grid/cell_editor_kind.rs +crates/app/src/services/grid/cell_filter.rs +crates/app/src/services/grid/cell_lock.rs +crates/app/src/services/grid/cell_preset.rs +crates/app/src/services/grid/column_layout.rs +crates/app/src/services/grid/column_width.rs +crates/app/src/services/grid/copy_scope.rs +crates/app/src/services/grid/delete_availability.rs +crates/app/src/services/grid/display_text.rs +crates/app/src/services/grid/edit_navigation.rs +crates/app/src/services/grid/edit_parse.rs +crates/app/src/services/grid/edit_policy.rs +crates/app/src/services/grid/find_matches.rs +crates/app/src/services/grid/grid_column.rs +crates/app/src/services/grid/grid_row_id.rs +crates/app/src/services/grid/mod.rs +crates/app/src/services/grid/row_key_builder.rs +crates/app/src/services/grid/row_snapshot.rs +crates/app/src/services/grid/test_columns.rs +crates/app/src/services/grid/value_order.rs crates/app/src/services/history_availability.rs crates/app/src/services/history_preferences.rs crates/app/src/services/history_service.rs @@ -148,20 +170,16 @@ crates/app/src/ui/browse_tab/browse_pager.rs crates/app/src/ui/browse_tab/browse_request_kind.rs crates/app/src/ui/browse_tab/browse_tab_input.rs crates/app/src/ui/browse_tab/browse_tab_widgets.rs -crates/app/src/ui/browse_tab/clipboard_handlers.rs -crates/app/src/ui/browse_tab/clipboard_input.rs crates/app/src/ui/browse_tab/column_recheck.rs crates/app/src/ui/browse_tab/columns_fetch.rs crates/app/src/ui/browse_tab/command_handlers.rs -crates/app/src/ui/browse_tab/confirming_delete.rs -crates/app/src/ui/browse_tab/confirming_paste.rs crates/app/src/ui/browse_tab/count_failure_text.rs crates/app/src/ui/browse_tab/count_policy.rs crates/app/src/ui/browse_tab/data_handlers.rs crates/app/src/ui/browse_tab/data_input.rs -crates/app/src/ui/browse_tab/edit_handlers.rs -crates/app/src/ui/browse_tab/edit_input.rs crates/app/src/ui/browse_tab/fetch.rs +crates/app/src/ui/browse_tab/grid_handlers.rs +crates/app/src/ui/browse_tab/grid_input.rs crates/app/src/ui/browse_tab/imp.rs crates/app/src/ui/browse_tab/layout.rs crates/app/src/ui/browse_tab/mod.rs @@ -171,23 +189,17 @@ crates/app/src/ui/browse_tab/page_state.rs crates/app/src/ui/browse_tab/pager_bar.rs crates/app/src/ui/browse_tab/paging_handlers.rs crates/app/src/ui/browse_tab/paging_input.rs -crates/app/src/ui/browse_tab/paste_at.rs -crates/app/src/ui/browse_tab/paste_handlers.rs -crates/app/src/ui/browse_tab/paste_read.rs crates/app/src/ui/browse_tab/pending_changes_bar.rs crates/app/src/ui/browse_tab/row_count.rs -crates/app/src/ui/browse_tab/row_deletion.rs crates/app/src/ui/browse_tab/row_total.rs crates/app/src/ui/browse_tab/save_handlers.rs crates/app/src/ui/browse_tab/save_input.rs -crates/app/src/ui/browse_tab/selection_handlers.rs -crates/app/src/ui/browse_tab/selection_input.rs -crates/app/src/ui/browse_tab/selection_snapshot.rs crates/app/src/ui/browse_tab/table_filter.rs crates/app/src/ui/browse_tab/tests/load.rs crates/app/src/ui/browse_tab/tests/mod.rs -crates/app/src/ui/browse_tab/tests/paste.rs crates/app/src/ui/browse_tab/tests/references.rs +crates/app/src/ui/browse_tab/tests/rows.rs +crates/app/src/ui/browse_tab/tests/save.rs crates/app/src/ui/browse_tab/tests/total.rs crates/app/src/ui/clipboard/clipboard_copy.rs crates/app/src/ui/clipboard/clipboard_format.rs @@ -217,6 +229,59 @@ crates/app/src/ui/credential_prompt/presenter.rs crates/app/src/ui/credential_prompt/prompt_form.rs crates/app/src/ui/credential_prompt/prompt_text.rs crates/app/src/ui/credential_prompt/prompter.rs +crates/app/src/ui/data_grid/client_sorter.rs +crates/app/src/ui/data_grid/column_chooser.rs +crates/app/src/ui/data_grid/columns.rs +crates/app/src/ui/data_grid/copy/mod.rs +crates/app/src/ui/data_grid/copy/tests.rs +crates/app/src/ui/data_grid/edit/actions.rs +crates/app/src/ui/data_grid/edit/cell_editor.rs +crates/app/src/ui/data_grid/edit/deletion.rs +crates/app/src/ui/data_grid/edit/mod.rs +crates/app/src/ui/data_grid/edit/paste.rs +crates/app/src/ui/data_grid/edit/row_edits.rs +crates/app/src/ui/data_grid/edit/tests/mod.rs +crates/app/src/ui/data_grid/edit/tests/paste.rs +crates/app/src/ui/data_grid/edit/tests/rows.rs +crates/app/src/ui/data_grid/find_bar.rs +crates/app/src/ui/data_grid/grid_actions.rs +crates/app/src/ui/data_grid/grid_cell.rs +crates/app/src/ui/data_grid/grid_config.rs +crates/app/src/ui/data_grid/grid_menu.rs +crates/app/src/ui/data_grid/grid_request.rs +crates/app/src/ui/data_grid/grid_row.rs +crates/app/src/ui/data_grid/grid_store.rs +crates/app/src/ui/data_grid/imp.rs +crates/app/src/ui/data_grid/keys.rs +crates/app/src/ui/data_grid/layout_binding.rs +crates/app/src/ui/data_grid/mod.rs +crates/app/src/ui/data_grid/pointer.rs +crates/app/src/ui/data_grid/references/mod.rs +crates/app/src/ui/data_grid/references/tests.rs +crates/app/src/ui/data_grid/row_detail/calendar.rs +crates/app/src/ui/data_grid/row_detail/fields.rs +crates/app/src/ui/data_grid/row_detail/imp.rs +crates/app/src/ui/data_grid/row_detail/mod.rs +crates/app/src/ui/data_grid/row_detail/tests.rs +crates/app/src/ui/data_grid/row_detail/value_editor.rs +crates/app/src/ui/data_grid/row_json_dialog.rs +crates/app/src/ui/data_grid/row_number_column.rs +crates/app/src/ui/data_grid/selection.rs +crates/app/src/ui/data_grid/server_sorter.rs +crates/app/src/ui/data_grid/table_keys.rs +crates/app/src/ui/data_grid/test_grid.rs +crates/app/src/ui/data_grid/tests/cells.rs +crates/app/src/ui/data_grid/tests/columns.rs +crates/app/src/ui/data_grid/tests/gutter.rs +crates/app/src/ui/data_grid/tests/keyboard.rs +crates/app/src/ui/data_grid/tests/layout.rs +crates/app/src/ui/data_grid/tests/menus.rs +crates/app/src/ui/data_grid/tests/mod.rs +crates/app/src/ui/data_grid/tests/selection.rs +crates/app/src/ui/data_grid/tests/sorting.rs +crates/app/src/ui/data_grid/tests/values.rs +crates/app/src/ui/data_grid/tests/view.rs +crates/app/src/ui/data_grid/tracked_values.rs crates/app/src/ui/delete_connection_dialog.rs crates/app/src/ui/diagram/canvas.rs crates/app/src/ui/diagram/canvas_imp.rs @@ -338,57 +403,6 @@ crates/app/src/ui/filter_strip/rule_value_editor.rs crates/app/src/ui/filter_strip/tests.rs crates/app/src/ui/format/mod.rs crates/app/src/ui/format/thousands.rs -crates/app/src/ui/grid/calendar_editor.rs -crates/app/src/ui/grid/cell_accessibility.rs -crates/app/src/ui/grid/cell_binding.rs -crates/app/src/ui/grid/cell_edit.rs -crates/app/src/ui/grid/cell_editability.rs -crates/app/src/ui/grid/cell_editor_kind.rs -crates/app/src/ui/grid/cell_focus.rs -crates/app/src/ui/grid/cell_lock.rs -crates/app/src/ui/grid/cell_preset.rs -crates/app/src/ui/grid/cell_tooltip.rs -crates/app/src/ui/grid/column_drawing.rs -crates/app/src/ui/grid/column_factory.rs -crates/app/src/ui/grid/column_figures.rs -crates/app/src/ui/grid/column_resize.rs -crates/app/src/ui/grid/column_width_binding.rs -crates/app/src/ui/grid/context_menu.rs -crates/app/src/ui/grid/delete_availability.rs -crates/app/src/ui/grid/display_text.rs -crates/app/src/ui/grid/edit_commit.rs -crates/app/src/ui/grid/edit_navigation.rs -crates/app/src/ui/grid/edit_snapshot.rs -crates/app/src/ui/grid/grid_attributes.rs -crates/app/src/ui/grid/grid_cell.rs -crates/app/src/ui/grid/grid_columns.rs -crates/app/src/ui/grid/grid_content.rs -crates/app/src/ui/grid/grid_keyboard.rs -crates/app/src/ui/grid/grid_menus.rs -crates/app/src/ui/grid/grid_msg.rs -crates/app/src/ui/grid/grid_row_id.rs -crates/app/src/ui/grid/grid_row_index.rs -crates/app/src/ui/grid/inline_editor.rs -crates/app/src/ui/grid/menu_rows.rs -crates/app/src/ui/grid/mod.rs -crates/app/src/ui/grid/multiline_editor.rs -crates/app/src/ui/grid/reference_menu.rs -crates/app/src/ui/grid/result_grid.rs -crates/app/src/ui/grid/result_sorters.rs -crates/app/src/ui/grid/row_factory.rs -crates/app/src/ui/grid/row_json_dialog.rs -crates/app/src/ui/grid/row_key_builder.rs -crates/app/src/ui/grid/row_object.rs -crates/app/src/ui/grid/row_snapshot.rs -crates/app/src/ui/grid/row_state_column.rs -crates/app/src/ui/grid/row_state_icon.rs -crates/app/src/ui/grid/row_watches.rs -crates/app/src/ui/grid/selection.rs -crates/app/src/ui/grid/tab_grid_context.rs -crates/app/src/ui/grid/table_keys.rs -crates/app/src/ui/grid/test_grid.rs -crates/app/src/ui/grid/toggle_cell.rs -crates/app/src/ui/grid/value_order.rs crates/app/src/ui/handler_guard.rs crates/app/src/ui/history_dialog/connection_filter.rs crates/app/src/ui/history_dialog/history_actions.rs @@ -646,9 +660,11 @@ data/resources/gtk/menus.ui data/resources/shortcuts-dialog.ui data/resources/ui/assistant-pane.ui data/resources/ui/browse-tab.ui +data/resources/ui/column-chooser.ui data/resources/ui/connect-dialog.ui data/resources/ui/connect-failed-page.ui data/resources/ui/connecting-page.ui +data/resources/ui/data-grid.ui data/resources/ui/database-file-row.ui data/resources/ui/diagram-tab.ui data/resources/ui/export-operations-button.ui @@ -668,6 +684,7 @@ data/resources/ui/preferences-general-page.ui data/resources/ui/preferences-history-group.ui data/resources/ui/preferences-mcp-page.ui data/resources/ui/results-pane.ui +data/resources/ui/row-detail-pane.ui data/resources/ui/session-window.ui data/resources/ui/sql-editor.ui data/resources/ui/ssh-section.ui From f5d1e3f4853306a7ff555f97dcad0fe8d0d03c0f Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 26 Oct 2026 00:28:08 +0700 Subject: [PATCH 2/4] docs(linux-grid): add the changelog fragments --- linux/changelog.d/3836.added.1.md | 1 + linux/changelog.d/3836.added.2.md | 1 + linux/changelog.d/3836.added.md | 1 + linux/changelog.d/3836.changed.md | 1 + linux/changelog.d/3836.fixed.1.md | 1 + linux/changelog.d/3836.fixed.2.md | 1 + linux/changelog.d/3836.fixed.3.md | 1 + linux/changelog.d/3836.fixed.4.md | 1 + linux/changelog.d/3836.fixed.5.md | 1 + linux/changelog.d/3836.fixed.6.md | 1 + linux/changelog.d/3836.fixed.md | 1 + 11 files changed, 11 insertions(+) create mode 100644 linux/changelog.d/3836.added.1.md create mode 100644 linux/changelog.d/3836.added.2.md create mode 100644 linux/changelog.d/3836.added.md create mode 100644 linux/changelog.d/3836.changed.md create mode 100644 linux/changelog.d/3836.fixed.1.md create mode 100644 linux/changelog.d/3836.fixed.2.md create mode 100644 linux/changelog.d/3836.fixed.3.md create mode 100644 linux/changelog.d/3836.fixed.4.md create mode 100644 linux/changelog.d/3836.fixed.5.md create mode 100644 linux/changelog.d/3836.fixed.6.md create mode 100644 linux/changelog.d/3836.fixed.md diff --git a/linux/changelog.d/3836.added.1.md b/linux/changelog.d/3836.added.1.md new file mode 100644 index 0000000000..1f21b5c38b --- /dev/null +++ b/linux/changelog.d/3836.added.1.md @@ -0,0 +1 @@ +Find text in SQL results and key tabs with Ctrl+F. diff --git a/linux/changelog.d/3836.added.2.md b/linux/changelog.d/3836.added.2.md new file mode 100644 index 0000000000..2c7e76b0a4 --- /dev/null +++ b/linux/changelog.d/3836.added.2.md @@ -0,0 +1 @@ +Filter a table by a cell's value, or exclude it, from the cell's menu. diff --git a/linux/changelog.d/3836.added.md b/linux/changelog.d/3836.added.md new file mode 100644 index 0000000000..ae452f2031 --- /dev/null +++ b/linux/changelog.d/3836.added.md @@ -0,0 +1 @@ +Hide, show and reorder columns from the Columns dialog or the header; each table keeps its layout. diff --git a/linux/changelog.d/3836.changed.md b/linux/changelog.d/3836.changed.md new file mode 100644 index 0000000000..c21f6ca5b4 --- /dev/null +++ b/linux/changelog.d/3836.changed.md @@ -0,0 +1 @@ +Table tabs, SQL results and key tabs show rows in one grid with row numbers and a Row Details pane. diff --git a/linux/changelog.d/3836.fixed.1.md b/linux/changelog.d/3836.fixed.1.md new file mode 100644 index 0000000000..871e09a868 --- /dev/null +++ b/linux/changelog.d/3836.fixed.1.md @@ -0,0 +1 @@ +Insert Row and Duplicate in the menu of a table without a primary key added rows that saved without a key. diff --git a/linux/changelog.d/3836.fixed.2.md b/linux/changelog.d/3836.fixed.2.md new file mode 100644 index 0000000000..a03dc79cef --- /dev/null +++ b/linux/changelog.d/3836.fixed.2.md @@ -0,0 +1 @@ +Ctrl+S skipped the value still being typed in a cell. diff --git a/linux/changelog.d/3836.fixed.3.md b/linux/changelog.d/3836.fixed.3.md new file mode 100644 index 0000000000..3ff5892808 --- /dev/null +++ b/linux/changelog.d/3836.fixed.3.md @@ -0,0 +1 @@ +A cell of a row marked for deletion could still be edited, and the edit was never saved. diff --git a/linux/changelog.d/3836.fixed.4.md b/linux/changelog.d/3836.fixed.4.md new file mode 100644 index 0000000000..935937f391 --- /dev/null +++ b/linux/changelog.d/3836.fixed.4.md @@ -0,0 +1 @@ +Sorting or editing a MongoDB field whose name has a dot or starts with $ failed at the server. diff --git a/linux/changelog.d/3836.fixed.5.md b/linux/changelog.d/3836.fixed.5.md new file mode 100644 index 0000000000..03614d90bc --- /dev/null +++ b/linux/changelog.d/3836.fixed.5.md @@ -0,0 +1 @@ +The selection count offered Delete on rows that Delete could not remove. diff --git a/linux/changelog.d/3836.fixed.6.md b/linux/changelog.d/3836.fixed.6.md new file mode 100644 index 0000000000..831ec8b1ea --- /dev/null +++ b/linux/changelog.d/3836.fixed.6.md @@ -0,0 +1 @@ +Booleans showed as check boxes, text or numbers depending on the grid, and made rows taller. diff --git a/linux/changelog.d/3836.fixed.md b/linux/changelog.d/3836.fixed.md new file mode 100644 index 0000000000..5b96a052da --- /dev/null +++ b/linux/changelog.d/3836.fixed.md @@ -0,0 +1 @@ +Ctrl+C copied nothing in SQL results and key tabs. From f5c16412f90073bbc09e3ab297d6da6365454a18 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 26 Oct 2026 03:49:31 +0700 Subject: [PATCH 3/4] fix(linux-grid): pin Row Details and the assistant, scroll Row Details in a short grid --- linux/build-aux/rpm/tablepro.spec | 4 +- linux/changelog.d/3836.fixed.7.md | 1 + .../app/src/ui/data_grid/row_detail/tests.rs | 35 ++ .../app/src/ui/workspace/tests/assistant.rs | 19 + linux/data/resources/ui/data-grid.ui | 3 + linux/data/resources/ui/row-detail-pane.ui | 341 +++++++++--------- linux/data/resources/ui/workspace.ui | 4 + 7 files changed, 239 insertions(+), 168 deletions(-) create mode 100644 linux/changelog.d/3836.fixed.7.md diff --git a/linux/build-aux/rpm/tablepro.spec b/linux/build-aux/rpm/tablepro.spec index bd4eef4a13..15d8da05c6 100644 --- a/linux/build-aux/rpm/tablepro.spec +++ b/linux/build-aux/rpm/tablepro.spec @@ -148,7 +148,9 @@ appstreamcli validate --no-net %{buildroot}%{_metainfodir}/app.tablepro.TablePro export CARGO_PROFILE_RELEASE_LTO=thin export CARGO_BUILD_JOBS=%{_smp_build_ncpus} # %%meson_test expands to lines of its own; shrink joins them to this one. -%{shrink:xvfb-run -a dbus-run-session -- %meson_test} +# Fedora's xvfb-run screen is 640x480, which the app-scale-2 test halves +# below the smallest screen the app supports; this is Debian's default. +%{shrink:xvfb-run -a -s "-screen 0 1280x1024x24" dbus-run-session -- %meson_test} %endif %files -f %{name}.lang diff --git a/linux/changelog.d/3836.fixed.7.md b/linux/changelog.d/3836.fixed.7.md new file mode 100644 index 0000000000..9dcb52bbc5 --- /dev/null +++ b/linux/changelog.d/3836.fixed.7.md @@ -0,0 +1 @@ +The AI assistant opened by itself when the window got wider. diff --git a/linux/crates/app/src/ui/data_grid/row_detail/tests.rs b/linux/crates/app/src/ui/data_grid/row_detail/tests.rs index 3c9661be31..55c5668801 100644 --- a/linux/crates/app/src/ui/data_grid/row_detail/tests.rs +++ b/linux/crates/app/src/ui/data_grid/row_detail/tests.rs @@ -803,6 +803,41 @@ fn row_details_from_a_row_menu_shows_that_row() { assert_eq!(pane.typed_text(), "B"); } +/// A grid that narrows past its breakpoint and widens again leaves Row +/// Details as the user left it. +#[gtk4::test] +fn crossing_the_breakpoint_neither_opens_nor_closes_row_details() { + let grid = Grid::open(two_text_columns(), two_text_values()); + let split_view = grid.grid.imp().split_view.get(); + for collapsed in [true, false] { + split_view.set_collapsed(collapsed); + assert!(!split_view.shows_sidebar(), "Row Details opened unasked"); + } + + grid.activate("show-details"); + for collapsed in [true, false] { + split_view.set_collapsed(collapsed); + assert!(split_view.shows_sidebar(), "Row Details closed unasked"); + } +} + +/// Row Details scrolls in a short grid rather than making the grid need +/// more height than its rows, which a host at its smallest cuts off. +#[gtk4::test] +fn row_details_never_make_the_grid_taller() { + let (grid, pane) = opened(two_text_columns(), two_text_values()); + take_keyboard(&grid); + grid.focus(0, 1); + pane.imp().message_label.set_label("a long reason"); + pane.imp().message_label.set_visible(true); + let split_view = grid.grid.imp().split_view.get(); + let high = |widget: >k::Widget| widget.measure(gtk::Orientation::Vertical, -1).0; + + let (rows, details) = (high(&split_view.content().expect("the rows")), high(pane.upcast_ref())); + + assert!(details <= rows, "Row Details need {details} px high, the rows {rows}"); +} + /// Alt+Return closes Row Details from inside the pane, where the keyboard /// is once a field was picked. #[gtk4::test] diff --git a/linux/crates/app/src/ui/workspace/tests/assistant.rs b/linux/crates/app/src/ui/workspace/tests/assistant.rs index a2f91d4cb0..44e7df5251 100644 --- a/linux/crates/app/src/ui/workspace/tests/assistant.rs +++ b/linux/crates/app/src/ui/workspace/tests/assistant.rs @@ -48,6 +48,25 @@ fn choosing_a_provider_brings_the_toggle_and_the_key_shows_the_pane() { assert!(!imp.assistant_split.shows_sidebar(), "the pane stayed with no provider"); } +/// A window that narrows past a breakpoint and widens again leaves the +/// assistant as the user left it. +#[gtk4::test] +fn crossing_a_breakpoint_neither_opens_nor_closes_the_assistant() { + let test = TestWindow::connected("AssistantPinned"); + let workspace = test.workspace(); + let split = workspace.imp().assistant_split.get(); + for collapsed in [true, false] { + split.set_collapsed(collapsed); + assert!(!split.shows_sidebar(), "the assistant opened unasked"); + } + + split.set_show_sidebar(true); + for collapsed in [true, false] { + split.set_collapsed(collapsed); + assert!(split.shows_sidebar(), "the assistant closed unasked"); + } +} + #[gtk4::test] fn insert_goes_to_the_editor_in_front_or_a_new_one() { let test = TestWindow::connected("AssistantInsert"); diff --git a/linux/data/resources/ui/data-grid.ui b/linux/data/resources/ui/data-grid.ui index fa99f9aab2..4ddaaa06aa 100644 --- a/linux/data/resources/ui/data-grid.ui +++ b/linux/data/resources/ui/data-grid.ui @@ -21,6 +21,9 @@ end false + + true 260 420 0.32 diff --git a/linux/data/resources/ui/row-detail-pane.ui b/linux/data/resources/ui/row-detail-pane.ui index a199ea7fbc..6c49dfaf69 100644 --- a/linux/data/resources/ui/row-detail-pane.ui +++ b/linux/data/resources/ui/row-detail-pane.ui @@ -50,214 +50,221 @@ row - - vertical - + + + never + - 6 - 6 - 6 - 12 - 6 + vertical - vertical - true - center + 6 + 6 + 6 + 12 + 6 - - 0 - end - - - - - - 4 - false - - - 16 - presentation - - + + vertical + true + center - + 0 end + + + 4 + false + + + 16 + presentation + + + + + 0 + end + + + + + + + + + + window-close-symbolic + center + Close Row Details + + Close Row Details + + - - window-close-symbolic - center - Close Row Details - - Close Row Details - - - + - - - - - - - - vertical - true - false - false - - - never - 120 - - - - Fields - + + + vertical + true + false + false + + + never + 120 + + + + Fields + + + - - - - - vertical - + - 6 - 6 - 6 - 12 - 6 + vertical - - true - 0 - end - - - - - - x-office-calendar-symbolic - false - Pick a Date - - Pick a Date - - - - - - - - Pick a Date - + + 6 + 6 + 6 + 12 + 6 + + + true + 0 + end + + + + + + x-office-calendar-symbolic + false + Pick a Date + + Pick a Date + + + + + + + + Pick a Date + + + - + + + + view-more-symbolic + value_menu + More Actions + + More Actions + + + + - - view-more-symbolic - value_menu - More Actions - - More Actions - - - - - - - - - - true - - - never - 120 + + + true - - true - word-char - true - 6 - 6 - 12 - 12 - - + + never + 120 + + + true + word-char + true + 6 + 6 + 12 + 12 + + + + + + + start + start + 6 + 12 + false + false + presentation + + + + + + - - - - start - start + + + + 0 + true + word-char + false 6 + 6 12 - false - false - presentation - - - + 12 - - - - 0 - true - word-char - false - 6 - 6 - 12 - 12 - - - + - + - + diff --git a/linux/data/resources/ui/workspace.ui b/linux/data/resources/ui/workspace.ui index 60b333bd70..68b7df06eb 100644 --- a/linux/data/resources/ui/workspace.ui +++ b/linux/data/resources/ui/workspace.ui @@ -112,6 +112,10 @@ end false + + true 300 440 0.32 From a7bd3da37d7592b2a5f67b8b9df516de98edf587 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 26 Oct 2026 07:00:53 +0700 Subject: [PATCH 4/4] fix(linux-grid): open the Row Details calendar toward the rows and bump the yanked crypto-bigint --- linux/Cargo.lock | 4 ++-- linux/build-aux/debian/copyright | 2 +- .../crates/app/src/ui/data_grid/row_detail/calendar.rs | 7 +++++++ linux/crates/app/src/ui/data_grid/row_detail/tests.rs | 10 ++++++++++ linux/crates/app/src/ui/data_grid/test_grid.rs | 3 +++ linux/data/licenses/THIRD-PARTY-NOTICE.md | 2 +- linux/data/licenses/third-party.json | 4 ++-- 7 files changed, 26 insertions(+), 6 deletions(-) diff --git a/linux/Cargo.lock b/linux/Cargo.lock index a25365bd48..b54511224f 100644 --- a/linux/Cargo.lock +++ b/linux/Cargo.lock @@ -1180,9 +1180,9 @@ checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" [[package]] name = "crypto-bigint" -version = "0.7.6" +version = "0.7.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6bb08299a6046833a879ace115697f12fd2bd6c8d3e93c909ffc9e0738e7cae0" +checksum = "d811ea23fdfbcce2e2d5132344d471abdb8f901ac496a0893a3df92e70525e52" dependencies = [ "cpubits", "ctutils", diff --git a/linux/build-aux/debian/copyright b/linux/build-aux/debian/copyright index 547d9e6ac8..d48e3c1655 100644 --- a/linux/build-aux/debian/copyright +++ b/linux/build-aux/debian/copyright @@ -2658,7 +2658,7 @@ License: Expat OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -Files: vendor/crypto-bigint-0.7.6/* +Files: vendor/crypto-bigint-0.7.9/* vendor/spki-0.8.0/* Copyright: 2021-2026 The RustCrypto Project Developers License: Apache-2.0 or Expat diff --git a/linux/crates/app/src/ui/data_grid/row_detail/calendar.rs b/linux/crates/app/src/ui/data_grid/row_detail/calendar.rs index cccb56a40b..ee4cc47678 100644 --- a/linux/crates/app/src/ui/data_grid/row_detail/calendar.rs +++ b/linux/crates/app/src/ui/data_grid/row_detail/calendar.rs @@ -47,6 +47,13 @@ pub fn day_start_offset(zone: &Tz, day: NaiveDate) -> FixedOffset impl RowDetailPane { pub(super) fn connect_calendar(&self) { let imp = self.imp(); + // Dropped below a button halfway down the pane, the calendar fits + // neither below nor above it on a short screen and GTK closes it; + // opened toward the rows, it slides up or down to fit. + imp.calendar_button.set_direction(match self.direction() { + gtk::TextDirection::Rtl => gtk::ArrowType::Right, + _ => gtk::ArrowType::Left, + }); imp.calendar_popover.connect_show(glib::clone!( #[weak(rename_to = pane)] self, diff --git a/linux/crates/app/src/ui/data_grid/row_detail/tests.rs b/linux/crates/app/src/ui/data_grid/row_detail/tests.rs index 55c5668801..653dfb4429 100644 --- a/linux/crates/app/src/ui/data_grid/row_detail/tests.rs +++ b/linux/crates/app/src/ui/data_grid/row_detail/tests.rs @@ -512,6 +512,16 @@ fn the_calendar_writes_a_date() { assert_eq!(pane.typed_text(), "2024-03-01"); } +#[gtk4::test] +fn the_calendar_opens_toward_the_rows() { + let (_grid, pane) = date_grid(a_date(2024, 2, 29)); + assert_eq!( + pane.imp().calendar_button.direction(), + gtk::ArrowType::Left, + "a calendar dropped below the pane's button has no room on a short screen" + ); +} + #[gtk4::test] fn clicking_today_on_a_null_date_writes_it() { let (grid, pane) = date_grid(Value::Null); diff --git a/linux/crates/app/src/ui/data_grid/test_grid.rs b/linux/crates/app/src/ui/data_grid/test_grid.rs index 7a53cf947b..1c2d10d237 100644 --- a/linux/crates/app/src/ui/data_grid/test_grid.rs +++ b/linux/crates/app/src/ui/data_grid/test_grid.rs @@ -215,6 +215,9 @@ impl Grid { // A saved layout can hide columns, which draw no cells. let width = opened.grid.shown_columns().len(); wait_until(WAIT, || opened.cells().len() >= width * count.min(20)).expect("the cells are drawn"); + // Rows are measured before the window's first layout; a user meets + // the grid laid out, with a width Row Details can open from. + wait_until(WAIT, || opened.grid.width() > 0).expect("the grid is laid out"); opened } diff --git a/linux/data/licenses/THIRD-PARTY-NOTICE.md b/linux/data/licenses/THIRD-PARTY-NOTICE.md index 322ffd389b..c2555623ea 100644 --- a/linux/data/licenses/THIRD-PARTY-NOTICE.md +++ b/linux/data/licenses/THIRD-PARTY-NOTICE.md @@ -6735,7 +6735,7 @@ DEALINGS IN THE SOFTWARE. Used by: -- crypto-bigint 0.7.6 +- crypto-bigint 0.7.9 - spki 0.8.0 ~~~~text diff --git a/linux/data/licenses/third-party.json b/linux/data/licenses/third-party.json index 08e782fda4..6c14ece54b 100644 --- a/linux/data/licenses/third-party.json +++ b/linux/data/licenses/third-party.json @@ -1004,7 +1004,7 @@ "name": "MIT License", "text": "Copyright (c) 2021-2026 The RustCrypto Project Developers\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n", "used_by": [ - { "crate": { "name": "crypto-bigint", "version": "0.7.6" } }, + { "crate": { "name": "crypto-bigint", "version": "0.7.9" } }, { "crate": { "name": "spki", "version": "0.8.0" } } ] }, @@ -2328,7 +2328,7 @@ { "name": "crossbeam-utils", "version": "0.8.23", "license": "MIT OR Apache-2.0" }, { "name": "crossterm", "version": "0.28.1", "license": "MIT" }, { "name": "crunchy", "version": "0.2.4", "license": "MIT" }, - { "name": "crypto-bigint", "version": "0.7.6", "license": "Apache-2.0 OR MIT" }, + { "name": "crypto-bigint", "version": "0.7.9", "license": "Apache-2.0 OR MIT" }, { "name": "crypto-common", "version": "0.1.7", "license": "MIT OR Apache-2.0" }, { "name": "crypto-common", "version": "0.2.2", "license": "MIT OR Apache-2.0" }, { "name": "crypto-primes", "version": "0.7.2", "license": "Apache-2.0 OR MIT" },