clean up
This commit is contained in:
Generated
+587
-386
File diff suppressed because it is too large
Load Diff
+15
-8
@@ -9,14 +9,21 @@ edition = "2024"
|
||||
publish = false
|
||||
|
||||
[workspace.dependencies]
|
||||
# GPUI
|
||||
gpui = { git = "https://github.com/zed-industries/zed" }
|
||||
gpui_platform = { git = "https://github.com/zed-industries/zed", features = ["font-kit", "x11", "wayland"] }
|
||||
gpui_linux = { git = "https://github.com/zed-industries/zed" }
|
||||
gpui_windows = { git = "https://github.com/zed-industries/zed" }
|
||||
gpui_macos = { git = "https://github.com/zed-industries/zed" }
|
||||
gpui_tokio = { git = "https://github.com/zed-industries/zed" }
|
||||
reqwest_client = { git = "https://github.com/zed-industries/zed" }
|
||||
# GPUI. The `gpui-pre` family is upstream zed's gpui republished unchanged, so these
|
||||
# aliases keep every `use gpui::..` site as it is while moving off the zed git pin.
|
||||
gpui = { package = "gpui-pre", version = "0.3.5" }
|
||||
gpui_platform = { package = "gpui-pre-platform", version = "0.3.5", features = ["font-kit", "x11", "wayland"] }
|
||||
gpui_linux = { package = "gpui-pre-linux", version = "0.3.5" }
|
||||
gpui_windows = { package = "gpui-pre-windows", version = "0.3.5" }
|
||||
gpui_macos = { package = "gpui-pre-macos", version = "0.3.5" }
|
||||
gpui_web = { package = "gpui-pre-web", version = "0.3.5" }
|
||||
gpui_util = { package = "gpui-pre-util", version = "0.3.5" }
|
||||
sum_tree = { package = "gpui-pre-sum-tree", version = "0.3.5" }
|
||||
reqwest_client = { package = "gpui-pre-reqwest-client", version = "0.3.5" }
|
||||
gpui_tokio = { path = "crates/gpui_tokio" }
|
||||
|
||||
# Unstyled behavior, state, and infrastructure from GPUI Kit
|
||||
gpui-base = "0.6.1"
|
||||
|
||||
# Nostr
|
||||
nostr-lmdb = { git = "https://github.com/rust-nostr/nostr" }
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
[package]
|
||||
name = "gpui_tokio"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
gpui.workspace = true
|
||||
gpui_util.workspace = true
|
||||
tokio = { version = "1", features = ["rt", "rt-multi-thread"] }
|
||||
@@ -0,0 +1,102 @@
|
||||
//! Vendored from zed's `crates/gpui_tokio` (Apache-2.0) because the `gpui-pre` family
|
||||
//! does not republish it, and `nostr-sdk`'s reqwest client needs a Tokio runtime.
|
||||
|
||||
use std::future::Future;
|
||||
|
||||
use gpui::{App, AppContext, Global, ReadGlobal, Task};
|
||||
use gpui_util::defer;
|
||||
pub use tokio::task::JoinError;
|
||||
|
||||
/// Initializes the Tokio wrapper using a new Tokio runtime with 2 worker threads.
|
||||
///
|
||||
/// If you need more threads (or access to the runtime outside of GPUI), you can create the runtime
|
||||
/// yourself and pass a Handle to `init_from_handle`.
|
||||
pub fn init(cx: &mut App) {
|
||||
let runtime = tokio::runtime::Builder::new_multi_thread()
|
||||
// Since we now have two executors, let's try to keep our footprint small
|
||||
.worker_threads(2)
|
||||
.enable_all()
|
||||
.build()
|
||||
.expect("Failed to initialize Tokio");
|
||||
|
||||
let handle = runtime.handle().clone();
|
||||
cx.set_global(GlobalTokio {
|
||||
owned_runtime: Some(runtime),
|
||||
handle,
|
||||
});
|
||||
}
|
||||
|
||||
/// Initializes the Tokio wrapper using a Tokio runtime handle.
|
||||
pub fn init_from_handle(cx: &mut App, handle: tokio::runtime::Handle) {
|
||||
cx.set_global(GlobalTokio {
|
||||
owned_runtime: None,
|
||||
handle,
|
||||
});
|
||||
}
|
||||
|
||||
struct GlobalTokio {
|
||||
owned_runtime: Option<tokio::runtime::Runtime>,
|
||||
handle: tokio::runtime::Handle,
|
||||
}
|
||||
|
||||
impl Global for GlobalTokio {}
|
||||
|
||||
impl Drop for GlobalTokio {
|
||||
fn drop(&mut self) {
|
||||
if let Some(runtime) = self.owned_runtime.take() {
|
||||
runtime.shutdown_background();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Tokio {}
|
||||
|
||||
impl Tokio {
|
||||
/// Spawns the given future on Tokio's thread pool, and returns it via a GPUI task
|
||||
/// Note that the Tokio task will be cancelled if the GPUI task is dropped
|
||||
pub fn spawn<C, Fut, R>(cx: &C, f: Fut) -> Task<Result<R, JoinError>>
|
||||
where
|
||||
C: AppContext,
|
||||
Fut: Future<Output = R> + Send + 'static,
|
||||
R: Send + 'static,
|
||||
{
|
||||
cx.read_global(|tokio: &GlobalTokio, cx| {
|
||||
let join_handle = tokio.handle.spawn(f);
|
||||
let abort_handle = join_handle.abort_handle();
|
||||
let cancel = defer(move || {
|
||||
abort_handle.abort();
|
||||
});
|
||||
cx.background_spawn(async move {
|
||||
let result = join_handle.await;
|
||||
drop(cancel);
|
||||
result
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// Spawns the given future on Tokio's thread pool, and returns it via a GPUI task
|
||||
/// Note that the Tokio task will be cancelled if the GPUI task is dropped
|
||||
pub fn spawn_result<C, Fut, R>(cx: &C, f: Fut) -> Task<anyhow::Result<R>>
|
||||
where
|
||||
C: AppContext,
|
||||
Fut: Future<Output = anyhow::Result<R>> + Send + 'static,
|
||||
R: Send + 'static,
|
||||
{
|
||||
cx.read_global(|tokio: &GlobalTokio, cx| {
|
||||
let join_handle = tokio.handle.spawn(f);
|
||||
let abort_handle = join_handle.abort_handle();
|
||||
let cancel = defer(move || {
|
||||
abort_handle.abort();
|
||||
});
|
||||
cx.background_spawn(async move {
|
||||
let result = join_handle.await?;
|
||||
drop(cancel);
|
||||
result
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
pub fn handle(cx: &App) -> tokio::runtime::Handle {
|
||||
GlobalTokio::global(cx).handle.clone()
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
gpui.workspace = true
|
||||
gpui-base.workspace = true
|
||||
anyhow.workspace = true
|
||||
log.workspace = true
|
||||
serde.workspace = true
|
||||
|
||||
@@ -46,6 +46,64 @@ pub fn init(cx: &mut App) {
|
||||
Theme::sync_scrollbar_appearance(cx);
|
||||
}
|
||||
|
||||
/// Mirror the active coop theme into the `gpui-base` global theme.
|
||||
///
|
||||
/// Base paints a few things from its own tokens -- the focus ring, the wash
|
||||
/// behind selected text, scrollbars, and overlay backdrops -- so the two
|
||||
/// globals have to agree or those details drift away from the palette.
|
||||
///
|
||||
/// Only roles base can act on are projected. Radius, spacing, typography sizes,
|
||||
/// shadows, and scrollbar geometry keep their base defaults: coop has a single
|
||||
/// `radius`/`radius_lg`/`font_size` where base has six-point scales, so any
|
||||
/// mapping would be invented rather than derived. Revisit when a base component
|
||||
/// is actually rendered.
|
||||
///
|
||||
/// This is a no-op before the coop theme global exists; [`Theme::change`] is the
|
||||
/// authoritative hook that keeps the projection current.
|
||||
pub fn sync_base(cx: &mut App) {
|
||||
let Some(theme) = cx.try_global::<Theme>() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let appearance = if theme.mode.is_dark() {
|
||||
gpui_base::ThemeAppearance::Dark
|
||||
} else {
|
||||
gpui_base::ThemeAppearance::Light
|
||||
};
|
||||
let scrollbar_mode = match theme.scrollbar_mode {
|
||||
ScrollbarMode::Scrolling => gpui_base::ScrollbarMode::Scrolling,
|
||||
ScrollbarMode::Hover => gpui_base::ScrollbarMode::Hover,
|
||||
ScrollbarMode::Always => gpui_base::ScrollbarMode::Always,
|
||||
};
|
||||
let colors = theme.colors;
|
||||
let font_family = theme.font_family.clone();
|
||||
|
||||
let base = gpui_base::Theme::global_mut(cx);
|
||||
base.appearance = appearance;
|
||||
base.scrollbar = base.scrollbar.clone().with_mode(scrollbar_mode);
|
||||
base.tokens.typography.sans = font_family;
|
||||
|
||||
let tokens = &mut base.tokens.colors;
|
||||
tokens.background = colors.background;
|
||||
tokens.foreground = colors.text;
|
||||
tokens.surface = colors.surface_background;
|
||||
tokens.surface_foreground = colors.text;
|
||||
tokens.primary = colors.element_background;
|
||||
tokens.primary_foreground = colors.element_foreground;
|
||||
tokens.secondary = colors.secondary_background;
|
||||
tokens.secondary_foreground = colors.secondary_foreground;
|
||||
tokens.muted = colors.ghost_element_background_alt;
|
||||
tokens.muted_foreground = colors.text_muted;
|
||||
tokens.accent = colors.ghost_element_hover;
|
||||
tokens.accent_foreground = colors.text;
|
||||
tokens.destructive = colors.danger_background;
|
||||
tokens.destructive_foreground = colors.danger_foreground;
|
||||
tokens.border = colors.border;
|
||||
tokens.input = colors.border;
|
||||
tokens.ring = colors.ring;
|
||||
tokens.selection = colors.selection;
|
||||
}
|
||||
|
||||
pub trait ActiveTheme {
|
||||
fn theme(&self) -> &Theme;
|
||||
}
|
||||
@@ -183,6 +241,9 @@ impl Theme {
|
||||
if let Some(window) = window {
|
||||
window.refresh();
|
||||
}
|
||||
|
||||
// Keep the base-layer projection in step with the coop palette
|
||||
sync_base(cx);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ common = { path = "../common" }
|
||||
theme = { path = "../theme" }
|
||||
|
||||
gpui.workspace = true
|
||||
gpui-base.workspace = true
|
||||
instant.workspace = true
|
||||
serde.workspace = true
|
||||
smallvec.workspace = true
|
||||
@@ -21,7 +22,7 @@ uuid = "1.10"
|
||||
regex = "1"
|
||||
lsp-types = "0.97.0"
|
||||
ropey = { version = "=2.0.0-beta.1", features = ["metric_lines_lf", "metric_utf16"] }
|
||||
sum_tree = { git = "https://github.com/zed-industries/zed" }
|
||||
sum_tree.workspace = true
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
smol.workspace = true
|
||||
|
||||
@@ -1,312 +0,0 @@
|
||||
use std::rc::Rc;
|
||||
use instant::Duration;
|
||||
|
||||
use gpui::prelude::FluentBuilder as _;
|
||||
use gpui::{
|
||||
div, px, relative, rems, svg, Animation, AnimationExt, AnyElement, App, Div, ElementId,
|
||||
InteractiveElement, IntoElement, ParentElement, RenderOnce, SharedString,
|
||||
StatefulInteractiveElement, StyleRefinement, Styled, Window,
|
||||
};
|
||||
use theme::ActiveTheme;
|
||||
|
||||
use crate::icon::IconNamed;
|
||||
use crate::{v_flex, Disableable, IconName, Selectable, Sizable, Size, StyledExt as _};
|
||||
|
||||
/// A Checkbox element.
|
||||
#[allow(clippy::type_complexity)]
|
||||
#[derive(IntoElement)]
|
||||
pub struct Checkbox {
|
||||
id: ElementId,
|
||||
base: Div,
|
||||
style: StyleRefinement,
|
||||
label: Option<SharedString>,
|
||||
children: Vec<AnyElement>,
|
||||
checked: bool,
|
||||
disabled: bool,
|
||||
size: Size,
|
||||
tab_stop: bool,
|
||||
tab_index: isize,
|
||||
on_click: Option<Rc<dyn Fn(&bool, &mut Window, &mut App) + 'static>>,
|
||||
}
|
||||
|
||||
impl Checkbox {
|
||||
/// Create a new Checkbox with the given id.
|
||||
pub fn new(id: impl Into<ElementId>) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
base: div(),
|
||||
style: StyleRefinement::default(),
|
||||
label: None,
|
||||
children: Vec::new(),
|
||||
checked: false,
|
||||
disabled: false,
|
||||
size: Size::default(),
|
||||
on_click: None,
|
||||
tab_stop: true,
|
||||
tab_index: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the label for the checkbox.
|
||||
pub fn label(mut self, label: impl Into<SharedString>) -> Self {
|
||||
self.label = Some(label.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the checked state for the checkbox.
|
||||
pub fn checked(mut self, checked: bool) -> Self {
|
||||
self.checked = checked;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the click handler for the checkbox.
|
||||
///
|
||||
/// The `&bool` parameter indicates the new checked state after the click.
|
||||
pub fn on_click(mut self, handler: impl Fn(&bool, &mut Window, &mut App) + 'static) -> Self {
|
||||
self.on_click = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the tab stop for the checkbox, default is true.
|
||||
pub fn tab_stop(mut self, tab_stop: bool) -> Self {
|
||||
self.tab_stop = tab_stop;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the tab index for the checkbox, default is 0.
|
||||
pub fn tab_index(mut self, tab_index: isize) -> Self {
|
||||
self.tab_index = tab_index;
|
||||
self
|
||||
}
|
||||
|
||||
#[allow(clippy::type_complexity)]
|
||||
fn handle_click(
|
||||
on_click: &Option<Rc<dyn Fn(&bool, &mut Window, &mut App) + 'static>>,
|
||||
checked: bool,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) {
|
||||
let new_checked = !checked;
|
||||
if let Some(f) = on_click {
|
||||
(f)(&new_checked, window, cx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl InteractiveElement for Checkbox {
|
||||
fn interactivity(&mut self) -> &mut gpui::Interactivity {
|
||||
self.base.interactivity()
|
||||
}
|
||||
}
|
||||
impl StatefulInteractiveElement for Checkbox {}
|
||||
|
||||
impl Styled for Checkbox {
|
||||
fn style(&mut self) -> &mut gpui::StyleRefinement {
|
||||
&mut self.style
|
||||
}
|
||||
}
|
||||
|
||||
impl Disableable for Checkbox {
|
||||
fn disabled(mut self, disabled: bool) -> Self {
|
||||
self.disabled = disabled;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Selectable for Checkbox {
|
||||
fn selected(self, selected: bool) -> Self {
|
||||
self.checked(selected)
|
||||
}
|
||||
|
||||
fn is_selected(&self) -> bool {
|
||||
self.checked
|
||||
}
|
||||
}
|
||||
|
||||
impl ParentElement for Checkbox {
|
||||
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
|
||||
self.children.extend(elements);
|
||||
}
|
||||
}
|
||||
|
||||
impl Sizable for Checkbox {
|
||||
fn with_size(mut self, size: impl Into<Size>) -> Self {
|
||||
self.size = size.into();
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn checkbox_check_icon(
|
||||
id: ElementId,
|
||||
size: Size,
|
||||
checked: bool,
|
||||
disabled: bool,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> impl IntoElement {
|
||||
let toggle_state = window.use_keyed_state(id, cx, |_, _| checked);
|
||||
|
||||
let color = if disabled {
|
||||
cx.theme().text.opacity(0.5)
|
||||
} else {
|
||||
cx.theme().text
|
||||
};
|
||||
|
||||
svg()
|
||||
.absolute()
|
||||
.top_px()
|
||||
.left_px()
|
||||
.map(|this| match size {
|
||||
Size::XSmall => this.size_2(),
|
||||
Size::Small => this.size_2p5(),
|
||||
Size::Medium => this.size_3(),
|
||||
Size::Large => this.size_3p5(),
|
||||
_ => this.size_3(),
|
||||
})
|
||||
.text_color(color)
|
||||
.map(|this| match checked {
|
||||
true => this.path(IconName::Check.path()),
|
||||
_ => this,
|
||||
})
|
||||
.map(|this| {
|
||||
if !disabled && checked != *toggle_state.read(cx) {
|
||||
let duration = Duration::from_secs_f64(0.25);
|
||||
cx.spawn({
|
||||
let toggle_state = toggle_state.clone();
|
||||
async move |cx| {
|
||||
cx.background_executor().timer(duration).await;
|
||||
toggle_state.update(cx, |this, _| *this = checked);
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
|
||||
this.with_animation(
|
||||
ElementId::NamedInteger("toggle".into(), checked as u64),
|
||||
Animation::new(Duration::from_secs_f64(0.25)),
|
||||
move |this, delta| {
|
||||
this.opacity(if checked { 1.0 * delta } else { 1.0 - delta })
|
||||
},
|
||||
)
|
||||
.into_any_element()
|
||||
} else {
|
||||
this.into_any_element()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
impl RenderOnce for Checkbox {
|
||||
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let focus_handle = window
|
||||
.use_keyed_state(self.id.clone(), cx, |_, cx| cx.focus_handle())
|
||||
.read(cx)
|
||||
.clone();
|
||||
|
||||
let checked = self.checked;
|
||||
let radius = cx.theme().radius.min(px(4.));
|
||||
|
||||
let border_color = if checked {
|
||||
cx.theme().border_focused
|
||||
} else {
|
||||
cx.theme().border
|
||||
};
|
||||
|
||||
let color = if self.disabled {
|
||||
border_color.opacity(0.5)
|
||||
} else {
|
||||
border_color
|
||||
};
|
||||
|
||||
div().child(
|
||||
self.base
|
||||
.id(self.id.clone())
|
||||
.when(!self.disabled, |this| {
|
||||
this.track_focus(
|
||||
&focus_handle
|
||||
.tab_stop(self.tab_stop)
|
||||
.tab_index(self.tab_index),
|
||||
)
|
||||
})
|
||||
.h_flex()
|
||||
.gap_2()
|
||||
.items_start()
|
||||
.line_height(relative(1.))
|
||||
.text_color(cx.theme().text)
|
||||
.map(|this| match self.size {
|
||||
Size::XSmall => this.text_xs(),
|
||||
Size::Small => this.text_sm(),
|
||||
Size::Medium => this.text_base(),
|
||||
Size::Large => this.text_lg(),
|
||||
_ => this,
|
||||
})
|
||||
.when(self.disabled, |this| this.text_color(cx.theme().text_muted))
|
||||
.rounded(cx.theme().radius * 0.5)
|
||||
.refine_style(&self.style)
|
||||
.child(
|
||||
div()
|
||||
.relative()
|
||||
.map(|this| match self.size {
|
||||
Size::XSmall => this.size_3(),
|
||||
Size::Small => this.size_3p5(),
|
||||
Size::Medium => this.size_4(),
|
||||
Size::Large => this.size(rems(1.125)),
|
||||
_ => this.size_4(),
|
||||
})
|
||||
.flex_shrink_0()
|
||||
.border_1()
|
||||
.border_color(color)
|
||||
.rounded(radius)
|
||||
.when(cx.theme().shadow && !self.disabled, |this| this.shadow_xs())
|
||||
.map(|this| match checked {
|
||||
false => this.bg(cx.theme().background),
|
||||
_ => this.bg(color),
|
||||
})
|
||||
.child(checkbox_check_icon(
|
||||
self.id,
|
||||
self.size,
|
||||
checked,
|
||||
self.disabled,
|
||||
window,
|
||||
cx,
|
||||
)),
|
||||
)
|
||||
.when(self.label.is_some() || !self.children.is_empty(), |this| {
|
||||
this.child(
|
||||
v_flex()
|
||||
.w_full()
|
||||
.line_height(relative(1.2))
|
||||
.gap_1()
|
||||
.map(|this| {
|
||||
if let Some(label) = self.label {
|
||||
this.child(
|
||||
div()
|
||||
.size_full()
|
||||
.text_color(cx.theme().text)
|
||||
.when(self.disabled, |this| {
|
||||
this.text_color(cx.theme().text_muted)
|
||||
})
|
||||
.line_height(relative(1.))
|
||||
.child(label),
|
||||
)
|
||||
} else {
|
||||
this
|
||||
}
|
||||
})
|
||||
.children(self.children),
|
||||
)
|
||||
})
|
||||
.on_mouse_down(gpui::MouseButton::Left, |_, window, _| {
|
||||
// Avoid focus on mouse down.
|
||||
window.prevent_default();
|
||||
})
|
||||
.when(!self.disabled, |this| {
|
||||
this.on_click({
|
||||
let on_click = self.on_click.clone();
|
||||
move |_, window, cx| {
|
||||
window.prevent_default();
|
||||
Self::handle_click(&on_click, checked, window, cx);
|
||||
}
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
use gpui::{canvas, App, Bounds, ParentElement, Pixels, Styled as _, Window};
|
||||
|
||||
/// A trait to extend [`gpui::Element`] with additional functionality.
|
||||
pub trait ElementExt: ParentElement + Sized {
|
||||
/// Add a prepaint callback to the element.
|
||||
///
|
||||
/// This is a helper method to get the bounds of the element after paint.
|
||||
///
|
||||
/// The first argument is the bounds of the element in pixels.
|
||||
///
|
||||
/// See also [`gpui::canvas`].
|
||||
fn on_prepaint<F>(self, f: F) -> Self
|
||||
where
|
||||
F: FnOnce(Bounds<Pixels>, &mut Window, &mut App) + 'static,
|
||||
{
|
||||
self.child(
|
||||
canvas(
|
||||
move |bounds, window, cx| f(bounds, window, cx),
|
||||
|_, _, _, _| {},
|
||||
)
|
||||
.absolute()
|
||||
.size_full(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: ParentElement> ElementExt for T {}
|
||||
@@ -1,21 +0,0 @@
|
||||
use gpui::{App, ClickEvent, InteractiveElement, Stateful, Window};
|
||||
|
||||
pub trait InteractiveElementExt: InteractiveElement {
|
||||
/// Set the listener for a double click event.
|
||||
fn on_double_click(
|
||||
mut self,
|
||||
listener: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
|
||||
) -> Self
|
||||
where
|
||||
Self: Sized,
|
||||
{
|
||||
self.interactivity().on_click(move |event, window, cx| {
|
||||
if event.click_count() == 2 {
|
||||
listener(event, window, cx);
|
||||
}
|
||||
});
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl<E: InteractiveElement> InteractiveElementExt for Stateful<E> {}
|
||||
@@ -1,39 +0,0 @@
|
||||
use gpui::{Context, FocusHandle, Window};
|
||||
|
||||
/// A trait for views that can cycle focus between its children.
|
||||
///
|
||||
/// This will provide a default implementation for the `cycle_focus` method that will cycle focus.
|
||||
///
|
||||
/// You should implement the `cycle_focus_handles` method to return a list of focus handles that
|
||||
/// should be cycled, and the cycle will follow the order of the list.
|
||||
pub trait FocusableCycle {
|
||||
/// Returns a list of focus handles that should be cycled.
|
||||
fn cycle_focus_handles(&self, window: &mut Window, cx: &mut Context<Self>) -> Vec<FocusHandle>
|
||||
where
|
||||
Self: Sized;
|
||||
|
||||
/// Cycles focus between the focus handles returned by `cycle_focus_handles`.
|
||||
/// If `is_next` is `true`, it will cycle to the next focus handle, otherwise it will cycle to prev.
|
||||
fn cycle_focus(&self, is_next: bool, window: &mut Window, cx: &mut Context<Self>)
|
||||
where
|
||||
Self: Sized,
|
||||
{
|
||||
let focused_handle = window.focused(cx);
|
||||
let handles = self.cycle_focus_handles(window, cx);
|
||||
let handles = if is_next {
|
||||
handles
|
||||
} else {
|
||||
handles.into_iter().rev().collect()
|
||||
};
|
||||
|
||||
let fallback_handle = handles[0].clone();
|
||||
let target_focus_handle = handles
|
||||
.into_iter()
|
||||
.skip_while(|handle| Some(handle) != focused_handle.as_ref())
|
||||
.nth(1)
|
||||
.unwrap_or(fallback_handle);
|
||||
|
||||
target_focus_handle.focus(window, cx);
|
||||
cx.stop_propagation();
|
||||
}
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
use std::fmt::{Debug, Display};
|
||||
|
||||
use gpui::ElementId;
|
||||
|
||||
/// Represents an index path in a list, which consists of a section index,
|
||||
///
|
||||
/// The default values for section, row, and column are all set to 0.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
pub struct IndexPath {
|
||||
/// The section index.
|
||||
pub section: usize,
|
||||
/// The item index in the section.
|
||||
pub row: usize,
|
||||
/// The column index.
|
||||
pub column: usize,
|
||||
}
|
||||
|
||||
impl From<IndexPath> for ElementId {
|
||||
fn from(path: IndexPath) -> Self {
|
||||
ElementId::Name(format!("index-path({},{},{})", path.section, path.row, path.column).into())
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for IndexPath {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(
|
||||
f,
|
||||
"IndexPath(section: {}, row: {}, column: {})",
|
||||
self.section, self.row, self.column
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl IndexPath {
|
||||
/// Create a new index path with the specified section and row.
|
||||
///
|
||||
/// The `section` is set to 0 by default.
|
||||
/// The `column` is set to 0 by default.
|
||||
pub fn new(row: usize) -> Self {
|
||||
IndexPath {
|
||||
section: 0,
|
||||
row,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the section for the index path.
|
||||
pub fn section(mut self, section: usize) -> Self {
|
||||
self.section = section;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the row for the index path.
|
||||
pub fn row(mut self, row: usize) -> Self {
|
||||
self.row = row;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the column for the index path.
|
||||
pub fn column(mut self, column: usize) -> Self {
|
||||
self.column = column;
|
||||
self
|
||||
}
|
||||
|
||||
/// Check if the self is equal to the given index path (Same section and row).
|
||||
pub fn eq_row(&self, index: IndexPath) -> bool {
|
||||
self.section == index.section && self.row == index.row
|
||||
}
|
||||
}
|
||||
+3
-11
@@ -1,8 +1,5 @@
|
||||
pub use element_ext::ElementExt;
|
||||
pub use event::InteractiveElementExt;
|
||||
pub use focusable::FocusableCycle;
|
||||
pub use gpui_base::{ElementExt, IndexPath, InteractiveElementExt};
|
||||
pub use icon::*;
|
||||
pub use index_path::IndexPath;
|
||||
pub use kbd::*;
|
||||
pub use root::{Root, window_paddings};
|
||||
pub use styled::*;
|
||||
@@ -15,14 +12,12 @@ pub mod actions;
|
||||
pub mod animation;
|
||||
pub mod avatar;
|
||||
pub mod button;
|
||||
pub mod checkbox;
|
||||
pub mod divider;
|
||||
pub mod dock;
|
||||
pub mod group_box;
|
||||
pub mod history;
|
||||
pub mod indicator;
|
||||
pub mod input;
|
||||
pub mod list;
|
||||
pub mod menu;
|
||||
pub mod modal;
|
||||
pub mod notification;
|
||||
@@ -34,11 +29,7 @@ pub mod switch;
|
||||
pub mod tab;
|
||||
pub mod tooltip;
|
||||
|
||||
mod element_ext;
|
||||
mod event;
|
||||
mod focusable;
|
||||
mod icon;
|
||||
mod index_path;
|
||||
mod kbd;
|
||||
mod root;
|
||||
mod styled;
|
||||
@@ -50,8 +41,9 @@ mod window_ext;
|
||||
/// This must be called before using any of the UI components.
|
||||
/// You can initialize the UI module at your application's entry point.
|
||||
pub fn init(cx: &mut gpui::App) {
|
||||
gpui_base::init(cx);
|
||||
theme::sync_base(cx);
|
||||
input::init(cx);
|
||||
list::init(cx);
|
||||
modal::init(cx);
|
||||
popover::init(cx);
|
||||
menu::init(cx);
|
||||
|
||||
@@ -1,221 +0,0 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::{App, Pixels, Size};
|
||||
|
||||
use crate::IndexPath;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum RowEntry {
|
||||
Entry(IndexPath),
|
||||
SectionHeader(usize),
|
||||
SectionFooter(usize),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub(crate) struct MeasuredEntrySize {
|
||||
pub(crate) item_size: Size<Pixels>,
|
||||
pub(crate) section_header_size: Size<Pixels>,
|
||||
pub(crate) section_footer_size: Size<Pixels>,
|
||||
}
|
||||
|
||||
impl RowEntry {
|
||||
#[inline]
|
||||
#[allow(unused)]
|
||||
pub(crate) fn is_section_header(&self) -> bool {
|
||||
matches!(self, RowEntry::SectionHeader(_))
|
||||
}
|
||||
|
||||
pub(crate) fn eq_index_path(&self, path: &IndexPath) -> bool {
|
||||
match self {
|
||||
RowEntry::Entry(index_path) => index_path == path,
|
||||
RowEntry::SectionHeader(_) | RowEntry::SectionFooter(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(unused)]
|
||||
pub(crate) fn index(&self) -> IndexPath {
|
||||
match self {
|
||||
RowEntry::Entry(index_path) => *index_path,
|
||||
RowEntry::SectionHeader(ix) => IndexPath::default().section(*ix),
|
||||
RowEntry::SectionFooter(ix) => IndexPath::default().section(*ix),
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
#[allow(unused)]
|
||||
pub(crate) fn is_section_footer(&self) -> bool {
|
||||
matches!(self, RowEntry::SectionFooter(_))
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub(crate) fn is_entry(&self) -> bool {
|
||||
matches!(self, RowEntry::Entry(_))
|
||||
}
|
||||
|
||||
#[inline]
|
||||
#[allow(unused)]
|
||||
pub(crate) fn section_ix(&self) -> Option<usize> {
|
||||
match self {
|
||||
RowEntry::SectionHeader(ix) | RowEntry::SectionFooter(ix) => Some(*ix),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default, Clone)]
|
||||
pub(crate) struct RowsCache {
|
||||
/// Only have section's that have rows.
|
||||
pub(crate) entities: Rc<Vec<RowEntry>>,
|
||||
pub(crate) items_count: usize,
|
||||
/// The sections, the item is number of rows in each section.
|
||||
pub(crate) sections: Rc<Vec<usize>>,
|
||||
pub(crate) entries_sizes: Rc<Vec<Size<Pixels>>>,
|
||||
measured_size: MeasuredEntrySize,
|
||||
}
|
||||
|
||||
impl RowsCache {
|
||||
pub(crate) fn get(&self, flatten_ix: usize) -> Option<RowEntry> {
|
||||
self.entities.get(flatten_ix).cloned()
|
||||
}
|
||||
|
||||
/// Returns the number of flattened rows (Includes header, item, footer).
|
||||
pub(crate) fn len(&self) -> usize {
|
||||
self.entities.len()
|
||||
}
|
||||
|
||||
/// Return the number of items in the cache.
|
||||
pub(crate) fn items_count(&self) -> usize {
|
||||
self.items_count
|
||||
}
|
||||
|
||||
/// Returns the index of the Entry with given path in the flattened rows.
|
||||
pub(crate) fn position_of(&self, path: &IndexPath) -> Option<usize> {
|
||||
self.entities
|
||||
.iter()
|
||||
.position(|p| p.is_entry() && p.eq_index_path(path))
|
||||
}
|
||||
|
||||
/// Return prev row, if the row is the first in the first section, goes to the last row.
|
||||
///
|
||||
/// Empty rows section are skipped.
|
||||
pub(crate) fn prev(&self, path: Option<IndexPath>) -> IndexPath {
|
||||
let path = path.unwrap_or_default();
|
||||
let Some(pos) = self.position_of(&path) else {
|
||||
return self
|
||||
.entities
|
||||
.iter()
|
||||
.rfind(|entry| entry.is_entry())
|
||||
.map(|entry| entry.index())
|
||||
.unwrap_or_default();
|
||||
};
|
||||
|
||||
if let Some(path) = self
|
||||
.entities
|
||||
.iter()
|
||||
.take(pos)
|
||||
.rev()
|
||||
.find(|entry| entry.is_entry())
|
||||
.map(|entry| entry.index())
|
||||
{
|
||||
path
|
||||
} else {
|
||||
self.entities
|
||||
.iter()
|
||||
.rfind(|entry| entry.is_entry())
|
||||
.map(|entry| entry.index())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the next row, if the row is the last in the last section, goes to the first row.
|
||||
///
|
||||
/// Empty rows section are skipped.
|
||||
pub(crate) fn next(&self, path: Option<IndexPath>) -> IndexPath {
|
||||
let Some(mut path) = path else {
|
||||
return IndexPath::default();
|
||||
};
|
||||
|
||||
let Some(pos) = self.position_of(&path) else {
|
||||
return self
|
||||
.entities
|
||||
.iter()
|
||||
.find(|entry| entry.is_entry())
|
||||
.map(|entry| entry.index())
|
||||
.unwrap_or_default();
|
||||
};
|
||||
|
||||
if let Some(next_path) = self
|
||||
.entities
|
||||
.iter()
|
||||
.skip(pos + 1)
|
||||
.find(|entry| entry.is_entry())
|
||||
.map(|entry| entry.index())
|
||||
{
|
||||
path = next_path;
|
||||
} else {
|
||||
path = self
|
||||
.entities
|
||||
.iter()
|
||||
.find(|entry| entry.is_entry())
|
||||
.map(|entry| entry.index())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
path
|
||||
}
|
||||
|
||||
pub(crate) fn prepare_if_needed<F>(
|
||||
&mut self,
|
||||
sections_count: usize,
|
||||
measured_size: MeasuredEntrySize,
|
||||
cx: &App,
|
||||
rows_count_f: F,
|
||||
) where
|
||||
F: Fn(usize, &App) -> usize,
|
||||
{
|
||||
let mut new_sections = vec![];
|
||||
for section_ix in 0..sections_count {
|
||||
new_sections.push(rows_count_f(section_ix, cx));
|
||||
}
|
||||
|
||||
let need_update = new_sections != *self.sections || self.measured_size != measured_size;
|
||||
|
||||
if !need_update {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut entries_sizes = vec![];
|
||||
let mut total_items_count = 0;
|
||||
self.measured_size = measured_size;
|
||||
self.sections = Rc::new(new_sections);
|
||||
self.entities = Rc::new(
|
||||
self.sections
|
||||
.iter()
|
||||
.enumerate()
|
||||
.flat_map(|(section, items_count)| {
|
||||
total_items_count += items_count;
|
||||
let mut children = vec![];
|
||||
if *items_count == 0 {
|
||||
return children;
|
||||
}
|
||||
|
||||
children.push(RowEntry::SectionHeader(section));
|
||||
entries_sizes.push(measured_size.section_header_size);
|
||||
for row in 0..*items_count {
|
||||
children.push(RowEntry::Entry(IndexPath {
|
||||
section,
|
||||
row,
|
||||
..Default::default()
|
||||
}));
|
||||
entries_sizes.push(measured_size.item_size);
|
||||
}
|
||||
children.push(RowEntry::SectionFooter(section));
|
||||
entries_sizes.push(measured_size.section_footer_size);
|
||||
children
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
self.entries_sizes = Rc::new(entries_sizes);
|
||||
self.items_count = total_items_count;
|
||||
}
|
||||
}
|
||||
@@ -1,171 +0,0 @@
|
||||
use gpui::{AnyElement, App, Context, IntoElement, ParentElement as _, Styled as _, Task, Window};
|
||||
use theme::ActiveTheme;
|
||||
|
||||
use crate::list::loading::Loading;
|
||||
use crate::list::ListState;
|
||||
use crate::{h_flex, Icon, IconName, IndexPath, Selectable};
|
||||
|
||||
/// A delegate for the List.
|
||||
#[allow(unused)]
|
||||
pub trait ListDelegate: Sized + 'static {
|
||||
type Item: Selectable + IntoElement;
|
||||
|
||||
/// When Query Input change, this method will be called.
|
||||
/// You can perform search here.
|
||||
fn perform_search(
|
||||
&mut self,
|
||||
query: &str,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<ListState<Self>>,
|
||||
) -> Task<()> {
|
||||
Task::ready(())
|
||||
}
|
||||
|
||||
/// Return the number of sections in the list, default is 1.
|
||||
///
|
||||
/// Min value is 1.
|
||||
fn sections_count(&self, cx: &App) -> usize {
|
||||
1
|
||||
}
|
||||
|
||||
/// Return the number of items in the section at the given index.
|
||||
///
|
||||
/// NOTE: Only the sections with items_count > 0 will be rendered. If the section has 0 items,
|
||||
/// the section header and footer will also be skipped.
|
||||
fn items_count(&self, section: usize, cx: &App) -> usize;
|
||||
|
||||
/// Render the item at the given index.
|
||||
///
|
||||
/// Return None will skip the item.
|
||||
///
|
||||
/// NOTE: Every item should have same height.
|
||||
fn render_item(
|
||||
&mut self,
|
||||
ix: IndexPath,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<ListState<Self>>,
|
||||
) -> Option<Self::Item>;
|
||||
|
||||
/// Render the section header at the given index, default is None.
|
||||
///
|
||||
/// NOTE: Every header should have same height.
|
||||
fn render_section_header(
|
||||
&mut self,
|
||||
section: usize,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<ListState<Self>>,
|
||||
) -> Option<impl IntoElement> {
|
||||
None::<AnyElement>
|
||||
}
|
||||
|
||||
/// Render the section footer at the given index, default is None.
|
||||
///
|
||||
/// NOTE: Every footer should have same height.
|
||||
fn render_section_footer(
|
||||
&mut self,
|
||||
section: usize,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<ListState<Self>>,
|
||||
) -> Option<impl IntoElement> {
|
||||
None::<AnyElement>
|
||||
}
|
||||
|
||||
/// Return a Element to show when list is empty.
|
||||
fn render_empty(
|
||||
&mut self,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<ListState<Self>>,
|
||||
) -> impl IntoElement {
|
||||
h_flex()
|
||||
.size_full()
|
||||
.justify_center()
|
||||
.text_color(cx.theme().text_muted.opacity(0.6))
|
||||
.child(Icon::new(IconName::Inbox).size_12())
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// Returns Some(AnyElement) to render the initial state of the list.
|
||||
///
|
||||
/// This can be used to show a view for the list before the user has
|
||||
/// interacted with it.
|
||||
///
|
||||
/// For example: The last search results, or the last selected item.
|
||||
///
|
||||
/// Default is None, that means no initial state.
|
||||
fn render_initial(
|
||||
&mut self,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<ListState<Self>>,
|
||||
) -> Option<AnyElement> {
|
||||
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(
|
||||
&mut self,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<ListState<Self>>,
|
||||
) -> impl IntoElement {
|
||||
Loading
|
||||
}
|
||||
|
||||
/// Set the selected index, just store the ix, don't confirm.
|
||||
fn set_selected_index(
|
||||
&mut self,
|
||||
ix: Option<IndexPath>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<ListState<Self>>,
|
||||
);
|
||||
|
||||
/// Set the index of the item that has been right clicked.
|
||||
fn set_right_clicked_index(
|
||||
&mut self,
|
||||
ix: Option<IndexPath>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<ListState<Self>>,
|
||||
) {
|
||||
}
|
||||
|
||||
/// Set the confirm and give the selected index,
|
||||
/// this is means user have clicked the item or pressed Enter.
|
||||
///
|
||||
/// This will always to `set_selected_index` before confirm.
|
||||
fn confirm(&mut self, secondary: bool, window: &mut Window, cx: &mut Context<ListState<Self>>) {
|
||||
}
|
||||
|
||||
/// Cancel the selection, e.g.: Pressed ESC.
|
||||
fn cancel(&mut self, window: &mut Window, cx: &mut Context<ListState<Self>>) {}
|
||||
|
||||
/// Return true to enable load more data when scrolling to the bottom.
|
||||
///
|
||||
/// Default: false
|
||||
fn has_more(&self, cx: &App) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Returns a threshold value (n entities), 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 entities (section header, footer and row)
|
||||
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<ListState<Self>>) {}
|
||||
}
|
||||
@@ -1,747 +0,0 @@
|
||||
use std::ops::Range;
|
||||
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
App, AppContext, AvailableSpace, ClickEvent, Context, DefiniteLength, EdgesRefinement, Entity,
|
||||
EventEmitter, FocusHandle, Focusable, InteractiveElement, IntoElement, KeyBinding, Length,
|
||||
ListSizingBehavior, MouseButton, ParentElement, Render, RenderOnce, ScrollStrategy,
|
||||
SharedString, StatefulInteractiveElement, StyleRefinement, Styled, Subscription, Task,
|
||||
UniformListScrollHandle, Window, div, px, size, uniform_list,
|
||||
};
|
||||
use instant::Duration;
|
||||
use theme::ActiveTheme;
|
||||
|
||||
use crate::actions::{Cancel, Confirm, SelectDown, SelectUp};
|
||||
use crate::input::{Input, InputEvent, InputState};
|
||||
use crate::list::ListDelegate;
|
||||
use crate::list::cache::{MeasuredEntrySize, RowEntry, RowsCache};
|
||||
use crate::scroll::{Scrollbar, ScrollbarHandle};
|
||||
use crate::{Icon, IconName, IndexPath, Selectable, Sizable, Size, StyledExt, v_flex};
|
||||
|
||||
pub(crate) fn init(cx: &mut App) {
|
||||
let context: Option<&str> = Some("List");
|
||||
cx.bind_keys([
|
||||
KeyBinding::new("escape", Cancel, context),
|
||||
KeyBinding::new("enter", Confirm { secondary: false }, context),
|
||||
KeyBinding::new("secondary-enter", Confirm { secondary: true }, context),
|
||||
KeyBinding::new("up", SelectUp, context),
|
||||
KeyBinding::new("down", SelectDown, context),
|
||||
]);
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub enum ListEvent {
|
||||
/// Move to select item.
|
||||
Select(IndexPath),
|
||||
/// Click on item or pressed Enter.
|
||||
Confirm(IndexPath),
|
||||
/// Pressed ESC to deselect the item.
|
||||
Cancel,
|
||||
}
|
||||
|
||||
struct ListOptions {
|
||||
size: Size,
|
||||
scrollbar_visible: bool,
|
||||
search_placeholder: Option<SharedString>,
|
||||
max_height: Option<Length>,
|
||||
paddings: EdgesRefinement<DefiniteLength>,
|
||||
}
|
||||
|
||||
impl Default for ListOptions {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
size: Size::default(),
|
||||
scrollbar_visible: true,
|
||||
max_height: None,
|
||||
search_placeholder: None,
|
||||
paddings: EdgesRefinement::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The state for List.
|
||||
///
|
||||
/// List required all items has the same height.
|
||||
pub struct ListState<D: ListDelegate> {
|
||||
pub(crate) focus_handle: FocusHandle,
|
||||
pub(crate) query_input: Entity<InputState>,
|
||||
options: ListOptions,
|
||||
delegate: D,
|
||||
last_query: Option<String>,
|
||||
scroll_handle: UniformListScrollHandle,
|
||||
rows_cache: RowsCache,
|
||||
selected_index: Option<IndexPath>,
|
||||
item_to_measure_index: IndexPath,
|
||||
deferred_scroll_to_index: Option<(IndexPath, ScrollStrategy)>,
|
||||
mouse_right_clicked_index: Option<IndexPath>,
|
||||
reset_on_cancel: bool,
|
||||
searchable: bool,
|
||||
selectable: bool,
|
||||
_search_task: Task<()>,
|
||||
_load_more_task: Task<()>,
|
||||
_query_input_subscription: Subscription,
|
||||
}
|
||||
|
||||
impl<D> ListState<D>
|
||||
where
|
||||
D: ListDelegate,
|
||||
{
|
||||
pub fn new(delegate: D, window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
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 {
|
||||
focus_handle: cx.focus_handle(),
|
||||
options: ListOptions::default(),
|
||||
delegate,
|
||||
rows_cache: RowsCache::default(),
|
||||
query_input,
|
||||
last_query: None,
|
||||
selected_index: None,
|
||||
selectable: true,
|
||||
searchable: false,
|
||||
item_to_measure_index: IndexPath::default(),
|
||||
deferred_scroll_to_index: None,
|
||||
mouse_right_clicked_index: None,
|
||||
scroll_handle: UniformListScrollHandle::new(),
|
||||
reset_on_cancel: true,
|
||||
_search_task: Task::ready(()),
|
||||
_load_more_task: Task::ready(()),
|
||||
_query_input_subscription,
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets whether the list is searchable, default is `false`.
|
||||
///
|
||||
/// When `true`, there will be a search input at the top of the list.
|
||||
pub fn searchable(mut self, searchable: bool) -> Self {
|
||||
self.searchable = searchable;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn set_searchable(&mut self, searchable: bool, cx: &mut Context<Self>) {
|
||||
self.searchable = searchable;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Sets whether the list is selectable, default is true.
|
||||
pub fn selectable(mut self, selectable: bool) -> Self {
|
||||
self.selectable = selectable;
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets whether the list is selectable, default is true.
|
||||
pub fn set_selectable(&mut self, selectable: bool, cx: &mut Context<Self>) {
|
||||
self.selectable = selectable;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub fn delegate(&self) -> &D {
|
||||
&self.delegate
|
||||
}
|
||||
|
||||
pub fn delegate_mut(&mut self) -> &mut D {
|
||||
&mut self.delegate
|
||||
}
|
||||
|
||||
/// Focus the list, if the list is searchable, focus the search input.
|
||||
pub fn focus(&mut self, window: &mut Window, cx: &mut App) {
|
||||
self.focus_handle(cx).focus(window, cx);
|
||||
}
|
||||
|
||||
/// Return true if either the list or the search input is focused.
|
||||
#[allow(dead_code)]
|
||||
pub(crate) fn is_focused(&self, window: &Window, cx: &App) -> bool {
|
||||
self.focus_handle.is_focused(window) || self.query_input.focus_handle(cx).is_focused(window)
|
||||
}
|
||||
|
||||
/// Set the selected index of the list,
|
||||
/// this will also scroll to the selected item.
|
||||
pub(crate) fn _set_selected_index(
|
||||
&mut self,
|
||||
ix: Option<IndexPath>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
if !self.selectable {
|
||||
return;
|
||||
}
|
||||
|
||||
self.selected_index = ix;
|
||||
self.delegate.set_selected_index(ix, window, cx);
|
||||
self.scroll_to_selected_item(window, cx);
|
||||
}
|
||||
|
||||
/// Set the selected index of the list,
|
||||
/// this method will not scroll to the selected item.
|
||||
pub fn set_selected_index(
|
||||
&mut self,
|
||||
ix: Option<IndexPath>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
self.selected_index = ix;
|
||||
self.delegate.set_selected_index(ix, window, cx);
|
||||
}
|
||||
|
||||
pub fn selected_index(&self) -> Option<IndexPath> {
|
||||
self.selected_index
|
||||
}
|
||||
|
||||
/// Set the index of the item that has been right clicked.
|
||||
pub fn set_right_clicked_index(
|
||||
&mut self,
|
||||
ix: Option<IndexPath>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
self.mouse_right_clicked_index = ix;
|
||||
self.delegate.set_right_clicked_index(ix, window, cx);
|
||||
}
|
||||
|
||||
/// Returns the index of the item that has been right clicked.
|
||||
pub fn right_clicked_index(&self) -> Option<IndexPath> {
|
||||
self.mouse_right_clicked_index
|
||||
}
|
||||
|
||||
/// Set a specific list item for measurement.
|
||||
pub fn set_item_to_measure_index(
|
||||
&mut self,
|
||||
ix: IndexPath,
|
||||
_: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
self.item_to_measure_index = ix;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Scroll to the item at the given index.
|
||||
pub fn scroll_to_item(
|
||||
&mut self,
|
||||
ix: IndexPath,
|
||||
strategy: ScrollStrategy,
|
||||
_: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
if ix.section == 0 && ix.row == 0 {
|
||||
// If the item is the first item, scroll to the top.
|
||||
let mut offset = self.scroll_handle.offset();
|
||||
offset.y = px(0.);
|
||||
self.scroll_handle.set_offset(offset);
|
||||
cx.notify();
|
||||
return;
|
||||
}
|
||||
self.deferred_scroll_to_index = Some((ix, strategy));
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Get scroll handle
|
||||
pub fn scroll_handle(&self) -> &UniformListScrollHandle {
|
||||
&self.scroll_handle
|
||||
}
|
||||
|
||||
pub fn scroll_to_selected_item(&mut self, _: &mut Window, cx: &mut Context<Self>) {
|
||||
if let Some(ix) = self.selected_index {
|
||||
self.deferred_scroll_to_index = Some((ix, ScrollStrategy::Top));
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
fn on_query_input_event(
|
||||
&mut self,
|
||||
state: &Entity<InputState>,
|
||||
event: &InputEvent,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
match event {
|
||||
InputEvent::Change => {
|
||||
let text = state.read(cx).value();
|
||||
let text = text.trim().to_string();
|
||||
if Some(&text) == self.last_query.as_ref() {
|
||||
return;
|
||||
}
|
||||
|
||||
self.set_searching(true, window, cx);
|
||||
|
||||
let search = self.delegate.perform_search(&text, window, cx);
|
||||
|
||||
if self.rows_cache.len() > 0 {
|
||||
self._set_selected_index(Some(IndexPath::default()), window, cx);
|
||||
} else {
|
||||
self._set_selected_index(None, window, cx);
|
||||
}
|
||||
|
||||
let executor = cx.background_executor().clone();
|
||||
self._search_task = cx.spawn_in(window, async move |this, window| {
|
||||
search.await;
|
||||
|
||||
_ = this.update_in(window, |this, _, _| {
|
||||
this.scroll_handle.scroll_to_item(0, ScrollStrategy::Top);
|
||||
this.last_query = Some(text);
|
||||
});
|
||||
|
||||
// Always wait 100ms to avoid flicker
|
||||
executor.timer(Duration::from_millis(100)).await;
|
||||
|
||||
_ = this.update_in(window, |this, window, cx| {
|
||||
this.set_searching(false, window, cx);
|
||||
});
|
||||
});
|
||||
}
|
||||
InputEvent::PressEnter { secondary, .. } => self.on_action_confirm(
|
||||
&Confirm {
|
||||
secondary: *secondary,
|
||||
},
|
||||
window,
|
||||
cx,
|
||||
),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn set_searching(&mut self, searching: bool, _window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.query_input
|
||||
.update(cx, |input, cx| input.set_loading(searching, cx));
|
||||
}
|
||||
|
||||
/// Dispatch delegate's `load_more` method when the
|
||||
/// visible range is near the end.
|
||||
fn load_more_if_need(
|
||||
&mut self,
|
||||
entities_count: usize,
|
||||
visible_end: usize,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
// FIXME: Here need void sections items count.
|
||||
|
||||
let threshold = self.delegate.load_more_threshold();
|
||||
// Securely handle subtract logic to prevent attempt
|
||||
// to subtract with overflow
|
||||
if visible_end >= entities_count.saturating_sub(threshold) {
|
||||
if !self.delegate.has_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);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
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>) {
|
||||
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: &Confirm,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
if self.rows_cache.len() == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
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: IndexPath, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if !self.selectable {
|
||||
return;
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
pub(crate) fn on_action_select_prev(
|
||||
&mut self,
|
||||
_: &SelectUp,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
if self.rows_cache.len() == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
let prev_ix = self.rows_cache.prev(self.selected_index);
|
||||
self.select_item(prev_ix, window, cx);
|
||||
}
|
||||
|
||||
pub(crate) fn on_action_select_next(
|
||||
&mut self,
|
||||
_: &SelectDown,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
if self.rows_cache.len() == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
let next_ix = self.rows_cache.next(self.selected_index);
|
||||
self.select_item(next_ix, window, cx);
|
||||
}
|
||||
|
||||
fn prepare_items_if_needed(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let sections_count = self.delegate.sections_count(cx).max(1);
|
||||
let mut measured_size = MeasuredEntrySize::default();
|
||||
|
||||
// Measure the item_height and section header/footer height.
|
||||
let available_space = size(AvailableSpace::MinContent, AvailableSpace::MinContent);
|
||||
measured_size.item_size = self
|
||||
.render_list_item(self.item_to_measure_index, window, cx)
|
||||
.into_any_element()
|
||||
.layout_as_root(available_space, window, cx);
|
||||
|
||||
if let Some(mut el) = self
|
||||
.delegate
|
||||
.render_section_header(0, window, cx)
|
||||
.map(|r| r.into_any_element())
|
||||
{
|
||||
measured_size.section_header_size = el.layout_as_root(available_space, window, cx);
|
||||
}
|
||||
if let Some(mut el) = self
|
||||
.delegate
|
||||
.render_section_footer(0, window, cx)
|
||||
.map(|r| r.into_any_element())
|
||||
{
|
||||
measured_size.section_footer_size = el.layout_as_root(available_space, window, cx);
|
||||
}
|
||||
|
||||
self.rows_cache
|
||||
.prepare_if_needed(sections_count, measured_size, cx, |section_ix, cx| {
|
||||
self.delegate.items_count(section_ix, cx)
|
||||
});
|
||||
}
|
||||
|
||||
fn render_list_item(
|
||||
&mut self,
|
||||
ix: IndexPath,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> impl IntoElement {
|
||||
let selectable = self.selectable;
|
||||
let selected = self.selected_index.map(|s| s.eq_row(ix)).unwrap_or(false);
|
||||
let mouse_right_clicked = self
|
||||
.mouse_right_clicked_index
|
||||
.map(|s| s.eq_row(ix))
|
||||
.unwrap_or(false);
|
||||
let id = SharedString::from(format!("list-item-{}", ix));
|
||||
|
||||
div()
|
||||
.id(id)
|
||||
.w_full()
|
||||
.relative()
|
||||
.overflow_hidden()
|
||||
.children(self.delegate.render_item(ix, window, cx).map(|item| {
|
||||
item.selected(selected)
|
||||
.secondary_selected(mouse_right_clicked)
|
||||
}))
|
||||
.when(selectable, |this| {
|
||||
this.on_click(cx.listener(move |this, e: &ClickEvent, window, cx| {
|
||||
this.set_right_clicked_index(None, window, cx);
|
||||
this.selected_index = Some(ix);
|
||||
this.on_action_confirm(
|
||||
&Confirm {
|
||||
secondary: e.modifiers().secondary(),
|
||||
},
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
}))
|
||||
.on_mouse_down(
|
||||
MouseButton::Right,
|
||||
cx.listener(move |this, _, window, cx| {
|
||||
this.set_right_clicked_index(Some(ix), window, cx);
|
||||
cx.notify();
|
||||
}),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn render_items(
|
||||
&mut self,
|
||||
items_count: usize,
|
||||
entities_count: usize,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> impl IntoElement {
|
||||
let rows_cache = self.rows_cache.clone();
|
||||
let scrollbar_visible = self.options.scrollbar_visible;
|
||||
let scroll_handle = self.scroll_handle.clone();
|
||||
|
||||
v_flex()
|
||||
.flex_grow_1()
|
||||
.relative()
|
||||
.size_full()
|
||||
.when_some(self.options.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(
|
||||
"virtual-list",
|
||||
rows_cache.items_count(),
|
||||
cx.processor(move |this, range: Range<usize>, window, cx| {
|
||||
this.load_more_if_need(entities_count, range.end, window, cx);
|
||||
|
||||
// NOTE: Here the v_virtual_list would not able to have gap_y,
|
||||
// because the section header, footer is always have rendered as a empty child item,
|
||||
// even the delegate give a None result.
|
||||
|
||||
range
|
||||
.map(|ix| {
|
||||
let Some(entry) = rows_cache.get(ix) else {
|
||||
return div();
|
||||
};
|
||||
|
||||
div().children(match entry {
|
||||
RowEntry::Entry(index) => Some(
|
||||
this.render_list_item(index, window, cx)
|
||||
.into_any_element(),
|
||||
),
|
||||
RowEntry::SectionHeader(section_ix) => this
|
||||
.delegate_mut()
|
||||
.render_section_header(section_ix, window, cx)
|
||||
.map(|r| r.into_any_element()),
|
||||
RowEntry::SectionFooter(section_ix) => this
|
||||
.delegate_mut()
|
||||
.render_section_footer(section_ix, window, cx)
|
||||
.map(|r| r.into_any_element()),
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
}),
|
||||
)
|
||||
.when(self.options.max_height.is_some(), |this| {
|
||||
this.with_sizing_behavior(ListSizingBehavior::Infer)
|
||||
})
|
||||
.track_scroll(&scroll_handle)
|
||||
.into_any_element(),
|
||||
)
|
||||
}
|
||||
})
|
||||
.when(scrollbar_visible, |this| {
|
||||
this.child(Scrollbar::vertical(&scroll_handle))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl<D> Focusable for ListState<D>
|
||||
where
|
||||
D: ListDelegate,
|
||||
{
|
||||
fn focus_handle(&self, cx: &App) -> FocusHandle {
|
||||
if self.searchable {
|
||||
self.query_input.focus_handle(cx)
|
||||
} else {
|
||||
self.focus_handle.clone()
|
||||
}
|
||||
}
|
||||
}
|
||||
impl<D> EventEmitter<ListEvent> for ListState<D> where D: ListDelegate {}
|
||||
impl<D> Render for ListState<D>
|
||||
where
|
||||
D: ListDelegate,
|
||||
{
|
||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
self.prepare_items_if_needed(window, cx);
|
||||
|
||||
// Scroll to the selected item if it is set.
|
||||
if let Some((ix, strategy)) = self.deferred_scroll_to_index.take()
|
||||
&& let Some(item_ix) = self.rows_cache.position_of(&ix)
|
||||
{
|
||||
self.scroll_handle.scroll_to_item(item_ix, strategy);
|
||||
}
|
||||
|
||||
let loading = self.delegate().loading(cx);
|
||||
let query_input = if self.searchable {
|
||||
// sync placeholder
|
||||
if let Some(placeholder) = &self.options.search_placeholder {
|
||||
self.query_input.update(cx, |input, cx| {
|
||||
input.set_placeholder(placeholder.clone(), window, cx);
|
||||
});
|
||||
}
|
||||
Some(self.query_input.clone())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let loading_view = if loading {
|
||||
Some(self.delegate.render_loading(window, cx).into_any_element())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let initial_view = if let Some(input) = &query_input {
|
||||
if input.read(cx).value().is_empty() {
|
||||
self.delegate.render_initial(window, cx)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let items_count = self.rows_cache.items_count();
|
||||
let entities_count = self.rows_cache.len();
|
||||
let mouse_right_clicked_index = self.mouse_right_clicked_index;
|
||||
|
||||
v_flex()
|
||||
.key_context("List")
|
||||
.id("list-state")
|
||||
.track_focus(&self.focus_handle)
|
||||
.size_full()
|
||||
.relative()
|
||||
.overflow_hidden()
|
||||
.when_some(query_input, |this, input| {
|
||||
this.child(
|
||||
div()
|
||||
.map(|this| match self.options.size {
|
||||
Size::Small => this.px_1p5(),
|
||||
_ => this.px_2(),
|
||||
})
|
||||
.border_b_1()
|
||||
.border_color(cx.theme().border)
|
||||
.child(
|
||||
Input::new(&input)
|
||||
.with_size(self.options.size)
|
||||
.appearance(false)
|
||||
.cleanable(true)
|
||||
.p_0()
|
||||
.prefix(
|
||||
Icon::new(IconName::Search).text_color(cx.theme().text_muted),
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
.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(self.render_items(items_count, entities_count, window, cx))
|
||||
}
|
||||
})
|
||||
// Click out to cancel right clicked row
|
||||
.when(mouse_right_clicked_index.is_some(), |this| {
|
||||
this.on_mouse_down_out(cx.listener(|this, _, window, cx| {
|
||||
this.set_right_clicked_index(None, window, cx);
|
||||
cx.notify();
|
||||
}))
|
||||
})
|
||||
})
|
||||
.children(loading_view)
|
||||
}
|
||||
}
|
||||
|
||||
/// The List element.
|
||||
#[derive(IntoElement)]
|
||||
pub struct List<D: ListDelegate + 'static> {
|
||||
state: Entity<ListState<D>>,
|
||||
style: StyleRefinement,
|
||||
options: ListOptions,
|
||||
}
|
||||
|
||||
impl<D> List<D>
|
||||
where
|
||||
D: ListDelegate + 'static,
|
||||
{
|
||||
/// Create a new List element with the given ListState entity.
|
||||
pub fn new(state: &Entity<ListState<D>>) -> Self {
|
||||
Self {
|
||||
state: state.clone(),
|
||||
style: StyleRefinement::default(),
|
||||
options: ListOptions::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Set whether the scrollbar is visible, default is `true`.
|
||||
pub fn scrollbar_visible(mut self, visible: bool) -> Self {
|
||||
self.options.scrollbar_visible = visible;
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the placeholder text for the search input.
|
||||
pub fn search_placeholder(mut self, placeholder: impl Into<SharedString>) -> Self {
|
||||
self.options.search_placeholder = Some(placeholder.into());
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl<D> Styled for List<D>
|
||||
where
|
||||
D: ListDelegate + 'static,
|
||||
{
|
||||
fn style(&mut self) -> &mut StyleRefinement {
|
||||
&mut self.style
|
||||
}
|
||||
}
|
||||
|
||||
impl<D> Sizable for List<D>
|
||||
where
|
||||
D: ListDelegate + 'static,
|
||||
{
|
||||
fn with_size(mut self, size: impl Into<Size>) -> Self {
|
||||
self.options.size = size.into();
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl<D> RenderOnce for List<D>
|
||||
where
|
||||
D: ListDelegate + 'static,
|
||||
{
|
||||
fn render(mut self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
// Take paddings, max_height to options, and clear them from style,
|
||||
// because they would be applied to the inner virtual list.
|
||||
self.options.paddings = self.style.padding.clone();
|
||||
self.options.max_height = self.style.max_size.height;
|
||||
self.style.padding = EdgesRefinement::default();
|
||||
self.style.max_size.height = None;
|
||||
|
||||
self.state.update(cx, |state, _| {
|
||||
state.options = self.options;
|
||||
});
|
||||
|
||||
div()
|
||||
.id("list")
|
||||
.size_full()
|
||||
.refine_style(&self.style)
|
||||
.child(self.state.clone())
|
||||
}
|
||||
}
|
||||
@@ -1,226 +0,0 @@
|
||||
use gpui::prelude::FluentBuilder as _;
|
||||
use gpui::{
|
||||
div, AnyElement, App, ClickEvent, Div, ElementId, InteractiveElement, IntoElement,
|
||||
MouseMoveEvent, ParentElement, RenderOnce, Stateful, StatefulInteractiveElement as _,
|
||||
StyleRefinement, Styled, Window,
|
||||
};
|
||||
use smallvec::SmallVec;
|
||||
use theme::ActiveTheme;
|
||||
|
||||
use crate::{h_flex, Disableable, Icon, Selectable, Sizable as _, StyledExt};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
enum ListItemMode {
|
||||
#[default]
|
||||
Entry,
|
||||
Separator,
|
||||
}
|
||||
|
||||
impl ListItemMode {
|
||||
#[inline]
|
||||
fn is_separator(&self) -> bool {
|
||||
matches!(self, ListItemMode::Separator)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(IntoElement)]
|
||||
pub struct ListItem {
|
||||
base: Stateful<Div>,
|
||||
mode: ListItemMode,
|
||||
style: StyleRefinement,
|
||||
disabled: bool,
|
||||
selected: bool,
|
||||
secondary_selected: bool,
|
||||
confirmed: bool,
|
||||
check_icon: Option<Icon>,
|
||||
#[allow(clippy::type_complexity)]
|
||||
on_click: Option<Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>,
|
||||
#[allow(clippy::type_complexity)]
|
||||
on_mouse_enter: Option<Box<dyn Fn(&MouseMoveEvent, &mut Window, &mut App) + 'static>>,
|
||||
#[allow(clippy::type_complexity)]
|
||||
suffix: Option<Box<dyn Fn(&mut Window, &mut App) -> AnyElement + 'static>>,
|
||||
children: SmallVec<[AnyElement; 2]>,
|
||||
}
|
||||
|
||||
impl ListItem {
|
||||
pub fn new(id: impl Into<ElementId>) -> Self {
|
||||
let id: ElementId = id.into();
|
||||
Self {
|
||||
mode: ListItemMode::Entry,
|
||||
base: h_flex().id(id),
|
||||
style: StyleRefinement::default(),
|
||||
disabled: false,
|
||||
selected: false,
|
||||
secondary_selected: false,
|
||||
confirmed: false,
|
||||
on_click: None,
|
||||
on_mouse_enter: None,
|
||||
check_icon: None,
|
||||
suffix: None,
|
||||
children: SmallVec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Set this list item to as a separator, it not able to be selected.
|
||||
pub fn separator(mut self) -> Self {
|
||||
self.mode = ListItemMode::Separator;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set to show check icon, default is None.
|
||||
pub fn check_icon(mut self, icon: impl Into<Icon>) -> Self {
|
||||
self.check_icon = Some(icon.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Set ListItem as the selected item style.
|
||||
pub fn selected(mut self, selected: bool) -> Self {
|
||||
self.selected = selected;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set ListItem as the confirmed item style, it will show a check icon.
|
||||
pub fn confirmed(mut self, confirmed: bool) -> Self {
|
||||
self.confirmed = confirmed;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn disabled(mut self, disabled: bool) -> Self {
|
||||
self.disabled = disabled;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the suffix element of the input field, for example a clear button.
|
||||
pub fn suffix<F, E>(mut self, builder: F) -> Self
|
||||
where
|
||||
F: Fn(&mut Window, &mut App) -> E + 'static,
|
||||
E: IntoElement,
|
||||
{
|
||||
self.suffix = Some(Box::new(move |window, cx| {
|
||||
builder(window, cx).into_any_element()
|
||||
}));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_click(
|
||||
mut self,
|
||||
handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
|
||||
) -> Self {
|
||||
self.on_click = Some(Box::new(handler));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_mouse_enter(
|
||||
mut self,
|
||||
handler: impl Fn(&MouseMoveEvent, &mut Window, &mut App) + 'static,
|
||||
) -> Self {
|
||||
self.on_mouse_enter = Some(Box::new(handler));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Disableable for ListItem {
|
||||
fn disabled(mut self, disabled: bool) -> Self {
|
||||
self.disabled = disabled;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Selectable for ListItem {
|
||||
fn selected(mut self, selected: bool) -> Self {
|
||||
self.selected = selected;
|
||||
self
|
||||
}
|
||||
|
||||
fn is_selected(&self) -> bool {
|
||||
self.selected
|
||||
}
|
||||
|
||||
fn secondary_selected(mut self, selected: bool) -> Self {
|
||||
self.secondary_selected = selected;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Styled for ListItem {
|
||||
fn style(&mut self) -> &mut gpui::StyleRefinement {
|
||||
&mut self.style
|
||||
}
|
||||
}
|
||||
|
||||
impl ParentElement for ListItem {
|
||||
fn extend(&mut self, elements: impl IntoIterator<Item = gpui::AnyElement>) {
|
||||
self.children.extend(elements);
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for ListItem {
|
||||
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let is_active = self.confirmed || self.selected;
|
||||
|
||||
let corner_radii = self.style.corner_radii.clone();
|
||||
|
||||
let _selected_style = StyleRefinement {
|
||||
corner_radii,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let is_selectable = !(self.disabled || self.mode.is_separator());
|
||||
|
||||
self.base
|
||||
.relative()
|
||||
.gap_x_1()
|
||||
.py_1()
|
||||
.px_3()
|
||||
.text_base()
|
||||
.text_color(cx.theme().text)
|
||||
.relative()
|
||||
.items_center()
|
||||
.justify_between()
|
||||
.refine_style(&self.style)
|
||||
.when(is_selectable, |this| {
|
||||
this.when_some(self.on_click, |this, on_click| this.on_click(on_click))
|
||||
.when_some(self.on_mouse_enter, |this, on_mouse_enter| {
|
||||
this.on_mouse_move(move |ev, window, cx| (on_mouse_enter)(ev, window, cx))
|
||||
})
|
||||
.when(!is_active, |this| {
|
||||
this.hover(|this| this.bg(cx.theme().ghost_element_hover))
|
||||
})
|
||||
})
|
||||
.when(!is_selectable, |this| {
|
||||
this.text_color(cx.theme().text_muted)
|
||||
})
|
||||
.child(
|
||||
h_flex()
|
||||
.w_full()
|
||||
.items_center()
|
||||
.justify_between()
|
||||
.gap_x_1()
|
||||
.child(div().w_full().children(self.children))
|
||||
.when_some(self.check_icon, |this, icon| {
|
||||
this.child(
|
||||
div()
|
||||
.w_5()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.when(self.confirmed, |this| {
|
||||
this.child(icon.small().text_color(cx.theme().text_muted))
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.when_some(self.suffix, |this, suffix| this.child(suffix(window, cx)))
|
||||
.map(|this| {
|
||||
if is_selectable && (self.selected || self.secondary_selected) {
|
||||
let bg = if self.selected {
|
||||
cx.theme().ghost_element_active
|
||||
} else {
|
||||
cx.theme().ghost_element_background
|
||||
};
|
||||
this.bg(bg)
|
||||
} else {
|
||||
this
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
use gpui::{IntoElement, ParentElement as _, RenderOnce, Styled};
|
||||
|
||||
use super::ListItem;
|
||||
use crate::skeleton::Skeleton;
|
||||
use crate::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().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,28 +0,0 @@
|
||||
pub(crate) mod cache;
|
||||
mod delegate;
|
||||
#[allow(clippy::module_inception)]
|
||||
mod list;
|
||||
mod list_item;
|
||||
mod loading;
|
||||
mod separator_item;
|
||||
|
||||
pub use delegate::*;
|
||||
pub use list::*;
|
||||
pub use list_item::*;
|
||||
pub use separator_item::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Settings for List.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ListSettings {
|
||||
/// Whether to use active highlight style on ListItem, default
|
||||
pub active_highlight: bool,
|
||||
}
|
||||
|
||||
impl Default for ListSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
active_highlight: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
use gpui::{AnyElement, ParentElement, RenderOnce, StyleRefinement};
|
||||
use smallvec::SmallVec;
|
||||
|
||||
use crate::list::ListItem;
|
||||
use crate::{Selectable, StyledExt};
|
||||
|
||||
pub struct ListSeparatorItem {
|
||||
style: StyleRefinement,
|
||||
children: SmallVec<[AnyElement; 2]>,
|
||||
}
|
||||
|
||||
impl ListSeparatorItem {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
style: StyleRefinement::default(),
|
||||
children: SmallVec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ListSeparatorItem {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl ParentElement for ListSeparatorItem {
|
||||
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
|
||||
self.children.extend(elements);
|
||||
}
|
||||
}
|
||||
|
||||
impl Selectable for ListSeparatorItem {
|
||||
fn selected(self, _: bool) -> Self {
|
||||
self
|
||||
}
|
||||
|
||||
fn is_selected(&self) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for ListSeparatorItem {
|
||||
fn render(self, _: &mut gpui::Window, _: &mut gpui::App) -> impl gpui::IntoElement {
|
||||
ListItem::new("separator")
|
||||
.refine_style(&self.style)
|
||||
.children(self.children)
|
||||
.disabled(true)
|
||||
}
|
||||
}
|
||||
+1
-26
@@ -1,4 +1,5 @@
|
||||
use gpui::{App, DefiniteLength, Div, Edges, Pixels, Refineable, StyleRefinement, Styled, div, px};
|
||||
pub use gpui_base::component_traits::{Collapsible, Disableable, Selectable};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use theme::ActiveTheme;
|
||||
|
||||
@@ -110,26 +111,6 @@ impl From<Pixels> for Size {
|
||||
}
|
||||
}
|
||||
|
||||
/// A trait for defining element that can be selected.
|
||||
pub trait Selectable: Sized {
|
||||
/// Set the selected state of the element.
|
||||
fn selected(self, selected: bool) -> Self;
|
||||
|
||||
/// Returns true if the element is selected.
|
||||
fn is_selected(&self) -> bool;
|
||||
|
||||
/// Set is the element mouse right clicked, default do nothing.
|
||||
fn secondary_selected(self, _: bool) -> Self {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// A trait for defining element that can be disabled.
|
||||
pub trait Disableable {
|
||||
/// Set the disabled state of the element.
|
||||
fn disabled(self, disabled: bool) -> Self;
|
||||
}
|
||||
|
||||
/// A trait for setting the size of an element.
|
||||
pub trait Sizable: Sized {
|
||||
/// Set the ui::Size of this element.
|
||||
@@ -267,9 +248,3 @@ impl<T: Styled> StyleSized<T> for T {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A trait for defining element that can be collapsed.
|
||||
pub trait Collapsible {
|
||||
fn collapsed(self, collapsed: bool) -> Self;
|
||||
fn is_collapsed(&self) -> bool;
|
||||
}
|
||||
|
||||
+158
-60
@@ -9,15 +9,28 @@ The facts below were checked against `gpui-base 0.6.1` (crates.io), the `gpui-ki
|
||||
repository at `main`, and this workspace's `Cargo.lock` (zed at `4b47ceb`,
|
||||
2026-09-17). Line counts come from `wc -l` under `crates/ui/src`.
|
||||
|
||||
## Status
|
||||
|
||||
- **Phase 0: landed.** Manifest only; no Rust changed. The API drift across the
|
||||
three days between the snapshot and the old pin turned out to be purely
|
||||
additive, so nothing had to be fixed.
|
||||
- **Phase 1: landed.** Base is wired in, `sync_base` is in place, and 1,945 lines
|
||||
of dead weight are gone. `history.rs` moved to phase 2 once it turned out its
|
||||
only consumer is `input/state.rs`. No dependency became unused, so the pruning
|
||||
step is a no-op (four dependencies were already unused before this work).
|
||||
- **Phases 2-5: not started.**
|
||||
- One pre-existing, unrelated breakage was found; see
|
||||
[A pre-existing wasm blocker](#a-pre-existing-wasm-blocker).
|
||||
|
||||
## The two facts that shape the work
|
||||
|
||||
**GPUI still comes from upstream — addressed as the `gpui-pre` package.** `gpui-base`
|
||||
declares its GPUI dependency as `gpui = { package = "gpui-pre", version = "0.3.1" }`:
|
||||
the crate in the graph is the published package `gpui-pre`, and `gpui` is only the name
|
||||
used in code. That package is upstream zed's gpui (a snapshot of `zed@d89e9c2`)
|
||||
republished unchanged, so nothing is forked and there is no source to align. Coop
|
||||
currently pins zed's git repository at `4b47ceb` (2026-09-17), roughly four days ahead
|
||||
of that snapshot.
|
||||
used in code. That package is upstream zed's gpui (a snapshot of `zed@d89e9c2`,
|
||||
published 2026-09-14) republished unchanged, so nothing is forked and there is no
|
||||
source to align. Coop previously pinned zed's git repository at `4b47ceb`
|
||||
(2026-09-17), a few days ahead of that snapshot.
|
||||
|
||||
The two cannot be mixed. Zed's git `gpui` and the `gpui-pre` package are different
|
||||
crates, so `App`, `Window`, `Entity`, and elements from one are not the other's types,
|
||||
@@ -39,9 +52,10 @@ deeper than `ui::<module>::<Item>`:
|
||||
| `notification`, `avatar`, `menu`, `scroll`, `group_box`, `indicator`, `switch`, `modal`, `tooltip` | 12 | 16 |
|
||||
| `list`, `checkbox`, `popover`, `resizable`, `skeleton`, `tab`, `divider` (module), `history`, `animation`, `actions` | 0 references | 0 |
|
||||
|
||||
`ui::list` and `ui::checkbox` have no consumers at all; the message list in
|
||||
`crates/chat_ui` uses GPUI's own `list::ListState`. The modules with zero external
|
||||
references still serve as internal machinery for `dock`, `menu`, `modal`, and `input`.
|
||||
`ui::list` and `ui::checkbox` had no consumers at all — the message list in
|
||||
`crates/chat_ui` uses GPUI's own `list::ListState` — so phase 1 deleted both. The other
|
||||
modules with zero external references still serve as internal machinery for `dock`,
|
||||
`menu`, `modal`, and `input`.
|
||||
|
||||
The consequence: this is not a rewrite of an app-facing library. Most of the work is
|
||||
deleting internals and re-expressing a few thousand lines of presentation over base
|
||||
@@ -59,15 +73,36 @@ primitives.
|
||||
- `gpui-component` is not adopted. It is a complete, styled visual language, and taking
|
||||
it would replace the design system rather than preserve it.
|
||||
|
||||
Two `Theme` types will exist — `theme::Theme` and `gpui_base::Theme` — as separate GPUI
|
||||
Two `Theme` types exist — `theme::Theme` and `gpui_base::Theme` — as separate GPUI
|
||||
globals. Coop's stays the application-facing one. Base's is touched in exactly one
|
||||
place: a `theme::sync_base(cx)` that projects coop's colors into
|
||||
`gpui_base::Theme::global_mut(cx).tokens` (`SemanticThemeTokens`: colors, radius,
|
||||
typography, shadow) plus `ThemeAppearance`, `ScrollbarTheme`, and `ResizableTheme`. It
|
||||
runs from `ui::init` and on every theme change. This is needed because base paints a
|
||||
few things itself — the focus ring from `FocusableExt`, text selection under glyphs,
|
||||
scrollbars, resize handles, and the dialog backdrop — and those should follow coop's
|
||||
palette rather than base's default.
|
||||
place: `theme::sync_base(cx)`, called from `ui::init` and from `Theme::change` so that
|
||||
it re-runs on every theme change. It is a no-op before coop's theme global exists,
|
||||
which is the case when `ui::init` runs ahead of `theme::init`; `Theme::change` is the
|
||||
hook that actually keeps the projection current.
|
||||
|
||||
It projects the color roles base can act on — the focus ring, the wash under selected
|
||||
text, scrollbars, and overlay backdrops — and nothing else:
|
||||
|
||||
| `gpui_base::ColorTokens` | coop `ThemeColors` |
|
||||
| --- | --- |
|
||||
| `background` / `foreground` | `background` / `text` |
|
||||
| `surface` / `surface_foreground` | `surface_background` / `text` |
|
||||
| `primary` / `primary_foreground` | `element_background` / `element_foreground` |
|
||||
| `secondary` / `secondary_foreground` | `secondary_background` / `secondary_foreground` |
|
||||
| `muted` / `muted_foreground` | `ghost_element_background_alt` / `text_muted` |
|
||||
| `accent` / `accent_foreground` | `ghost_element_hover` / `text` |
|
||||
| `destructive` / `destructive_foreground` | `danger_background` / `danger_foreground` |
|
||||
| `border` / `input` | `border` |
|
||||
| `ring` | `ring` |
|
||||
| `selection` | `selection` |
|
||||
|
||||
It also sets `ThemeAppearance` from coop's mode, `ScrollbarTheme`'s mode from coop's
|
||||
`scrollbar_mode`, and `TypographyTokens::sans` from coop's `font_family`. Radius,
|
||||
spacing, typography sizes, shadows, and scrollbar geometry keep their base defaults:
|
||||
coop has a single `radius`/`radius_lg`/`font_size` where base has six-point scales, so
|
||||
any mapping would be invented rather than derived. `ResizableTheme` needs nothing —
|
||||
base's documented `None` fallback already resolves to `border` at rest and `ring` while
|
||||
dragging, both of which are projected.
|
||||
|
||||
## What each module becomes
|
||||
|
||||
@@ -85,8 +120,9 @@ palette rather than base's default.
|
||||
| `button.rs` | 626 | Skin: base behavior plus coop's existing variant tables | `Button`, `StateStyle` |
|
||||
| `switch.rs` | 287 | Skin | `Switch`, `SwitchTrack`, `SwitchThumb` |
|
||||
| `avatar.rs` | 141 | Skin | `Avatar`, `AvatarImage`, `AvatarFallback` |
|
||||
| `history.rs`, `index_path.rs`, `element_ext.rs`, `event.rs`, `focusable.rs` | 340 | Delete | `History`/`UndoHistory`, `IndexPath`, `ElementExt`, `InteractiveElementExt`, `FocusableExt`, `FocusTrapElement` |
|
||||
| `styled.rs`, `actions.rs`, `animation.rs` | 305 | Keep `ui::StyledExt`, `Size`, and `Sizable` as the app's import; base's `h_flex`/`v_flex` helpers are identical (`flex_row` + `items_center`) and can be delegated to | `styled`, `StateStyle` |
|
||||
| `history.rs` | 184 | Defer to phase 2 | `UndoHistory`, not `History`: base's `History` is navigation (back/forward), while `UndoHistory` is the grouped undo/redo with `max_undos`, `group_interval`, `start_grouping`/`end_grouping`, and `set_ignoring` in place of the fork's `pub(crate) ignore` field. Its only consumer is `input/state.rs`, which phase 2 replaces |
|
||||
| `index_path.rs`, `element_ext.rs`, `event.rs`, `focusable.rs` | 156 | Delete | `IndexPath`, `ElementExt`, `InteractiveElementExt`. `FocusableCycle` has no counterpart — base's `FocusableExt` is a different concept (whether a component draws a focus ring) — so it is dropped rather than re-based |
|
||||
| `styled.rs`, `actions.rs`, `animation.rs` | 305 | Keep `ui::StyledExt`, `Size`, and `Sizable` as the app's import. `Selectable`, `Disableable`, and `Collapsible` now come from `gpui_base::component_traits`; the local three-line `h_flex`/`v_flex` wrappers stay rather than delegating to base's identical ones | `styled`, `StateStyle` |
|
||||
| `icon.rs`, `kbd.rs`, `divider.rs`, `skeleton.rs`, `group_box.rs`, `indicator.rs` | 1,023 | Keep; no base equivalent, these are the design system | — |
|
||||
| `menu/` | 2,208 | Keep; base has no menu. Optional later: re-base anchoring and dismissal on `Popup`/`Positioner` | `Popup` (optional) |
|
||||
| `dock/` + `tab/` | 3,356 | Keep for now; see phase 5 | base dock (different contract) |
|
||||
@@ -100,58 +136,99 @@ The workspace manifest's GPUI entries become:
|
||||
|
||||
```toml
|
||||
[workspace.dependencies]
|
||||
gpui = { package = "gpui-pre", version = "0.3.5" }
|
||||
gpui_platform = { package = "gpui-pre-platform", version = "0.3.5", features = ["font-kit", "x11", "wayland"] }
|
||||
gpui_linux = { package = "gpui-pre-linux", version = "0.3.5" }
|
||||
gpui_windows = { package = "gpui-pre-windows", version = "0.3.5" }
|
||||
gpui_macos = { package = "gpui-pre-macos", version = "0.3.5" }
|
||||
gpui_web = { package = "gpui-pre-web", version = "0.3.5" }
|
||||
reqwest_client = { package = "gpui-pre-reqwest-client", version = "0.3.5" }
|
||||
sum_tree = { package = "gpui-pre-sum-tree", version = "0.3.5" }
|
||||
gpui = { package = "gpui-pre", version = "0.3.5" }
|
||||
gpui_platform = { package = "gpui-pre-platform", version = "0.3.5", features = ["font-kit", "x11", "wayland"] }
|
||||
gpui_linux = { package = "gpui-pre-linux", version = "0.3.5" }
|
||||
gpui_windows = { package = "gpui-pre-windows", version = "0.3.5" }
|
||||
gpui_macos = { package = "gpui-pre-macos", version = "0.3.5" }
|
||||
gpui_web = { package = "gpui-pre-web", version = "0.3.5" }
|
||||
gpui_util = { package = "gpui-pre-util", version = "0.3.5" }
|
||||
reqwest_client = { package = "gpui-pre-reqwest-client", version = "0.3.5" }
|
||||
sum_tree = { package = "gpui-pre-sum-tree", version = "0.3.5" }
|
||||
gpui_tokio = { path = "crates/gpui_tokio" }
|
||||
gpui-base = "0.6.1"
|
||||
```
|
||||
|
||||
Because of the `package =` alias, `use gpui::…` and `use gpui_platform::…` keep
|
||||
compiling unchanged. `gpui_web` moves from `web/Cargo.toml` into the workspace table
|
||||
with the rest.
|
||||
compiling unchanged. The aliases match the ones `gpui-pre` uses internally, and
|
||||
`gpui_web` moved out of `web/Cargo.toml` into this table with the rest.
|
||||
|
||||
The only alternative — leaving the workspace on zed's git `gpui` and redirecting
|
||||
`gpui-base`'s dependency to it — means vendoring `gpui-base` and owning its source.
|
||||
That is a fork, and this plan deliberately avoids it.
|
||||
|
||||
`gpui_tokio` is the one missing piece: longbridge does not republish it, and
|
||||
`crates/state` uses it in three places (`init`, `spawn`, `spawn_result`). Either vendor
|
||||
zed's small crate into the workspace, or drop it for `cx.background_spawn`. Decide in
|
||||
phase 0.
|
||||
`gpui_tokio` is the one crate in the family longbridge does not republish. `crates/state`
|
||||
uses it to run `browser-signer-proxy` and `nostr-blossom` work, and the nostr client's
|
||||
reqwest backend needs a Tokio reactor, so the runtime cannot be dropped for
|
||||
`cx.background_spawn`. It is vendored verbatim from zed at `4b47ceb` into
|
||||
`crates/gpui_tokio` (Apache-2.0, ~100 lines), which is the smallest change that keeps
|
||||
the existing behaviour.
|
||||
|
||||
`gpui-base` and `gpui-pre` move together on minor versions (`0.6.x` requires `0.3.x`);
|
||||
bump both in the same change.
|
||||
|
||||
## Phases
|
||||
|
||||
### Phase 0 — move `gpui` onto the `gpui-pre` package (manifest only)
|
||||
### Phase 0 — move `gpui` onto the `gpui-pre` package (manifest only) — landed
|
||||
|
||||
Point the workspace's GPUI entries at the published `gpui-pre` crates and fix whatever
|
||||
the four days of API drift between `4b47ceb` and `zed@d89e9c2` broke. There is no GPUI
|
||||
source to align, patch, or vendor. Confirm that the entry points coop calls still exist
|
||||
in 0.3.5: `gpui_platform::application()`, `web_init()`, and `single_threaded_web()`.
|
||||
Point the workspace's GPUI entries at the published `gpui-pre` crates. There is no GPUI
|
||||
source to align, patch, or vendor.
|
||||
|
||||
Exit criteria: `cargo check` passes for `desktop` and for
|
||||
`cargo check -p coop_web --target wasm32-unknown-unknown`, and the drift fixes are
|
||||
listed in the pull request. The change rewrites the dependency graph, so it stays in a
|
||||
pull request of its own.
|
||||
**No drift had to be fixed.** The gap between the snapshot (`zed@d89e9c2`) and the old
|
||||
pin (`4b47ceb`) is 67 commits, but only 16 touch the GPUI crates, and the public surface
|
||||
only gained names: `ShapedLineCursor`, `MissingGlyphSink`, `MissingGlyph`,
|
||||
`FallbackFontClass`, `MEASUREMENT_VERSION`, dynamic font installation, and inspector
|
||||
registration. Nothing coop used was removed or changed shape, so every `use gpui::…`
|
||||
compiled unchanged. The three entry points coop calls —
|
||||
`gpui_platform::application()`, `gpui_platform::web_init()`, and
|
||||
`gpui_platform::single_threaded_web()` — all exist in 0.3.5.
|
||||
|
||||
### Phase 1 — Wire base, delete dead weight (no visual change)
|
||||
Exit criteria: `cargo check` passes for `desktop`, and the wasm criterion is blocked by
|
||||
a pre-existing bug unrelated to GPUI — see
|
||||
[A pre-existing wasm blocker](#a-pre-existing-wasm-blocker). `cargo check -p theme -p ui
|
||||
--target wasm32-unknown-unknown`, which covers everything this migration touches,
|
||||
passes. The change rewrites the dependency graph, so it stays in a pull request of its
|
||||
own.
|
||||
|
||||
Add `gpui-base`, make `ui::init` call `gpui_base::init(cx)` followed by
|
||||
`theme::sync_base(cx)`, and re-export the base utilities the app already imports under
|
||||
their current names (`ElementExt`, `InteractiveElementExt`, `IndexPath`, `History`,
|
||||
`Disableable`, `Selectable`). Delete `checkbox.rs` and `list/`, which have no
|
||||
consumers, along with `history.rs`, `index_path.rs`, `element_ext.rs`, and `event.rs`
|
||||
once base supplies them. Drop the dependencies this leaves unused.
|
||||
### Phase 1 — Wire base, delete dead weight (no visual change) — landed
|
||||
|
||||
Exit criteria: no diff outside `crates/ui` and `crates/theme`, the app launches, and
|
||||
switching the theme still restyles everything.
|
||||
What changed:
|
||||
|
||||
- `crates/ui` and `crates/theme` take `gpui-base`.
|
||||
- `ui::init` calls `gpui_base::init(cx)` then `theme::sync_base(cx)`; the `list::init(cx)`
|
||||
call went with `list/`.
|
||||
- `ui`'s crate root re-exports `ElementExt`, `IndexPath`, and `InteractiveElementExt`
|
||||
from `gpui_base`, so existing `use ui::{…}` sites are unchanged. In particular
|
||||
`chat_ui`'s `.on_double_click(…)` is served by base's `InteractiveElementExt`, which
|
||||
is the same implementation as the fork's.
|
||||
- `ui::styled` no longer defines `Selectable`, `Disableable`, or `Collapsible`; it
|
||||
re-exports them from `gpui_base::component_traits`. All three are signature-identical
|
||||
to the fork's, so the `impl` blocks in `avatar`, `button`, `input`, and the rest
|
||||
compile untouched. The path is `component_traits` rather than the crate root because
|
||||
`gpui_base::Collapsible` is base's *component* of that name, not the trait.
|
||||
- Deleted: `checkbox.rs` (312), `list/` (1,477), `index_path.rs` (69),
|
||||
`element_ext.rs` (27), `event.rs` (21), `focusable.rs` (39) — 1,945 lines, with no
|
||||
external consumers and a base counterpart for everything except `FocusableCycle`.
|
||||
|
||||
Two corrections this phase produced:
|
||||
|
||||
- **`history.rs` moved to phase 2.** It maps to base's `UndoHistory`, not `History`:
|
||||
base's `History` is navigation (back/forward), while `UndoHistory` is the grouped
|
||||
undo/redo. Swapping it means editing `input/state.rs` — six `ignore` writes become
|
||||
`set_ignoring`, and `Change` loses its `HistoryItem` impl — which is phase 2's file.
|
||||
- **No dependency became unused.** `ropey`, `sum_tree`, `lsp-types`, `tree-sitter`,
|
||||
`regex`, `unicode-segmentation`, `uuid`, and `instant` are all still used by `input/`
|
||||
and `history.rs`, and the deleted files used none of the others, so pruning happens in
|
||||
phase 2. Separately, four dependencies — `common`, `anyhow`, `itertools`, and `smol` —
|
||||
were already unreferenced anywhere in `crates/ui/src` *before* this change. They are
|
||||
left alone here because removing them is unrelated to the migration.
|
||||
|
||||
Exit criteria: no diff outside `crates/ui` and `crates/theme` — met; the only files
|
||||
touched are the two manifests, `ui/src/lib.rs`, `ui/src/styled.rs`, and
|
||||
`theme/src/lib.rs`. `cargo check` and `cargo build` both pass with no warnings, and
|
||||
`theme` and `ui` still compile for `wasm32-unknown-unknown`. The remaining part of the
|
||||
acceptance — launching the app and walking the settings dialog and chat panel — has to
|
||||
be done by hand and has not been run.
|
||||
|
||||
### Phase 2 — `input/` (the largest single win, ~6.9k lines)
|
||||
|
||||
@@ -213,8 +290,10 @@ menu positioning and dismissal on base `Popup`/`Positioner` is optional and late
|
||||
|
||||
There is no UI test suite to lean on, so each phase gets the same treatment:
|
||||
|
||||
- `cargo check` at the workspace root, plus
|
||||
`cargo check -p coop_web --target wasm32-unknown-unknown` for the web target.
|
||||
- `cargo check` and `cargo build` at the workspace root.
|
||||
- `cargo check -p theme -p ui --target wasm32-unknown-unknown`. The web target cannot
|
||||
be checked end to end until the pre-existing blocker below is fixed, so the migrated
|
||||
crates are checked directly.
|
||||
- Launch the app and walk the surfaces the phase touched. The settings dialog is the
|
||||
densest single smoke surface (Button, GroupBox, Switch, Input, DropdownMenu,
|
||||
PopupMenuItem), followed by the chat panel and the sidebar.
|
||||
@@ -223,6 +302,22 @@ There is no UI test suite to lean on, so each phase gets the same treatment:
|
||||
because base has no equivalent (`set_loading` is the known candidate), list it in the
|
||||
pull request.
|
||||
|
||||
### A pre-existing wasm blocker
|
||||
|
||||
`cargo check -p coop_web --target wasm32-unknown-unknown` fails while compiling
|
||||
`errno 0.3.14`, which refuses `wasm32-unknown-unknown`. The path is
|
||||
`coop_web → workspace → browser-signer-proxy → smol → async-io → rustix → errno`, none
|
||||
of which involves GPUI. `crates/workspace/Cargo.toml` declares `browser-signer-proxy`,
|
||||
but nothing under `crates/workspace/src` references it; the crate is only used by
|
||||
`crates/state`, where it is already gated `#[cfg(not(target_arch = "wasm32"))]`.
|
||||
|
||||
Every version on that path (`errno 0.3.14`, `rustix 1.1.5`, `async-io 2.6.0`,
|
||||
`smol 2.0.2`) is identical before and after phase 0, and no file on it is part of this
|
||||
work, so the web build was already broken. The remedy is deleting that one stale
|
||||
dependency line, but that is unrelated to the migration and is deliberately left out.
|
||||
Until it is done, read the wasm exit criterion for phases 1-4 as "`theme` and `ui`
|
||||
compile for `wasm32-unknown-unknown`".
|
||||
|
||||
## Risks and non-goals
|
||||
|
||||
- **Snapshot lag.** The `gpui-pre` package is a republished snapshot, so it trails zed
|
||||
@@ -236,21 +331,24 @@ There is no UI test suite to lean on, so each phase gets the same treatment:
|
||||
- **Two `Theme` globals.** Confine `gpui_base::Theme` to `theme::sync_base` and
|
||||
`crates/ui` internals; application code keeps using `theme::ActiveTheme`. Avoid
|
||||
importing both `Theme` types into one file.
|
||||
- **`gpui_tokio`** has to be vendored or dropped (phase 0).
|
||||
- **`gpui_tokio` is vendored, not ours.** `crates/gpui_tokio` is zed's crate kept
|
||||
verbatim at `crates/gpui_tokio/src/lib.rs` because the `gpui-pre` family does not
|
||||
publish it and the nostr client needs a Tokio reactor. Re-sync or delete it if
|
||||
longbridge ever ships an equivalent.
|
||||
- **Non-goals:** adopting `gpui-component`, migrating dock/tab/menu, rewriting the
|
||||
self-contained pieces (`icon`, `kbd`, `divider`, `skeleton`, `group_box`,
|
||||
`indicator`), and changing any color, radius, or spacing value.
|
||||
|
||||
## Pull request sequence
|
||||
|
||||
| PR | Content | Touches outside `crates/ui` |
|
||||
| --- | --- | --- |
|
||||
| 1 | Phase 0: move `gpui` to the `gpui-pre` package, fix drift | `Cargo.toml`, possibly `crates/state` |
|
||||
| 2 | Phase 1: base wiring, `sync_base`, deletions | none |
|
||||
| 3 | Phase 2: input | none, or the named gaps above |
|
||||
| 4 | Phase 3: popover, modal, notification, tooltip | none |
|
||||
| 5–10 | Phase 4: one leaf module each | none |
|
||||
| later | Phase 5: dock, as its own plan | `crates/workspace`, `crates/chat_ui` |
|
||||
| PR | Content | Touches outside `crates/ui` | Status |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | Phase 0: `gpui` moves to the `gpui-pre` package, `gpui_tokio` vendored | root `Cargo.toml`, `Cargo.lock`, `web/Cargo.toml`, new `crates/gpui_tokio`; `crates/state` needed no edit | landed |
|
||||
| 2 | Phase 1: base wiring, `sync_base`, deletions | `crates/theme` | landed |
|
||||
| 3 | Phase 2: input, plus `history.rs` → `UndoHistory` and the `ropey`/`sum_tree`/… pruning | none, or the named gaps | not started |
|
||||
| 4 | Phase 3: popover, modal, notification, tooltip | none | not started |
|
||||
| 5–10 | Phase 4: one leaf module each | none | not started |
|
||||
| later | Phase 5: dock, as its own plan | `crates/workspace`, `crates/chat_ui` | not started |
|
||||
|
||||
The end state: the application keeps its design system and its call sites, `crates/ui`
|
||||
shrinks by roughly half, and the parts that are genuinely hard — text editing,
|
||||
|
||||
+1
-1
@@ -21,7 +21,7 @@ person = { path = "../crates/person" }
|
||||
|
||||
gpui.workspace = true
|
||||
gpui_platform.workspace = true
|
||||
gpui_web = { git = "https://github.com/zed-industries/zed" }
|
||||
gpui_web.workspace = true
|
||||
|
||||
log.workspace = true
|
||||
instant = { workspace = true, features = ["wasm-bindgen"] }
|
||||
|
||||
Reference in New Issue
Block a user