use crate::tab::TabId;
use egui::{Context, RichText};
use winit::window::WindowId;
const VISIBLE_ROWS: usize = 14;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TreeTarget {
Window(WindowId),
Tab(WindowId, TabId),
Pane(WindowId, TabId, crate::pane::PaneId),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TreeRow {
pub target: TreeTarget,
pub depth: u8,
pub label: String,
pub detail: String,
}
impl TreeRow {
fn haystack(&self) -> String {
format!("{} {}", self.label, self.detail)
}
}
pub type TreeSnapshot = Vec<TreeRow>;
#[derive(Default)]
pub struct TreePickerUI {
pub visible: bool,
query: String,
nav: crate::app::overlay::picker::ListNav,
request_focus: bool,
rows: TreeSnapshot,
toggle_chord: Option<String>,
}
impl TreePickerUI {
pub fn new() -> Self {
Self::default()
}
pub fn set_toggle_chord(&mut self, chord: Option<String>) {
self.toggle_chord = chord;
}
#[cfg(test)]
pub(crate) fn toggle_chord(&self) -> Option<&str> {
self.toggle_chord.as_deref()
}
pub fn open(&mut self) {
self.visible = true;
self.query.clear();
self.nav.reset();
self.request_focus = true;
}
pub fn close(&mut self) {
self.visible = false;
}
pub fn toggle(&mut self) {
if self.visible {
self.close();
} else {
self.open();
}
}
#[cfg(test)]
pub(crate) fn set_query_for_test(&mut self, query: &str) {
self.query = query.to_string();
}
pub fn set_rows(&mut self, rows: TreeSnapshot) {
self.rows = rows;
}
pub fn filtered(&self) -> Vec<&TreeRow> {
if self.query.trim().is_empty() {
return self.rows.iter().collect();
}
let mut scored: Vec<(u32, usize, &TreeRow)> = self
.rows
.iter()
.enumerate()
.filter_map(|(i, row)| {
crate::command_palette::fuzzy::score(&self.query, &row.haystack())
.map(|s| (s, i, row))
})
.collect();
scored.sort_by(|a, b| b.0.cmp(&a.0).then(a.1.cmp(&b.1)));
scored.into_iter().map(|(_, _, row)| row).collect()
}
pub fn show(&mut self, ctx: &Context) -> Option<TreeTarget> {
use crate::app::overlay::picker::{self, ListConfig, ListOutcome};
if !self.visible {
return None;
}
let matches: Vec<TreeRow> = self.filtered().into_iter().cloned().collect();
let config = ListConfig {
id: "Open Quickly",
hint: "Window, tab, pane, or directory",
visible_rows: VISIBLE_ROWS,
width: crate::app::overlay::theme::WIDTH_MEDIUM,
empty_text: "Nothing matches",
enter_verb: "jump",
toggle_chord: self.toggle_chord.as_deref(),
alternates: false,
alternate_labels: None,
extra_keys: &[],
multi_select: false,
};
let (outcome, query_changed) = picker::show_list(
ctx,
&config,
&mut self.query,
&mut self.request_focus,
&mut self.nav,
matches.len(),
|ui, index, selected| draw_row(ui, &matches[index], selected),
);
if query_changed {
self.nav.reset();
}
match outcome {
ListOutcome::Chosen { index, .. } => {
self.close();
matches.get(index).map(|row| row.target)
}
ListOutcome::Closed => {
self.close();
None
}
ListOutcome::Open | ListOutcome::ToggleMark(_) => None,
}
}
}
fn draw_row(ui: &mut egui::Ui, row: &TreeRow, selected: bool) -> bool {
let mut clicked = false;
ui.horizontal(|ui| {
ui.add_space(f32::from(row.depth) * 16.0);
let label = if row.depth == 0 {
RichText::new(&row.label).strong()
} else {
RichText::new(&row.label)
};
clicked = ui.selectable_label(selected, label).clicked();
if !row.detail.is_empty() {
ui.weak(&row.detail);
}
});
clicked
}
#[cfg(test)]
mod tests {
use super::*;
fn row(target: TreeTarget, depth: u8, label: &str, detail: &str) -> TreeRow {
TreeRow {
target,
depth,
label: label.to_string(),
detail: detail.to_string(),
}
}
#[test]
fn an_empty_query_lists_the_whole_tree_in_order() {
let w = WindowId::from(1u64);
let mut picker = TreePickerUI::new();
picker.set_rows(vec![
row(TreeTarget::Window(w), 0, "Window 1", ""),
row(TreeTarget::Tab(w, 1), 1, "build", ""),
row(TreeTarget::Pane(w, 1, 1), 2, "cargo", "~/src"),
]);
assert_eq!(picker.filtered().len(), 3);
}
#[test]
fn a_query_matches_detail_as_well_as_label() {
let w = WindowId::from(1u64);
let mut picker = TreePickerUI::new();
picker.set_rows(vec![
row(TreeTarget::Tab(w, 1), 1, "build", ""),
row(TreeTarget::Tab(w, 2), 1, "logs", "hidden ยท par-mux work"),
]);
picker.query = "hidden".to_string();
let hits = picker.filtered();
assert_eq!(hits.len(), 1);
assert_eq!(hits[0].target, TreeTarget::Tab(w, 2));
}
fn frame(ctx: &Context, picker: &mut TreePickerUI, keys: &[egui::Key]) -> Option<TreeTarget> {
let events = keys
.iter()
.map(|&key| egui::Event::Key {
key,
physical_key: None,
pressed: true,
repeat: false,
modifiers: Default::default(),
})
.collect();
let mut chosen = None;
let mut out = ctx.run_ui(
egui::RawInput {
screen_rect: Some(egui::Rect::from_min_size(
egui::Pos2::ZERO,
egui::vec2(1200.0, 800.0),
)),
events,
..Default::default()
},
|ui| chosen = picker.show(ui.ctx()),
);
out.textures_delta.clear();
chosen
}
#[test]
fn the_shared_picker_keys_drive_the_tree() {
let w = WindowId::from(1u64);
let rows: Vec<TreeRow> = (0..30)
.map(|i| row(TreeTarget::Tab(w, i), 1, &format!("tab {i}"), ""))
.collect();
let ctx = Context::default();
let mut picker = TreePickerUI::new();
picker.set_rows(rows);
picker.open();
frame(&ctx, &mut picker, &[]);
frame(&ctx, &mut picker, &[egui::Key::End]);
assert!(picker.nav.selected_is_visible(VISIBLE_ROWS));
assert_eq!(
frame(&ctx, &mut picker, &[egui::Key::Enter]),
Some(TreeTarget::Tab(w, 29))
);
assert!(!picker.visible, "a jump closes the picker");
picker.open();
frame(&ctx, &mut picker, &[]);
assert_eq!(frame(&ctx, &mut picker, &[egui::Key::Escape]), None);
assert!(!picker.visible, "Escape closes the picker");
}
}