Skip to content

Commit

Permalink
Implement table row selection and hover highlighting (#3347)
Browse files Browse the repository at this point in the history
* Based on #3105 by @vvv.

## Additions and Changes

- Add `TableBuilder::sense()` and `StripBuilder::sense()` to enable
detecting clicks or drags on table and strip cells.
- Add `TableRow::select()` which takes a boolean that sets the highlight
state for all cells added after a call to it. This allows highlighting
an entire row or specific cells.
- Add `TableRow::response()` which returns the union of the `Response`
of all cells added to the row up to that point. This makes it easy to
detect interactions with an entire row. See below for an alternative
design.
- Add `TableRow::index()` and `TableRow::col_index()` helpers.
- Remove explicit `row_index` from callback passed to
`TableBody::rows()` and `TableBody::heterogeneous_rows()`, possible due
to the above. This is a breaking change but makes the callback
compatible with `TableBody::row()`.
- Update Table example to demonstrate all of the above.

## Design Decisions

An alternative design to `TableRow::response()` would be to return the
row response from `TableBody`s `row()`, `rows()` and
`heterogeneous_rows()` functions. `row()` could just return the
response. `rows()` and `heterogeneous_rows()` could return a tuple of
the hovered row index and that rows response. I feel like this might be
the cleaner soluction if only returning the hovered rows response isn't
too limiting.

I didn't implement `TableBuilder::select_rows()` as described
[here](#3105 (comment))
because it requires an immutable borrow of the selection state for the
lifetime of the `TableBuilder`. This makes updating the selection state
from within the body unnecessarily complicated. Additionally the current
design allows for selecting specific cells, though that could be
possible by modifying `TableBuilder::select_rows()` to provide row and
column indices like below.

```rust
pub fn select_cells(is_selected: impl Fn(usize, usize) -> bool) -> Self
```

## Hover Highlighting

EDIT: Thanks to @samitbasu we now have hover highlighting too.

~This is not implemented yet. Ideally we'd have an api that allows to
choose between highlighting the hovered cell, column or row. Should
cells containing interactive widgets, be highlighted when hovering over
the widget or only when hovering over the cell itself? I'd like to
implement that before this gets merged though.~

Feedback is more than welcome. I'd be happy to make any changes
necessary to get this merged.

* Closes #1519
* Closes #1553
* Closes #3069

---------

Co-authored-by: Samit Basu <[email protected]>
Co-authored-by: Emil Ernerfeldt <[email protected]>
  • Loading branch information
3 people authored Jan 6, 2024
1 parent 37762f7 commit 5a6d1cb
Show file tree
Hide file tree
Showing 4 changed files with 319 additions and 109 deletions.
108 changes: 75 additions & 33 deletions crates/egui_demo_lib/src/demo/table_demo.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,12 @@ pub struct TableDemo {
demo: DemoType,
striped: bool,
resizable: bool,
clickable: bool,
num_rows: usize,
scroll_to_row_slider: usize,
scroll_to_row: Option<usize>,
selection: std::collections::HashSet<usize>,
checked: bool,
}

impl Default for TableDemo {
Expand All @@ -23,9 +26,12 @@ impl Default for TableDemo {
demo: DemoType::Manual,
striped: true,
resizable: true,
clickable: true,
num_rows: 10_000,
scroll_to_row_slider: 0,
scroll_to_row: None,
selection: Default::default(),
checked: false,
}
}
}
Expand Down Expand Up @@ -54,6 +60,7 @@ impl super::View for TableDemo {
ui.horizontal(|ui| {
ui.checkbox(&mut self.striped, "Striped");
ui.checkbox(&mut self.resizable, "Resizable columns");
ui.checkbox(&mut self.clickable, "Clickable rows");
});

ui.label("Table type:");
Expand Down Expand Up @@ -121,27 +128,38 @@ impl TableDemo {
fn table_ui(&mut self, ui: &mut egui::Ui) {
use egui_extras::{Column, TableBuilder};

let text_height = egui::TextStyle::Body.resolve(ui.style()).size;
let text_height = egui::TextStyle::Body
.resolve(ui.style())
.size
.max(ui.spacing().interact_size.y);

let mut table = TableBuilder::new(ui)
.striped(self.striped)
.resizable(self.resizable)
.cell_layout(egui::Layout::left_to_right(egui::Align::Center))
.column(Column::auto())
.column(Column::auto())
.column(Column::initial(100.0).range(40.0..=300.0))
.column(Column::initial(100.0).at_least(40.0).clip(true))
.column(Column::remainder())
.min_scrolled_height(0.0);

if let Some(row_nr) = self.scroll_to_row.take() {
table = table.scroll_to_row(row_nr, None);
if self.clickable {
table = table.sense(egui::Sense::click());
}

if let Some(row_index) = self.scroll_to_row.take() {
table = table.scroll_to_row(row_index, None);
}

table
.header(20.0, |mut header| {
header.col(|ui| {
ui.strong("Row");
});
header.col(|ui| {
ui.strong("Interaction");
});
header.col(|ui| {
ui.strong("Expanding content");
});
Expand All @@ -158,9 +176,14 @@ impl TableDemo {
let is_thick = thick_row(row_index);
let row_height = if is_thick { 30.0 } else { 18.0 };
body.row(row_height, |mut row| {
row.set_selected(self.selection.contains(&row_index));

row.col(|ui| {
ui.label(row_index.to_string());
});
row.col(|ui| {
ui.checkbox(&mut self.checked, "Click me");
});
row.col(|ui| {
expanding_content(ui);
});
Expand All @@ -175,14 +198,22 @@ impl TableDemo {
ui.label("Normal row");
}
});

self.toggle_row_selection(row_index, &row.response());
});
}
}
DemoType::ManyHomogeneous => {
body.rows(text_height, self.num_rows, |row_index, mut row| {
body.rows(text_height, self.num_rows, |mut row| {
let row_index = row.index();
row.set_selected(self.selection.contains(&row_index));

row.col(|ui| {
ui.label(row_index.to_string());
});
row.col(|ui| {
ui.checkbox(&mut self.checked, "Click me");
});
row.col(|ui| {
expanding_content(ui);
});
Expand All @@ -194,41 +225,52 @@ impl TableDemo {
egui::Label::new("Thousands of rows of even height").wrap(false),
);
});

self.toggle_row_selection(row_index, &row.response());
});
}
DemoType::ManyHeterogenous => {
fn row_thickness(row_index: usize) -> f32 {
if thick_row(row_index) {
30.0
} else {
18.0
}
}
body.heterogeneous_rows(
(0..self.num_rows).map(row_thickness),
|row_index, mut row| {
row.col(|ui| {
ui.label(row_index.to_string());
});
row.col(|ui| {
expanding_content(ui);
});
row.col(|ui| {
ui.label(long_text(row_index));
});
row.col(|ui| {
ui.style_mut().wrap = Some(false);
if thick_row(row_index) {
ui.heading("Extra thick row");
} else {
ui.label("Normal row");
}
});
},
);
let row_height = |i: usize| if thick_row(i) { 30.0 } else { 18.0 };
body.heterogeneous_rows((0..self.num_rows).map(row_height), |mut row| {
let row_index = row.index();
row.set_selected(self.selection.contains(&row_index));

row.col(|ui| {
ui.label(row_index.to_string());
});
row.col(|ui| {
ui.checkbox(&mut self.checked, "Click me");
});
row.col(|ui| {
expanding_content(ui);
});
row.col(|ui| {
ui.label(long_text(row_index));
});
row.col(|ui| {
ui.style_mut().wrap = Some(false);
if thick_row(row_index) {
ui.heading("Extra thick row");
} else {
ui.label("Normal row");
}
});

self.toggle_row_selection(row_index, &row.response());
});
}
});
}

fn toggle_row_selection(&mut self, row_index: usize, row_response: &egui::Response) {
if row_response.clicked() {
if self.selection.contains(&row_index) {
self.selection.remove(&row_index);
} else {
self.selection.insert(row_index);
}
}
}
}

