This commit is contained in:
2026-09-17 15:50:53 +07:00
parent 6c6f2b57f2
commit 7118565b4d
23 changed files with 942 additions and 2438 deletions
Generated
+587 -386
View File
File diff suppressed because it is too large Load Diff
+15 -8
View File
@@ -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" }
+11
View File
@@ -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"] }
+102
View File
@@ -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()
}
}
+1
View File
@@ -6,6 +6,7 @@ publish.workspace = true
[dependencies]
gpui.workspace = true
gpui-base.workspace = true
anyhow.workspace = true
log.workspace = true
serde.workspace = true
+61
View File
@@ -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);
}
}
+2 -1
View File
@@ -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
-312
View File
@@ -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);
}
})
}),
)
}
}
-27
View File
@@ -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 {}
-21
View File
@@ -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> {}
-39
View File
@@ -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();
}
}
-69
View File
@@ -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
View File
@@ -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);
-221
View File
@@ -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;
}
}
-171
View File
@@ -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>>) {}
}
-747
View File
@@ -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())
}
}
-226
View File
@@ -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
}
})
}
}
-34
View File
@@ -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)
}
}
-28
View File
@@ -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,
}
}
}
-50
View File
@@ -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
View File
@@ -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
View File
@@ -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 |
| 510 | 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 |
| 510 | 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
View File
@@ -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"] }