Skip to content

Commit

Permalink
Filter entities in the UI (part 1): Introduce a filter widget (#8652)
Browse files Browse the repository at this point in the history
  • Loading branch information
abey79 authored Jan 13, 2025
1 parent fbea1ea commit 2480a89
Show file tree
Hide file tree
Showing 9 changed files with 361 additions and 3 deletions.
1 change: 1 addition & 0 deletions Cargo.lock
Original file line number Diff line number Diff line change
Expand Up @@ -6587,6 +6587,7 @@ dependencies = [
"egui_extras",
"egui_kittest",
"egui_tiles",
"getrandom",
"itertools 0.13.0",
"once_cell",
"parking_lot",
Expand Down
3 changes: 2 additions & 1 deletion crates/viewer/re_ui/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -45,10 +45,10 @@ egui_commonmark = { workspace = true, features = ["pulldown_cmark"] }
egui_extras.workspace = true
egui_tiles.workspace = true
egui.workspace = true
getrandom.workspace = true
itertools.workspace = true
once_cell.workspace = true
parking_lot.workspace = true
rand.workspace = true
serde = { workspace = true, features = ["derive"] }
serde_json.workspace = true
smallvec.workspace = true
Expand All @@ -70,4 +70,5 @@ time = { workspace = true, features = [

[dev-dependencies]
egui_kittest.workspace = true
rand.workspace = true
re_types.workspace = true
Binary file added crates/viewer/re_ui/data/icons/search.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
29 changes: 27 additions & 2 deletions crates/viewer/re_ui/examples/re_ui_example/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ mod right_panel;

use re_ui::notifications;
use re_ui::{
list_item, CommandPalette, ContextExt as _, DesignTokens, UICommand, UICommandSender,
UiExt as _,
filter_widget::FilterState, list_item, CommandPalette, ContextExt as _, DesignTokens,
UICommand, UICommandSender, UiExt as _,
};

/// Sender that queues up the execution of a command.
Expand Down Expand Up @@ -86,6 +86,8 @@ pub struct ExampleApp {

dummy_bool: bool,

filter_state: FilterState,

cmd_palette: CommandPalette,

/// Commands to run at the end of the frame.
Expand Down Expand Up @@ -119,6 +121,8 @@ impl ExampleApp {

dummy_bool: true,

filter_state: FilterState::default(),

cmd_palette: CommandPalette::default(),
command_sender,
command_receiver,
Expand Down Expand Up @@ -244,6 +248,27 @@ impl eframe::App for ExampleApp {
ui.re_checkbox(&mut self.dummy_bool, "Checkbox");
});
});

ui.scope(|ui| {
ui.spacing_mut().item_spacing.y = 0.0;

ui.full_span_separator();
self.filter_state
.ui(ui, egui::RichText::new("Filter demo").strong());
ui.full_span_separator();

let names = vec![
"Andreas", "Antoine", "Clement", "Emil", "Jan", "Jeremy", "Jochen", "Katya",
"Moritz", "Niko", "Zeljko",
];

let filter = self.filter_state.filter();
for name in names {
if let Some(widget_text) = filter.matches_formatted(ui.ctx(), name) {
ui.list_item_flat_noninteractive(list_item::LabelContent::new(widget_text));
}
}
});
};

// UI code
Expand Down
289 changes: 289 additions & 0 deletions crates/viewer/re_ui/src/filter_widget.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,289 @@
use std::ops::Range;

use egui::{Color32, NumExt as _, Widget as _};
use itertools::Either;

use crate::{list_item, UiExt as _};

/// State for the filter widget when it is toggled on.
#[derive(Debug, Clone)]
struct InnerState {
/// The filter query string.
filter_query: String,

/// This ID is recreated every time the filter is toggled and tracks the current filtering
/// session.
///
/// This can be useful for client code to store session-specific state (e.g., the state of tree
/// collapsed-ness).
session_id: egui::Id,
}

impl Default for InnerState {
fn default() -> Self {
let mut random_bytes = [0u8; 8];
getrandom::getrandom(&mut random_bytes).expect("Couldn't get random bytes");

Self {
filter_query: String::new(),

// create a new session id each time the filter is toggled
session_id: egui::Id::new(random_bytes),
}
}
}

/// State and UI for the filter widget.
///
/// The filter widget is designed as a toggle between a title widget and the filter text field.
/// [`Self`] is responsible for storing the widget state as well as the query text typed by the
/// user. [`FilterMatcher`] performs the actual filtering.
#[derive(Debug, Clone, Default)]
pub struct FilterState {
/// The current state of the filter widget.
///
/// This is `None` when the filter is not active.
inner_state: Option<InnerState>,

/// Should the text field be focused?
///
/// Set to `true` upon clicking on the search button.
request_focus: bool,
}

impl FilterState {
/// Return the filter if any.
///
/// Returns `None` if the filter is disabled. Returns `Some(query)` if the filter is enabled
/// (even if the query string is empty, in which case it should match nothing).
pub fn query(&self) -> Option<&str> {
self.inner_state
.as_ref()
.map(|state| state.filter_query.as_str())
}

/// Return the current session ID of the filter widget, if active.
pub fn session_id(&self) -> Option<egui::Id> {
self.inner_state.as_ref().map(|state| state.session_id)
}

/// Return a filter matcher for the current query.
pub fn filter(&self) -> FilterMatcher {
FilterMatcher::new(self.query())
}

/// Display the filter widget.
///
/// Note: this uses [`egui::Ui::available_width`] to determine the location of the right-aligned
/// search button, as usual for [`list_item::ListItem`]-based widgets.
pub fn ui(&mut self, ui: &mut egui::Ui, section_title: impl Into<egui::WidgetText>) {
let mut button_clicked = false;

let icon = if self.inner_state.is_none() {
&crate::icons::SEARCH
} else {
&crate::icons::CLOSE
};

// precompute the title layout such that we know the size we need for the list item content
let section_title = section_title.into();
let galley = section_title.into_galley(
ui,
Some(egui::TextWrapMode::Extend),
f32::INFINITY,
egui::FontSelection::default(),
);
let text_width = galley.size().x;

list_item::list_item_scope(ui, ui.next_auto_id(), |ui| {
ui.list_item()
.interactive(false)
.with_height(30.0)
.show_flat(
ui,
list_item::CustomContent::new(|ui, _| {
if self.inner_state.is_some()
&& ui.input_mut(|i| {
i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)
})
{
self.inner_state = None;
}

if let Some(inner_state) = self.inner_state.as_mut() {
// we add additional spacing for aesthetic reasons (active text edits have a
// fat border)
ui.spacing_mut().text_edit_width =
(ui.max_rect().width() - 10.0).at_least(0.0);

let response =
egui::TextEdit::singleline(&mut inner_state.filter_query)
.lock_focus(true)
.ui(ui);

if self.request_focus {
self.request_focus = false;
response.request_focus();
}
} else {
ui.label(galley);
}
})
.with_content_width(text_width)
.action_button(icon, || {
button_clicked = true;
}),
);
});

// defer button handling because we can't mutably borrow `self` in both closures above
if button_clicked {
if self.inner_state.is_none() {
self.inner_state = Some(InnerState::default());
self.request_focus = true;
} else {
self.inner_state = None;
}
}
}
}

// --

/// Full-text, case-insensitive matcher.
pub struct FilterMatcher {
/// The lowercase version of the query string.
///
/// If this is `None`, the filter is inactive and the matcher will accept everything. If this
/// is `Some("")`, the matcher will reject any input.
lowercase_query: Option<String>,
}

impl FilterMatcher {
fn new(query: Option<&str>) -> Self {
Self {
lowercase_query: query.map(|s| s.to_lowercase()),
}
}

/// Is the filter set to match everything?
///
/// Can be used by client code to short-circuit more expansive matching logic.
pub fn matches_everything(&self) -> bool {
self.lowercase_query.is_none()
}

/// Is the filter set to match nothing?
///
/// Can be used by client code to short-circuit more expansive matching logic.
pub fn matches_nothing(&self) -> bool {
self.lowercase_query
.as_ref()
.is_some_and(|query| query.is_empty())
}

/// Does the given text match the filter?
pub fn matches(&self, text: &str) -> bool {
match self.lowercase_query.as_deref() {
None => true,
Some("") => false,
Some(query) => text.to_lowercase().contains(query),
}
}

/// Find all matches in the given text.
///
/// Can be used to format the text, e.g. with [`format_matching_text`].
///
/// Returns `None` when there is no match.
/// Returns `Some` when the filter is inactive (and thus matches everything), or if there is an
/// actual match.
fn find_matches(&self, text: &str) -> Option<impl Iterator<Item = Range<usize>> + '_> {
let query = match self.lowercase_query.as_deref() {
None => {
return Some(Either::Left(std::iter::empty()));
}
Some("") => {
return None;
}
Some(query) => query,
};

let mut start = 0;
let lower_case_text = text.to_lowercase();
let query_len = query.len();

if !lower_case_text.contains(query) {
return None;
}

Some(Either::Right(std::iter::from_fn(move || {
let index = lower_case_text[start..].find(query)?;
let start_index = start + index;
start = start_index + query_len;
Some(start_index..(start_index + query_len))
})))
}

/// Returns a formatted version of the text with the matching sections highlighted.
///
/// Returns `None` when there is no match (so nothing should be displayed).
/// Returns `Some` when the filter is inactive (and thus matches everything), or if there is an
/// actual match.
pub fn matches_formatted(&self, ctx: &egui::Context, text: &str) -> Option<egui::WidgetText> {
self.find_matches(text)
.map(|match_iter| format_matching_text(ctx, text, match_iter))
}
}

