diff --git a/linux/changelog.d/3566.added.md b/linux/changelog.d/3566.added.md new file mode 100644 index 0000000000..673a5a6f87 --- /dev/null +++ b/linux/changelog.d/3566.added.md @@ -0,0 +1 @@ +Widen Column and Narrow Column in the grid cell menu, on Shift+Alt+Right and Shift+Alt+Left. diff --git a/linux/crates/app/src/application/accelerators.rs b/linux/crates/app/src/application/accelerators.rs index b194fa618b..619ac096de 100644 --- a/linux/crates/app/src/application/accelerators.rs +++ b/linux/crates/app/src/application/accelerators.rs @@ -159,6 +159,10 @@ pub const ACCELERATORS: &[Accelerator] = &[ component(GridCell, "cell.toggle", &["space"], Listed(DataGrid)), component(GridCell, "cell.menu", &["Menu", "F10"], Listed(DataGrid)), component(GridCell, "cell.set-null", &["BackSpace"], 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)), @@ -422,6 +426,8 @@ pub(crate) fn keypad_twins(key: gdk::Key) -> &'static [gdk::Key] { gdk::Key::Return => &[gdk::Key::ISO_Enter, gdk::Key::KP_Enter], gdk::Key::space => &[gdk::Key::KP_Space], gdk::Key::Delete => &[gdk::Key::KP_Delete], + gdk::Key::Left => &[gdk::Key::KP_Left], + gdk::Key::Right => &[gdk::Key::KP_Right], _ => &[], } } diff --git a/linux/crates/app/src/ui/grid/column_figures.rs b/linux/crates/app/src/ui/grid/column_figures.rs index 2449d5b558..4c3725e0b5 100644 --- a/linux/crates/app/src/ui/grid/column_figures.rs +++ b/linux/crates/app/src/ui/grid/column_figures.rs @@ -168,7 +168,7 @@ fn widest<'a>(column_type: &ColumnType, values: impl Iterator) /// How wide `chars` digits are in tabular figures, which libadwaita's /// `numeric` class draws. -fn digits_width(widget: &impl IsA, chars: u32) -> i32 { +pub(super) fn digits_width(widget: &impl IsA, chars: u32) -> i32 { tabular_width(widget, &"0".repeat(usize::try_from(chars).unwrap_or_default())) } diff --git a/linux/crates/app/src/ui/grid/column_resize.rs b/linux/crates/app/src/ui/grid/column_resize.rs new file mode 100644 index 0000000000..859f980565 --- /dev/null +++ b/linux/crates/app/src/ui/grid/column_resize.rs @@ -0,0 +1,221 @@ +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); + + 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/context_menu.rs b/linux/crates/app/src/ui/grid/context_menu.rs index 57a9688ed3..e0a9634832 100644 --- a/linux/crates/app/src/ui/grid/context_menu.rs +++ b/linux/crates/app/src/ui/grid/context_menu.rs @@ -229,6 +229,25 @@ mod tests { 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() diff --git a/linux/crates/app/src/ui/grid/grid_keyboard.rs b/linux/crates/app/src/ui/grid/grid_keyboard.rs index f62c3c8844..87a5000208 100644 --- a/linux/crates/app/src/ui/grid/grid_keyboard.rs +++ b/linux/crates/app/src/ui/grid/grid_keyboard.rs @@ -4,6 +4,7 @@ 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::{CellPreset, GridCell, GridMsg, GridSink}; @@ -36,6 +37,14 @@ impl GridKeyboard { 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.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); } } @@ -89,6 +98,14 @@ fn show_menu(widget: >k::Widget, cell: &GridCell, menus: &GridMenus) -> glib:: 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; diff --git a/linux/crates/app/src/ui/grid/grid_menus.rs b/linux/crates/app/src/ui/grid/grid_menus.rs index 3fb2784762..c1aaeaa71a 100644 --- a/linux/crates/app/src/ui/grid/grid_menus.rs +++ b/linux/crates/app/src/ui/grid/grid_menus.rs @@ -9,6 +9,7 @@ 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::row_json_dialog::RowJsonRequest; @@ -180,6 +181,14 @@ fn build_cell_menu(shape: MenuShape) -> gio::Menu { 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"), @@ -321,8 +330,8 @@ impl CellActionKit { entries } - /// Sort by the cell's column, as a header click does, or reset the - /// width a header drag set. + /// 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(); @@ -344,6 +353,14 @@ impl CellActionKit { 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(); @@ -353,6 +370,7 @@ impl CellActionKit { 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); } @@ -362,6 +380,8 @@ impl CellActionKit { 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, ] } @@ -524,7 +544,7 @@ fn install_empty_space_menu(column_view: >k::ColumnView) -> gtk::PopoverMenu { } /// The view's column that `cell` sits in. -fn shown_column(view: >k::ColumnView, cell: &GridCell) -> Option { +pub(super) fn shown_column(view: >k::ColumnView, cell: &GridCell) -> Option { cell.columns()? .shown(view) .into_iter() diff --git a/linux/crates/app/src/ui/grid/mod.rs b/linux/crates/app/src/ui/grid/mod.rs index cd039973fe..221e7c4931 100644 --- a/linux/crates/app/src/ui/grid/mod.rs +++ b/linux/crates/app/src/ui/grid/mod.rs @@ -11,6 +11,7 @@ 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; diff --git a/linux/data/resources/shortcuts-dialog.ui b/linux/data/resources/shortcuts-dialog.ui index b87c4350a6..7a8aba7fbb 100644 --- a/linux/data/resources/shortcuts-dialog.ui +++ b/linux/data/resources/shortcuts-dialog.ui @@ -216,6 +216,18 @@ <Primary>BackSpace + + + Widen Column + <Shift><Alt>Right + + + + + Narrow Column + <Shift><Alt>Left + + Finish Editing diff --git a/linux/po/POTFILES.in b/linux/po/POTFILES.in index 243abb11dc..60f9122734 100644 --- a/linux/po/POTFILES.in +++ b/linux/po/POTFILES.in @@ -292,6 +292,7 @@ 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 diff --git a/linux/po/tablepro.pot b/linux/po/tablepro.pot index 8dece2d49f..e1156248f7 100644 --- a/linux/po/tablepro.pot +++ b/linux/po/tablepro.pot @@ -2735,6 +2735,14 @@ msgstr "" msgid "Clear Sort" msgstr "" +#: crates/app/src/ui/grid/grid_menus.rs data/resources/shortcuts-dialog.ui +msgid "Widen Column" +msgstr "" + +#: crates/app/src/ui/grid/grid_menus.rs data/resources/shortcuts-dialog.ui +msgid "Narrow Column" +msgstr "" + #: crates/app/src/ui/grid/grid_menus.rs msgid "Reset Column Width" msgstr ""