Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Filter entities in the UI (part 1): Introduce a filter widget #8652

Merged
merged 10 commits into from
Jan 13, 2025
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
Loading