/// Given a list of highlight sections defined by start/end indices and a string, produce a properly
/// highlighted [`egui::WidgetText`].
pub fn format_matching_text(
ctx: &egui::Context,
text: &str,
match_iter: impl Iterator<Item = Range<usize>>,
) -> egui::WidgetText {
let mut current = 0;
let mut job = egui::text::LayoutJob::default();

for Range { start, end } in match_iter {
if current < start {
job.append(
&text[current..start],
0.0,
egui::TextFormat {
font_id: egui::TextStyle::Body.resolve(&ctx.style()),
color: Color32::PLACEHOLDER,
..Default::default()
},
);
}

job.append(
&text[start..end],
0.0,
egui::TextFormat {
font_id: egui::TextStyle::Body.resolve(&ctx.style()),
color: Color32::PLACEHOLDER,
background: ctx.style().visuals.selection.bg_fill,
..Default::default()
},
);

current = end;
}

if current < text.len() {
job.append(
&text[current..],
0.0,
egui::TextFormat {
font_id: egui::TextStyle::Body.resolve(&ctx.style()),
color: Color32::PLACEHOLDER,
..Default::default()
},
);
}

job.into()
}
2 changes: 2 additions & 0 deletions crates/viewer/re_ui/src/icons.rs
Original file line number Diff line number Diff line change
Expand Up @@ -136,3 +136,5 @@ pub const DND_MOVE: Icon = icon_from_path!("../data/icons/dnd_move.png");

/// `>`
pub const BREADCRUMBS_SEPARATOR: Icon = icon_from_path!("../data/icons/breadcrumbs_separator.png");

pub const SEARCH: Icon = icon_from_path!("../data/icons/search.png");
1 change: 1 addition & 0 deletions crates/viewer/re_ui/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ mod command_palette;
mod context_ext;
mod design_tokens;
pub mod drag_and_drop;
pub mod filter_widget;
pub mod icons;
pub mod list_item;
mod markdown_utils;
Expand Down
Loading

0 comments on commit 2480a89

Please sign in to comment.