fn expanding_content(ui: &mut egui::Ui) {
Expand Down
68 changes: 54 additions & 14 deletions crates/egui_extras/src/layout.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,15 @@ pub(crate) enum CellDirection {
Vertical,
}

/// Flags used by [`StripLayout::add`].
#[derive(Clone, Copy, Default)]
pub(crate) struct StripLayoutFlags {
pub(crate) clip: bool,
pub(crate) striped: bool,
pub(crate) hovered: bool,
pub(crate) selected: bool,
}

/// Positions cells in [`CellDirection`] and starts a new line on [`StripLayout::end_line`]
pub struct StripLayout<'l> {
pub(crate) ui: &'l mut Ui,
Expand All @@ -38,10 +47,16 @@ pub struct StripLayout<'l> {
max: Pos2,

cell_layout: egui::Layout,
sense: Sense,
}

impl<'l> StripLayout<'l> {
pub(crate) fn new(ui: &'l mut Ui, direction: CellDirection, cell_layout: egui::Layout) -> Self {
pub(crate) fn new(
ui: &'l mut Ui,
direction: CellDirection,
cell_layout: egui::Layout,
sense: Sense,
) -> Self {
let rect = ui.available_rect_before_wrap();
let pos = rect.left_top();

Expand All @@ -52,6 +67,7 @@ impl<'l> StripLayout<'l> {
cursor: pos,
max: pos,
cell_layout,
sense,
}
}

Expand Down Expand Up @@ -94,34 +110,53 @@ impl<'l> StripLayout<'l> {
/// Return the used space (`min_rect`) plus the [`Response`] of the whole cell.
pub(crate) fn add(
&mut self,
clip: bool,
striped: bool,
flags: StripLayoutFlags,
width: CellSize,
height: CellSize,
add_cell_contents: impl FnOnce(&mut Ui),
) -> (Rect, Response) {
let max_rect = self.cell_rect(&width, &height);

if striped {
// Make sure we don't have a gap in the stripe background:
let stripe_rect = max_rect.expand2(0.5 * self.ui.spacing().item_spacing);
// Make sure we don't have a gap in the stripe/frame/selection background:
let item_spacing = self.ui.spacing().item_spacing;
let gapless_rect = max_rect.expand2(0.5 * item_spacing);

if flags.striped {
self.ui.painter().rect_filled(
gapless_rect,
egui::Rounding::ZERO,
self.ui.visuals().faint_bg_color,
);
}

if flags.selected {
self.ui.painter().rect_filled(
gapless_rect,
egui::Rounding::ZERO,
self.ui.visuals().selection.bg_fill,
);
}

self.ui
.painter()
.rect_filled(stripe_rect, 0.0, self.ui.visuals().faint_bg_color);
if flags.hovered && !flags.selected && self.sense.interactive() {
self.ui.painter().rect_filled(
gapless_rect,
egui::Rounding::ZERO,
self.ui.visuals().widgets.hovered.bg_fill,
);
}

let used_rect = self.cell(clip, max_rect, add_cell_contents);
let response = self.ui.allocate_rect(max_rect, self.sense);
let used_rect = self.cell(flags, max_rect, add_cell_contents);

self.set_pos(max_rect);

let allocation_rect = if clip {
let allocation_rect = if flags.clip {
max_rect
} else {
max_rect.union(used_rect)
};

let response = self.ui.allocate_rect(allocation_rect, Sense::hover());
let response = response.with_new_rect(allocation_rect);

(used_rect, response)
}
Expand All @@ -148,10 +183,15 @@ impl<'l> StripLayout<'l> {
self.ui.allocate_rect(rect, Sense::hover());
}

fn cell(&mut self, clip: bool, rect: Rect, add_cell_contents: impl FnOnce(&mut Ui)) -> Rect {
fn cell(
&mut self,
flags: StripLayoutFlags,
rect: Rect,
add_cell_contents: impl FnOnce(&mut Ui),
) -> Rect {
let mut child_ui = self.ui.child_ui(rect, self.cell_layout);

if clip {
if flags.clip {
let margin = egui::Vec2::splat(self.ui.visuals().clip_rect_margin);
let margin = margin.min(0.5 * self.ui.spacing().item_spacing);
let clip_rect = rect.expand2(margin);
Expand Down
Loading

0 comments on commit 5a6d1cb

Please sign in to comment.