diff --git a/apps/zbugs-gpui/src/app.rs b/apps/zbugs-gpui/src/app.rs index 2305ca818b..3be04454cd 100644 --- a/apps/zbugs-gpui/src/app.rs +++ b/apps/zbugs-gpui/src/app.rs @@ -5,6 +5,7 @@ pub mod e2e; use crate::live::Live; +use crate::picker::{self, Choice}; use crate::session::{Session, app_dir, now_ms, save_setting}; use futures::StreamExt; use futures::channel::mpsc; @@ -145,14 +146,6 @@ impl Saved { } } -/// One entry of a picker menu: `value` is what picking it passes on, -/// `None` for the entry that clears the choice. -struct Choice { - label: String, - value: Option, - checked: bool, -} - /// The reactions the add-reaction menu offers, with their annotations. const REACTIONS: [(&str, &str); 6] = [ ("👍", "thumbs up"), @@ -225,7 +218,15 @@ struct Seen { /// `user`'s avatar, round, as zbugs' `AvatarImage`; GitHub's at 64 /// pixels, as `avatarURLWithSize` asks for. fn avatar(user: &User, size: Pixels) -> impl IntoElement { - let url = match http_client::Url::parse(&user.avatar) { + img(avatar_url(user)) + .flex_none() + .size(size) + .rounded_full() + .with_fallback(move || div().size(size).into_any_element()) +} + +fn avatar_url(user: &User) -> String { + match http_client::Url::parse(&user.avatar) { Ok(mut url) if url.host_str() == Some("avatars.githubusercontent.com") => { let query = url .query_pairs() @@ -239,12 +240,7 @@ fn avatar(user: &User, size: Pixels) -> impl IntoElement { url.to_string() } _ => user.avatar.clone(), - }; - img(url) - .flex_none() - .size(size) - .rounded_full() - .with_fallback(move || div().size(size).into_any_element()) + } } /// What a reaction is on. @@ -1577,6 +1573,7 @@ impl ZbugsApp { projects .into_iter() .map(|p| Choice { + avatar: None, checked: p.lower_case_name == current, label: p.name.clone(), value: Some(p.name), @@ -1690,8 +1687,33 @@ impl ZbugsApp { .into_any_element() } + /// `button` opens `choices` with a search, as zbugs' comboboxes do; + /// see `picker::searchable`. + fn searchable( + &self, + id: &'static str, + multi: bool, + button: Button, + choices: Vec, + on_pick: OnPick, + cx: &mut Context, + ) -> AnyElement { + let app = cx.entity().downgrade(); + picker::searchable( + id, + button, + choices, + multi, + Rc::new(move |value, window, cx| { + let _ = app.update(cx, |this, cx| on_pick(this, value, window, cx)); + }), + ) + .into_any_element() + } + fn user_choices(users: &Live>, none: &str, current: Option<&str>) -> Vec { let mut choices = vec![Choice { + avatar: None, label: none.to_owned(), value: None, checked: current.is_none(), @@ -1699,6 +1721,7 @@ impl ZbugsApp { let mut users = users.snapshot().value.clone().unwrap_or_default(); users.sort_by_key(|u| u.login.to_lowercase()); choices.extend(users.into_iter().map(|u| Choice { + avatar: Some(avatar_url(&u)), checked: current == Some(u.login.as_str()), label: u.login.clone(), value: Some(u.login), @@ -1743,7 +1766,9 @@ impl ZbugsApp { .into_any_element(); } let f = &self.filters; - let creator = self.picker( + let creator = self.searchable( + "filter-creator", + false, Button::new("filter-creator") .label( f.creator @@ -1755,7 +1780,9 @@ impl ZbugsApp { Rc::new(|this, login, _, cx| this.refilter(|f| f.creator = login, cx)), cx, ); - let assignee = self.picker( + let assignee = self.searchable( + "filter-assignee", + false, Button::new("filter-assignee") .label( f.assignee @@ -1777,16 +1804,20 @@ impl ZbugsApp { .collect::>(); names.sort_by_key(|n| n.to_lowercase()); let mut label_choices = vec![Choice { + avatar: None, label: "Any label".into(), value: None, checked: f.labels.is_empty(), }]; label_choices.extend(names.into_iter().map(|name| Choice { + avatar: None, checked: f.labels.contains(&name), label: name.clone(), value: Some(name), })); - let labels = self.picker( + let labels = self.searchable( + "filter-labels", + true, Button::new("filter-labels") .label(match f.labels.as_slice() { [] => "Labels".into(), @@ -2190,6 +2221,7 @@ impl ZbugsApp { let edit = issue.clone(); let assignee_id = issue.assignee_id.clone(); let mut assignee_choices = vec![Choice { + avatar: None, label: "Unassigned".into(), value: None, checked: assignee_id.is_none(), @@ -2197,13 +2229,16 @@ impl ZbugsApp { let mut crew = self.crew.snapshot().value.clone().unwrap_or_default(); crew.sort_by_key(|u| u.login.to_lowercase()); assignee_choices.extend(crew.into_iter().map(|u| Choice { + avatar: Some(avatar_url(&u)), checked: assignee_id.as_deref() == Some(u.id.as_str()), label: u.login, value: Some(u.id), })); let logged_in = self.session.auth.is_some(); let id = issue.id.clone(); - let assignee = self.picker( + let assignee = self.searchable( + "detail-assignee", + false, Button::new("detail-assignee") .label(match &issue.assignee { Some(u) => format!("Assigned to {}", u.login), @@ -2219,6 +2254,7 @@ impl ZbugsApp { let mut label_choices: Vec = all_labels .into_iter() .map(|l| Choice { + avatar: None, checked: issue.labels.iter().flatten().any(|has| has.id == l.id), label: l.name, value: Some(l.id), @@ -2227,6 +2263,7 @@ impl ZbugsApp { // The crew can make a new label, as in the web app's label picker. if self.session.auth.as_ref().is_some_and(|a| a.is_crew()) { label_choices.push(Choice { + avatar: None, label: "New label…".into(), value: Some(NEW_LABEL.into()), checked: false, @@ -2248,11 +2285,13 @@ impl ZbugsApp { .disabled(!logged_in), vec![ Choice { + avatar: None, label: "Subscribed".into(), value: Some("subscribe".into()), checked: subscribed, }, Choice { + avatar: None, label: "Unsubscribed".into(), value: Some("unsubscribe".into()), checked: !subscribed, @@ -2276,11 +2315,13 @@ impl ZbugsApp { .tooltip("Visibility"), vec![ Choice { + avatar: None, label: "Public".into(), value: Some("public".into()), checked: !internal, }, Choice { + avatar: None, label: "Internal".into(), value: Some("internal".into()), checked: internal, @@ -2296,7 +2337,9 @@ impl ZbugsApp { ) }); let labeled = issue.clone(); - let labels = self.picker( + let labels = self.searchable( + "detail-labels", + true, Button::new("detail-labels") .label("Labels…") .disabled(!logged_in), diff --git a/apps/zbugs-gpui/src/main.rs b/apps/zbugs-gpui/src/main.rs index 200e3ae82d..87d1eec5e3 100644 --- a/apps/zbugs-gpui/src/main.rs +++ b/apps/zbugs-gpui/src/main.rs @@ -4,6 +4,7 @@ mod app; mod http; mod live; mod login; +mod picker; mod session; mod token; mod transport; diff --git a/apps/zbugs-gpui/src/picker.rs b/apps/zbugs-gpui/src/picker.rs new file mode 100644 index 0000000000..8629bbda69 --- /dev/null +++ b/apps/zbugs-gpui/src/picker.rs @@ -0,0 +1,231 @@ +//! The searchable pickers for users and labels: a button that opens a list +//! with a search field, ranked as zbugs' `fuzzySearch` ranks its combobox. + +use gpui_kit::component::button::*; +use gpui_kit::component::input::{Input, InputEvent, InputState}; +use gpui_kit::component::popover::Popover; +use gpui_kit::component::*; +use gpui_kit::prelude::FluentBuilder as _; +use gpui_kit::*; +use std::rc::Rc; + +/// One entry of a picker: `value` is what picking it passes on, `None` +/// for the entry that clears the choice. +#[derive(Clone)] +pub struct Choice { + pub label: String, + pub value: Option, + pub checked: bool, + /// The user's avatar URL, for a user. + pub avatar: Option, +} + +/// What picking a choice does, with its value. +pub type OnPick = Rc, &mut Window, &mut App)>; + +/// `button` opens `choices` with a search field. A `multi` picker, such as +/// labels, stays open for more until a pick clears or the user leaves. +pub fn searchable( + id: &'static str, + button: Button, + choices: Vec, + multi: bool, + on_pick: OnPick, +) -> impl IntoElement { + let choices = Rc::new(choices); + Popover::new(id) + .trigger(button.small().ghost()) + .p_1() + .content(move |_, window, cx| { + let popover = cx.entity().downgrade(); + let list = window.use_keyed_state(SharedString::from(format!("{id}-list")), cx, { + |window, cx| ChoiceList::new(window, cx) + }); + let (choices, on_pick) = (choices.clone(), on_pick.clone()); + list.update(cx, |list, _| { + list.choices = choices; + list.pick = Rc::new(move |value, window, cx| { + let close = !multi || value.is_none(); + on_pick(value, window, cx); + if close { + let _ = popover.update(cx, |p, cx| p.dismiss(window, cx)); + } + }); + }); + list + }) +} + +pub struct ChoiceList { + query: Entity, + choices: Rc>, + pick: OnPick, + /// The highlighted entry of the shown ones, which Enter picks. + highlighted: usize, + scroll: ScrollHandle, + _search: Subscription, +} + +impl ChoiceList { + fn new(window: &mut Window, cx: &mut Context) -> Self { + let query = cx.new(|cx| InputState::new(window, cx).placeholder("Search")); + let search = cx.subscribe_in(&query, window, |this, _, event, window, cx| match event { + InputEvent::Change => { + this.highlighted = 0; + this.scroll.scroll_to_item(0); + cx.notify(); + } + InputEvent::PressEnter { .. } => { + if let Some(choice) = this.shown(cx).get(this.highlighted) { + (this.pick.clone())(choice.value.clone(), window, cx); + } + } + _ => {} + }); + let focus = query.clone(); + window.defer(cx, move |window, cx| { + focus.update(cx, |state, cx| state.focus(window, cx)); + }); + Self { + query, + choices: Rc::default(), + pick: Rc::new(|_, _, _| {}), + highlighted: 0, + scroll: ScrollHandle::new(), + _search: search, + } + } + + /// The choices the search finds, best first, or all of them. + fn shown(&self, cx: &App) -> Vec { + let query = self.query.read(cx).value().trim().to_lowercase(); + if query.is_empty() { + return self.choices.to_vec(); + } + let mut ranked = self + .choices + .iter() + .map(|c| (rank(&query, &c.label), c)) + .filter(|(rank, _)| *rank > 0.) + .collect::>(); + ranked.sort_by(|(ra, a), (rb, b)| { + rb.total_cmp(ra) + .then_with(|| a.label.to_lowercase().cmp(&b.label.to_lowercase())) + }); + ranked.into_iter().map(|(_, c)| c.clone()).collect() + } +} + +/// How well `text` matches `query`, 0 for not at all, as zbugs' +/// `fuzzySearch`: every query character in order, worth 10 at the start, +/// 1.1 right after another match, and 1 elsewhere. +fn rank(query: &str, text: &str) -> f64 { + let mut query = query.chars().peekable(); + let (mut rank, mut weight) = (0., 10.); + for c in text.to_lowercase().chars() { + let Some(&q) = query.peek() else { break }; + if q == c { + rank += weight; + weight = 1.1; + query.next(); + } else { + weight = 1.; + } + } + if query.peek().is_some() { 0. } else { rank } +} + +impl Render for ChoiceList { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let shown = self.shown(cx); + let highlighted = self.highlighted.min(shown.len().saturating_sub(1)); + let rows = shown.into_iter().enumerate().map(|(ix, choice)| { + let value = choice.value.clone(); + h_flex() + .id(ix) + .gap_2() + .px_2() + .py_1() + .rounded_md() + .cursor_pointer() + // One highlight for the pointer and the arrow keys. + .when(ix == highlighted, |d| d.bg(cx.theme().accent)) + .on_mouse_move(cx.listener(move |this, _, _, cx| { + if this.highlighted != ix { + this.highlighted = ix; + cx.notify(); + } + })) + .child( + div() + .w_3() + .flex_none() + .child(if choice.checked { "✓" } else { "" }), + ) + .children( + choice + .avatar + .map(|url| img(url).flex_none().size_4().rounded_full()), + ) + .child(choice.label) + .on_click(cx.listener(move |this, _, window, cx| { + (this.pick.clone())(value.clone(), window, cx) + })) + }); + let rows = rows.collect::>(); + let empty = rows.is_empty(); + v_flex() + .w(px(240.)) + .gap_1() + .capture_key_down(cx.listener(|this, event: &KeyDownEvent, _, cx| { + let len = this.shown(cx).len(); + let step: isize = match event.keystroke.key.as_str() { + "down" => 1, + "up" => -1, + _ => return, + }; + cx.stop_propagation(); + if len > 0 { + let at = + (this.highlighted.min(len - 1) as isize + step).clamp(0, len as isize - 1); + this.highlighted = at as usize; + this.scroll.scroll_to_item(this.highlighted); + cx.notify(); + } + })) + .child(Input::new(&self.query).small()) + .child( + v_flex() + .id("choices") + .max_h(px(320.)) + .overflow_y_scroll() + .track_scroll(&self.scroll) + .text_sm() + .children(rows) + .when(empty, |d| { + d.child( + div() + .p_2() + .text_color(cx.theme().muted_foreground) + .child("No matches"), + ) + }), + ) + } +} + +#[cfg(test)] +mod tests { + use super::rank; + + #[test] + fn ranks_as_zbugs_fuzzy_search() { + // In order, not necessarily together; out of order doesn't match. + assert!(rank("bg", "bug") > 0.); + assert_eq!(rank("gb", "bug"), 0.); + // A match at the start beats one later on. + assert!(rank("p", "perf") > rank("p", "zero-cache perf")); + // Consecutive characters beat scattered ones. + assert!(rank("ze", "zero") > rank("ze", "zone-cache")); + } +}