feat: add combobox filter
This commit is contained in:
@@ -5,7 +5,8 @@ use std::time::Duration;
|
||||
|
||||
use gpui::prelude::FluentBuilder as _;
|
||||
use gpui::{
|
||||
AnyView, App, ClipboardItem, ElementId, Entity, IntoElement, RenderOnce, SharedString, Window,
|
||||
AnyView, App, ClipboardItem, ElementId, Entity, IntoElement, RenderOnce, SharedString, Task,
|
||||
Window,
|
||||
};
|
||||
use gpui_native_icons::IconRole;
|
||||
use gpui_native_theme::Size;
|
||||
@@ -92,11 +93,22 @@ impl Sizable for ClipboardButton {
|
||||
}
|
||||
}
|
||||
|
||||
/// The transient copied marker keyed per button.
|
||||
struct CopiedState {
|
||||
generation: u64,
|
||||
tasks: Vec<Task<()>>,
|
||||
}
|
||||
|
||||
impl RenderOnce for ClipboardButton {
|
||||
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let copied: Entity<Option<u64>> =
|
||||
window.use_keyed_state((self.id.clone(), "clipboard-copied"), cx, |_, _| None);
|
||||
let is_copied = copied.read(cx).is_some();
|
||||
let copied: Entity<CopiedState> =
|
||||
window.use_keyed_state((self.id.clone(), "clipboard-copied"), cx, |_, _| {
|
||||
CopiedState {
|
||||
generation: 0,
|
||||
tasks: Vec::new(),
|
||||
}
|
||||
});
|
||||
let is_copied = copied.read(cx).generation != 0;
|
||||
|
||||
let selector = if is_copied {
|
||||
"clipboard-copied"
|
||||
@@ -126,30 +138,29 @@ impl RenderOnce for ClipboardButton {
|
||||
cx.write_to_clipboard(ClipboardItem::new_string(value.to_string()));
|
||||
|
||||
let generation = copied.update(cx, |state, cx| {
|
||||
let generation = state.map_or(1, |generation| generation.wrapping_add(1));
|
||||
*state = Some(generation);
|
||||
state.generation += 1;
|
||||
cx.notify();
|
||||
generation
|
||||
state.generation
|
||||
});
|
||||
|
||||
// Clear the copied state after the duration expires
|
||||
window
|
||||
.spawn(cx, {
|
||||
let copied = copied.clone();
|
||||
async move |cx| {
|
||||
cx.background_executor().timer(COPIED_DURATION).await;
|
||||
cx.update(|_, cx| {
|
||||
copied.update(cx, |state, cx| {
|
||||
if *state == Some(generation) {
|
||||
*state = None;
|
||||
cx.notify();
|
||||
}
|
||||
})
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
let weak = copied.downgrade();
|
||||
let task = window.spawn(cx, async move |cx| {
|
||||
cx.background_executor().timer(COPIED_DURATION).await;
|
||||
cx.update(|_, cx| {
|
||||
weak.update(cx, |state, cx| {
|
||||
if state.generation == generation {
|
||||
state.generation = 0;
|
||||
cx.notify();
|
||||
}
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.detach();
|
||||
.ok();
|
||||
});
|
||||
|
||||
copied.update(cx, |state, _| {
|
||||
state.tasks.push(task);
|
||||
});
|
||||
|
||||
// Invoke the callback after copying
|
||||
if let Some(on_copied) = &on_copied {
|
||||
|
||||
+32
-10
@@ -12,8 +12,8 @@ use gpui::prelude::FluentBuilder as _;
|
||||
use gpui::{
|
||||
App, Bounds, Context, ElementId, Entity, EventEmitter, InteractiveElement as _, IntoElement,
|
||||
KeyBinding, MouseButton, MouseDownEvent, ParentElement as _, Pixels, Render, Role,
|
||||
ScrollHandle, SharedString, StatefulInteractiveElement as _, Styled as _, Subscription, Window,
|
||||
actions, canvas, deferred, div, px,
|
||||
ScrollHandle, SharedString, StatefulInteractiveElement as _, Styled as _, Subscription, Task,
|
||||
Window, actions, canvas, deferred, div, px,
|
||||
};
|
||||
use gpui_native_icons::{IconRole, icon};
|
||||
use gpui_native_theme::{ActiveTheme as _, Size};
|
||||
@@ -59,12 +59,12 @@ pub enum UnmatchedText {
|
||||
|
||||
/// An option for a [`Combobox`]: a value and the label the filter matches on.
|
||||
pub type ComboboxOption<T> = (T, SharedString);
|
||||
/// Reports the value whenever it changes — including when typing clears it.
|
||||
/// Reports the value whenever it changes;
|
||||
pub type ComboboxHandler<T> = Rc<dyn Fn(Option<T>, &mut Window, &mut App)>;
|
||||
/// Receives unmatched text when the field loses focus in create mode.
|
||||
pub type ComboboxCreateHandler = Rc<dyn Fn(SharedString, &mut Window, &mut App)>;
|
||||
/// A filter over an option: the query, the value, the label.
|
||||
type Filter<T> = Rc<dyn Fn(&str, &T, &SharedString) -> bool>;
|
||||
/// Decides which options survive a query: the query, the value, the label.
|
||||
pub type ComboboxFilter<T> = Rc<dyn Fn(&str, &T, &SharedString) -> bool>;
|
||||
|
||||
/// Builder for a combobox. Pass it to [`ComboboxState::new`].
|
||||
pub struct Combobox<T>
|
||||
@@ -78,6 +78,7 @@ where
|
||||
placeholder: SharedString,
|
||||
on_change: Option<ComboboxHandler<T>>,
|
||||
on_create: Option<ComboboxCreateHandler>,
|
||||
filter: Option<ComboboxFilter<T>>,
|
||||
unmatched: UnmatchedText,
|
||||
size: Size,
|
||||
}
|
||||
@@ -99,6 +100,7 @@ where
|
||||
placeholder: SharedString::from("Select..."),
|
||||
on_change: None,
|
||||
on_create: None,
|
||||
filter: None,
|
||||
unmatched: UnmatchedText::default(),
|
||||
size: Size::default(),
|
||||
}
|
||||
@@ -138,6 +140,15 @@ where
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the matcher that decides which options survive a query.
|
||||
pub fn filter<F>(mut self, filter: F) -> Self
|
||||
where
|
||||
F: Fn(&str, &T, &SharedString) -> bool + 'static,
|
||||
{
|
||||
self.filter = Some(Rc::new(filter));
|
||||
self
|
||||
}
|
||||
|
||||
/// Leaves unmatched text in the field on blur.
|
||||
pub fn keep_unmatched_text(mut self) -> Self {
|
||||
self.unmatched = UnmatchedText::Keep;
|
||||
@@ -175,9 +186,11 @@ where
|
||||
highlight: Option<usize>,
|
||||
on_change: Option<ComboboxHandler<T>>,
|
||||
on_create: Option<ComboboxCreateHandler>,
|
||||
filter: Filter<T>,
|
||||
filter: ComboboxFilter<T>,
|
||||
unmatched: UnmatchedText,
|
||||
size: Size,
|
||||
/// Handler reports in flight; held so they die with the state.
|
||||
tasks: Vec<Task<()>>,
|
||||
/// Keeps the query-event subscription tied to the state's lifetime.
|
||||
_subscription: Subscription,
|
||||
}
|
||||
@@ -230,11 +243,12 @@ where
|
||||
highlight: None,
|
||||
on_change: combobox.on_change,
|
||||
on_create: combobox.on_create,
|
||||
filter: Rc::new(|query, _value, label| {
|
||||
label.to_lowercase().contains(&query.to_lowercase())
|
||||
filter: combobox.filter.unwrap_or_else(|| {
|
||||
Rc::new(|query, _value, label| label.to_lowercase().contains(&query.to_lowercase()))
|
||||
}),
|
||||
unmatched: combobox.unmatched,
|
||||
size: combobox.size,
|
||||
tasks: Vec::new(),
|
||||
_subscription: subscription,
|
||||
}
|
||||
}
|
||||
@@ -313,7 +327,11 @@ where
|
||||
}
|
||||
self.selected = value.clone();
|
||||
if let Some(on_change) = self.on_change.clone() {
|
||||
on_change(value, window, cx);
|
||||
let value = value.clone();
|
||||
let task = window.spawn(cx, async move |cx| {
|
||||
cx.update(|window, cx| on_change(value, window, cx)).ok();
|
||||
});
|
||||
self.tasks.push(task);
|
||||
}
|
||||
cx.emit(ComboboxChanged);
|
||||
cx.notify();
|
||||
@@ -367,7 +385,11 @@ where
|
||||
if !text.is_empty()
|
||||
&& let Some(on_create) = self.on_create.clone()
|
||||
{
|
||||
on_create(SharedString::from(text), window, cx);
|
||||
let text = SharedString::from(text);
|
||||
let task = window.spawn(cx, async move |cx| {
|
||||
cx.update(|window, cx| on_create(text, window, cx)).ok();
|
||||
});
|
||||
self.tasks.push(task);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ use gpui::{
|
||||
DispatchPhase, ElementId, Entity, EntityInputHandler, EventEmitter, Font, HitboxBehavior,
|
||||
InteractiveElement as _, IntoElement, KeyBinding, KeyDownEvent, MouseButton, MouseDownEvent,
|
||||
MouseMoveEvent, MouseUpEvent, ParentElement as _, Pixels, RenderOnce, Role, ScrollDelta,
|
||||
ScrollEvent, SharedString, StatefulInteractiveElement as _, Styled as _, Subscription,
|
||||
ScrollEvent, SharedString, StatefulInteractiveElement as _, Styled as _, Subscription, Task,
|
||||
TextAlign, TextRun, UTF16Selection, Window, WrappedLine, actions, canvas, div, fill, point, px,
|
||||
};
|
||||
use gpui_native_icons::IconRole;
|
||||
@@ -483,6 +483,8 @@ struct CaretBlink {
|
||||
visible: bool,
|
||||
active: bool,
|
||||
paused: bool,
|
||||
/// Pending blink timers; held so they die with the controller.
|
||||
tasks: Vec<Task<()>>,
|
||||
}
|
||||
|
||||
impl CaretBlink {
|
||||
@@ -493,6 +495,7 @@ impl CaretBlink {
|
||||
visible: true,
|
||||
active: false,
|
||||
paused: false,
|
||||
tasks: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -550,7 +553,7 @@ impl CaretBlink {
|
||||
fn schedule_pause(&mut self, cx: &mut Context<Self>) {
|
||||
let generation = self.generation;
|
||||
let interval = self.interval;
|
||||
cx.spawn(async move |this, cx| {
|
||||
let task = cx.spawn(async move |this, cx| {
|
||||
cx.background_executor().timer(interval).await;
|
||||
this.update(cx, |this, cx| {
|
||||
if this.generation == generation {
|
||||
@@ -558,8 +561,9 @@ impl CaretBlink {
|
||||
this.tick(cx);
|
||||
}
|
||||
})
|
||||
})
|
||||
.detach();
|
||||
.ok();
|
||||
});
|
||||
self.tasks.push(task);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ use gpui::prelude::FluentBuilder as _;
|
||||
use gpui::{
|
||||
Animation, AnimationExt as _, App, Context, ElementId, Entity, InteractiveElement as _,
|
||||
IntoElement, ParentElement as _, RenderOnce, SharedString, StatefulInteractiveElement as _,
|
||||
Styled as _, Window, deferred, div, ease_out_quint, px,
|
||||
Styled as _, Task, Window, deferred, div, ease_out_quint, px,
|
||||
};
|
||||
use gpui_native_icons::{IconRole, icon};
|
||||
use gpui_native_theme::ActiveTheme as _;
|
||||
@@ -107,6 +107,8 @@ pub struct Toasts {
|
||||
current: Option<ToastEntry>,
|
||||
exiting: Option<ToastEntry>,
|
||||
next_id: usize,
|
||||
/// Pending dismiss and fade timers; held so they die with the store.
|
||||
tasks: Vec<Task<()>>,
|
||||
}
|
||||
|
||||
impl Toasts {
|
||||
@@ -125,11 +127,11 @@ impl Toasts {
|
||||
cx.notify();
|
||||
|
||||
if let Some(duration) = duration.filter(|duration| !duration.is_zero()) {
|
||||
cx.spawn(async move |this, cx| {
|
||||
let task = cx.spawn(async move |this, cx| {
|
||||
cx.background_executor().timer(duration).await;
|
||||
this.update(cx, |toasts, cx| toasts.dismiss(id, cx)).ok();
|
||||
})
|
||||
.detach();
|
||||
});
|
||||
self.tasks.push(task);
|
||||
}
|
||||
|
||||
id
|
||||
@@ -178,12 +180,11 @@ impl Toasts {
|
||||
cx.notify();
|
||||
|
||||
let fade = cx.theme().motion.overlay;
|
||||
|
||||
cx.spawn(async move |this, cx| {
|
||||
let task = cx.spawn(async move |this, cx| {
|
||||
cx.background_executor().timer(fade).await;
|
||||
this.update(cx, |toasts, cx| toasts.finish_exit(cx)).ok();
|
||||
})
|
||||
.detach();
|
||||
});
|
||||
self.tasks.push(task);
|
||||
}
|
||||
|
||||
fn finish_exit(&mut self, cx: &mut Context<Self>) {
|
||||
|
||||
Reference in New Issue
Block a user