chore: Improve Chat Performance (#35)
* refactor * optimistically update message list * fix * update * handle duplicate messages * update ui * refactor input * update multi line input * clean up
This commit is contained in:
11
crates/ui/src/actions.rs
Normal file
11
crates/ui/src/actions.rs
Normal file
@@ -0,0 +1,11 @@
|
||||
use gpui::{actions, impl_internal_actions};
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Clone, PartialEq, Eq, Deserialize)]
|
||||
pub struct Confirm {
|
||||
/// Is confirm with secondary.
|
||||
pub secondary: bool,
|
||||
}
|
||||
|
||||
actions!(list, [Cancel, SelectPrev, SelectNext]);
|
||||
impl_internal_actions!(list, [Confirm]);
|
||||
@@ -1,28 +1,42 @@
|
||||
use gpui::{
|
||||
actions, anchored, canvas, deferred, div, prelude::FluentBuilder, px, rems, AnyElement, App,
|
||||
AppContext, Bounds, ClickEvent, Context, DismissEvent, ElementId, Entity, EventEmitter,
|
||||
FocusHandle, Focusable, InteractiveElement, IntoElement, KeyBinding, Length, ParentElement,
|
||||
Pixels, Render, SharedString, StatefulInteractiveElement, Styled, Subscription, Task,
|
||||
WeakEntity, Window,
|
||||
anchored, canvas, deferred, div, prelude::FluentBuilder, px, rems, AnyElement, App, AppContext,
|
||||
Bounds, ClickEvent, Context, DismissEvent, ElementId, Empty, Entity, EventEmitter, FocusHandle,
|
||||
Focusable, InteractiveElement, IntoElement, KeyBinding, Length, ParentElement, Pixels, Render,
|
||||
RenderOnce, SharedString, StatefulInteractiveElement, Styled, Subscription, Task, WeakEntity,
|
||||
Window,
|
||||
};
|
||||
use theme::ActiveTheme;
|
||||
|
||||
use crate::{
|
||||
actions::{Cancel, Confirm, SelectNext, SelectPrev},
|
||||
h_flex,
|
||||
list::{self, List, ListDelegate, ListItem},
|
||||
v_flex, Icon, IconName, Sizable, Size, StyleSized, StyledExt,
|
||||
input::clear_button::clear_button,
|
||||
list::{List, ListDelegate, ListItem},
|
||||
v_flex, Disableable as _, Icon, IconName, Sizable, Size, StyleSized,
|
||||
};
|
||||
|
||||
actions!(dropdown, [Up, Down, Enter, Escape]);
|
||||
#[derive(Clone)]
|
||||
pub enum ListEvent {
|
||||
/// Single click or move to selected row.
|
||||
SelectItem(usize),
|
||||
/// Double click on the row.
|
||||
ConfirmItem(usize),
|
||||
// Cancel the selection.
|
||||
Cancel,
|
||||
}
|
||||
|
||||
const CONTEXT: &str = "Dropdown";
|
||||
|
||||
pub fn init(cx: &mut App) {
|
||||
cx.bind_keys([
|
||||
KeyBinding::new("up", Up, Some(CONTEXT)),
|
||||
KeyBinding::new("down", Down, Some(CONTEXT)),
|
||||
KeyBinding::new("enter", Enter, Some(CONTEXT)),
|
||||
KeyBinding::new("escape", Escape, Some(CONTEXT)),
|
||||
KeyBinding::new("up", SelectPrev, Some(CONTEXT)),
|
||||
KeyBinding::new("down", SelectNext, Some(CONTEXT)),
|
||||
KeyBinding::new("enter", Confirm { secondary: false }, Some(CONTEXT)),
|
||||
KeyBinding::new(
|
||||
"secondary-enter",
|
||||
Confirm { secondary: true },
|
||||
Some(CONTEXT),
|
||||
),
|
||||
KeyBinding::new("escape", Cancel, Some(CONTEXT)),
|
||||
])
|
||||
}
|
||||
|
||||
@@ -30,6 +44,12 @@ pub fn init(cx: &mut App) {
|
||||
pub trait DropdownItem {
|
||||
type Value: Clone;
|
||||
fn title(&self) -> SharedString;
|
||||
/// Customize the display title used to selected item in Dropdown Input.
|
||||
///
|
||||
/// If return None, the title will be used.
|
||||
fn display_title(&self) -> Option<AnyElement> {
|
||||
None
|
||||
}
|
||||
fn value(&self) -> &Self::Value;
|
||||
}
|
||||
|
||||
@@ -80,12 +100,7 @@ pub trait DropdownDelegate: Sized {
|
||||
false
|
||||
}
|
||||
|
||||
fn perform_search(
|
||||
&mut self,
|
||||
_query: &str,
|
||||
_window: &mut Window,
|
||||
_cx: &mut Context<Dropdown<Self>>,
|
||||
) -> Task<()> {
|
||||
fn perform_search(&mut self, _query: &str, _window: &mut Window, _: &mut App) -> Task<()> {
|
||||
Task::ready(())
|
||||
}
|
||||
}
|
||||
@@ -112,7 +127,7 @@ impl<T: DropdownItem> DropdownDelegate for Vec<T> {
|
||||
|
||||
struct DropdownListDelegate<D: DropdownDelegate + 'static> {
|
||||
delegate: D,
|
||||
dropdown: WeakEntity<Dropdown<D>>,
|
||||
dropdown: WeakEntity<DropdownState<D>>,
|
||||
selected_index: Option<usize>,
|
||||
}
|
||||
|
||||
@@ -126,14 +141,10 @@ where
|
||||
self.delegate.len()
|
||||
}
|
||||
|
||||
fn confirmed_index(&self, _: &App) -> Option<usize> {
|
||||
self.selected_index
|
||||
}
|
||||
|
||||
fn render_item(
|
||||
&self,
|
||||
ix: usize,
|
||||
_window: &mut gpui::Window,
|
||||
_: &mut gpui::Window,
|
||||
cx: &mut gpui::Context<List<Self>>,
|
||||
) -> Option<Self::Item> {
|
||||
let selected = self.selected_index == Some(ix);
|
||||
@@ -145,9 +156,8 @@ where
|
||||
if let Some(item) = self.delegate.get(ix) {
|
||||
let list_item = ListItem::new(("list-item", ix))
|
||||
.check_icon(IconName::Check)
|
||||
.cursor_pointer()
|
||||
.selected(selected)
|
||||
.input_text_size(size)
|
||||
.input_font_size(size)
|
||||
.list_size(size)
|
||||
.child(div().whitespace_nowrap().child(item.title().to_string()));
|
||||
Some(list_item)
|
||||
@@ -166,9 +176,7 @@ where
|
||||
});
|
||||
}
|
||||
|
||||
fn confirm(&mut self, ix: Option<usize>, window: &mut Window, cx: &mut Context<List<Self>>) {
|
||||
self.selected_index = ix;
|
||||
|
||||
fn confirm(&mut self, _secondary: bool, window: &mut Window, cx: &mut Context<List<Self>>) {
|
||||
let selected_value = self
|
||||
.selected_index
|
||||
.and_then(|ix| self.delegate.get(ix))
|
||||
@@ -227,27 +235,35 @@ pub enum DropdownEvent<D: DropdownDelegate + 'static> {
|
||||
Confirm(Option<<D::Item as DropdownItem>::Value>),
|
||||
}
|
||||
|
||||
type Empty = Option<Box<dyn Fn(&Window, &App) -> AnyElement + 'static>>;
|
||||
type DropdownStateEmpty = Option<Box<dyn Fn(&Window, &App) -> AnyElement>>;
|
||||
|
||||
/// A Dropdown element.
|
||||
pub struct Dropdown<D: DropdownDelegate + 'static> {
|
||||
id: ElementId,
|
||||
/// State of the [`Dropdown`].
|
||||
pub struct DropdownState<D: DropdownDelegate + 'static> {
|
||||
focus_handle: FocusHandle,
|
||||
list: Entity<List<DropdownListDelegate<D>>>,
|
||||
size: Size,
|
||||
icon: Option<IconName>,
|
||||
open: bool,
|
||||
placeholder: Option<SharedString>,
|
||||
title_prefix: Option<SharedString>,
|
||||
selected_value: Option<<D::Item as DropdownItem>::Value>,
|
||||
empty: Empty,
|
||||
width: Length,
|
||||
menu_width: Length,
|
||||
empty: DropdownStateEmpty,
|
||||
/// Store the bounds of the input
|
||||
bounds: Bounds<Pixels>,
|
||||
open: bool,
|
||||
selected_value: Option<<D::Item as DropdownItem>::Value>,
|
||||
_subscriptions: Vec<Subscription>,
|
||||
}
|
||||
|
||||
/// A Dropdown element.
|
||||
#[derive(IntoElement)]
|
||||
pub struct Dropdown<D: DropdownDelegate + 'static> {
|
||||
id: ElementId,
|
||||
state: Entity<DropdownState<D>>,
|
||||
size: Size,
|
||||
icon: Option<Icon>,
|
||||
cleanable: bool,
|
||||
placeholder: Option<SharedString>,
|
||||
title_prefix: Option<SharedString>,
|
||||
empty: Option<AnyElement>,
|
||||
width: Length,
|
||||
menu_width: Length,
|
||||
disabled: bool,
|
||||
#[allow(dead_code)]
|
||||
subscriptions: Vec<Subscription>,
|
||||
}
|
||||
|
||||
pub struct SearchableVec<T> {
|
||||
@@ -258,7 +274,6 @@ pub struct SearchableVec<T> {
|
||||
impl<T: DropdownItem + Clone> SearchableVec<T> {
|
||||
pub fn new(items: impl Into<Vec<T>>) -> Self {
|
||||
let items = items.into();
|
||||
|
||||
Self {
|
||||
items: items.clone(),
|
||||
matched_items: items,
|
||||
@@ -295,12 +310,7 @@ impl<T: DropdownItem + Clone> DropdownDelegate for SearchableVec<T> {
|
||||
true
|
||||
}
|
||||
|
||||
fn perform_search(
|
||||
&mut self,
|
||||
query: &str,
|
||||
_window: &mut Window,
|
||||
_cx: &mut Context<Dropdown<Self>>,
|
||||
) -> Task<()> {
|
||||
fn perform_search(&mut self, query: &str, _window: &mut Window, _: &mut App) -> Task<()> {
|
||||
self.matched_items = self
|
||||
.items
|
||||
.iter()
|
||||
@@ -321,12 +331,11 @@ impl From<Vec<SharedString>> for SearchableVec<SharedString> {
|
||||
}
|
||||
}
|
||||
|
||||
impl<D> Dropdown<D>
|
||||
impl<D> DropdownState<D>
|
||||
where
|
||||
D: DropdownDelegate + 'static,
|
||||
{
|
||||
pub fn new(
|
||||
id: impl Into<ElementId>,
|
||||
delegate: D,
|
||||
selected_index: Option<usize>,
|
||||
window: &mut Window,
|
||||
@@ -342,83 +351,34 @@ where
|
||||
let searchable = delegate.delegate.can_search();
|
||||
|
||||
let list = cx.new(|cx| {
|
||||
let mut list = List::new(delegate, window, cx).max_h(rems(20.));
|
||||
let mut list = List::new(delegate, window, cx)
|
||||
.max_h(rems(20.))
|
||||
.reset_on_cancel(false);
|
||||
if !searchable {
|
||||
list = list.no_query();
|
||||
}
|
||||
list
|
||||
});
|
||||
|
||||
let subscriptions = vec![
|
||||
let _subscriptions = vec![
|
||||
cx.on_blur(&list.focus_handle(cx), window, Self::on_blur),
|
||||
cx.on_blur(&focus_handle, window, Self::on_blur),
|
||||
];
|
||||
|
||||
let mut this = Self {
|
||||
id: id.into(),
|
||||
focus_handle,
|
||||
placeholder: None,
|
||||
list,
|
||||
size: Size::Medium,
|
||||
icon: None,
|
||||
selected_value: None,
|
||||
open: false,
|
||||
title_prefix: None,
|
||||
empty: None,
|
||||
width: Length::Auto,
|
||||
menu_width: Length::Auto,
|
||||
bounds: Bounds::default(),
|
||||
disabled: false,
|
||||
subscriptions,
|
||||
empty: None,
|
||||
_subscriptions,
|
||||
};
|
||||
this.set_selected_index(selected_index, window, cx);
|
||||
this
|
||||
}
|
||||
|
||||
/// Set the width of the dropdown input, default: Length::Auto
|
||||
pub fn width(mut self, width: impl Into<Length>) -> Self {
|
||||
self.width = width.into();
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the width of the dropdown menu, default: Length::Auto
|
||||
pub fn menu_width(mut self, width: impl Into<Length>) -> Self {
|
||||
self.menu_width = width.into();
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the placeholder for display when dropdown value is empty.
|
||||
pub fn placeholder(mut self, placeholder: impl Into<SharedString>) -> Self {
|
||||
self.placeholder = Some(placeholder.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the right icon for the dropdown input, instead of the default arrow icon.
|
||||
pub fn icon(mut self, icon: impl Into<IconName>) -> Self {
|
||||
self.icon = Some(icon.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Set title prefix for the dropdown.
|
||||
///
|
||||
/// e.g.: Country: United States
|
||||
///
|
||||
/// You should set the label is `Country: `
|
||||
pub fn title_prefix(mut self, prefix: impl Into<SharedString>) -> Self {
|
||||
self.title_prefix = Some(prefix.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the disable state for the dropdown.
|
||||
pub fn disabled(mut self, disabled: bool) -> Self {
|
||||
self.disabled = disabled;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn set_disabled(&mut self, disabled: bool) {
|
||||
self.disabled = disabled;
|
||||
}
|
||||
|
||||
pub fn empty<E, F>(mut self, f: F) -> Self
|
||||
where
|
||||
E: IntoElement,
|
||||
@@ -453,13 +413,13 @@ where
|
||||
self.set_selected_index(selected_index, window, cx);
|
||||
}
|
||||
|
||||
pub fn selected_index(&self, _window: &Window, cx: &App) -> Option<usize> {
|
||||
pub fn selected_index(&self, cx: &App) -> Option<usize> {
|
||||
self.list.read(cx).selected_index()
|
||||
}
|
||||
|
||||
fn update_selected_value(&mut self, window: &Window, cx: &App) {
|
||||
fn update_selected_value(&mut self, _: &Window, cx: &App) {
|
||||
self.selected_value = self
|
||||
.selected_index(window, cx)
|
||||
.selected_index(cx)
|
||||
.and_then(|ix| self.list.read(cx).delegate().delegate.get(ix))
|
||||
.map(|item| item.value().clone());
|
||||
}
|
||||
@@ -482,24 +442,25 @@ where
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn up(&mut self, _: &Up, window: &mut Window, cx: &mut Context<Self>) {
|
||||
fn up(&mut self, _: &SelectPrev, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if !self.open {
|
||||
return;
|
||||
}
|
||||
|
||||
self.list.focus_handle(cx).focus(window);
|
||||
window.dispatch_action(Box::new(list::SelectPrev), cx);
|
||||
cx.propagate();
|
||||
}
|
||||
|
||||
fn down(&mut self, _: &Down, window: &mut Window, cx: &mut Context<Self>) {
|
||||
fn down(&mut self, _: &SelectNext, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if !self.open {
|
||||
self.open = true;
|
||||
}
|
||||
|
||||
self.list.focus_handle(cx).focus(window);
|
||||
window.dispatch_action(Box::new(list::SelectNext), cx);
|
||||
cx.propagate();
|
||||
}
|
||||
|
||||
fn enter(&mut self, _: &Enter, window: &mut Window, cx: &mut Context<Self>) {
|
||||
fn enter(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context<Self>) {
|
||||
// Propagate the event to the parent view, for example to the Modal to support ENTER to confirm.
|
||||
cx.propagate();
|
||||
|
||||
@@ -508,7 +469,6 @@ where
|
||||
cx.notify();
|
||||
} else {
|
||||
self.list.focus_handle(cx).focus(window);
|
||||
window.dispatch_action(Box::new(list::Confirm), cx);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -522,39 +482,150 @@ where
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn escape(&mut self, _: &Escape, _window: &mut Window, cx: &mut Context<Self>) {
|
||||
// Propagate the event to the parent view, for example to the Modal to support ESC to close.
|
||||
cx.propagate();
|
||||
fn escape(&mut self, _: &Cancel, _: &mut Window, cx: &mut Context<Self>) {
|
||||
if !self.open {
|
||||
cx.propagate();
|
||||
}
|
||||
|
||||
self.open = false;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn display_title(&self, window: &Window, cx: &App) -> impl IntoElement {
|
||||
let title = if let Some(selected_index) = &self.selected_index(window, cx) {
|
||||
let title = self
|
||||
.list
|
||||
.read(cx)
|
||||
.delegate()
|
||||
.delegate
|
||||
.get(*selected_index)
|
||||
.map(|item| item.title().to_string())
|
||||
.unwrap_or_default();
|
||||
fn clean(&mut self, _: &ClickEvent, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.set_selected_index(None, window, cx);
|
||||
cx.emit(DropdownEvent::Confirm(None));
|
||||
}
|
||||
|
||||
h_flex()
|
||||
.when_some(self.title_prefix.clone(), |this, prefix| this.child(prefix))
|
||||
.child(title.clone())
|
||||
} else {
|
||||
div().text_color(cx.theme().text_accent).child(
|
||||
/// Set the items for the dropdown.
|
||||
pub fn set_items(&mut self, items: D, _: &mut Window, cx: &mut Context<Self>)
|
||||
where
|
||||
D: DropdownDelegate + 'static,
|
||||
{
|
||||
self.list.update(cx, |list, _| {
|
||||
list.delegate_mut().delegate = items;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl<D> Render for DropdownState<D>
|
||||
where
|
||||
D: DropdownDelegate + 'static,
|
||||
{
|
||||
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
|
||||
Empty
|
||||
}
|
||||
}
|
||||
|
||||
impl<D> Dropdown<D>
|
||||
where
|
||||
D: DropdownDelegate + 'static,
|
||||
{
|
||||
pub fn new(state: &Entity<DropdownState<D>>) -> Self {
|
||||
Self {
|
||||
id: ("dropdown", state.entity_id()).into(),
|
||||
state: state.clone(),
|
||||
placeholder: None,
|
||||
size: Size::Medium,
|
||||
icon: None,
|
||||
cleanable: false,
|
||||
title_prefix: None,
|
||||
empty: None,
|
||||
width: Length::Auto,
|
||||
menu_width: Length::Auto,
|
||||
disabled: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the width of the dropdown input, default: Length::Auto
|
||||
pub fn width(mut self, width: impl Into<Length>) -> Self {
|
||||
self.width = width.into();
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the width of the dropdown menu, default: Length::Auto
|
||||
pub fn menu_width(mut self, width: impl Into<Length>) -> Self {
|
||||
self.menu_width = width.into();
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the placeholder for display when dropdown value is empty.
|
||||
pub fn placeholder(mut self, placeholder: impl Into<SharedString>) -> Self {
|
||||
self.placeholder = Some(placeholder.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the right icon for the dropdown input, instead of the default arrow icon.
|
||||
pub fn icon(mut self, icon: impl Into<Icon>) -> Self {
|
||||
self.icon = Some(icon.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Set title prefix for the dropdown.
|
||||
///
|
||||
/// e.g.: Country: United States
|
||||
///
|
||||
/// You should set the label is `Country: `
|
||||
pub fn title_prefix(mut self, prefix: impl Into<SharedString>) -> Self {
|
||||
self.title_prefix = Some(prefix.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Set true to show the clear button when the input field is not empty.
|
||||
pub fn cleanable(mut self) -> Self {
|
||||
self.cleanable = true;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the disable state for the dropdown.
|
||||
pub fn disabled(mut self, disabled: bool) -> Self {
|
||||
self.disabled = disabled;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn empty(mut self, el: impl IntoElement) -> Self {
|
||||
self.empty = Some(el.into_any_element());
|
||||
self
|
||||
}
|
||||
|
||||
/// Returns the title element for the dropdown input.
|
||||
fn display_title(&self, _: &Window, cx: &App) -> impl IntoElement {
|
||||
let default_title = div()
|
||||
.text_color(cx.theme().text_accent)
|
||||
.child(
|
||||
self.placeholder
|
||||
.clone()
|
||||
.unwrap_or_else(|| "Please select".into()),
|
||||
)
|
||||
.when(self.disabled, |this| this.text_color(cx.theme().text_muted));
|
||||
|
||||
let Some(selected_index) = &self.state.read(cx).selected_index(cx) else {
|
||||
return default_title;
|
||||
};
|
||||
|
||||
title.when(self.disabled, |this| {
|
||||
this.cursor_not_allowed().text_color(cx.theme().text_muted)
|
||||
})
|
||||
let Some(title) = self
|
||||
.state
|
||||
.read(cx)
|
||||
.list
|
||||
.read(cx)
|
||||
.delegate()
|
||||
.delegate
|
||||
.get(*selected_index)
|
||||
.map(|item| {
|
||||
if let Some(el) = item.display_title() {
|
||||
el
|
||||
} else if let Some(prefix) = self.title_prefix.as_ref() {
|
||||
format!("{}{}", prefix, item.title()).into_any_element()
|
||||
} else {
|
||||
item.title().into_any_element()
|
||||
}
|
||||
})
|
||||
else {
|
||||
return default_title;
|
||||
};
|
||||
|
||||
div()
|
||||
.when(self.disabled, |this| this.text_color(cx.theme().text_muted))
|
||||
.child(title)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -568,11 +639,11 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
impl<D> EventEmitter<DropdownEvent<D>> for Dropdown<D> where D: DropdownDelegate + 'static {}
|
||||
impl<D> EventEmitter<DismissEvent> for Dropdown<D> where D: DropdownDelegate + 'static {}
|
||||
impl<D> Focusable for Dropdown<D>
|
||||
impl<D> EventEmitter<DropdownEvent<D>> for DropdownState<D> where D: DropdownDelegate + 'static {}
|
||||
impl<D> EventEmitter<DismissEvent> for DropdownState<D> where D: DropdownDelegate + 'static {}
|
||||
impl<D> Focusable for DropdownState<D>
|
||||
where
|
||||
D: DropdownDelegate + 'static,
|
||||
D: DropdownDelegate,
|
||||
{
|
||||
fn focus_handle(&self, cx: &App) -> FocusHandle {
|
||||
if self.open {
|
||||
@@ -582,38 +653,55 @@ where
|
||||
}
|
||||
}
|
||||
}
|
||||
impl<D> Focusable for Dropdown<D>
|
||||
where
|
||||
D: DropdownDelegate,
|
||||
{
|
||||
fn focus_handle(&self, cx: &App) -> FocusHandle {
|
||||
self.state.focus_handle(cx)
|
||||
}
|
||||
}
|
||||
|
||||
impl<D> Render for Dropdown<D>
|
||||
impl<D> RenderOnce for Dropdown<D>
|
||||
where
|
||||
D: DropdownDelegate + 'static,
|
||||
{
|
||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let is_focused = self.focus_handle.is_focused(window);
|
||||
let view = cx.entity().clone();
|
||||
let bounds = self.bounds;
|
||||
let allow_open = !(self.open || self.disabled);
|
||||
let outline_visible = self.open || is_focused && !self.disabled;
|
||||
|
||||
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let is_focused = self.focus_handle(cx).is_focused(window);
|
||||
// If the size has change, set size to self.list, to change the QueryInput size.
|
||||
if self.list.read(cx).size != self.size {
|
||||
self.list
|
||||
.update(cx, |this, cx| this.set_size(self.size, window, cx))
|
||||
let old_size = self.state.read(cx).list.read(cx).size;
|
||||
if old_size != self.size {
|
||||
self.state
|
||||
.read(cx)
|
||||
.list
|
||||
.clone()
|
||||
.update(cx, |this, cx| this.set_size(self.size, window, cx));
|
||||
self.state.update(cx, |this, _| {
|
||||
this.size = self.size;
|
||||
});
|
||||
}
|
||||
|
||||
let state = self.state.read(cx);
|
||||
let show_clean = self.cleanable && state.selected_index(cx).is_some();
|
||||
let bounds = state.bounds;
|
||||
let allow_open = !(state.open || self.disabled);
|
||||
let outline_visible = state.open || is_focused && !self.disabled;
|
||||
let popup_radius = cx.theme().radius.min(px(8.));
|
||||
|
||||
div()
|
||||
.id(self.id.clone())
|
||||
.key_context(CONTEXT)
|
||||
.track_focus(&self.focus_handle)
|
||||
.on_action(cx.listener(Self::up))
|
||||
.on_action(cx.listener(Self::down))
|
||||
.on_action(cx.listener(Self::enter))
|
||||
.on_action(cx.listener(Self::escape))
|
||||
.track_focus(&self.focus_handle(cx))
|
||||
.on_action(window.listener_for(&self.state, DropdownState::up))
|
||||
.on_action(window.listener_for(&self.state, DropdownState::down))
|
||||
.on_action(window.listener_for(&self.state, DropdownState::enter))
|
||||
.on_action(window.listener_for(&self.state, DropdownState::escape))
|
||||
.size_full()
|
||||
.relative()
|
||||
.input_text_size(self.size)
|
||||
.input_font_size(self.size)
|
||||
.child(
|
||||
div()
|
||||
.id("dropdown-input")
|
||||
.id(ElementId::Name(format!("{}-input", self.id).into()))
|
||||
.relative()
|
||||
.flex()
|
||||
.items_center()
|
||||
@@ -623,23 +711,16 @@ where
|
||||
.border_color(cx.theme().border)
|
||||
.rounded(cx.theme().radius)
|
||||
.shadow_sm()
|
||||
.map(|this| {
|
||||
if self.disabled {
|
||||
this.cursor_not_allowed()
|
||||
} else {
|
||||
this.cursor_pointer()
|
||||
}
|
||||
})
|
||||
.overflow_hidden()
|
||||
.input_text_size(self.size)
|
||||
.input_font_size(self.size)
|
||||
.map(|this| match self.width {
|
||||
Length::Definite(l) => this.flex_none().w(l),
|
||||
Length::Auto => this.w_full(),
|
||||
})
|
||||
.when(outline_visible, |this| this.outline(window, cx))
|
||||
.when(outline_visible, |this| this.border_color(cx.theme().ring))
|
||||
.input_size(self.size)
|
||||
.when(allow_open, |this| {
|
||||
this.on_click(cx.listener(Self::toggle_menu))
|
||||
this.on_click(window.listener_for(&self.state, DropdownState::toggle_menu))
|
||||
})
|
||||
.child(
|
||||
h_flex()
|
||||
@@ -651,41 +732,52 @@ where
|
||||
div()
|
||||
.w_full()
|
||||
.overflow_hidden()
|
||||
.whitespace_nowrap()
|
||||
.truncate()
|
||||
.child(self.display_title(window, cx)),
|
||||
)
|
||||
.map(|this| {
|
||||
.when(show_clean, |this| {
|
||||
this.child(clear_button(cx).map(|this| {
|
||||
if self.disabled {
|
||||
this.disabled(true)
|
||||
} else {
|
||||
this.on_click(
|
||||
window.listener_for(&self.state, DropdownState::clean),
|
||||
)
|
||||
}
|
||||
}))
|
||||
})
|
||||
.when(!show_clean, |this| {
|
||||
let icon = match self.icon.clone() {
|
||||
Some(icon) => icon,
|
||||
None => {
|
||||
if self.open {
|
||||
IconName::CaretUp
|
||||
if state.open {
|
||||
Icon::new(IconName::CaretUp)
|
||||
} else {
|
||||
IconName::CaretDown
|
||||
Icon::new(IconName::CaretDown)
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
this.child(
|
||||
Icon::new(icon)
|
||||
.xsmall()
|
||||
.text_color(match self.disabled {
|
||||
true => cx.theme().icon_muted,
|
||||
false => cx.theme().icon,
|
||||
})
|
||||
.when(self.disabled, |this| this.cursor_not_allowed()),
|
||||
)
|
||||
this.child(icon.xsmall().text_color(match self.disabled {
|
||||
true => cx.theme().text_placeholder,
|
||||
false => cx.theme().text_muted,
|
||||
}))
|
||||
}),
|
||||
)
|
||||
.child(
|
||||
canvas(
|
||||
move |bounds, _, cx| view.update(cx, |r, _| r.bounds = bounds),
|
||||
{
|
||||
let state = self.state.clone();
|
||||
move |bounds, _, cx| state.update(cx, |r, _| r.bounds = bounds)
|
||||
},
|
||||
|_, _, _, _| {},
|
||||
)
|
||||
.absolute()
|
||||
.size_full(),
|
||||
),
|
||||
)
|
||||
.when(self.open, |this| {
|
||||
.when(state.open, |this| {
|
||||
this.child(
|
||||
deferred(
|
||||
anchored().snap_to_window_with_margin(px(8.)).child(
|
||||
@@ -701,17 +793,17 @@ where
|
||||
.mt_1p5()
|
||||
.bg(cx.theme().background)
|
||||
.border_1()
|
||||
.border_color(cx.theme().border_focused)
|
||||
.rounded(cx.theme().radius)
|
||||
.border_color(cx.theme().border)
|
||||
.rounded(popup_radius)
|
||||
.shadow_md()
|
||||
.on_mouse_down_out(|_, _, cx| {
|
||||
cx.dispatch_action(&Escape);
|
||||
})
|
||||
.child(self.list.clone()),
|
||||
.child(state.list.clone()),
|
||||
)
|
||||
.on_mouse_down_out(cx.listener(|this, _, window, cx| {
|
||||
this.escape(&Escape, window, cx);
|
||||
})),
|
||||
.on_mouse_down_out(window.listener_for(
|
||||
&self.state,
|
||||
|this, _, window, cx| {
|
||||
this.escape(&Cancel, window, cx);
|
||||
},
|
||||
)),
|
||||
),
|
||||
)
|
||||
.with_priority(1),
|
||||
|
||||
@@ -10,7 +10,7 @@ use theme::ActiveTheme;
|
||||
|
||||
use crate::{
|
||||
button::{Button, ButtonVariants},
|
||||
input::TextInput,
|
||||
input::InputState,
|
||||
popover::{Popover, PopoverContent},
|
||||
Icon,
|
||||
};
|
||||
@@ -24,12 +24,12 @@ impl_internal_actions!(emoji, [EmitEmoji]);
|
||||
pub struct EmojiPicker {
|
||||
icon: Option<Icon>,
|
||||
anchor: Option<Corner>,
|
||||
target_input: WeakEntity<TextInput>,
|
||||
target_input: WeakEntity<InputState>,
|
||||
emojis: Rc<Vec<SharedString>>,
|
||||
}
|
||||
|
||||
impl EmojiPicker {
|
||||
pub fn new(target_input: WeakEntity<TextInput>) -> Self {
|
||||
pub fn new(target_input: WeakEntity<InputState>) -> Self {
|
||||
let mut emojis: Vec<SharedString> = vec![];
|
||||
|
||||
emojis.extend(
|
||||
@@ -102,7 +102,7 @@ impl RenderOnce for EmojiPicker {
|
||||
move |_, window, cx| {
|
||||
if let Some(input) = input.as_ref() {
|
||||
input.update(cx, |this, cx| {
|
||||
let current = this.text();
|
||||
let current = this.value();
|
||||
let new_text = if current.is_empty() {
|
||||
format!("{}", item)
|
||||
} else if current.ends_with(" ") {
|
||||
@@ -110,7 +110,7 @@ impl RenderOnce for EmojiPicker {
|
||||
} else {
|
||||
format!("{} {}", current, item)
|
||||
};
|
||||
this.set_text(new_text, window, cx);
|
||||
this.set_value(new_text, window, cx);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,7 +68,6 @@ pub enum IconName {
|
||||
ToggleFill,
|
||||
ThumbsDown,
|
||||
ThumbsUp,
|
||||
TriangleAlert,
|
||||
Upload,
|
||||
UsersThreeFill,
|
||||
WindowClose,
|
||||
@@ -139,7 +138,6 @@ impl IconName {
|
||||
Self::ToggleFill => "icons/toggle-fill.svg",
|
||||
Self::ThumbsDown => "icons/thumbs-down.svg",
|
||||
Self::ThumbsUp => "icons/thumbs-up.svg",
|
||||
Self::TriangleAlert => "icons/triangle-alert.svg",
|
||||
Self::Upload => "icons/upload.svg",
|
||||
Self::UsersThreeFill => "icons/users-three-fill.svg",
|
||||
Self::WindowClose => "icons/window-close.svg",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use gpui::{Context, Timer};
|
||||
use std::time::Duration;
|
||||
|
||||
use gpui::{Context, Timer};
|
||||
|
||||
static INTERVAL: Duration = Duration::from_millis(500);
|
||||
static PAUSE_DELAY: Duration = Duration::from_millis(300);
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ use std::{fmt::Debug, ops::Range};
|
||||
|
||||
use crate::history::HistoryItem;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
#[derive(Debug, PartialEq, Clone)]
|
||||
pub struct Change {
|
||||
pub(crate) old_range: Range<usize>,
|
||||
pub(crate) old_text: String,
|
||||
|
||||
16
crates/ui/src/input/clear_button.rs
Normal file
16
crates/ui/src/input/clear_button.rs
Normal file
@@ -0,0 +1,16 @@
|
||||
use gpui::{App, Styled};
|
||||
use theme::ActiveTheme;
|
||||
|
||||
use crate::{
|
||||
button::{Button, ButtonVariants as _},
|
||||
Icon, IconName, Sizable as _,
|
||||
};
|
||||
|
||||
#[inline]
|
||||
pub(crate) fn clear_button(cx: &App) -> Button {
|
||||
Button::new("clean")
|
||||
.icon(Icon::new(IconName::CloseCircle))
|
||||
.ghost()
|
||||
.xsmall()
|
||||
.text_color(cx.theme().text_muted)
|
||||
}
|
||||
@@ -1,24 +1,35 @@
|
||||
use gpui::{
|
||||
fill, point, px, relative, size, App, Bounds, Corners, Element, ElementId, ElementInputHandler,
|
||||
Entity, GlobalElementId, IntoElement, LayoutId, MouseButton, MouseMoveEvent, PaintQuad, Path,
|
||||
Pixels, Point, Style, TextRun, UnderlineStyle, Window, WrappedLine,
|
||||
Pixels, Point, SharedString, Style, TextAlign, TextRun, UnderlineStyle, Window, WrappedLine,
|
||||
};
|
||||
use smallvec::SmallVec;
|
||||
use theme::ActiveTheme;
|
||||
|
||||
use super::TextInput;
|
||||
use super::InputState;
|
||||
use crate::Root;
|
||||
|
||||
const RIGHT_MARGIN: Pixels = px(5.);
|
||||
const BOTTOM_MARGIN: Pixels = px(20.);
|
||||
const CURSOR_THICKNESS: Pixels = px(2.);
|
||||
|
||||
pub(super) struct TextElement {
|
||||
input: Entity<TextInput>,
|
||||
input: Entity<InputState>,
|
||||
placeholder: SharedString,
|
||||
}
|
||||
|
||||
impl TextElement {
|
||||
pub(super) fn new(input: Entity<TextInput>) -> Self {
|
||||
Self { input }
|
||||
pub(super) fn new(input: Entity<InputState>) -> Self {
|
||||
Self {
|
||||
input,
|
||||
placeholder: SharedString::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the placeholder text of the input field.
|
||||
pub fn placeholder(mut self, placeholder: impl Into<SharedString>) -> Self {
|
||||
self.placeholder = placeholder.into();
|
||||
self
|
||||
}
|
||||
|
||||
fn paint_mouse_listeners(&mut self, window: &mut Window, _: &mut App) {
|
||||
@@ -142,7 +153,6 @@ impl TextElement {
|
||||
// cursor blink
|
||||
let cursor_height =
|
||||
window.text_style().font_size.to_pixels(window.rem_size()) + px(4.);
|
||||
|
||||
cursor = Some(fill(
|
||||
Bounds::new(
|
||||
point(
|
||||
@@ -301,6 +311,31 @@ impl IntoElement for TextElement {
|
||||
}
|
||||
}
|
||||
|
||||
/// A debug function to print points as SVG path.
|
||||
#[allow(unused)]
|
||||
fn print_points_as_svg_path(line_corners: &Vec<Corners<Point<Pixels>>>, points: &[Point<Pixels>]) {
|
||||
for corners in line_corners {
|
||||
println!(
|
||||
"tl: ({}, {}), tr: ({}, {}), bl: ({}, {}), br: ({}, {})",
|
||||
corners.top_left.x.0 as i32,
|
||||
corners.top_left.y.0 as i32,
|
||||
corners.top_right.x.0 as i32,
|
||||
corners.top_right.y.0 as i32,
|
||||
corners.bottom_left.x.0 as i32,
|
||||
corners.bottom_left.y.0 as i32,
|
||||
corners.bottom_right.x.0 as i32,
|
||||
corners.bottom_right.y.0 as i32,
|
||||
);
|
||||
}
|
||||
|
||||
if !points.is_empty() {
|
||||
println!("M{},{}", points[0].x.0 as i32, points[0].y.0 as i32);
|
||||
for p in points.iter().skip(1) {
|
||||
println!("L{},{}", p.x.0 as i32, p.y.0 as i32);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Element for TextElement {
|
||||
type RequestLayoutState = ();
|
||||
type PrepaintState = PrepaintState;
|
||||
@@ -319,11 +354,19 @@ impl Element for TextElement {
|
||||
let mut style = Style::default();
|
||||
style.size.width = relative(1.).into();
|
||||
if self.input.read(cx).is_multi_line() {
|
||||
style.size.height = relative(1.).into();
|
||||
style.min_size.height = (input.rows.max(1) as f32 * window.line_height()).into();
|
||||
style.flex_grow = 1.0;
|
||||
if let Some(h) = input.height {
|
||||
style.size.height = h.into();
|
||||
style.min_size.height = window.line_height().into();
|
||||
} else {
|
||||
style.size.height = relative(1.).into();
|
||||
style.min_size.height = (input.rows.max(1) as f32 * window.line_height()).into();
|
||||
}
|
||||
} else {
|
||||
// For single-line inputs, the minimum height should be the line height
|
||||
style.size.height = window.line_height().into();
|
||||
};
|
||||
|
||||
(window.request_layout(style, [], cx), ())
|
||||
}
|
||||
|
||||
@@ -339,7 +382,7 @@ impl Element for TextElement {
|
||||
let line_height = window.line_height();
|
||||
let input = self.input.read(cx);
|
||||
let text = input.text.clone();
|
||||
let placeholder = input.placeholder.clone();
|
||||
let placeholder = self.placeholder.clone();
|
||||
let style = window.text_style();
|
||||
let mut bounds = bounds;
|
||||
|
||||
@@ -388,7 +431,6 @@ impl Element for TextElement {
|
||||
};
|
||||
|
||||
let font_size = style.font_size.to_pixels(window.rem_size());
|
||||
|
||||
let wrap_width = if multi_line {
|
||||
Some(bounds.size.width - RIGHT_MARGIN)
|
||||
} else {
|
||||
@@ -465,6 +507,32 @@ impl Element for TextElement {
|
||||
cx,
|
||||
);
|
||||
|
||||
// Set Root focused_input when self is focused
|
||||
if focused {
|
||||
let state = self.input.clone();
|
||||
|
||||
if Root::read(window, cx).focused_input.as_ref() != Some(&state) {
|
||||
Root::update(window, cx, |root, _, cx| {
|
||||
root.focused_input = Some(state);
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// And reset focused_input when next_frame start
|
||||
window.on_next_frame({
|
||||
let state = self.input.clone();
|
||||
|
||||
move |window, cx| {
|
||||
if !focused && Root::read(window, cx).focused_input.as_ref() == Some(&state) {
|
||||
Root::update(window, cx, |root, _, cx| {
|
||||
root.focused_input = None;
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Paint selections
|
||||
if let Some(path) = prepaint.selection_path.take() {
|
||||
window.paint_path(path, cx.theme().element_disabled);
|
||||
@@ -475,7 +543,6 @@ impl Element for TextElement {
|
||||
let origin = bounds.origin;
|
||||
|
||||
let mut offset_y = px(0.);
|
||||
|
||||
if self.input.read(cx).masked {
|
||||
// Move down offset for vertical centering the *****
|
||||
if cfg!(target_os = "macos") {
|
||||
@@ -484,10 +551,9 @@ impl Element for TextElement {
|
||||
offset_y = px(2.5);
|
||||
}
|
||||
}
|
||||
|
||||
for line in prepaint.lines.iter() {
|
||||
let p = point(origin.x, origin.y + offset_y);
|
||||
_ = line.paint(p, line_height, gpui::TextAlign::Left, None, window, cx);
|
||||
_ = line.paint(p, line_height, TextAlign::Left, None, window, cx);
|
||||
offset_y += line.size(line_height).height;
|
||||
}
|
||||
|
||||
|
||||
380
crates/ui/src/input/mask_pattern.rs
Normal file
380
crates/ui/src/input/mask_pattern.rs
Normal file
@@ -0,0 +1,380 @@
|
||||
use gpui::SharedString;
|
||||
|
||||
#[derive(Clone, PartialEq, Debug)]
|
||||
pub enum MaskToken {
|
||||
/// 0 Digit, equivalent to `[0]`
|
||||
// Digit0,
|
||||
/// Digit, equivalent to `[0-9]`
|
||||
Digit,
|
||||
/// Letter, equivalent to `[a-zA-Z]`
|
||||
Letter,
|
||||
/// Letter or digit, equivalent to `[a-zA-Z0-9]`
|
||||
LetterOrDigit,
|
||||
/// Separator
|
||||
Sep(char),
|
||||
/// Any character
|
||||
Any,
|
||||
}
|
||||
|
||||
#[allow(unused)]
|
||||
impl MaskToken {
|
||||
/// Check if the token is any character.
|
||||
pub fn is_any(&self) -> bool {
|
||||
matches!(self, MaskToken::Any)
|
||||
}
|
||||
|
||||
/// Check if the token is a match for the given character.
|
||||
///
|
||||
/// The separator is always a match any input character.
|
||||
fn is_match(&self, ch: char) -> bool {
|
||||
match self {
|
||||
MaskToken::Digit => ch.is_ascii_digit(),
|
||||
MaskToken::Letter => ch.is_ascii_alphabetic(),
|
||||
MaskToken::LetterOrDigit => ch.is_ascii_alphanumeric(),
|
||||
MaskToken::Any => true,
|
||||
MaskToken::Sep(c) => *c == ch,
|
||||
}
|
||||
}
|
||||
|
||||
/// Is the token a separator (Can be ignored)
|
||||
fn is_sep(&self) -> bool {
|
||||
matches!(self, MaskToken::Sep(_))
|
||||
}
|
||||
|
||||
/// Check if the token is a number.
|
||||
pub fn is_number(&self) -> bool {
|
||||
matches!(self, MaskToken::Digit)
|
||||
}
|
||||
|
||||
pub fn placeholder(&self) -> char {
|
||||
match self {
|
||||
MaskToken::Sep(c) => *c,
|
||||
_ => '_',
|
||||
}
|
||||
}
|
||||
|
||||
fn mask_char(&self, ch: char) -> char {
|
||||
match self {
|
||||
MaskToken::Digit | MaskToken::LetterOrDigit | MaskToken::Letter => ch,
|
||||
MaskToken::Sep(c) => *c,
|
||||
MaskToken::Any => ch,
|
||||
}
|
||||
}
|
||||
|
||||
fn unmask_char(&self, ch: char) -> Option<char> {
|
||||
match self {
|
||||
MaskToken::Digit => Some(ch),
|
||||
MaskToken::Letter => Some(ch),
|
||||
MaskToken::LetterOrDigit => Some(ch),
|
||||
MaskToken::Any => Some(ch),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub enum MaskPattern {
|
||||
#[default]
|
||||
None,
|
||||
Pattern {
|
||||
pattern: SharedString,
|
||||
tokens: Vec<MaskToken>,
|
||||
},
|
||||
Number {
|
||||
/// Group separator, e.g. "," or " "
|
||||
separator: Option<char>,
|
||||
/// Number of fraction digits, e.g. 2 for 123.45
|
||||
fraction: Option<usize>,
|
||||
},
|
||||
}
|
||||
|
||||
impl From<&str> for MaskPattern {
|
||||
fn from(pattern: &str) -> Self {
|
||||
Self::new(pattern)
|
||||
}
|
||||
}
|
||||
|
||||
impl MaskPattern {
|
||||
/// Create a new mask pattern
|
||||
///
|
||||
/// - `9` - Digit
|
||||
/// - `A` - Letter
|
||||
/// - `#` - Letter or Digit
|
||||
/// - `*` - Any character
|
||||
/// - other characters - Separator
|
||||
///
|
||||
/// For example:
|
||||
///
|
||||
/// - `(999)999-9999` - US phone number: (123)456-7890
|
||||
/// - `99999-9999` - ZIP code: 12345-6789
|
||||
/// - `AAAA-99-####` - Custom pattern: ABCD-12-3AB4
|
||||
/// - `*999*` - Custom pattern: (123) or [123]
|
||||
pub fn new(pattern: &str) -> Self {
|
||||
let tokens = pattern
|
||||
.chars()
|
||||
.map(|ch| match ch {
|
||||
// '0' => MaskToken::Digit0,
|
||||
'9' => MaskToken::Digit,
|
||||
'A' => MaskToken::Letter,
|
||||
'#' => MaskToken::LetterOrDigit,
|
||||
'*' => MaskToken::Any,
|
||||
_ => MaskToken::Sep(ch),
|
||||
})
|
||||
.collect();
|
||||
|
||||
Self::Pattern {
|
||||
pattern: pattern.to_owned().into(),
|
||||
tokens,
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(unused)]
|
||||
fn tokens(&self) -> Option<&Vec<MaskToken>> {
|
||||
match self {
|
||||
Self::Pattern { tokens, .. } => Some(tokens),
|
||||
Self::Number { .. } => None,
|
||||
Self::None => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new mask pattern with group separator, e.g. "," or " "
|
||||
pub fn number(sep: Option<char>) -> Self {
|
||||
Self::Number {
|
||||
separator: sep,
|
||||
fraction: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn placeholder(&self) -> Option<String> {
|
||||
match self {
|
||||
Self::Pattern { tokens, .. } => {
|
||||
Some(tokens.iter().map(|token| token.placeholder()).collect())
|
||||
}
|
||||
Self::Number { .. } => None,
|
||||
Self::None => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Return true if the mask pattern is None or no any pattern.
|
||||
pub fn is_none(&self) -> bool {
|
||||
match self {
|
||||
Self::Pattern { tokens, .. } => tokens.is_empty(),
|
||||
Self::Number { .. } => false,
|
||||
Self::None => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check is the mask text is valid.
|
||||
///
|
||||
/// If the mask pattern is None, always return true.
|
||||
pub fn is_valid(&self, mask_text: &str) -> bool {
|
||||
if self.is_none() {
|
||||
return true;
|
||||
}
|
||||
|
||||
let mut text_index = 0;
|
||||
let mask_text_chars: Vec<char> = mask_text.chars().collect();
|
||||
match self {
|
||||
Self::Pattern { tokens, .. } => {
|
||||
for token in tokens {
|
||||
if text_index >= mask_text_chars.len() {
|
||||
break;
|
||||
}
|
||||
|
||||
let ch = mask_text_chars[text_index];
|
||||
if token.is_match(ch) {
|
||||
text_index += 1;
|
||||
}
|
||||
}
|
||||
text_index == mask_text.len()
|
||||
}
|
||||
Self::Number { separator, .. } => {
|
||||
if mask_text.is_empty() {
|
||||
return true;
|
||||
}
|
||||
|
||||
// check if the text is valid number
|
||||
let mut parts = mask_text.split('.');
|
||||
let int_part = parts.next().unwrap_or("");
|
||||
let frac_part = parts.next();
|
||||
|
||||
if int_part.is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
// check if the integer part is valid
|
||||
if !int_part
|
||||
.chars()
|
||||
.all(|ch| ch.is_ascii_digit() || Some(ch) == *separator)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// check if the fraction part is valid
|
||||
if let Some(frac) = frac_part {
|
||||
if !frac
|
||||
.chars()
|
||||
.all(|ch| ch.is_ascii_digit() || Some(ch) == *separator)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
Self::None => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if valid input char at the given position.
|
||||
pub fn is_valid_at(&self, ch: char, pos: usize) -> bool {
|
||||
if self.is_none() {
|
||||
return true;
|
||||
}
|
||||
|
||||
match self {
|
||||
Self::Pattern { tokens, .. } => {
|
||||
if let Some(token) = tokens.get(pos) {
|
||||
if token.is_match(ch) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if token.is_sep() {
|
||||
// If next token is match, it's valid
|
||||
if let Some(next_token) = tokens.get(pos + 1) {
|
||||
if next_token.is_match(ch) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
Self::Number { .. } => true,
|
||||
Self::None => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Format the text according to the mask pattern
|
||||
///
|
||||
/// For example:
|
||||
///
|
||||
/// - pattern: (999)999-999
|
||||
/// - text: 123456789
|
||||
/// - mask_text: (123)456-789
|
||||
pub fn mask(&self, text: &str) -> SharedString {
|
||||
if self.is_none() {
|
||||
return text.to_owned().into();
|
||||
}
|
||||
|
||||
match self {
|
||||
Self::Number {
|
||||
separator,
|
||||
fraction,
|
||||
} => {
|
||||
if let Some(sep) = *separator {
|
||||
// Remove the existing group separator
|
||||
let text = text.replace(sep, "");
|
||||
|
||||
let mut parts = text.split('.');
|
||||
let int_part = parts.next().unwrap_or("");
|
||||
|
||||
// Limit the fraction part to the given range, if not enough, pad with 0
|
||||
let frac_part = parts.next().map(|part| {
|
||||
part.chars()
|
||||
.take(fraction.unwrap_or(usize::MAX))
|
||||
.collect::<String>()
|
||||
});
|
||||
|
||||
// Reverse the integer part for easier grouping
|
||||
let chars: Vec<char> = int_part.chars().rev().collect();
|
||||
let mut result = String::new();
|
||||
for (i, ch) in chars.iter().enumerate() {
|
||||
if i > 0 && i % 3 == 0 {
|
||||
result.push(sep);
|
||||
}
|
||||
result.push(*ch);
|
||||
}
|
||||
let int_with_sep: String = result.chars().rev().collect();
|
||||
|
||||
let final_str = if let Some(frac) = frac_part {
|
||||
if fraction == &Some(0) {
|
||||
int_with_sep
|
||||
} else {
|
||||
format!("{}.{}", int_with_sep, frac)
|
||||
}
|
||||
} else {
|
||||
int_with_sep
|
||||
};
|
||||
return final_str.into();
|
||||
}
|
||||
|
||||
text.to_owned().into()
|
||||
}
|
||||
Self::Pattern { tokens, .. } => {
|
||||
let mut result = String::new();
|
||||
let mut text_index = 0;
|
||||
let text_chars: Vec<char> = text.chars().collect();
|
||||
for (pos, token) in tokens.iter().enumerate() {
|
||||
if text_index >= text_chars.len() {
|
||||
break;
|
||||
}
|
||||
let ch = text_chars[text_index];
|
||||
// Break if expected char is not match
|
||||
if !token.is_sep() && !self.is_valid_at(ch, pos) {
|
||||
break;
|
||||
}
|
||||
let mask_ch = token.mask_char(ch);
|
||||
result.push(mask_ch);
|
||||
if ch == mask_ch {
|
||||
text_index += 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
result.into()
|
||||
}
|
||||
Self::None => text.to_owned().into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract original text from masked text
|
||||
pub fn unmask(&self, mask_text: &str) -> String {
|
||||
match self {
|
||||
Self::Number { separator, .. } => {
|
||||
if let Some(sep) = *separator {
|
||||
let mut result = String::new();
|
||||
for ch in mask_text.chars() {
|
||||
if ch == sep {
|
||||
continue;
|
||||
}
|
||||
result.push(ch);
|
||||
}
|
||||
|
||||
if result.contains('.') {
|
||||
result = result.trim_end_matches('0').to_string();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
mask_text.to_owned()
|
||||
}
|
||||
Self::Pattern { tokens, .. } => {
|
||||
let mut result = String::new();
|
||||
let mask_text_chars: Vec<char> = mask_text.chars().collect();
|
||||
for (text_index, token) in tokens.iter().enumerate() {
|
||||
if text_index >= mask_text_chars.len() {
|
||||
break;
|
||||
}
|
||||
let ch = mask_text_chars[text_index];
|
||||
let unmask_ch = token.unmask_char(ch);
|
||||
if let Some(ch) = unmask_ch {
|
||||
result.push(ch);
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
Self::None => mask_text.to_owned(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,12 @@
|
||||
mod blink_cursor;
|
||||
mod change;
|
||||
mod element;
|
||||
#[allow(clippy::module_inception)]
|
||||
mod input;
|
||||
mod mask_pattern;
|
||||
mod state;
|
||||
mod text_input;
|
||||
|
||||
pub use input::*;
|
||||
pub(crate) mod clear_button;
|
||||
|
||||
#[allow(ambiguous_glob_reexports)]
|
||||
pub use state::*;
|
||||
pub use text_input::*;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
302
crates/ui/src/input/text_input.rs
Normal file
302
crates/ui/src/input/text_input.rs
Normal file
@@ -0,0 +1,302 @@
|
||||
use gpui::{
|
||||
div, prelude::FluentBuilder as _, px, relative, AnyElement, App, DefiniteLength, Entity,
|
||||
InteractiveElement as _, IntoElement, MouseButton, ParentElement as _, Rems, RenderOnce,
|
||||
Styled, Window,
|
||||
};
|
||||
use theme::ActiveTheme;
|
||||
|
||||
use super::InputState;
|
||||
use crate::{
|
||||
button::{Button, ButtonVariants as _},
|
||||
h_flex,
|
||||
indicator::Indicator,
|
||||
input::clear_button::clear_button,
|
||||
scroll::{Scrollbar, ScrollbarAxis},
|
||||
IconName, Sizable, Size, StyleSized,
|
||||
};
|
||||
|
||||
#[derive(IntoElement)]
|
||||
pub struct TextInput {
|
||||
state: Entity<InputState>,
|
||||
size: Size,
|
||||
no_gap: bool,
|
||||
prefix: Option<AnyElement>,
|
||||
suffix: Option<AnyElement>,
|
||||
height: Option<DefiniteLength>,
|
||||
appearance: bool,
|
||||
cleanable: bool,
|
||||
mask_toggle: bool,
|
||||
disabled: bool,
|
||||
}
|
||||
|
||||
impl Sizable for TextInput {
|
||||
fn with_size(mut self, size: impl Into<Size>) -> Self {
|
||||
self.size = size.into();
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl TextInput {
|
||||
/// Create a new [`TextInput`] element bind to the [`InputState`].
|
||||
pub fn new(state: &Entity<InputState>) -> Self {
|
||||
Self {
|
||||
state: state.clone(),
|
||||
size: Size::default(),
|
||||
no_gap: false,
|
||||
prefix: None,
|
||||
suffix: None,
|
||||
height: None,
|
||||
appearance: true,
|
||||
cleanable: false,
|
||||
mask_toggle: false,
|
||||
disabled: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn prefix(mut self, prefix: impl IntoElement) -> Self {
|
||||
self.prefix = Some(prefix.into_any_element());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn suffix(mut self, suffix: impl IntoElement) -> Self {
|
||||
self.suffix = Some(suffix.into_any_element());
|
||||
self
|
||||
}
|
||||
|
||||
/// Set full height of the input (Multi-line only).
|
||||
pub fn h_full(mut self) -> Self {
|
||||
self.height = Some(relative(1.));
|
||||
self
|
||||
}
|
||||
|
||||
/// Set height of the input (Multi-line only).
|
||||
pub fn h(mut self, height: impl Into<DefiniteLength>) -> Self {
|
||||
self.height = Some(height.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the appearance of the input field.
|
||||
pub fn appearance(mut self, appearance: bool) -> Self {
|
||||
self.appearance = appearance;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set true to show the clear button when the input field is not empty.
|
||||
pub fn cleanable(mut self) -> Self {
|
||||
self.cleanable = true;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set to enable toggle button for password mask state.
|
||||
pub fn mask_toggle(mut self) -> Self {
|
||||
self.mask_toggle = true;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set to disable the input field.
|
||||
pub fn disabled(mut self, disabled: bool) -> Self {
|
||||
self.disabled = disabled;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set true to not use gap between input and prefix, suffix, and clear button.
|
||||
///
|
||||
/// Default: false
|
||||
#[allow(dead_code)]
|
||||
pub(super) fn no_gap(mut self) -> Self {
|
||||
self.no_gap = true;
|
||||
self
|
||||
}
|
||||
|
||||
fn render_toggle_mask_button(state: Entity<InputState>) -> impl IntoElement {
|
||||
Button::new("toggle-mask")
|
||||
.icon(IconName::Eye)
|
||||
.xsmall()
|
||||
.ghost()
|
||||
.on_mouse_down(MouseButton::Left, {
|
||||
let state = state.clone();
|
||||
move |_, window, cx| {
|
||||
state.update(cx, |state, cx| {
|
||||
state.set_masked(false, window, cx);
|
||||
})
|
||||
}
|
||||
})
|
||||
.on_mouse_up(MouseButton::Left, {
|
||||
let state = state.clone();
|
||||
move |_, window, cx| {
|
||||
state.update(cx, |state, cx| {
|
||||
state.set_masked(true, window, cx);
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for TextInput {
|
||||
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
const LINE_HEIGHT: Rems = Rems(1.25);
|
||||
|
||||
self.state.update(cx, |state, _| {
|
||||
state.height = self.height;
|
||||
state.disabled = self.disabled;
|
||||
});
|
||||
|
||||
let state = self.state.read(cx);
|
||||
let focused = state.focus_handle.is_focused(window);
|
||||
|
||||
let mut gap_x = match self.size {
|
||||
Size::Small => px(4.),
|
||||
Size::Large => px(8.),
|
||||
_ => px(4.),
|
||||
};
|
||||
|
||||
if self.no_gap {
|
||||
gap_x = px(0.);
|
||||
}
|
||||
|
||||
let prefix = self.prefix;
|
||||
let suffix = self.suffix;
|
||||
|
||||
let show_clear_button =
|
||||
self.cleanable && !state.loading && !state.text.is_empty() && state.is_single_line();
|
||||
|
||||
let bg = if state.disabled {
|
||||
cx.theme().surface_background
|
||||
} else {
|
||||
cx.theme().elevated_surface_background
|
||||
};
|
||||
|
||||
div()
|
||||
.id(("input", self.state.entity_id()))
|
||||
.flex()
|
||||
.key_context(crate::input::CONTEXT)
|
||||
.track_focus(&state.focus_handle)
|
||||
.when(!state.disabled, |this| {
|
||||
this.on_action(window.listener_for(&self.state, InputState::backspace))
|
||||
.on_action(window.listener_for(&self.state, InputState::delete))
|
||||
.on_action(
|
||||
window.listener_for(&self.state, InputState::delete_to_beginning_of_line),
|
||||
)
|
||||
.on_action(window.listener_for(&self.state, InputState::delete_to_end_of_line))
|
||||
.on_action(window.listener_for(&self.state, InputState::delete_previous_word))
|
||||
.on_action(window.listener_for(&self.state, InputState::delete_next_word))
|
||||
.on_action(window.listener_for(&self.state, InputState::enter))
|
||||
.on_action(window.listener_for(&self.state, InputState::escape))
|
||||
})
|
||||
.on_action(window.listener_for(&self.state, InputState::left))
|
||||
.on_action(window.listener_for(&self.state, InputState::right))
|
||||
.on_action(window.listener_for(&self.state, InputState::select_left))
|
||||
.on_action(window.listener_for(&self.state, InputState::select_right))
|
||||
.when(state.multi_line, |this| {
|
||||
this.on_action(window.listener_for(&self.state, InputState::up))
|
||||
.on_action(window.listener_for(&self.state, InputState::down))
|
||||
.on_action(window.listener_for(&self.state, InputState::select_up))
|
||||
.on_action(window.listener_for(&self.state, InputState::select_down))
|
||||
.on_action(window.listener_for(&self.state, InputState::shift_to_new_line))
|
||||
})
|
||||
.on_action(window.listener_for(&self.state, InputState::select_all))
|
||||
.on_action(window.listener_for(&self.state, InputState::select_to_start_of_line))
|
||||
.on_action(window.listener_for(&self.state, InputState::select_to_end_of_line))
|
||||
.on_action(window.listener_for(&self.state, InputState::select_to_previous_word))
|
||||
.on_action(window.listener_for(&self.state, InputState::select_to_next_word))
|
||||
.on_action(window.listener_for(&self.state, InputState::home))
|
||||
.on_action(window.listener_for(&self.state, InputState::end))
|
||||
.on_action(window.listener_for(&self.state, InputState::move_to_start))
|
||||
.on_action(window.listener_for(&self.state, InputState::move_to_end))
|
||||
.on_action(window.listener_for(&self.state, InputState::move_to_previous_word))
|
||||
.on_action(window.listener_for(&self.state, InputState::move_to_next_word))
|
||||
.on_action(window.listener_for(&self.state, InputState::select_to_start))
|
||||
.on_action(window.listener_for(&self.state, InputState::select_to_end))
|
||||
.on_action(window.listener_for(&self.state, InputState::show_character_palette))
|
||||
.on_action(window.listener_for(&self.state, InputState::copy))
|
||||
.on_action(window.listener_for(&self.state, InputState::paste))
|
||||
.on_action(window.listener_for(&self.state, InputState::cut))
|
||||
.on_action(window.listener_for(&self.state, InputState::undo))
|
||||
.on_action(window.listener_for(&self.state, InputState::redo))
|
||||
.on_key_down(window.listener_for(&self.state, InputState::on_key_down))
|
||||
.on_mouse_down(
|
||||
MouseButton::Left,
|
||||
window.listener_for(&self.state, InputState::on_mouse_down),
|
||||
)
|
||||
.on_mouse_up(
|
||||
MouseButton::Left,
|
||||
window.listener_for(&self.state, InputState::on_mouse_up),
|
||||
)
|
||||
.on_scroll_wheel(window.listener_for(&self.state, InputState::on_scroll_wheel))
|
||||
.size_full()
|
||||
.line_height(LINE_HEIGHT)
|
||||
.cursor_text()
|
||||
.input_py(self.size)
|
||||
.input_h(self.size)
|
||||
.when(state.multi_line, |this| {
|
||||
this.h_auto()
|
||||
.when_some(self.height, |this, height| this.h(height))
|
||||
})
|
||||
.when(self.appearance, |this| {
|
||||
this.bg(bg)
|
||||
.rounded(cx.theme().radius)
|
||||
.when(focused, |this| this.border_color(cx.theme().ring))
|
||||
})
|
||||
.when(prefix.is_none(), |this| this.input_pl(self.size))
|
||||
.input_pr(self.size)
|
||||
.items_center()
|
||||
.gap(gap_x)
|
||||
.children(prefix)
|
||||
// TODO: Define height here, and use it in the input element
|
||||
.child(self.state.clone())
|
||||
.child(
|
||||
h_flex()
|
||||
.id("suffix")
|
||||
.absolute()
|
||||
.gap(gap_x)
|
||||
.when(self.appearance, |this| this.bg(bg))
|
||||
.items_center()
|
||||
.when(suffix.is_none(), |this| this.pr_1())
|
||||
.right_0()
|
||||
.when(state.loading, |this| {
|
||||
this.child(Indicator::new().color(cx.theme().text_muted))
|
||||
})
|
||||
.when(self.mask_toggle, |this| {
|
||||
this.child(Self::render_toggle_mask_button(self.state.clone()))
|
||||
})
|
||||
.when(show_clear_button, |this| {
|
||||
this.child(clear_button(cx).on_click({
|
||||
let state = self.state.clone();
|
||||
move |_, window, cx| {
|
||||
state.update(cx, |state, cx| {
|
||||
state.clean(window, cx);
|
||||
})
|
||||
}
|
||||
}))
|
||||
})
|
||||
.children(suffix),
|
||||
)
|
||||
.when(state.is_multi_line(), |this| {
|
||||
let entity_id = self.state.entity_id();
|
||||
|
||||
if state.last_layout.is_some() {
|
||||
let scroll_size = state.scroll_size;
|
||||
|
||||
this.relative().child(
|
||||
div()
|
||||
.absolute()
|
||||
.top_0()
|
||||
.left_0()
|
||||
.right(px(1.))
|
||||
.bottom_0()
|
||||
.child(
|
||||
Scrollbar::vertical(
|
||||
entity_id,
|
||||
state.scrollbar_state.clone(),
|
||||
state.scroll_handle.clone(),
|
||||
scroll_size,
|
||||
)
|
||||
.axis(ScrollbarAxis::Vertical),
|
||||
),
|
||||
)
|
||||
} else {
|
||||
this
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ mod styled;
|
||||
mod title_bar;
|
||||
mod window_border;
|
||||
|
||||
pub(crate) mod actions;
|
||||
pub mod animation;
|
||||
pub mod button;
|
||||
pub mod checkbox;
|
||||
|
||||
@@ -1,33 +1,43 @@
|
||||
use std::{cell::Cell, rc::Rc, time::Duration};
|
||||
|
||||
use gpui::{
|
||||
actions, div, prelude::FluentBuilder, px, uniform_list, AnyElement, App, AppContext, Context,
|
||||
Entity, FocusHandle, Focusable, InteractiveElement, IntoElement, KeyBinding, Length,
|
||||
ListSizingBehavior, MouseButton, ParentElement, Render, ScrollStrategy, SharedString, Styled,
|
||||
Subscription, Task, UniformListScrollHandle, Window,
|
||||
div, prelude::FluentBuilder, uniform_list, AnyElement, AppContext, Entity, FocusHandle,
|
||||
Focusable, InteractiveElement, IntoElement, KeyBinding, Length, ListSizingBehavior,
|
||||
MouseButton, ParentElement, Render, Styled, Task, UniformListScrollHandle, Window,
|
||||
};
|
||||
use gpui::{px, App, Context, EventEmitter, MouseDownEvent, ScrollStrategy, Subscription};
|
||||
use smol::Timer;
|
||||
use theme::ActiveTheme;
|
||||
|
||||
use super::loading::Loading;
|
||||
use crate::{
|
||||
input::{InputEvent, TextInput},
|
||||
actions::{Cancel, Confirm, SelectNext, SelectPrev},
|
||||
input::{InputEvent, InputState, TextInput},
|
||||
scroll::{Scrollbar, ScrollbarState},
|
||||
v_flex, Icon, IconName, Size,
|
||||
v_flex, Icon, IconName, Sizable as _, Size,
|
||||
};
|
||||
|
||||
actions!(list, [Cancel, Confirm, SelectPrev, SelectNext]);
|
||||
|
||||
pub fn init(cx: &mut App) {
|
||||
let context: Option<&str> = Some("List");
|
||||
|
||||
cx.bind_keys([
|
||||
KeyBinding::new("escape", Cancel, context),
|
||||
KeyBinding::new("enter", Confirm, context),
|
||||
KeyBinding::new("enter", Confirm { secondary: false }, context),
|
||||
KeyBinding::new("secondary-enter", Confirm { secondary: true }, context),
|
||||
KeyBinding::new("up", SelectPrev, context),
|
||||
KeyBinding::new("down", SelectNext, context),
|
||||
]);
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub enum ListEvent {
|
||||
/// Move to select item.
|
||||
Select(usize),
|
||||
/// Click on item or pressed Enter.
|
||||
Confirm(usize),
|
||||
/// Pressed ESC to deselect the item.
|
||||
Cancel,
|
||||
}
|
||||
|
||||
/// A delegate for the List.
|
||||
#[allow(unused)]
|
||||
pub trait ListDelegate: Sized + 'static {
|
||||
@@ -77,9 +87,18 @@ pub trait ListDelegate: Sized + 'static {
|
||||
None
|
||||
}
|
||||
|
||||
/// Return the confirmed index of the selected item.
|
||||
fn confirmed_index(&self, cx: &App) -> Option<usize> {
|
||||
None
|
||||
/// Returns the loading state to show the loading view.
|
||||
fn loading(&self, cx: &App) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Returns a Element to show when loading, default is built-in Skeleton loading view.
|
||||
fn render_loading(
|
||||
&self,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<List<Self>>,
|
||||
) -> impl IntoElement {
|
||||
Loading
|
||||
}
|
||||
|
||||
/// Set the selected index, just store the ix, don't confirm.
|
||||
@@ -91,29 +110,56 @@ pub trait ListDelegate: Sized + 'static {
|
||||
);
|
||||
|
||||
/// Set the confirm and give the selected index, this is means user have clicked the item or pressed Enter.
|
||||
fn confirm(&mut self, ix: Option<usize>, window: &mut Window, cx: &mut Context<List<Self>>) {}
|
||||
///
|
||||
/// This will always to `set_selected_index` before confirm.
|
||||
fn confirm(&mut self, secondary: bool, window: &mut Window, cx: &mut Context<List<Self>>) {}
|
||||
|
||||
/// Cancel the selection, e.g.: Pressed ESC.
|
||||
fn cancel(&mut self, window: &mut Window, cx: &mut Context<List<Self>>) {}
|
||||
|
||||
/// Return true to enable load more data when scrolling to the bottom.
|
||||
///
|
||||
/// Default: true
|
||||
fn can_load_more(&self, cx: &App) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// Returns a threshold value (n rows), of course, when scrolling to the bottom,
|
||||
/// the remaining number of rows triggers `load_more`.
|
||||
/// This should smaller than the total number of first load rows.
|
||||
///
|
||||
/// Default: 20 rows
|
||||
fn load_more_threshold(&self) -> usize {
|
||||
20
|
||||
}
|
||||
|
||||
/// Load more data when the table is scrolled to the bottom.
|
||||
///
|
||||
/// This will performed in a background task.
|
||||
///
|
||||
/// This is always called when the table is near the bottom,
|
||||
/// so you must check if there is more data to load or lock the loading state.
|
||||
fn load_more(&mut self, window: &mut Window, cx: &mut Context<List<Self>>) {}
|
||||
}
|
||||
|
||||
pub struct List<D: ListDelegate> {
|
||||
focus_handle: FocusHandle,
|
||||
delegate: D,
|
||||
max_height: Option<Length>,
|
||||
query_input: Option<Entity<TextInput>>,
|
||||
query_input: Option<Entity<InputState>>,
|
||||
last_query: Option<String>,
|
||||
loading: bool,
|
||||
|
||||
enable_scrollbar: bool,
|
||||
selectable: bool,
|
||||
querying: bool,
|
||||
scrollbar_visible: bool,
|
||||
vertical_scroll_handle: UniformListScrollHandle,
|
||||
scrollbar_state: Rc<Cell<ScrollbarState>>,
|
||||
|
||||
pub(crate) size: Size,
|
||||
selected_index: Option<usize>,
|
||||
right_clicked_index: Option<usize>,
|
||||
reset_on_cancel: bool,
|
||||
_search_task: Task<()>,
|
||||
query_input_subscription: Subscription,
|
||||
_load_more_task: Task<()>,
|
||||
_query_input_subscription: Subscription,
|
||||
}
|
||||
|
||||
impl<D> List<D>
|
||||
@@ -121,15 +167,8 @@ where
|
||||
D: ListDelegate,
|
||||
{
|
||||
pub fn new(delegate: D, window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let query_input = cx.new(|cx| {
|
||||
TextInput::new(window, cx)
|
||||
.appearance(false)
|
||||
.prefix(|_window, cx| Icon::new(IconName::Search).text_color(cx.theme().text_muted))
|
||||
.placeholder("Search...")
|
||||
.cleanable()
|
||||
});
|
||||
|
||||
let query_input_subscription =
|
||||
let query_input = cx.new(|cx| InputState::new(window, cx).placeholder("Search..."));
|
||||
let _query_input_subscription =
|
||||
cx.subscribe_in(&query_input, window, Self::on_query_input_event);
|
||||
|
||||
Self {
|
||||
@@ -142,21 +181,19 @@ where
|
||||
vertical_scroll_handle: UniformListScrollHandle::new(),
|
||||
scrollbar_state: Rc::new(Cell::new(ScrollbarState::new())),
|
||||
max_height: None,
|
||||
enable_scrollbar: true,
|
||||
loading: false,
|
||||
scrollbar_visible: true,
|
||||
selectable: true,
|
||||
querying: false,
|
||||
size: Size::default(),
|
||||
reset_on_cancel: true,
|
||||
_search_task: Task::ready(()),
|
||||
query_input_subscription,
|
||||
_load_more_task: Task::ready(()),
|
||||
_query_input_subscription,
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the size
|
||||
pub fn set_size(&mut self, size: Size, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if let Some(input) = &self.query_input {
|
||||
input.update(cx, |input, cx| {
|
||||
input.set_size(size, window, cx);
|
||||
})
|
||||
}
|
||||
pub fn set_size(&mut self, size: Size, _: &mut Window, _: &mut Context<Self>) {
|
||||
self.size = size;
|
||||
}
|
||||
|
||||
@@ -165,8 +202,9 @@ where
|
||||
self
|
||||
}
|
||||
|
||||
pub fn no_scrollbar(mut self) -> Self {
|
||||
self.enable_scrollbar = false;
|
||||
/// Set the visibility of the scrollbar, default is true.
|
||||
pub fn scrollbar_visible(mut self, visible: bool) -> Self {
|
||||
self.scrollbar_visible = visible;
|
||||
self
|
||||
}
|
||||
|
||||
@@ -175,17 +213,28 @@ where
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets whether the list is selectable, default is true.
|
||||
pub fn selectable(mut self, selectable: bool) -> Self {
|
||||
self.selectable = selectable;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn set_query_input(
|
||||
&mut self,
|
||||
query_input: Entity<TextInput>,
|
||||
query_input: Entity<InputState>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
self.query_input_subscription =
|
||||
self._query_input_subscription =
|
||||
cx.subscribe_in(&query_input, window, Self::on_query_input_event);
|
||||
self.query_input = Some(query_input);
|
||||
}
|
||||
|
||||
/// Get the query input entity.
|
||||
pub fn query_input(&self) -> Option<&Entity<InputState>> {
|
||||
self.query_input.as_ref()
|
||||
}
|
||||
|
||||
pub fn delegate(&self) -> &D {
|
||||
&self.delegate
|
||||
}
|
||||
@@ -198,6 +247,7 @@ where
|
||||
self.focus_handle(cx).focus(window);
|
||||
}
|
||||
|
||||
/// Set the selected index of the list, this will also scroll to the selected item.
|
||||
pub fn set_selected_index(
|
||||
&mut self,
|
||||
ix: Option<usize>,
|
||||
@@ -206,31 +256,15 @@ where
|
||||
) {
|
||||
self.selected_index = ix;
|
||||
self.delegate.set_selected_index(ix, window, cx);
|
||||
self.scroll_to_selected_item(window, cx);
|
||||
}
|
||||
|
||||
pub fn selected_index(&self) -> Option<usize> {
|
||||
self.selected_index
|
||||
}
|
||||
|
||||
/// Set the query_input text
|
||||
pub fn set_query(&mut self, query: &str, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if let Some(query_input) = &self.query_input {
|
||||
let query = query.to_owned();
|
||||
query_input.update(cx, |input, cx| input.set_text(query, window, cx))
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the query_input text
|
||||
pub fn query(&self, _window: &mut Window, cx: &mut Context<Self>) -> Option<SharedString> {
|
||||
self.query_input.as_ref().map(|input| input.read(cx).text())
|
||||
}
|
||||
|
||||
fn render_scrollbar(
|
||||
&self,
|
||||
_window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Option<impl IntoElement> {
|
||||
if !self.enable_scrollbar {
|
||||
fn render_scrollbar(&self, _: &mut Window, cx: &mut Context<Self>) -> Option<impl IntoElement> {
|
||||
if !self.scrollbar_visible {
|
||||
return None;
|
||||
}
|
||||
|
||||
@@ -241,6 +275,18 @@ where
|
||||
))
|
||||
}
|
||||
|
||||
/// Scroll to the item at the given index.
|
||||
pub fn scroll_to_item(&mut self, ix: usize, _: &mut Window, cx: &mut Context<Self>) {
|
||||
self.vertical_scroll_handle
|
||||
.scroll_to_item(ix, ScrollStrategy::Top);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Get scroll handle
|
||||
pub fn scroll_handle(&self) -> &UniformListScrollHandle {
|
||||
&self.vertical_scroll_handle
|
||||
}
|
||||
|
||||
fn scroll_to_selected_item(&mut self, _window: &mut Window, _cx: &mut Context<Self>) {
|
||||
if let Some(ix) = self.selected_index {
|
||||
self.vertical_scroll_handle
|
||||
@@ -250,7 +296,7 @@ where
|
||||
|
||||
fn on_query_input_event(
|
||||
&mut self,
|
||||
_: &Entity<TextInput>,
|
||||
_: &Entity<InputState>,
|
||||
event: &InputEvent,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
@@ -262,9 +308,15 @@ where
|
||||
return;
|
||||
}
|
||||
|
||||
self.set_loading(true, window, cx);
|
||||
self.set_querying(true, window, cx);
|
||||
let search = self.delegate.perform_search(&text, window, cx);
|
||||
|
||||
if self.delegate.items_count(cx) > 0 {
|
||||
self.set_selected_index(Some(0), window, cx);
|
||||
} else {
|
||||
self.set_selected_index(None, window, cx);
|
||||
}
|
||||
|
||||
self._search_task = cx.spawn_in(window, async move |this, window| {
|
||||
search.await;
|
||||
|
||||
@@ -277,35 +329,97 @@ where
|
||||
// Always wait 100ms to avoid flicker
|
||||
Timer::after(Duration::from_millis(100)).await;
|
||||
_ = this.update_in(window, |this, window, cx| {
|
||||
this.set_loading(false, window, cx);
|
||||
this.set_querying(false, window, cx);
|
||||
});
|
||||
});
|
||||
}
|
||||
InputEvent::PressEnter => self.on_action_confirm(&Confirm, window, cx),
|
||||
InputEvent::PressEnter { secondary } => self.on_action_confirm(
|
||||
&Confirm {
|
||||
secondary: *secondary,
|
||||
},
|
||||
window,
|
||||
cx,
|
||||
),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn set_loading(&mut self, loading: bool, _window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.loading = loading;
|
||||
fn set_querying(&mut self, querying: bool, _: &mut Window, cx: &mut Context<Self>) {
|
||||
self.querying = querying;
|
||||
if let Some(input) = &self.query_input {
|
||||
input.update(cx, |input, cx| input.set_loading(loading, cx))
|
||||
input.update(cx, |input, cx| input.set_loading(querying, cx))
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Dispatch delegate's `load_more` method when the visible range is near the end.
|
||||
fn load_more_if_need(
|
||||
&mut self,
|
||||
items_count: usize,
|
||||
visible_end: usize,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let threshold = self.delegate.load_more_threshold();
|
||||
// Securely handle subtract logic to prevent attempt to subtract with overflow
|
||||
if visible_end >= items_count.saturating_sub(threshold) {
|
||||
if !self.delegate.can_load_more(cx) {
|
||||
return;
|
||||
}
|
||||
|
||||
self._load_more_task = cx.spawn_in(window, async move |view, cx| {
|
||||
_ = view.update_in(cx, |view, window, cx| {
|
||||
view.delegate.load_more(window, cx);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn reset_on_cancel(mut self, reset: bool) -> Self {
|
||||
self.reset_on_cancel = reset;
|
||||
self
|
||||
}
|
||||
|
||||
fn on_action_cancel(&mut self, _: &Cancel, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.set_selected_index(None, window, cx);
|
||||
if self.selected_index.is_none() {
|
||||
cx.propagate();
|
||||
}
|
||||
|
||||
if self.reset_on_cancel {
|
||||
self.set_selected_index(None, window, cx);
|
||||
}
|
||||
|
||||
self.delegate.cancel(window, cx);
|
||||
cx.emit(ListEvent::Cancel);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn on_action_confirm(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context<Self>) {
|
||||
fn on_action_confirm(
|
||||
&mut self,
|
||||
confirm: &Confirm,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
if self.delegate.items_count(cx) == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
self.delegate.confirm(self.selected_index, window, cx);
|
||||
let Some(ix) = self.selected_index else {
|
||||
return;
|
||||
};
|
||||
|
||||
self.delegate
|
||||
.set_selected_index(self.selected_index, window, cx);
|
||||
self.delegate.confirm(confirm.secondary, window, cx);
|
||||
cx.emit(ListEvent::Confirm(ix));
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn select_item(&mut self, ix: usize, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.selected_index = Some(ix);
|
||||
self.delegate.set_selected_index(Some(ix), window, cx);
|
||||
self.scroll_to_selected_item(window, cx);
|
||||
cx.emit(ListEvent::Select(ix));
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
@@ -315,21 +429,18 @@ where
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
if self.delegate.items_count(cx) == 0 {
|
||||
let items_count = self.delegate.items_count(cx);
|
||||
if items_count == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
let selected_index = self.selected_index.unwrap_or(0);
|
||||
let mut selected_index = self.selected_index.unwrap_or(0);
|
||||
if selected_index > 0 {
|
||||
self.selected_index = Some(selected_index - 1);
|
||||
selected_index -= 1;
|
||||
} else {
|
||||
self.selected_index = Some(self.delegate.items_count(cx) - 1);
|
||||
selected_index = items_count - 1;
|
||||
}
|
||||
|
||||
self.delegate
|
||||
.set_selected_index(self.selected_index, window, cx);
|
||||
self.scroll_to_selected_item(window, cx);
|
||||
cx.notify();
|
||||
self.select_item(selected_index, window, cx);
|
||||
}
|
||||
|
||||
fn on_action_select_next(
|
||||
@@ -338,24 +449,25 @@ where
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
if self.delegate.items_count(cx) == 0 {
|
||||
let items_count = self.delegate.items_count(cx);
|
||||
if items_count == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(selected_index) = self.selected_index {
|
||||
if selected_index < self.delegate.items_count(cx) - 1 {
|
||||
self.selected_index = Some(selected_index + 1);
|
||||
let selected_index;
|
||||
if let Some(ix) = self.selected_index {
|
||||
if ix < items_count - 1 {
|
||||
selected_index = ix + 1;
|
||||
} else {
|
||||
self.selected_index = Some(0);
|
||||
// When the last item is selected, select the first item.
|
||||
selected_index = 0;
|
||||
}
|
||||
} else {
|
||||
self.selected_index = Some(0);
|
||||
// When no selected index, select the first item.
|
||||
selected_index = 0;
|
||||
}
|
||||
|
||||
self.delegate
|
||||
.set_selected_index(self.selected_index, window, cx);
|
||||
self.scroll_to_selected_item(window, cx);
|
||||
cx.notify();
|
||||
self.select_item(selected_index, window, cx);
|
||||
}
|
||||
|
||||
fn render_list_item(
|
||||
@@ -364,13 +476,16 @@ where
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> impl IntoElement {
|
||||
let selected = self.selected_index == Some(ix);
|
||||
let right_clicked = self.right_clicked_index == Some(ix);
|
||||
|
||||
div()
|
||||
.id("list-item")
|
||||
.w_full()
|
||||
.relative()
|
||||
.children(self.delegate.render_item(ix, window, cx))
|
||||
.when_some(self.selected_index, |this, selected_index| {
|
||||
this.when(ix == selected_index, |this| {
|
||||
.when(self.selectable, |this| {
|
||||
this.when(selected || right_clicked, |this| {
|
||||
this.child(
|
||||
div()
|
||||
.absolute()
|
||||
@@ -378,39 +493,33 @@ where
|
||||
.left(px(0.))
|
||||
.right(px(0.))
|
||||
.bottom(px(0.))
|
||||
.bg(cx.theme().element_background)
|
||||
.when(selected, |this| this.bg(cx.theme().element_background))
|
||||
.border_1()
|
||||
.border_color(cx.theme().border_selected),
|
||||
)
|
||||
})
|
||||
})
|
||||
.when(self.right_clicked_index == Some(ix), |this| {
|
||||
this.child(
|
||||
div()
|
||||
.absolute()
|
||||
.top(px(0.))
|
||||
.left(px(0.))
|
||||
.right(px(0.))
|
||||
.bottom(px(0.))
|
||||
.border_1()
|
||||
.border_color(cx.theme().element_active),
|
||||
.on_mouse_down(
|
||||
MouseButton::Left,
|
||||
cx.listener(move |this, ev: &MouseDownEvent, window, cx| {
|
||||
this.right_clicked_index = None;
|
||||
this.selected_index = Some(ix);
|
||||
this.on_action_confirm(
|
||||
&Confirm {
|
||||
secondary: ev.modifiers.secondary(),
|
||||
},
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
}),
|
||||
)
|
||||
.on_mouse_down(
|
||||
MouseButton::Right,
|
||||
cx.listener(move |this, _, _, cx| {
|
||||
this.right_clicked_index = Some(ix);
|
||||
cx.notify();
|
||||
}),
|
||||
)
|
||||
})
|
||||
.on_mouse_down(
|
||||
MouseButton::Left,
|
||||
cx.listener(move |this, _, window, cx| {
|
||||
this.right_clicked_index = None;
|
||||
this.selected_index = Some(ix);
|
||||
this.on_action_confirm(&Confirm, window, cx);
|
||||
}),
|
||||
)
|
||||
.on_mouse_down(
|
||||
MouseButton::Right,
|
||||
cx.listener(move |this, _, _window, cx| {
|
||||
this.right_clicked_index = Some(ix);
|
||||
cx.notify();
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -426,7 +535,7 @@ where
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<D> EventEmitter<ListEvent> for List<D> where D: ListDelegate {}
|
||||
impl<D> Render for List<D>
|
||||
where
|
||||
D: ListDelegate,
|
||||
@@ -435,6 +544,7 @@ where
|
||||
let view = cx.entity().clone();
|
||||
let vertical_scroll_handle = self.vertical_scroll_handle.clone();
|
||||
let items_count = self.delegate.items_count(cx);
|
||||
let loading = self.delegate.loading(cx);
|
||||
let sizing_behavior = if self.max_height.is_some() {
|
||||
ListSizingBehavior::Infer
|
||||
} else {
|
||||
@@ -442,7 +552,7 @@ where
|
||||
};
|
||||
|
||||
let initial_view = if let Some(input) = &self.query_input {
|
||||
if input.read(cx).text().is_empty() {
|
||||
if input.read(cx).value().is_empty() {
|
||||
self.delegate().render_initial(window, cx)
|
||||
} else {
|
||||
None
|
||||
@@ -458,10 +568,6 @@ where
|
||||
.size_full()
|
||||
.relative()
|
||||
.overflow_hidden()
|
||||
.on_action(cx.listener(Self::on_action_cancel))
|
||||
.on_action(cx.listener(Self::on_action_confirm))
|
||||
.on_action(cx.listener(Self::on_action_select_next))
|
||||
.on_action(cx.listener(Self::on_action_select_prev))
|
||||
.when_some(self.query_input.clone(), |this, input| {
|
||||
this.child(
|
||||
div()
|
||||
@@ -471,47 +577,73 @@ where
|
||||
})
|
||||
.border_b_1()
|
||||
.border_color(cx.theme().border)
|
||||
.child(input),
|
||||
.child(
|
||||
TextInput::new(&input)
|
||||
.with_size(self.size)
|
||||
.prefix(
|
||||
Icon::new(IconName::Search).text_color(cx.theme().text_muted),
|
||||
)
|
||||
.cleanable()
|
||||
.appearance(false),
|
||||
),
|
||||
)
|
||||
})
|
||||
.map(|this| {
|
||||
if let Some(view) = initial_view {
|
||||
this.child(view)
|
||||
} else {
|
||||
this.child(
|
||||
v_flex()
|
||||
.flex_grow()
|
||||
.relative()
|
||||
.when_some(self.max_height, |this, h| this.max_h(h))
|
||||
.overflow_hidden()
|
||||
.when(items_count == 0, |this| {
|
||||
this.child(self.delegate().render_empty(window, cx))
|
||||
})
|
||||
.when(items_count > 0, |this| {
|
||||
this.child(
|
||||
uniform_list(view, "uniform-list", items_count, {
|
||||
move |list, visible_range, window, cx| {
|
||||
visible_range
|
||||
.map(|ix| list.render_list_item(ix, window, cx))
|
||||
.collect::<Vec<_>>()
|
||||
}
|
||||
})
|
||||
.flex_grow()
|
||||
.with_sizing_behavior(sizing_behavior)
|
||||
.track_scroll(vertical_scroll_handle)
|
||||
.into_any_element(),
|
||||
)
|
||||
})
|
||||
.children(self.render_scrollbar(window, cx)),
|
||||
)
|
||||
}
|
||||
.when(loading, |this| {
|
||||
this.child(self.delegate().render_loading(window, cx))
|
||||
})
|
||||
// Click out to cancel right clicked row
|
||||
.when(self.right_clicked_index.is_some(), |this| {
|
||||
this.on_mouse_down_out(cx.listener(|this, _, _window, cx| {
|
||||
this.right_clicked_index = None;
|
||||
cx.notify();
|
||||
}))
|
||||
.when(!loading, |this| {
|
||||
this.on_action(cx.listener(Self::on_action_cancel))
|
||||
.on_action(cx.listener(Self::on_action_confirm))
|
||||
.on_action(cx.listener(Self::on_action_select_next))
|
||||
.on_action(cx.listener(Self::on_action_select_prev))
|
||||
.map(|this| {
|
||||
if let Some(view) = initial_view {
|
||||
this.child(view)
|
||||
} else {
|
||||
this.child(
|
||||
v_flex()
|
||||
.flex_grow()
|
||||
.relative()
|
||||
.when_some(self.max_height, |this, h| this.max_h(h))
|
||||
.overflow_hidden()
|
||||
.when(items_count == 0, |this| {
|
||||
this.child(self.delegate().render_empty(window, cx))
|
||||
})
|
||||
.when(items_count > 0, |this| {
|
||||
this.child(
|
||||
uniform_list(view, "uniform-list", items_count, {
|
||||
move |list, visible_range, window, cx| {
|
||||
list.load_more_if_need(
|
||||
items_count,
|
||||
visible_range.end,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
|
||||
visible_range
|
||||
.map(|ix| {
|
||||
list.render_list_item(ix, window, cx)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
}
|
||||
})
|
||||
.flex_grow()
|
||||
.with_sizing_behavior(sizing_behavior)
|
||||
.track_scroll(vertical_scroll_handle)
|
||||
.into_any_element(),
|
||||
)
|
||||
})
|
||||
.children(self.render_scrollbar(window, cx)),
|
||||
)
|
||||
}
|
||||
})
|
||||
// Click out to cancel right clicked row
|
||||
.when(self.right_clicked_index.is_some(), |this| {
|
||||
this.on_mouse_down_out(cx.listener(|this, _, _, cx| {
|
||||
this.right_clicked_index = None;
|
||||
cx.notify();
|
||||
}))
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
33
crates/ui/src/list/loading.rs
Normal file
33
crates/ui/src/list/loading.rs
Normal file
@@ -0,0 +1,33 @@
|
||||
use gpui::{IntoElement, ParentElement as _, RenderOnce, Styled};
|
||||
|
||||
use super::ListItem;
|
||||
use crate::{skeleton::Skeleton, v_flex};
|
||||
|
||||
#[derive(IntoElement)]
|
||||
pub struct Loading;
|
||||
|
||||
#[derive(IntoElement)]
|
||||
struct LoadingItem;
|
||||
|
||||
impl RenderOnce for LoadingItem {
|
||||
fn render(self, _window: &mut gpui::Window, _cx: &mut gpui::App) -> impl IntoElement {
|
||||
ListItem::new("skeleton").disabled(true).child(
|
||||
v_flex()
|
||||
.gap_1p5()
|
||||
.overflow_hidden()
|
||||
.child(Skeleton::new().h_5().w_48().max_w_full())
|
||||
.child(Skeleton::new().secondary(true).h_3().w_64().max_w_full()),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for Loading {
|
||||
fn render(self, _window: &mut gpui::Window, _cx: &mut gpui::App) -> impl IntoElement {
|
||||
v_flex()
|
||||
.py_2p5()
|
||||
.gap_3()
|
||||
.child(LoadingItem)
|
||||
.child(LoadingItem)
|
||||
.child(LoadingItem)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
#[allow(clippy::module_inception)]
|
||||
mod list;
|
||||
mod list_item;
|
||||
mod loading;
|
||||
|
||||
pub use list::*;
|
||||
pub use list_item::*;
|
||||
|
||||
@@ -216,11 +216,9 @@ impl Render for Notification {
|
||||
Some(icon) => icon,
|
||||
None => match self.kind {
|
||||
NotificationType::Info => Icon::new(IconName::Info).text_color(blue()),
|
||||
NotificationType::Warning => Icon::new(IconName::Info).text_color(yellow()),
|
||||
NotificationType::Error => Icon::new(IconName::CloseCircle).text_color(red()),
|
||||
NotificationType::Success => Icon::new(IconName::CheckCircle).text_color(green()),
|
||||
NotificationType::Warning => {
|
||||
Icon::new(IconName::TriangleAlert).text_color(yellow())
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ use gpui::{
|
||||
use theme::ActiveTheme;
|
||||
|
||||
use crate::{
|
||||
input::InputState,
|
||||
modal::Modal,
|
||||
notification::{Notification, NotificationList},
|
||||
window_border,
|
||||
@@ -36,6 +37,12 @@ pub trait ContextModal: Sized {
|
||||
|
||||
/// Clear all notifications
|
||||
fn clear_notifications(&mut self, cx: &mut App);
|
||||
|
||||
/// Return current focused Input entity.
|
||||
fn focused_input(&mut self, cx: &mut App) -> Option<Entity<InputState>>;
|
||||
|
||||
/// Returns true if there is a focused Input entity.
|
||||
fn has_focused_input(&mut self, cx: &mut App) -> bool;
|
||||
}
|
||||
|
||||
impl ContextModal for Window {
|
||||
@@ -110,12 +117,20 @@ impl ContextModal for Window {
|
||||
let entity = Root::read(self, cx).notification.clone();
|
||||
Rc::new(entity.read(cx).notifications())
|
||||
}
|
||||
|
||||
fn has_focused_input(&mut self, cx: &mut App) -> bool {
|
||||
Root::read(self, cx).focused_input.is_some()
|
||||
}
|
||||
|
||||
fn focused_input(&mut self, cx: &mut App) -> Option<Entity<InputState>> {
|
||||
Root::read(self, cx).focused_input.clone()
|
||||
}
|
||||
}
|
||||
|
||||
type Builder = Rc<dyn Fn(Modal, &mut Window, &mut App) -> Modal + 'static>;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct ActiveModal {
|
||||
pub struct ActiveModal {
|
||||
focus_handle: FocusHandle,
|
||||
builder: Builder,
|
||||
}
|
||||
@@ -124,11 +139,13 @@ struct ActiveModal {
|
||||
///
|
||||
/// It is used to manage the Modal, and Notification.
|
||||
pub struct Root {
|
||||
pub active_modals: Vec<ActiveModal>,
|
||||
pub notification: Entity<NotificationList>,
|
||||
pub focused_input: Option<Entity<InputState>>,
|
||||
/// Used to store the focus handle of the previous view.
|
||||
///
|
||||
/// When the Modal closes, we will focus back to the previous view.
|
||||
previous_focus_handle: Option<FocusHandle>,
|
||||
active_modals: Vec<ActiveModal>,
|
||||
pub notification: Entity<NotificationList>,
|
||||
view: AnyView,
|
||||
}
|
||||
|
||||
@@ -136,6 +153,7 @@ impl Root {
|
||||
pub fn new(view: AnyView, window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
Self {
|
||||
previous_focus_handle: None,
|
||||
focused_input: None,
|
||||
active_modals: Vec::new(),
|
||||
notification: cx.new(|cx| NotificationList::new(window, cx)),
|
||||
view,
|
||||
|
||||
@@ -9,14 +9,21 @@ use theme::ActiveTheme;
|
||||
#[derive(IntoElement)]
|
||||
pub struct Skeleton {
|
||||
base: Div,
|
||||
secondary: bool,
|
||||
}
|
||||
|
||||
impl Skeleton {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
base: div().w_full().h_4().rounded_md(),
|
||||
secondary: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn secondary(mut self, secondary: bool) -> Self {
|
||||
self.secondary = secondary;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Skeleton {
|
||||
@@ -33,19 +40,23 @@ impl Styled for Skeleton {
|
||||
|
||||
impl RenderOnce for Skeleton {
|
||||
fn render(self, _window: &mut gpui::Window, cx: &mut gpui::App) -> impl IntoElement {
|
||||
let color = if self.secondary {
|
||||
cx.theme().ghost_element_active.opacity(0.5)
|
||||
} else {
|
||||
cx.theme().ghost_element_active
|
||||
};
|
||||
|
||||
div().child(
|
||||
self.base
|
||||
.bg(cx.theme().ghost_element_active)
|
||||
.with_animation(
|
||||
"skeleton",
|
||||
Animation::new(Duration::from_secs(2))
|
||||
.repeat()
|
||||
.with_easing(bounce(ease_in_out)),
|
||||
move |this, delta| {
|
||||
let v = 1.0 - delta * 0.5;
|
||||
this.opacity(v)
|
||||
},
|
||||
),
|
||||
self.base.bg(color).with_animation(
|
||||
"skeleton",
|
||||
Animation::new(Duration::from_secs(2))
|
||||
.repeat()
|
||||
.with_easing(bounce(ease_in_out)),
|
||||
move |this, delta| {
|
||||
let v = 1.0 - delta * 0.5;
|
||||
this.opacity(v)
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,7 +135,7 @@ pub trait Sizable: Sized {
|
||||
|
||||
#[allow(unused)]
|
||||
pub trait StyleSized<T: Styled> {
|
||||
fn input_text_size(self, size: Size) -> Self;
|
||||
fn input_font_size(self, size: Size) -> Self;
|
||||
fn input_size(self, size: Size) -> Self;
|
||||
fn input_pl(self, size: Size) -> Self;
|
||||
fn input_pr(self, size: Size) -> Self;
|
||||
@@ -150,7 +150,7 @@ pub trait StyleSized<T: Styled> {
|
||||
}
|
||||
|
||||
impl<T: Styled> StyleSized<T> for T {
|
||||
fn input_text_size(self, size: Size) -> Self {
|
||||
fn input_font_size(self, size: Size) -> Self {
|
||||
match size {
|
||||
Size::XSmall => self.text_xs(),
|
||||
Size::Small => self.text_sm(),
|
||||
@@ -203,11 +203,11 @@ impl<T: Styled> StyleSized<T> for T {
|
||||
Size::Large => self.h_12(),
|
||||
_ => self.h(px(24.)),
|
||||
}
|
||||
.input_text_size(size)
|
||||
.input_font_size(size)
|
||||
}
|
||||
|
||||
fn list_size(self, size: Size) -> Self {
|
||||
self.list_px(size).list_py(size).input_text_size(size)
|
||||
self.list_px(size).list_py(size).input_font_size(size)
|
||||
}
|
||||
|
||||
fn list_px(self, size: Size) -> Self {
|
||||
|
||||
Reference in New Issue
Block a user