use egui::{Context, Frame, Key, RichText, Window, epaint::Shadow};
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub(crate) struct ListNav {
pub(crate) selected: usize,
pub(crate) scroll_offset: usize,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub(crate) struct NavInput {
pub(crate) down: usize,
pub(crate) up: usize,
pub(crate) page_down: usize,
pub(crate) page_up: usize,
pub(crate) home: bool,
pub(crate) end: bool,
}
impl ListNav {
pub(crate) fn reset(&mut self) {
*self = Self::default();
}
pub(crate) fn apply(&mut self, input: NavInput, len: usize, visible: usize) {
let last = len.saturating_sub(1);
let page = visible.max(1);
let mut sel = self.selected.min(last);
sel = (sel + input.down + input.page_down * page).min(last);
sel = sel.saturating_sub(input.up + input.page_up * page);
if input.home {
sel = 0;
}
if input.end {
sel = last;
}
self.selected = sel;
self.keep_visible(len, visible);
}
pub(crate) fn keep_visible(&mut self, len: usize, visible: usize) {
self.selected = self.selected.min(len.saturating_sub(1));
if self.selected < self.scroll_offset {
self.scroll_offset = self.selected;
} else if self.selected >= self.scroll_offset + visible {
self.scroll_offset = self.selected + 1 - visible;
}
self.scroll_offset = self.scroll_offset.min(len.saturating_sub(visible));
}
pub(crate) fn selected_is_visible(&self, visible: usize) -> bool {
self.selected >= self.scroll_offset && self.selected < self.scroll_offset + visible
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Activation {
Primary,
Shift,
Command,
Extra(usize),
}
#[derive(Debug, Clone, Copy)]
pub(crate) struct ExtraKey {
pub(crate) modifiers: egui::Modifiers,
pub(crate) key: Key,
pub(crate) label: &'static str,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ListOutcome {
Open,
Chosen { index: usize, how: Activation },
ToggleMark(usize),
Closed,
}
pub(crate) struct ListConfig<'a> {
pub(crate) id: &'a str,
pub(crate) hint: &'a str,
pub(crate) visible_rows: usize,
pub(crate) width: f32,
pub(crate) empty_text: &'a str,
pub(crate) enter_verb: &'a str,
pub(crate) toggle_chord: Option<&'a str>,
pub(crate) alternates: bool,
pub(crate) alternate_labels: Option<(&'a str, &'a str)>,
pub(crate) extra_keys: &'a [ExtraKey],
pub(crate) multi_select: bool,
}
#[derive(Default)]
pub(crate) struct ListHooks<'a> {
pub(crate) keys_follow_filter: bool,
pub(crate) above: Option<&'a mut dyn FnMut(&mut egui::Ui)>,
pub(crate) below: Option<&'a mut dyn FnMut(&mut egui::Ui)>,
}
pub(crate) fn footer_text(config: &ListConfig<'_>) -> String {
let mut parts = vec![
"\u{f062}\u{f063} PgUp/PgDn Home/End select".to_string(),
format!("Enter {}", config.enter_verb),
];
if config.alternates {
parts.push(match config.alternate_labels {
Some((shift, command)) => format!("Shift+Enter {shift} · Cmd+Enter {command}"),
None => "Shift+Enter / Cmd+Enter alternate".to_string(),
});
}
parts.extend(config.extra_keys.iter().map(|k| k.label.to_string()));
if config.multi_select {
parts.push("Ctrl+Space mark".to_string());
}
parts.push(match config.toggle_chord {
Some(chord) => format!("Esc or {chord} close"),
None => "Esc close".to_string(),
});
parts.join(" · ")
}
fn keys_are_live(follow_filter: bool, focused: Option<egui::Id>, filter_id: egui::Id) -> bool {
!follow_filter || focused.is_none_or(|id| id == filter_id)
}
fn read_nav(ctx: &Context) -> NavInput {
ctx.input(|i| NavInput {
down: i.num_presses(Key::ArrowDown),
up: i.num_presses(Key::ArrowUp),
page_down: i.num_presses(Key::PageDown),
page_up: i.num_presses(Key::PageUp),
home: i.key_pressed(Key::Home) && i.modifiers.is_none(),
end: i.key_pressed(Key::End) && i.modifiers.is_none(),
})
}
fn read_activation(ctx: &Context, alternates: bool, extra: &[ExtraKey]) -> Option<Activation> {
ctx.input_mut(|i| {
let mut order: Vec<usize> = (0..extra.len()).collect();
order.sort_by_key(|&n| std::cmp::Reverse(modifier_count(extra[n].modifiers)));
if let Some(n) = order
.into_iter()
.find(|&n| i.consume_key(extra[n].modifiers, extra[n].key))
{
Some(Activation::Extra(n))
} else if alternates && i.consume_key(egui::Modifiers::COMMAND, Key::Enter) {
Some(Activation::Command)
} else if alternates && i.consume_key(egui::Modifiers::SHIFT, Key::Enter) {
Some(Activation::Shift)
} else if i.consume_key(egui::Modifiers::NONE, Key::Enter) {
Some(Activation::Primary)
} else {
None
}
})
}
fn modifier_count(m: egui::Modifiers) -> u8 {
u8::from(m.alt) + u8::from(m.ctrl) + u8::from(m.shift) + u8::from(m.mac_cmd || m.command)
}
pub(crate) fn show_list(
ctx: &Context,
config: &ListConfig<'_>,
query: &mut String,
request_focus: &mut bool,
nav: &mut ListNav,
rows_len: usize,
draw_row: impl FnMut(&mut egui::Ui, usize, bool) -> bool,
) -> (ListOutcome, bool) {
show_list_with(
ctx,
config,
ListHooks::default(),
query,
request_focus,
nav,
rows_len,
draw_row,
)
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn show_list_with(
ctx: &Context,
config: &ListConfig<'_>,
hooks: ListHooks<'_>,
query: &mut String,
request_focus: &mut bool,
nav: &mut ListNav,
rows_len: usize,
draw_row: impl FnMut(&mut egui::Ui, usize, bool) -> bool,
) -> (ListOutcome, bool) {
let mut result = (ListOutcome::Open, false);
Window::new(config.id)
.title_bar(false)
.resizable(false)
.collapsible(false)
.anchor(egui::Align2::CENTER_TOP, [0.0, 80.0])
.frame(Frame::popup(&ctx.global_style()).shadow(Shadow::default()))
.show(ctx, |ui| {
ui.set_min_width(config.width);
result = body(
ui,
config,
ListBodyState {
query,
request_focus,
nav,
},
rows_len,
true,
hooks,
draw_row,
);
});
result
}
pub(crate) struct ListBodyState<'s> {
pub(crate) query: &'s mut String,
pub(crate) request_focus: &'s mut bool,
pub(crate) nav: &'s mut ListNav,
}
pub(crate) fn list_body(
ui: &mut egui::Ui,
config: &ListConfig<'_>,
state: ListBodyState<'_>,
rows_len: usize,
owns_keys: bool,
draw_row: impl FnMut(&mut egui::Ui, usize, bool) -> bool,
) -> (ListOutcome, bool) {
body(
ui,
config,
state,
rows_len,
owns_keys,
ListHooks::default(),
draw_row,
)
}
fn body(
ui: &mut egui::Ui,
config: &ListConfig<'_>,
state: ListBodyState<'_>,
rows_len: usize,
owns_keys: bool,
mut hooks: ListHooks<'_>,
mut draw_row: impl FnMut(&mut egui::Ui, usize, bool) -> bool,
) -> (ListOutcome, bool) {
let ListBodyState {
query,
request_focus,
nav,
} = state;
let filter_id = ui.make_persistent_id((config.id, "filter"));
let focused = ui.ctx().memory(|m| m.focused());
let escape_live = owns_keys || focused == Some(filter_id);
let keys_live = escape_live && keys_are_live(hooks.keys_follow_filter, focused, filter_id);
let ctx = ui.ctx().clone();
let mut outcome = ListOutcome::Open;
if escape_live && ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, Key::Escape)) {
return (ListOutcome::Closed, false);
}
if keys_live {
nav.apply(read_nav(&ctx), rows_len, config.visible_rows);
if config.multi_select
&& rows_len > 0
&& ctx.input_mut(|i| i.consume_key(egui::Modifiers::CTRL, Key::Space))
{
outcome = ListOutcome::ToggleMark(nav.selected);
} else if let Some(how) = read_activation(&ctx, config.alternates, config.extra_keys)
&& rows_len > 0
{
outcome = ListOutcome::Chosen {
index: nav.selected,
how,
};
}
} else if escape_live {
nav.keep_visible(rows_len, config.visible_rows);
}
let field = ui.add(
egui::TextEdit::singleline(query)
.id(filter_id)
.hint_text(config.hint)
.desired_width(f32::INFINITY),
);
if *request_focus {
field.request_focus();
*request_focus = false;
}
let query_changed = field.changed();
ui.separator();
if let Some(above) = hooks.above.as_mut() {
above(ui);
ui.separator();
}
let end = (nav.scroll_offset + config.visible_rows).min(rows_len);
for index in nav.scroll_offset..end {
if draw_row(ui, index, index == nav.selected) {
outcome = ListOutcome::Chosen {
index,
how: Activation::Primary,
};
}
}
if rows_len == 0 {
ui.weak(config.empty_text);
}
ui.separator();
if let Some(below) = hooks.below.as_mut() {
below(ui);
ui.separator();
}
ui.label(RichText::new(footer_text(config)).weak().small());
(outcome, query_changed)
}
#[cfg(test)]
mod tests {
use super::*;
fn down(n: usize) -> NavInput {
NavInput {
down: n,
..Default::default()
}
}
#[test]
fn arrowing_to_the_20th_row_scrolls_it_into_view() {
let mut nav = ListNav::default();
nav.apply(down(19), 50, 12);
assert_eq!(nav.selected, 19);
assert_eq!(nav.scroll_offset, 8);
assert!(nav.selected_is_visible(12));
}
#[test]
fn arrowing_back_up_pulls_the_window_home() {
let mut nav = ListNav {
selected: 19,
scroll_offset: 8,
};
nav.apply(
NavInput {
up: 19,
..Default::default()
},
50,
12,
);
assert_eq!(nav, ListNav::default());
}
#[test]
fn a_shorter_list_parks_the_window_at_zero_and_clamps_the_selection() {
let mut nav = ListNav {
selected: 30,
scroll_offset: 40,
};
nav.apply(NavInput::default(), 5, 12);
assert_eq!(nav.selected, 4, "selection clamps to the last row");
assert_eq!(nav.scroll_offset, 0);
assert!(nav.selected_is_visible(12));
nav.apply(NavInput::default(), 0, 12);
assert_eq!(nav, ListNav::default(), "an empty list parks at 0");
}
#[test]
fn page_keys_move_by_the_window_and_home_end_jump() {
let mut nav = ListNav::default();
nav.apply(
NavInput {
page_down: 2,
..Default::default()
},
100,
10,
);
assert_eq!(nav.selected, 20);
assert!(nav.selected_is_visible(10));
nav.apply(
NavInput {
end: true,
..Default::default()
},
100,
10,
);
assert_eq!(nav.selected, 99);
assert_eq!(nav.scroll_offset, 90);
nav.apply(
NavInput {
page_up: 1,
..Default::default()
},
100,
10,
);
assert_eq!(nav.selected, 89);
nav.apply(
NavInput {
home: true,
..Default::default()
},
100,
10,
);
assert_eq!(nav, ListNav::default());
}
#[test]
fn an_embedded_text_field_takes_the_keys_when_gated() {
let filter = egui::Id::new("filter");
let other = egui::Id::new("rename");
assert!(keys_are_live(false, Some(other), filter), "ungated");
assert!(keys_are_live(true, None, filter), "nothing focused");
assert!(keys_are_live(true, Some(filter), filter), "filter focused");
assert!(!keys_are_live(true, Some(other), filter), "other field");
}
fn config(toggle_chord: Option<&str>, alternates: bool) -> ListConfig<'_> {
ListConfig {
id: "t",
hint: "",
visible_rows: 12,
width: 0.0,
empty_text: "",
enter_verb: "run",
toggle_chord,
alternates,
alternate_labels: None,
extra_keys: &[],
multi_select: false,
}
}
#[test]
fn the_footer_names_the_live_toggle_chord_and_drops_it_when_unbound() {
assert_eq!(
footer_text(&config(Some("Cmd+Shift+P"), false)),
"\u{f062}\u{f063} PgUp/PgDn Home/End select · Enter run · Esc or Cmd+Shift+P close"
);
assert_eq!(
footer_text(&config(None, false)),
"\u{f062}\u{f063} PgUp/PgDn Home/End select · Enter run · Esc close"
);
}
#[test]
fn the_footer_advertises_alternates_only_when_the_picker_takes_them() {
assert!(!footer_text(&config(None, false)).contains("Shift+Enter"));
assert!(footer_text(&config(None, true)).contains("Shift+Enter / Cmd+Enter"));
}
#[test]
fn enter_alternates_resolve_by_modifier() {
let ctx = egui::Context::default();
let key = |modifiers| egui::Event::Key {
key: Key::Enter,
physical_key: None,
pressed: true,
repeat: false,
modifiers,
};
for (modifiers, alternates, expected) in [
(egui::Modifiers::NONE, true, Some(Activation::Primary)),
(egui::Modifiers::SHIFT, true, Some(Activation::Shift)),
(egui::Modifiers::COMMAND, true, Some(Activation::Command)),
(egui::Modifiers::SHIFT, false, Some(Activation::Primary)),
] {
let mut got = None;
let mut out = ctx.run_ui(
egui::RawInput {
events: vec![key(modifiers)],
..Default::default()
},
|ui| got = read_activation(ui.ctx(), alternates, &[]),
);
out.textures_delta.clear();
assert_eq!(got, expected, "{modifiers:?} alternates={alternates}");
}
}
#[test]
fn extra_keys_match_most_specific_first() {
let extra = [
ExtraKey {
modifiers: egui::Modifiers::COMMAND,
key: Key::D,
label: "Cmd+D split right",
},
ExtraKey {
modifiers: egui::Modifiers::COMMAND | egui::Modifiers::SHIFT,
key: Key::D,
label: "Cmd+Shift+D split down",
},
];
let ctx = egui::Context::default();
for (modifiers, expected) in [
(egui::Modifiers::COMMAND, Activation::Extra(0)),
(
egui::Modifiers::COMMAND | egui::Modifiers::SHIFT,
Activation::Extra(1),
),
] {
let mut got = None;
let mut out = ctx.run_ui(
egui::RawInput {
events: vec![egui::Event::Key {
key: Key::D,
physical_key: None,
pressed: true,
repeat: false,
modifiers,
}],
..Default::default()
},
|ui| got = read_activation(ui.ctx(), true, &extra),
);
out.textures_delta.clear();
assert_eq!(got, Some(expected), "{modifiers:?}");
}
}
#[test]
fn the_footer_names_extra_keys_alternate_meanings_and_marking() {
let extra = [ExtraKey {
modifiers: egui::Modifiers::COMMAND,
key: Key::D,
label: "Cmd+D split right",
}];
let config = ListConfig {
alternate_labels: Some(("new window", "this tab")),
extra_keys: &extra,
multi_select: true,
..config(None, true)
};
let footer = footer_text(&config);
assert!(footer.contains("Shift+Enter new window · Cmd+Enter this tab"));
assert!(footer.contains("Cmd+D split right"));
assert!(footer.contains("Ctrl+Space mark"));
}
}