Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions linux/changelog.d/3932.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Every other row of a data grid is shaded, with a switch in Preferences to turn it off.
17 changes: 15 additions & 2 deletions linux/crates/app/src/ui/data_grid/columns.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ use crate::ui::handler_guard::HandlerGuard;

/// 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
/// The padding `style.css` gives a cell's child, both sides, and the
/// column separator.
const CELL_CHROME_PX: i32 = 13;
/// A resize step, in digits of the font the cells draw in, so a step is
Expand Down Expand Up @@ -204,6 +204,7 @@ impl DataGrid {
let grid_cell = GridCell::new(setup_paint.clone());
grid_cell.attach(cell);
cell.set_child(Some(&grid_cell));
super::stripes::follow(cell);
});
factory.connect_bind(|_, item| {
let Some(cell) = item.downcast_ref::<gtk::ColumnViewCell>() else {
Expand Down Expand Up @@ -754,8 +755,20 @@ impl DataGrid {

// GTK makes the header and rows only as wide as the columns unless one
// expands, and never resizes the last column; this one takes both.
// Its empty child carries the stripe out to the view's edge.
fn filler_column() -> gtk::ColumnViewColumn {
let column = gtk::ColumnViewColumn::builder().expand(true).build();
let factory = gtk::SignalListItemFactory::new();
factory.connect_setup(|_, item| {
let Some(cell) = item.downcast_ref::<gtk::ColumnViewCell>() else {
return;
};
let blank = adw::Bin::builder()
.accessible_role(gtk::AccessibleRole::Presentation)
.build();
cell.set_child(Some(&blank));
super::stripes::follow(cell);
});
let column = gtk::ColumnViewColumn::builder().expand(true).factory(&factory).build();
column.set_id(Some("filler"));
column
}
Expand Down
2 changes: 1 addition & 1 deletion linux/crates/app/src/ui/data_grid/grid_cell.rs
Original file line number Diff line number Diff line change
Expand Up @@ -344,7 +344,7 @@ fn shown_text<'a>(
}
}

fn set_class(widget: &impl IsA<gtk::Widget>, class: &str, on: bool) {
pub(super) fn set_class(widget: &impl IsA<gtk::Widget>, class: &str, on: bool) {
match on {
true => widget.as_ref().add_css_class(class),
false => widget.as_ref().remove_css_class(class),
Expand Down
1 change: 1 addition & 0 deletions linux/crates/app/src/ui/data_grid/imp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ pub struct DataGrid {
/// The window's focus handler, while the grid is in a window.
pub focus_watch: RefCell<Option<(glib::WeakRef<gtk4::Window>, glib::SignalHandlerId)>>,
pub(super) font_watch: RefCell<Option<HandlerGuard>>,
pub(super) stripes_watch: RefCell<Option<HandlerGuard>>,
}

#[glib::object_subclass]
Expand Down
2 changes: 2 additions & 0 deletions linux/crates/app/src/ui/data_grid/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ mod row_number_column;
mod selection;
mod selection_summary;
mod server_sorter;
mod stripes;
mod table_keys;
#[cfg(test)]
pub(crate) mod test_grid;
Expand Down Expand Up @@ -88,6 +89,7 @@ impl DataGrid {
imp.detail_pane.attach(&grid);
grid.set_columns(columns, sorting, rules);
grid.watch_fonts();
grid.watch_stripes();
grid
}

Expand Down
1 change: 1 addition & 0 deletions linux/crates/app/src/ui/data_grid/row_number_column.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ pub fn build(grid: &DataGrid) -> gtk::ColumnViewColumn {
content.append(&icon);
content.append(&number);
cell.set_child(Some(&content));
super::stripes::follow(cell);
});
let watches: Watches = Rc::default();
let weak = grid.downgrade();
Expand Down
46 changes: 46 additions & 0 deletions linux/crates/app/src/ui/data_grid/stripes.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
use gtk4::prelude::*;
use gtk4::subclass::prelude::*;
use gtk4::{self as gtk, glib};
use tablepro_storage::settings::keys;

use super::DataGrid;
use super::grid_cell::set_class;
use crate::ui::handler_guard::HandlerGuard;

const STRIPE: &str = "tp-stripe";
const STRIPED: &str = "tp-striped";

// Rows are recycled, so `:nth-child` cannot stripe them and GTK has no API
// to class a row: each cell's child follows its row's position instead.
pub(super) fn follow(cell: &gtk::ColumnViewCell) {
cell.connect_notify_local(Some("position"), |cell, _| {
if let Some(child) = cell.child() {
let position = cell.position();
let odd = position != gtk::INVALID_LIST_POSITION && !position.is_multiple_of(2);
set_class(&child, STRIPE, odd);
}
});
}

impl DataGrid {
pub(super) fn watch_stripes(&self) {
let Some(settings) = self.config().map(|config| config.settings.clone()) else {
return;
};
let view = self.column_view();
// Connected before the read: GSettings emits `changed` only for a key
// read while a handler was connected.
let handler = settings.gio().connect_changed(
Some(keys::SHOW_ALTERNATE_ROWS),
glib::clone!(
#[weak]
view,
move |settings, key| set_class(&view, STRIPED, settings.boolean(key))
),
);
self.imp()
.stripes_watch
.replace(Some(HandlerGuard::new(settings.gio(), handler)));
set_class(&view, STRIPED, settings.show_alternate_rows());
}
}
71 changes: 71 additions & 0 deletions linux/crates/app/src/ui/data_grid/tests/cells.rs
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,77 @@ fn a_row_with_a_boolean_is_as_tall_as_one_without() {
assert_eq!(height(&flags), height(&texts));
}

// Every cell of the row, the gutter and the filler included.
fn stripes_across(cell: &GridCell) -> Vec<bool> {
let row = row_widget(cell);
std::iter::successors(row.first_child(), |column| column.next_sibling())
.map(|column| {
column
.first_child()
.is_some_and(|child| child.has_css_class("tp-stripe"))
})
.collect()
}

fn striped_rows(ids: &[i64]) -> Grid {
Grid::build(GridSpec::table(two_text_columns()), keyed(ids))
}

#[gtk4::test]
fn every_other_row_is_striped_out_to_the_filler() {
let grid = striped_rows(&[1, 2, 3, 4]);

assert!(grid.grid.column_view().has_css_class("tp-striped"), "on by default");
for position in 0..4 {
let row = stripes_across(&grid.cell_at(position, 1));
assert_eq!(row.len(), 4, "gutter, two columns and the filler");
assert!(
row.iter().all(|striped| *striped == (position % 2 == 1)),
"row {position}: {row:?}"
);
}
}

#[gtk4::test]
fn a_row_added_above_moves_the_stripes() {
let grid = two_text_rows();

grid.insert_draft();

assert!(stripes_across(&grid.cell_of(1, 1)).iter().all(|striped| *striped));
assert!(stripes_across(&grid.cell_of(2, 1)).iter().all(|striped| !striped));
}

#[gtk4::test]
fn recycled_rows_take_the_stripe_of_their_new_position() {
let ids: Vec<i64> = (1..=300).collect();
let grid = striped_rows(&ids);

grid.focus(250, 1);

let drawn = grid.cells();
assert!(drawn.iter().any(|cell| cell.position() == Some(250)));
for cell in drawn {
let position = cell.position().expect("a bound cell");
assert_eq!(cell.has_css_class("tp-stripe"), position % 2 == 1, "row {position}");
}
}

#[gtk4::test]
fn open_grids_follow_the_stripes_preference() {
let grid = two_text_rows();
let settings = grid.grid.config().expect("a configured grid").settings.clone();
let striped = || grid.grid.column_view().has_css_class("tp-striped");

settings.set_show_alternate_rows(false).expect("store the flag");
drain_main_context();
assert!(!striped());

settings.set_show_alternate_rows(true).expect("store the flag");
drain_main_context();
assert!(striped());
}

#[gtk4::test]
fn column_view_carries_data_table_class() {
let grid = two_text_rows();
Expand Down
12 changes: 12 additions & 0 deletions linux/crates/app/src/ui/preferences_dialog/data_browser_group.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ mod imp {
#[template_child]
pub page_size_row: TemplateChild<adw::SpinRow>,
#[template_child]
pub alternate_rows_row: TemplateChild<adw::SwitchRow>,
#[template_child]
pub confirm_row: TemplateChild<adw::SwitchRow>,
}

Expand Down Expand Up @@ -72,6 +74,9 @@ impl DataBrowserGroup {
gio_settings
.bind(keys::DEFAULT_PAGE_SIZE, &imp.page_size_row.get(), "value")
.build();
gio_settings
.bind(keys::SHOW_ALTERNATE_ROWS, &imp.alternate_rows_row.get(), "active")
.build();
gio_settings
.bind(keys::CONFIRM_BULK_DELETE, &imp.confirm_row.get(), "active")
.build();
Expand All @@ -97,18 +102,25 @@ mod tests {
let settings = MemorySettings::new();
settings.get().set_default_page_size(500).expect("store the page size");
settings.get().set_confirm_bulk_delete(false).expect("store the flag");
settings
.get()
.set_show_alternate_rows(false)
.expect("store the stripes");

let group = bound_group(&settings);
let imp = group.imp();

assert_eq!(imp.page_size_row.value(), 500.0);
assert!(!imp.confirm_row.is_active());
assert!(!imp.alternate_rows_row.is_active());

imp.page_size_row.set_value(2_500.0);
imp.confirm_row.set_active(true);
imp.alternate_rows_row.set_active(true);

assert_eq!(settings.get().default_page_size(), 2_500);
assert!(settings.get().confirm_bulk_delete());
assert!(settings.get().show_alternate_rows());
}

#[gtk4::test]
Expand Down
1 change: 1 addition & 0 deletions linux/crates/storage/src/settings/keys.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
pub const DEFAULT_PAGE_SIZE: &str = "default-page-size";
pub const CONFIRM_BULK_DELETE: &str = "confirm-bulk-delete";
pub const SHOW_ALTERNATE_ROWS: &str = "show-alternate-rows";
pub const SAVE_QUERY_HISTORY: &str = "save-query-history";
pub const HISTORY_RETENTION_DAYS: &str = "history-retention-days";
pub const QUERY_TIMEOUT_SECS: &str = "query-timeout-secs";
Expand Down
8 changes: 8 additions & 0 deletions linux/crates/storage/src/settings/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,14 @@ impl AppSettings {
self.set_boolean(keys::CONFIRM_BULK_DELETE, confirm)
}

pub fn show_alternate_rows(&self) -> bool {
self.settings.boolean(keys::SHOW_ALTERNATE_ROWS)
}

pub fn set_show_alternate_rows(&self, show: bool) -> Result<(), SettingsError> {
self.set_boolean(keys::SHOW_ALTERNATE_ROWS, show)
}

/// Whether runs go into the query history. Off records nothing and
/// leaves the entries already there.
pub fn save_query_history(&self) -> bool {
Expand Down
12 changes: 12 additions & 0 deletions linux/crates/storage/tests/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ fn defaults_match_schema() -> TestResult {

assert_eq!(fixture.settings.default_page_size(), 1_000);
assert!(fixture.settings.confirm_bulk_delete());
assert!(fixture.settings.show_alternate_rows());
assert_eq!(fixture.settings.history_retention_days(), 30);
assert_eq!(fixture.settings.query_timeout(), Some(Duration::from_secs(60)));
assert_eq!(fixture.settings.style_scheme(), "Adwaita");
Expand Down Expand Up @@ -152,6 +153,17 @@ fn confirm_bulk_delete_round_trip() -> TestResult {
Ok(())
}

#[test]
fn show_alternate_rows_round_trip() -> TestResult {
let fixture = Fixture::new()?;

fixture.settings.set_show_alternate_rows(false)?;
assert!(!fixture.settings.show_alternate_rows());
fixture.settings.set_show_alternate_rows(true)?;
assert!(fixture.settings.show_alternate_rows());
Ok(())
}

#[test]
fn save_query_history_round_trip() -> TestResult {
let fixture = Fixture::new()?;
Expand Down
6 changes: 6 additions & 0 deletions linux/data/app.tablepro.TablePro.gschema.xml.in
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,12 @@
<description>Ask before deleting 10 or more rows at once in a table tab. Deleted rows stay marked until Save, and Undo brings them back.</description>
</key>

<key name="show-alternate-rows" type="b">
<default>true</default>
<summary>Show alternate row backgrounds</summary>
<description>Shade every other row of a data grid so a row reads across its columns.</description>
</key>

<key name="save-query-history" type="b">
<default>true</default>
<summary>Save query history</summary>
Expand Down
22 changes: 22 additions & 0 deletions linux/data/resources/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,28 @@ columnview > listview > row > cell > tp-grid-cell > text:focus-within {
}
}

/* The cell's child carries the padding, as in libadwaita's file chooser,
so a stripe on the child spans the cell. */
columnview.data-table > listview > row > cell {
padding: 0;
}

columnview.data-table > listview > row > cell > * {
padding: 2px 6px;
}

/* A selected row keeps its accent: GTK draws it on the row, beneath the
cells. */
columnview.tp-striped > listview > row:not(:selected) > cell > .tp-stripe {
background-color: color-mix(in srgb, var(--view-fg-color) 4%, transparent);
}

@media (prefers-contrast: more) {
columnview.tp-striped > listview > row:not(:selected) > cell > .tp-stripe {
background-color: color-mix(in srgb, var(--view-fg-color) 8%, transparent);
}
}

/* Connection colour tags. The palette is libadwaita's accent set, kept
as literal values rather than the accent variables: a tag names a
specific colour, so it must not follow whatever accent the user has
Expand Down
5 changes: 5 additions & 0 deletions linux/data/resources/ui/preferences-data-browser-group.ui
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@
</property>
</object>
</child>
<child>
<object class="AdwSwitchRow" id="alternate_rows_row">
<property name="title" translatable="yes">Show Alternate Row Backgrounds</property>
</object>
</child>
<child>
<object class="AdwSwitchRow" id="confirm_row">
<property name="title" translatable="yes">Confirm Before Deleting Rows</property>
Expand Down
1 change: 1 addition & 0 deletions linux/po/POTFILES.in
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,7 @@ crates/app/src/ui/data_grid/row_number_column.rs
crates/app/src/ui/data_grid/selection.rs
crates/app/src/ui/data_grid/selection_summary.rs
crates/app/src/ui/data_grid/server_sorter.rs
crates/app/src/ui/data_grid/stripes.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
Expand Down
Loading