update dock

This commit is contained in:
2026-09-17 18:44:26 +07:00
parent 2faba843a7
commit e9b5441104
15 changed files with 977 additions and 4014 deletions
+1 -2
View File
@@ -55,8 +55,7 @@ pub fn init(cx: &mut App) {
/// 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.
/// mapping would be invented rather than derived.
///
/// This is a no-op before the coop theme global exists; [`Theme::change`] is the
/// authoritative hook that keeps the projection current.
-12
View File
@@ -1,12 +0,0 @@
use gpui::{actions, Action};
use serde::Deserialize;
/// Define a custom confirm action
#[derive(Clone, Action, PartialEq, Eq, Deserialize)]
#[action(namespace = list, no_json)]
pub struct Confirm {
/// Is confirm with secondary.
pub secondary: bool,
}
actions!(ui, [Cancel, SelectUp, SelectDown, SelectLeft, SelectRight]);
-450
View File
@@ -1,450 +0,0 @@
use std::ops::Deref;
use std::sync::Arc;
use gpui::prelude::FluentBuilder as _;
use gpui::{
App, AppContext, Axis, Context, Element, Empty, Entity, IntoElement, MouseMoveEvent,
MouseUpEvent, ParentElement as _, Pixels, Point, Render, Style, StyleRefinement, Styled as _,
WeakEntity, Window, div, px,
};
use gpui_base::Side;
use super::{DockArea, DockItem};
use crate::StyledExt;
use crate::dock::panel::PanelView;
use crate::dock::tab_panel::TabPanel;
use crate::resizable::{PANEL_MIN_SIZE, resize_handle, resize_handle_appearance};
#[derive(Clone)]
struct ResizePanel;
impl Render for ResizePanel {
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
Empty
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DockPlacement {
Center,
Left,
Bottom,
Right,
}
impl DockPlacement {
fn axis(&self) -> Axis {
match self {
Self::Left | Self::Right => Axis::Horizontal,
Self::Bottom => Axis::Vertical,
Self::Center => unreachable!(),
}
}
pub fn is_left(&self) -> bool {
matches!(self, Self::Left)
}
pub fn is_bottom(&self) -> bool {
matches!(self, Self::Bottom)
}
pub fn is_right(&self) -> bool {
matches!(self, Self::Right)
}
/// Base positions the handle against the window edge for the left dock only,
/// so every other placement is the same to it as the right one.
fn side(&self) -> Side {
if self.is_left() {
Side::Left
} else {
Side::Right
}
}
}
/// The Dock is a fixed container that places at left, bottom, right of the Windows.
///
/// This is unlike Panel, it can't be move or add any other panel.
pub struct Dock {
pub(super) placement: DockPlacement,
dock_area: WeakEntity<DockArea>,
/// Dock layout
pub(crate) panel: DockItem,
/// The size is means the width or height of the Dock, if the placement is left or right, the size is width, otherwise the size is height.
pub(super) size: Pixels,
/// Whether the Dock is open
pub(super) open: bool,
/// Whether the Dock is collapsible, default: true
pub(super) collapsible: bool,
/// Whether the Dock is resizing
resizing: bool,
}
impl Dock {
pub(crate) fn new(
dock_area: WeakEntity<DockArea>,
placement: DockPlacement,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let panel = cx.new(|cx| {
let mut tab = TabPanel::new(None, dock_area.clone(), window, cx);
tab.closable = true;
tab
});
let panel = DockItem::Tabs {
items: Vec::new(),
active_ix: 0,
view: panel.clone(),
};
Self::subscribe_panel_events(dock_area.clone(), &panel, window, cx);
Self {
placement,
dock_area,
panel,
open: true,
collapsible: true,
size: px(200.0),
resizing: false,
}
}
pub fn left(
dock_area: WeakEntity<DockArea>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
Self::new(dock_area, DockPlacement::Left, window, cx)
}
pub fn bottom(
dock_area: WeakEntity<DockArea>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
Self::new(dock_area, DockPlacement::Bottom, window, cx)
}
pub fn right(
dock_area: WeakEntity<DockArea>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
Self::new(dock_area, DockPlacement::Right, window, cx)
}
/// Update the Dock to be collapsible or not.
///
/// And if the Dock is not collapsible, it will be open.
pub fn set_collapsible(
&mut self,
collapsible: bool,
_window: &mut Window,
cx: &mut Context<Self>,
) {
self.collapsible = collapsible;
if !collapsible {
self.open = true
}
cx.notify();
}
fn subscribe_panel_events(
dock_area: WeakEntity<DockArea>,
panel: &DockItem,
window: &mut Window,
cx: &mut App,
) {
match panel {
DockItem::Tabs { view, .. } => {
window.defer(cx, {
let view = view.clone();
move |window, cx| {
_ = dock_area.update(cx, |this, cx| {
this.subscribe_panel(&view, window, cx);
});
}
});
}
DockItem::Split { items, view, .. } => {
for item in items {
Self::subscribe_panel_events(dock_area.clone(), item, window, cx);
}
window.defer(cx, {
let view = view.clone();
move |window, cx| {
_ = dock_area.update(cx, |this, cx| {
this.subscribe_panel(&view, window, cx);
});
}
});
}
DockItem::Panel { .. } => {
// Not supported
}
}
}
pub fn set_panel(&mut self, panel: DockItem, _window: &mut Window, cx: &mut Context<Self>) {
self.panel = panel;
cx.notify();
}
pub fn is_open(&self) -> bool {
self.open
}
pub fn toggle_open(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.set_open(!self.open, window, cx);
}
/// Returns the size of the Dock, the size is means the width or height of
/// the Dock, if the placement is left or right, the size is width,
/// otherwise the size is height.
pub fn size(&self) -> Pixels {
self.size
}
/// Set the size of the Dock.
pub fn set_size(&mut self, size: Pixels, _window: &mut Window, cx: &mut Context<Self>) {
self.size = size.max(PANEL_MIN_SIZE);
cx.notify();
}
/// Set the open state of the Dock.
pub fn set_open(&mut self, open: bool, window: &mut Window, cx: &mut Context<Self>) {
self.open = open;
let item = self.panel.clone();
// Use defer_in (not window.defer) so the callback is cancelled
// if this Dock entity is dropped before the deferred frame runs.
cx.defer_in(window, move |_, window, cx| {
item.set_collapsed(!open, window, cx);
});
cx.notify();
}
/// Add item to the Dock.
pub fn add_panel(
&mut self,
panel: Arc<dyn PanelView>,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.panel.add_panel(panel, &self.dock_area, window, cx);
cx.notify();
}
fn render_resize_handle(
&mut self,
_window: &mut Window,
cx: &mut Context<Self>,
) -> impl IntoElement {
let axis = self.placement.axis();
let view = cx.entity().clone();
resize_handle("resize-handle", axis)
.placement(self.placement.side())
.with_appearance(resize_handle_appearance())
.on_drag(ResizePanel {}, move |info, _, _, cx| {
cx.stop_propagation();
view.update(cx, |view, _cx| {
view.resizing = true;
});
cx.new(|_| info.deref().clone())
})
}
fn resize(
&mut self,
mouse_position: Point<Pixels>,
_window: &mut Window,
cx: &mut Context<Self>,
) {
if !self.resizing {
return;
}
let dock_area = self
.dock_area
.upgrade()
.expect("DockArea is missing")
.read(cx);
let area_bounds = dock_area.bounds;
let mut left_dock_size = px(0.0);
let mut right_dock_size = px(0.0);
// Get the size of the left dock if it's open and not the current dock
if let Some(left_dock) = &dock_area.left_dock
&& left_dock.entity_id() != cx.entity().entity_id()
{
let left_dock_read = left_dock.read(cx);
if left_dock_read.is_open() {
left_dock_size = left_dock_read.size;
}
}
// Get the size of the right dock if it's open and not the current dock
if let Some(right_dock) = &dock_area.right_dock
&& right_dock.entity_id() != cx.entity().entity_id()
{
let right_dock_read = right_dock.read(cx);
if right_dock_read.is_open() {
right_dock_size = right_dock_read.size;
}
}
let size = match self.placement {
DockPlacement::Left => mouse_position.x - area_bounds.left(),
DockPlacement::Right => area_bounds.right() - mouse_position.x,
DockPlacement::Bottom => area_bounds.bottom() - mouse_position.y,
DockPlacement::Center => unreachable!(),
};
match self.placement {
DockPlacement::Left => {
let max_size = area_bounds.size.width - PANEL_MIN_SIZE - right_dock_size;
self.size = size.clamp(PANEL_MIN_SIZE, max_size);
}
DockPlacement::Right => {
let max_size = area_bounds.size.width - PANEL_MIN_SIZE - left_dock_size;
self.size = size.clamp(PANEL_MIN_SIZE, max_size);
}
DockPlacement::Bottom => {
let max_size = area_bounds.size.height - PANEL_MIN_SIZE;
self.size = size.clamp(PANEL_MIN_SIZE, max_size);
}
DockPlacement::Center => unreachable!(),
}
cx.notify();
}
fn done_resizing(&mut self, _window: &mut Window, _cx: &mut Context<Self>) {
self.resizing = false;
}
}
impl Render for Dock {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl gpui::IntoElement {
if !self.open && !self.placement.is_bottom() {
return div();
}
let cache_style = StyleRefinement::default().absolute().size_full();
div()
.relative()
.overflow_hidden()
.map(|this| match self.placement {
DockPlacement::Left | DockPlacement::Right => this.h_flex().h_full().w(self.size),
DockPlacement::Bottom => this.w_full().h(self.size),
DockPlacement::Center => unreachable!(),
})
// Bottom Dock should keep the title bar, then user can click the Toggle button
.when(!self.open && self.placement.is_bottom(), |this| {
this.h(px(29.))
})
.map(|this| match &self.panel {
DockItem::Split { view, .. } => this.child(view.clone()),
DockItem::Tabs { view, .. } => this.child(view.clone()),
DockItem::Panel { view, .. } => this.child(view.clone().view().cached(cache_style)),
})
.child(self.render_resize_handle(window, cx))
.child(DockElement {
view: cx.entity().clone(),
})
}
}
struct DockElement {
view: Entity<Dock>,
}
impl IntoElement for DockElement {
type Element = Self;
fn into_element(self) -> Self::Element {
self
}
}
impl Element for DockElement {
type PrepaintState = ();
type RequestLayoutState = ();
fn id(&self) -> Option<gpui::ElementId> {
None
}
fn source_location(&self) -> Option<&'static std::panic::Location<'static>> {
None
}
fn request_layout(
&mut self,
_: Option<&gpui::GlobalElementId>,
_: Option<&gpui::InspectorElementId>,
window: &mut gpui::Window,
cx: &mut App,
) -> (gpui::LayoutId, Self::RequestLayoutState) {
(window.request_layout(Style::default(), None, cx), ())
}
fn prepaint(
&mut self,
_: Option<&gpui::GlobalElementId>,
_: Option<&gpui::InspectorElementId>,
_: gpui::Bounds<Pixels>,
_: &mut Self::RequestLayoutState,
_window: &mut Window,
_cx: &mut App,
) -> Self::PrepaintState {
}
fn paint(
&mut self,
_: Option<&gpui::GlobalElementId>,
_: Option<&gpui::InspectorElementId>,
_: gpui::Bounds<Pixels>,
_: &mut Self::RequestLayoutState,
_: &mut Self::PrepaintState,
window: &mut gpui::Window,
cx: &mut App,
) {
window.on_mouse_event({
let view = self.view.clone();
let is_resizing = view.read(cx).resizing;
move |e: &MouseMoveEvent, phase, window, cx| {
if !is_resizing {
return;
}
if !phase.bubble() {
return;
}
view.update(cx, |view, cx| view.resize(e.position, window, cx))
}
});
// When any mouse up, stop dragging
window.on_mouse_event({
let view = self.view.clone();
move |_: &MouseUpEvent, phase, window, cx| {
if phase.bubble() {
view.update(cx, |view, cx| view.done_resizing(window, cx));
}
}
})
}
}
+824 -728
View File
File diff suppressed because it is too large Load Diff
+85 -8
View File
@@ -1,7 +1,11 @@
use std::any::Any;
use std::sync::Arc;
use gpui::{
AnyElement, AnyView, App, Element, Entity, EventEmitter, FocusHandle, Focusable, Render,
SharedString, Window,
};
use gpui_base::dock::{PanelId, PanelState};
use crate::button::Button;
use crate::menu::PopupMenu;
@@ -13,14 +17,6 @@ pub enum PanelEvent {
LayoutChanged,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PanelStyle {
/// Display the TabBar when there are multiple tabs, otherwise display the simple title.
Default,
/// Always display the tab bar.
TabBar,
}
pub trait Panel: EventEmitter<PanelEvent> + Render + Focusable {
/// The name of the panel used to serialize, deserialize and identify the panel.
///
@@ -156,3 +152,84 @@ impl PartialEq for dyn PanelView {
self.view() == other.view()
}
}
#[derive(Clone)]
pub struct PanelHandle {
id: PanelId,
panel: Arc<dyn PanelView>,
}
impl PanelHandle {
pub fn new<P: Panel>(panel: Entity<P>) -> Self {
Self {
id: PanelId::from(panel.entity_id()),
panel: Arc::new(panel),
}
}
/// Recover the coop handle behind one of base's.
pub fn of(panel: &Arc<dyn gpui_base::dock::PanelView>) -> Option<&Self> {
panel.as_any().downcast_ref::<Self>()
}
/// The coop panel behind this handle.
pub fn panel(&self) -> &Arc<dyn PanelView> {
&self.panel
}
}
impl gpui_base::dock::PanelView for PanelHandle {
fn panel_name(&self, _: &App) -> &'static str {
"CoopPanel"
}
fn panel_id(&self, _: &App) -> PanelId {
self.id
}
fn closable(&self, cx: &App) -> bool {
self.panel.closable(cx)
}
fn zoomable(&self, cx: &App) -> bool {
self.panel.zoomable(cx)
}
fn visible(&self, cx: &App) -> bool {
self.panel.visible(cx)
}
fn set_active(&self, active: bool, _: &mut Window, cx: &mut App) {
self.panel.set_active(active, cx);
}
fn set_zoomed(&self, zoomed: bool, _: &mut Window, cx: &mut App) {
self.panel.set_zoomed(zoomed, cx);
}
fn on_added_to(
&self,
_group: gpui::WeakEntity<gpui_base::dock::TabGroup>,
_: &mut Window,
_: &mut App,
) {
}
fn on_removed(&self, _: &mut Window, _: &mut App) {}
fn view(&self) -> AnyView {
self.panel.view()
}
fn focus_handle(&self, cx: &App) -> FocusHandle {
self.panel.focus_handle(cx)
}
fn dump(&self, cx: &App) -> PanelState {
PanelState::new(self.panel_name(cx))
}
fn as_any(&self) -> &dyn Any {
self
}
}
-388
View File
@@ -1,388 +0,0 @@
use std::sync::Arc;
use gpui::{
App, AppContext, Axis, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable,
IntoElement, ParentElement, Pixels, Render, SharedString, Styled, Subscription, WeakEntity,
Window,
};
use smallvec::SmallVec;
use theme::{AxisExt as _, Placement};
use super::{DockArea, PanelEvent};
use crate::dock::panel::{Panel, PanelView};
use crate::dock::tab_panel::TabPanel;
use crate::h_flex;
use crate::resizable::{
PANEL_MIN_SIZE, ResizablePanelEvent, ResizablePanelGroup, ResizableState, resizable_panel,
resize_handle_appearance,
};
pub struct StackPanel {
pub(super) parent: Option<WeakEntity<StackPanel>>,
pub(super) axis: Axis,
focus_handle: FocusHandle,
pub(crate) panels: SmallVec<[Arc<dyn PanelView>; 2]>,
state: Entity<ResizableState>,
_subscriptions: Vec<Subscription>,
}
impl Panel for StackPanel {
fn panel_id(&self) -> SharedString {
"StackPanel".into()
}
fn title(&self, _cx: &App) -> gpui::AnyElement {
"StackPanel".into_any_element()
}
}
impl StackPanel {
pub fn new(axis: Axis, window: &mut Window, cx: &mut Context<Self>) -> Self {
let state = cx.new(|_| ResizableState::default());
// Bubble up the resize event.
let subscriptions =
vec![
cx.subscribe_in(&state, window, |_, _, _: &ResizablePanelEvent, _, cx| {
cx.emit(PanelEvent::LayoutChanged)
}),
];
Self {
axis,
parent: None,
focus_handle: cx.focus_handle(),
panels: SmallVec::new(),
state,
_subscriptions: subscriptions,
}
}
/// The first level of the stack panel is root, will not have a parent.
fn is_root(&self) -> bool {
self.parent.is_none()
}
/// Return true if self or parent only have last panel.
pub fn is_last_panel(&self, cx: &App) -> bool {
if self.panels.len() > 1 {
return false;
}
if let Some(parent) = &self.parent
&& let Some(parent) = parent.upgrade()
{
return parent.read(cx).is_last_panel(cx);
}
true
}
pub fn panels_len(&self) -> usize {
self.panels.len()
}
/// Return the index of the panel.
pub fn index_of_panel(&self, panel: Arc<dyn PanelView>) -> Option<usize> {
self.panels.iter().position(|p| p == &panel)
}
/// Add a panel at the end of the stack.
pub fn add_panel(
&mut self,
panel: Arc<dyn PanelView>,
size: Option<Pixels>,
dock_area: WeakEntity<DockArea>,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.insert_panel(panel, self.panels.len(), size, dock_area, window, cx);
}
pub fn add_panel_at(
&mut self,
panel: Arc<dyn PanelView>,
placement: Placement,
size: Option<Pixels>,
dock_area: WeakEntity<DockArea>,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.insert_panel_at(
panel,
self.panels_len(),
placement,
size,
dock_area,
window,
cx,
);
}
#[allow(clippy::too_many_arguments)]
pub fn insert_panel_at(
&mut self,
panel: Arc<dyn PanelView>,
ix: usize,
placement: Placement,
size: Option<Pixels>,
dock_area: WeakEntity<DockArea>,
window: &mut Window,
cx: &mut Context<Self>,
) {
match placement {
Placement::Top | Placement::Left => {
self.insert_panel_before(panel, ix, size, dock_area, window, cx)
}
Placement::Right | Placement::Bottom => {
self.insert_panel_after(panel, ix, size, dock_area, window, cx)
}
}
}
/// Insert a panel at the index.
pub fn insert_panel_before(
&mut self,
panel: Arc<dyn PanelView>,
ix: usize,
size: Option<Pixels>,
dock_area: WeakEntity<DockArea>,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.insert_panel(panel, ix, size, dock_area, window, cx);
}
/// Insert a panel after the index.
pub fn insert_panel_after(
&mut self,
panel: Arc<dyn PanelView>,
ix: usize,
size: Option<Pixels>,
dock_area: WeakEntity<DockArea>,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.insert_panel(panel, ix + 1, size, dock_area, window, cx);
}
fn insert_panel(
&mut self,
panel: Arc<dyn PanelView>,
ix: usize,
size: Option<Pixels>,
dock_area: WeakEntity<DockArea>,
window: &mut Window,
cx: &mut Context<Self>,
) {
// If the panel is already in the stack, return.
if self.index_of_panel(panel.clone()).is_some() {
return;
}
let view = cx.entity().clone();
window.defer(cx, {
let panel = panel.clone();
move |window, cx| {
// If the panel is a TabPanel, set its parent to this.
if let Ok(tab_panel) = panel.view().downcast::<TabPanel>() {
tab_panel.update(cx, |tab_panel, _| tab_panel.set_parent(view.downgrade()));
} else if let Ok(stack_panel) = panel.view().downcast::<Self>() {
stack_panel.update(cx, |stack_panel, _| {
stack_panel.parent = Some(view.downgrade())
});
}
// Subscribe to the panel's layout change event.
_ = dock_area.update(cx, |this, cx| {
if let Ok(tab_panel) = panel.view().downcast::<TabPanel>() {
this.subscribe_panel(&tab_panel, window, cx);
} else if let Ok(stack_panel) = panel.view().downcast::<Self>() {
this.subscribe_panel(&stack_panel, window, cx);
}
});
}
});
let ix = if ix > self.panels.len() {
self.panels.len()
} else {
ix
};
// Get avg size of all panels to insert new panel, if size is None.
let size = match size {
Some(size) => size,
None => {
let state = self.state.read(cx);
(state.container_size() / (state.sizes().len() + 1) as f32).max(PANEL_MIN_SIZE)
}
};
// Insert panel
self.panels.insert(ix, panel.clone());
// Update resizable state
self.state.update(cx, |state, cx| {
state.insert_panel(Some(size), Some(ix), cx);
});
cx.emit(PanelEvent::LayoutChanged);
cx.notify();
}
/// Remove panel from the stack.
///
/// If `ix` is not found, do nothing.
pub fn remove_panel(
&mut self,
panel: Arc<dyn PanelView>,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(ix) = self.index_of_panel(panel.clone()) else {
return;
};
self.panels.remove(ix);
self.state.update(cx, |state, cx| {
state.remove_panel(ix, cx);
});
cx.emit(PanelEvent::LayoutChanged);
self.remove_self_if_empty(window, cx);
}
/// Replace the old panel with the new panel at same index.
pub fn replace_panel(
&mut self,
old_panel: Arc<dyn PanelView>,
new_panel: Entity<StackPanel>,
_window: &mut Window,
cx: &mut Context<Self>,
) {
if let Some(ix) = self.index_of_panel(old_panel.clone()) {
self.panels[ix] = Arc::new(new_panel.clone());
self.state.update(cx, |state, cx| {
state.reset_panel(ix, cx);
});
cx.emit(PanelEvent::LayoutChanged);
}
}
/// If children is empty, remove self from parent view.
pub fn remove_self_if_empty(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self.is_root() {
return;
}
if !self.panels.is_empty() {
return;
}
let view = cx.entity().clone();
if let Some(parent) = self.parent.as_ref() {
_ = parent.update(cx, |parent, cx| {
parent.remove_panel(Arc::new(view.clone()), window, cx);
});
}
cx.emit(PanelEvent::LayoutChanged);
cx.notify();
}
/// Find the first top left in the stack.
pub fn left_top_tab_panel(&self, check_parent: bool, cx: &App) -> Option<Entity<TabPanel>> {
if check_parent
&& let Some(parent) = self.parent.as_ref().and_then(|parent| parent.upgrade())
&& let Some(panel) = parent.read(cx).left_top_tab_panel(true, cx)
{
return Some(panel);
}
let first_panel = self.panels.first();
if let Some(view) = first_panel {
if let Ok(tab_panel) = view.view().downcast::<TabPanel>() {
Some(tab_panel)
} else if let Ok(stack_panel) = view.view().downcast::<StackPanel>() {
stack_panel.read(cx).left_top_tab_panel(false, cx)
} else {
None
}
} else {
None
}
}
/// Find the first top right in the stack.
pub fn right_top_tab_panel(&self, check_parent: bool, cx: &App) -> Option<Entity<TabPanel>> {
if check_parent
&& let Some(parent) = self.parent.as_ref().and_then(|parent| parent.upgrade())
&& let Some(panel) = parent.read(cx).right_top_tab_panel(true, cx)
{
return Some(panel);
}
let panel = if self.axis.is_vertical() {
self.panels.first()
} else {
self.panels.last()
};
if let Some(view) = panel {
if let Ok(tab_panel) = view.view().downcast::<TabPanel>() {
Some(tab_panel)
} else if let Ok(stack_panel) = view.view().downcast::<StackPanel>() {
stack_panel.read(cx).right_top_tab_panel(false, cx)
} else {
None
}
} else {
None
}
}
/// Remove all panels from the stack.
pub fn remove_all_panels(&mut self, _: &mut Window, cx: &mut Context<Self>) {
self.panels.clear();
self.state.update(cx, |state, cx| {
state.clear();
cx.notify();
});
}
/// Change the axis of the stack panel.
pub fn set_axis(&mut self, axis: Axis, _: &mut Window, cx: &mut Context<Self>) {
self.axis = axis;
cx.notify();
}
}
impl Focusable for StackPanel {
fn focus_handle(&self, _cx: &App) -> FocusHandle {
self.focus_handle.clone()
}
}
impl EventEmitter<PanelEvent> for StackPanel {}
impl EventEmitter<DismissEvent> for StackPanel {}
impl Render for StackPanel {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
h_flex().size_full().overflow_hidden().child(
ResizablePanelGroup::new("stack-panel-group")
.with_state(&self.state)
.with_handle_appearance(resize_handle_appearance())
.axis(self.axis)
.children(self.panels.clone().into_iter().map(|panel| {
resizable_panel()
.child(panel.view())
.visible(panel.visible(cx))
})),
)
}
}
File diff suppressed because it is too large Load Diff
-1
View File
@@ -8,7 +8,6 @@ pub use window_ext::*;
pub use crate::Disableable;
pub mod actions;
pub mod animation;
pub mod avatar;
pub mod button;
-257
View File
@@ -1,257 +0,0 @@
use gpui::prelude::FluentBuilder;
use gpui::{
App, AppContext as _, ClickEvent, Context, DismissEvent, Entity, Focusable,
InteractiveElement as _, IntoElement, KeyBinding, MouseButton, OwnedMenu, ParentElement,
Render, SharedString, StatefulInteractiveElement, Styled, Subscription, Window, anchored,
deferred, div, px,
};
use crate::actions::{Cancel, SelectLeft, SelectRight};
use crate::button::{Button, ButtonVariants};
use crate::menu::PopupMenu;
use crate::{Selectable, Sizable, h_flex};
const CONTEXT: &str = "AppMenuBar";
pub fn init(cx: &mut App) {
cx.bind_keys([
KeyBinding::new("escape", Cancel, Some(CONTEXT)),
KeyBinding::new("left", SelectLeft, Some(CONTEXT)),
KeyBinding::new("right", SelectRight, Some(CONTEXT)),
]);
}
/// The application menu bar, for Windows and Linux.
pub struct AppMenuBar {
menus: Vec<Entity<AppMenu>>,
selected_index: Option<usize>,
}
impl AppMenuBar {
/// Create a new app menu bar.
pub fn new(cx: &mut App) -> Entity<Self> {
cx.new(|cx| {
let mut this = Self {
selected_index: None,
menus: Vec::new(),
};
this.reload(cx);
this
})
}
/// Reload the menus from the app.
pub fn reload(&mut self, cx: &mut Context<Self>) {
let menu_bar = cx.entity();
self.menus = cx
.get_menus()
.unwrap_or_default()
.iter()
.enumerate()
.map(|(ix, menu)| AppMenu::new(ix, menu, menu_bar.clone(), cx))
.collect();
self.selected_index = None;
cx.notify();
}
fn on_move_left(&mut self, _: &SelectLeft, window: &mut Window, cx: &mut Context<Self>) {
let Some(selected_index) = self.selected_index else {
return;
};
let new_ix = if selected_index == 0 {
self.menus.len().saturating_sub(1)
} else {
selected_index.saturating_sub(1)
};
self.set_selected_index(Some(new_ix), window, cx);
}
fn on_move_right(&mut self, _: &SelectRight, window: &mut Window, cx: &mut Context<Self>) {
let Some(selected_index) = self.selected_index else {
return;
};
let new_ix = if selected_index + 1 >= self.menus.len() {
0
} else {
selected_index + 1
};
self.set_selected_index(Some(new_ix), window, cx);
}
fn on_cancel(&mut self, _: &Cancel, window: &mut Window, cx: &mut Context<Self>) {
self.set_selected_index(None, window, cx);
}
fn set_selected_index(&mut self, ix: Option<usize>, _: &mut Window, cx: &mut Context<Self>) {
self.selected_index = ix;
cx.notify();
}
#[inline]
fn has_activated_menu(&self) -> bool {
self.selected_index.is_some()
}
}
impl Render for AppMenuBar {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
h_flex()
.id("app-menu-bar")
.key_context(CONTEXT)
.on_action(cx.listener(Self::on_move_left))
.on_action(cx.listener(Self::on_move_right))
.on_action(cx.listener(Self::on_cancel))
.size_full()
.gap_x_1()
.overflow_x_scroll()
.children(self.menus.clone())
}
}
/// A menu in the menu bar.
pub(super) struct AppMenu {
menu_bar: Entity<AppMenuBar>,
ix: usize,
name: SharedString,
menu: OwnedMenu,
popup_menu: Option<Entity<PopupMenu>>,
_subscription: Option<Subscription>,
}
impl AppMenu {
pub(super) fn new(
ix: usize,
menu: &OwnedMenu,
menu_bar: Entity<AppMenuBar>,
cx: &mut App,
) -> Entity<Self> {
let name = menu.name.clone();
cx.new(|_| Self {
ix,
menu_bar,
name,
menu: menu.clone(),
popup_menu: None,
_subscription: None,
})
}
fn build_popup_menu(
&mut self,
window: &mut Window,
cx: &mut Context<Self>,
) -> Entity<PopupMenu> {
let popup_menu = match self.popup_menu.as_ref() {
None => {
let items = self.menu.items.clone();
let popup_menu = PopupMenu::build(window, cx, |menu, window, cx| {
menu.when_some(window.focused(cx), |this, handle| {
this.action_context(handle)
})
.with_menu_items(items, window, cx)
});
popup_menu.read(cx).focus_handle(cx).focus(window, cx);
self._subscription =
Some(cx.subscribe_in(&popup_menu, window, Self::handle_dismiss));
self.popup_menu = Some(popup_menu.clone());
popup_menu
}
Some(menu) => menu.clone(),
};
let focus_handle = popup_menu.read(cx).focus_handle(cx);
if !focus_handle.contains_focused(window, cx) {
focus_handle.focus(window, cx);
}
popup_menu
}
fn handle_dismiss(
&mut self,
_: &Entity<PopupMenu>,
_: &DismissEvent,
window: &mut Window,
cx: &mut Context<Self>,
) {
self._subscription.take();
self.popup_menu.take();
self.menu_bar.update(cx, |state, cx| {
state.on_cancel(&Cancel, window, cx);
});
}
fn handle_trigger_click(
&mut self,
_: &ClickEvent,
window: &mut Window,
cx: &mut Context<Self>,
) {
let is_selected = self.menu_bar.read(cx).selected_index == Some(self.ix);
self.menu_bar.update(cx, |state, cx| {
let new_ix = if is_selected { None } else { Some(self.ix) };
state.set_selected_index(new_ix, window, cx);
});
}
fn handle_hover(&mut self, hovered: &bool, window: &mut Window, cx: &mut Context<Self>) {
if !*hovered {
return;
}
let has_activated_menu = self.menu_bar.read(cx).has_activated_menu();
if !has_activated_menu {
return;
}
self.menu_bar.update(cx, |state, cx| {
state.set_selected_index(Some(self.ix), window, cx);
});
}
}
impl Render for AppMenu {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let menu_bar = self.menu_bar.read(cx);
let is_selected = menu_bar.selected_index == Some(self.ix);
div()
.id(self.ix)
.relative()
.child(
Button::new("menu")
.small()
.py_0p5()
.compact()
.ghost()
.label(self.name.clone())
.selected(is_selected)
.on_mouse_down(MouseButton::Left, |_, window, cx| {
// Stop propagation to avoid dragging the window.
window.prevent_default();
cx.stop_propagation();
})
.on_click(cx.listener(Self::handle_trigger_click)),
)
.on_hover(cx.listener(Self::handle_hover))
.when(is_selected, |this| {
this.child(deferred(
anchored()
.anchor(gpui::Anchor::TopLeft)
.snap_to_window_with_margin(px(8.))
.child(
div()
.size_full()
.occlude()
.top_1()
.child(self.build_popup_menu(window, cx)),
),
))
})
}
}
-323
View File
@@ -1,323 +0,0 @@
use std::cell::RefCell;
use std::rc::Rc;
use gpui::prelude::FluentBuilder;
use gpui::{
Anchor, AnyElement, App, Context, DismissEvent, Element, ElementId, Entity, Focusable,
GlobalElementId, Hitbox, HitboxBehavior, InspectorElementId, InteractiveElement, IntoElement,
MouseButton, MouseDownEvent, ParentElement, Pixels, Point, StyleRefinement, Styled,
Subscription, Window, anchored, deferred, div, px,
};
use crate::menu::PopupMenu;
/// A extension trait for adding a context menu to an element.
pub trait ContextMenuExt: ParentElement + Styled {
/// Add a context menu to the element.
///
/// This will changed the element to be `relative` positioned, and add a child `ContextMenu` element.
/// Because the `ContextMenu` element is positioned `absolute`, it will not affect the layout of the parent element.
fn context_menu(
self,
f: impl Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu + 'static,
) -> ContextMenu<Self>
where
Self: Sized,
{
// Generate a unique ID based on the element's memory address to ensure
// each context menu has its own state and doesn't share with others
let id = format!("context-menu-{:p}", &self as *const _);
ContextMenu::new(id, self).menu(f)
}
}
impl<E: ParentElement + Styled> ContextMenuExt for E {}
/// A context menu that can be shown on right-click.
pub struct ContextMenu<E: ParentElement + Styled + Sized> {
id: ElementId,
element: Option<E>,
#[allow(clippy::type_complexity)]
menu: Option<Rc<dyn Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu>>,
// This is not in use, just for style refinement forwarding.
_ignore_style: StyleRefinement,
anchor: Anchor,
}
impl<E: ParentElement + Styled> ContextMenu<E> {
/// Create a new context menu with the given ID.
pub fn new(id: impl Into<ElementId>, element: E) -> Self {
Self {
id: id.into(),
element: Some(element),
menu: None,
anchor: Anchor::TopLeft,
_ignore_style: StyleRefinement::default(),
}
}
/// Build the context menu using the given builder function.
#[must_use]
fn menu<F>(mut self, builder: F) -> Self
where
F: Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu + 'static,
{
self.menu = Some(Rc::new(builder));
self
}
fn with_element_state<R>(
&mut self,
id: &GlobalElementId,
window: &mut Window,
cx: &mut App,
f: impl FnOnce(&mut Self, &mut ContextMenuState, &mut Window, &mut App) -> R,
) -> R {
window.with_optional_element_state::<ContextMenuState, _>(
Some(id),
|element_state, window| {
let mut element_state = element_state.unwrap().unwrap_or_default();
let result = f(self, &mut element_state, window, cx);
(result, Some(element_state))
},
)
}
}
impl<E: ParentElement + Styled> ParentElement for ContextMenu<E> {
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
if let Some(element) = &mut self.element {
element.extend(elements);
}
}
}
impl<E: ParentElement + Styled> Styled for ContextMenu<E> {
fn style(&mut self) -> &mut StyleRefinement {
if let Some(element) = &mut self.element {
element.style()
} else {
&mut self._ignore_style
}
}
}
impl<E: ParentElement + Styled + IntoElement + 'static> IntoElement for ContextMenu<E> {
type Element = Self;
fn into_element(self) -> Self::Element {
self
}
}
struct ContextMenuSharedState {
menu_view: Option<Entity<PopupMenu>>,
open: bool,
position: Point<Pixels>,
_subscription: Option<Subscription>,
}
pub struct ContextMenuState {
element: Option<AnyElement>,
shared_state: Rc<RefCell<ContextMenuSharedState>>,
}
impl Default for ContextMenuState {
fn default() -> Self {
Self {
element: None,
shared_state: Rc::new(RefCell::new(ContextMenuSharedState {
menu_view: None,
open: false,
position: Default::default(),
_subscription: None,
})),
}
}
}
impl<E: ParentElement + Styled + IntoElement + 'static> Element for ContextMenu<E> {
type PrepaintState = Hitbox;
type RequestLayoutState = ContextMenuState;
fn id(&self) -> Option<ElementId> {
Some(self.id.clone())
}
fn source_location(&self) -> Option<&'static std::panic::Location<'static>> {
None
}
fn request_layout(
&mut self,
id: Option<&gpui::GlobalElementId>,
_: Option<&gpui::InspectorElementId>,
window: &mut Window,
cx: &mut App,
) -> (gpui::LayoutId, Self::RequestLayoutState) {
let anchor = self.anchor;
self.with_element_state(
id.unwrap(),
window,
cx,
|this, state: &mut ContextMenuState, window, cx| {
let (position, open) = {
let shared_state = state.shared_state.borrow();
(shared_state.position, shared_state.open)
};
let menu_view = state.shared_state.borrow().menu_view.clone();
let mut menu_element = None;
if open {
let has_menu_item = menu_view
.as_ref()
.map(|menu| !menu.read(cx).is_empty())
.unwrap_or(false);
if has_menu_item {
menu_element = Some(
deferred(
anchored().child(
div()
.w(window.bounds().size.width)
.h(window.bounds().size.height)
.on_scroll_wheel(|_, _, cx| {
cx.stop_propagation();
})
.child(
anchored()
.position(position)
.snap_to_window_with_margin(px(8.))
.anchor(anchor)
.when_some(menu_view, |this, menu| {
// Focus the menu, so that can be handle the action.
if !menu
.focus_handle(cx)
.contains_focused(window, cx)
{
menu.focus_handle(cx).focus(window, cx);
}
this.child(menu.clone())
}),
),
),
)
.with_priority(1)
.into_any(),
);
}
}
let mut element = this
.element
.take()
.expect("Element should exists.")
.children(menu_element)
.into_any_element();
let layout_id = element.request_layout(window, cx);
(
layout_id,
ContextMenuState {
element: Some(element),
..Default::default()
},
)
},
)
}
fn prepaint(
&mut self,
_: Option<&gpui::GlobalElementId>,
_: Option<&InspectorElementId>,
bounds: gpui::Bounds<gpui::Pixels>,
request_layout: &mut Self::RequestLayoutState,
window: &mut Window,
cx: &mut App,
) -> Self::PrepaintState {
if let Some(element) = &mut request_layout.element {
element.prepaint(window, cx);
}
window.insert_hitbox(bounds, HitboxBehavior::Normal)
}
fn paint(
&mut self,
id: Option<&gpui::GlobalElementId>,
_: Option<&InspectorElementId>,
_: gpui::Bounds<gpui::Pixels>,
request_layout: &mut Self::RequestLayoutState,
hitbox: &mut Self::PrepaintState,
window: &mut Window,
cx: &mut App,
) {
if let Some(element) = &mut request_layout.element {
element.paint(window, cx);
}
// Take the builder before setting up element state to avoid borrow issues
let builder = self.menu.clone();
self.with_element_state(
id.unwrap(),
window,
cx,
|_view, state: &mut ContextMenuState, window, _| {
let shared_state = state.shared_state.clone();
let hitbox = hitbox.clone();
// When right mouse click, to build content menu, and show it at the mouse position.
window.on_mouse_event(move |event: &MouseDownEvent, phase, window, cx| {
if phase.bubble()
&& event.button == MouseButton::Right
&& hitbox.is_hovered(window)
{
{
let mut shared_state = shared_state.borrow_mut();
// Clear any existing menu view to allow immediate replacement
// Set the new position and open the menu
shared_state.menu_view = None;
shared_state._subscription = None;
shared_state.position = event.position;
shared_state.open = true;
}
// Use defer to build the menu in the next frame, avoiding race conditions
window.defer(cx, {
let shared_state = shared_state.clone();
let builder = builder.clone();
move |window, cx| {
let menu = PopupMenu::build(window, cx, move |menu, window, cx| {
let Some(build) = &builder else {
return menu;
};
build(menu, window, cx)
});
// Set up the subscription for dismiss handling
let _subscription = window.subscribe(&menu, cx, {
let shared_state = shared_state.clone();
move |_, _: &DismissEvent, window, _cx| {
shared_state.borrow_mut().open = false;
window.refresh();
}
});
// Update the shared state with the built menu and subscription
{
let mut state = shared_state.borrow_mut();
state.menu_view = Some(menu.clone());
state._subscription = Some(_subscription);
window.refresh();
}
}
});
}
});
},
);
}
}
-5
View File
@@ -1,17 +1,12 @@
use gpui::App;
mod app_menu_bar;
mod context_menu;
mod dropdown_menu;
mod menu_item;
mod popup_menu;
pub use app_menu_bar::AppMenuBar;
pub use context_menu::{ContextMenu, ContextMenuExt, ContextMenuState};
pub use dropdown_menu::DropdownMenu;
pub use popup_menu::{PopupMenu, PopupMenuItem};
pub(crate) fn init(cx: &mut App) {
app_menu_bar::init(cx);
popup_menu::init(cx);
}
+3 -40
View File
@@ -4,13 +4,12 @@ use gpui::prelude::FluentBuilder;
use gpui::{
Action, Anchor, AnyElement, App, AppContext, Axis, Bounds, ClickEvent, Context, DismissEvent,
Edges, Entity, EventEmitter, FocusHandle, Focusable, Half, InteractiveElement, IntoElement,
KeyBinding, MouseDownEvent, OwnedMenuItem, ParentElement, Pixels, Point, Render, ScrollHandle,
SharedString, StatefulInteractiveElement, Styled, Subscription, WeakEntity, Window, anchored,
div, px, rems,
KeyBinding, MouseDownEvent, ParentElement, Pixels, Point, Render, ScrollHandle, SharedString,
StatefulInteractiveElement, Styled, Subscription, WeakEntity, Window, anchored, div, px, rems,
};
use gpui_base::actions::{Cancel, Confirm, SelectDown, SelectLeft, SelectRight, SelectUp};
use theme::{ActiveTheme, Side};
use crate::actions::{Cancel, Confirm, SelectDown, SelectLeft, SelectRight, SelectUp};
use crate::kbd::Kbd;
use crate::menu::menu_item::MenuItemElement;
use crate::scroll::ScrollableElement;
@@ -682,42 +681,6 @@ impl PopupMenu {
self
}
pub(super) fn with_menu_items<I>(
mut self,
items: impl IntoIterator<Item = I>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self
where
I: Into<OwnedMenuItem>,
{
for item in items {
match item.into() {
OwnedMenuItem::Action {
name,
action,
checked,
..
} => self = self.menu_with_check(name, checked, action.boxed_clone()),
OwnedMenuItem::Separator => {
self = self.separator();
}
OwnedMenuItem::Submenu(submenu) => {
self = self.submenu(submenu.name, window, cx, move |menu, window, cx| {
menu.with_menu_items(submenu.items.clone(), window, cx)
})
}
OwnedMenuItem::SystemMenu(_) => {}
}
}
if self.menu_items.len() > 20 {
self.scrollable = true;
}
self
}
pub(crate) fn active_submenu(&self) -> Option<Entity<PopupMenu>> {
if let Some(ix) = self.selected_index
&& let Some(item) = self.menu_items.get(ix)
+1 -4
View File
@@ -2,7 +2,7 @@ use std::rc::Rc;
use gpui::prelude::FluentBuilder as _;
use gpui::{App, InteractiveElement as _, IntoElement, Pixels, Styled as _, Window, div, px};
pub(crate) use gpui_base::{PANEL_MIN_SIZE, resize_handle};
pub(crate) use gpui_base::resize_handle;
pub use gpui_base::{
ResizablePanel, ResizablePanelEvent, ResizablePanelGroup, ResizableState, h_resizable,
resizable_panel, v_resizable,
@@ -12,9 +12,6 @@ use theme::{ActiveTheme as _, AxisExt as _};
const HANDLE_SIZE: Pixels = px(1.);
/// The coop divider: a 1px line that appears only while the handle is hovered
/// or dragged, tinted from the coop palette. Base's built-in line is always
/// painted and takes its colours from `ResizableTheme`.
pub(crate) fn resize_handle_appearance() -> ResizeHandleRenderer {
Rc::new(
|context: &ResizeHandleContext, _: &mut Window, cx: &mut App| {
+63 -68
View File
@@ -8,7 +8,7 @@ use common::download_dir;
use device::{DeviceEvent, DeviceRegistry};
use gpui::prelude::FluentBuilder;
use gpui::{
Action, App, AppContext, Axis, Context, Entity, InteractiveElement, IntoElement, ParentElement,
Action, App, AppContext, Context, Entity, InteractiveElement, IntoElement, ParentElement,
Render, SharedString, Styled, Subscription, Task, Window, div, px,
};
use nostr_sdk::prelude::*;
@@ -19,7 +19,7 @@ use state::{NostrRegistry, StateEvent};
use theme::{ActiveTheme, SIDEBAR_WIDTH, Theme, ThemeRegistry};
use ui::avatar::Avatar;
use ui::button::{Button, ButtonVariants};
use ui::dock::{ClosePanel, DockArea, DockItem, DockPlacement, PanelView};
use ui::dock::{self, ClosePanel, DockArea, DockLayout, DockPlacement, Panel, PanelHandle};
use ui::menu::{DropdownMenu, PopupMenuItem};
use ui::notification::{Notification, NotificationKind};
use ui::{Icon, IconName, Root, Sizable, TitleBar, WindowExtension, h_flex, v_flex};
@@ -78,7 +78,7 @@ impl Workspace {
let nostr = NostrRegistry::global(cx);
let sidebar = cx.new(|cx| Sidebar::new(window, cx));
let dock = cx.new(|cx| DockArea::new(window, cx));
let dock = dock::dock_area("coop", window, cx);
let mut subscriptions = smallvec![];
@@ -185,20 +185,18 @@ impl Workspace {
}
ChatEvent::OpenRoom(id) => {
if let Some(room) = chat.read(cx).room(id, cx) {
this.dock.update(cx, |this, cx| {
this.add_panel(
Arc::new(chat_ui::init(room, window, cx)),
DockPlacement::Center,
window,
cx,
);
});
this.add_panel_to_dock(
chat_ui::init(room, window, cx),
DockPlacement::Center,
window,
cx,
);
}
}
ChatEvent::CloseRoom(..) => {
this.dock.update(cx, |this, cx| {
this.dock.update(cx, |area, cx| {
// Force focus to the tab panel
this.focus_tab_panel(window, cx);
ui::dock::focus_tab_panel(area, window, cx);
// Dispatch the close panel action
cx.defer_in(window, |_, window, cx| {
@@ -216,14 +214,12 @@ impl Workspace {
);
cx.defer_in(window, |this, window, cx| {
let dock = this.dock.downgrade();
let greeter = Arc::new(greeter::init(window, cx));
let tabs = DockItem::tabs(vec![greeter], None, &dock, window, cx);
let center = DockItem::split(Axis::Vertical, vec![tabs], &dock, window, cx);
let greeter = PanelHandle::new(greeter::init(window, cx));
let center = DockLayout::v_split()
.child(DockLayout::tabs().panel_view(Arc::new(greeter), cx), None);
this.dock.update(cx, |this, cx| {
this.set_center(center, window, cx);
});
this.dock
.update(cx, |area, cx| area.set_center(center, window, cx));
});
Self {
@@ -234,22 +230,36 @@ impl Workspace {
}
}
/// Add panel to the dock
pub fn add_panel<P>(panel: P, placement: DockPlacement, window: &mut Window, cx: &mut App)
where
P: PanelView,
{
/// Add a panel to the dock, from anywhere that has the window but not the
/// workspace.
pub fn add_panel<P: Panel>(
panel: Entity<P>,
placement: DockPlacement,
window: &mut Window,
cx: &mut App,
) {
if let Some(root) = window.root::<Root>().flatten()
&& let Ok(workspace) = root.read(cx).view().clone().downcast::<Self>()
{
workspace.update(cx, |this, cx| {
this.dock.update(cx, |this, cx| {
this.add_panel(Arc::new(panel), placement, window, cx);
});
this.add_panel_to_dock(panel, placement, window, cx)
});
}
}
/// Add a panel to the dock, or focus it if it is already docked.
fn add_panel_to_dock<P: Panel>(
&mut self,
panel: Entity<P>,
placement: DockPlacement,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.dock.update(cx, |area, cx| {
ui::dock::add_panel(area, PanelHandle::new(panel), placement, window, cx)
});
}
/// Handle command events
fn on_command(&mut self, command: &Command, window: &mut Window, cx: &mut Context<Self>) {
match command {
@@ -268,45 +278,32 @@ impl Workspace {
let nostr = NostrRegistry::global(cx);
if let Some(public_key) = nostr.read(cx).current_user() {
self.dock.update(cx, |this, cx| {
this.add_panel(
Arc::new(profile::init(public_key, window, cx)),
DockPlacement::Left,
window,
cx,
);
});
self.add_panel_to_dock(
profile::init(public_key, window, cx),
DockPlacement::Left,
window,
cx,
);
}
}
Command::ShowContactList => {
self.dock.update(cx, |this, cx| {
this.add_panel(
Arc::new(contact_list::init(window, cx)),
DockPlacement::Left,
window,
cx,
);
});
self.add_panel_to_dock(
contact_list::init(window, cx),
DockPlacement::Left,
window,
cx,
);
}
Command::ShowBackup => {
self.dock.update(cx, |this, cx| {
this.add_panel(
Arc::new(backup::init(window, cx)),
DockPlacement::Left,
window,
cx,
);
});
self.add_panel_to_dock(backup::init(window, cx), DockPlacement::Left, window, cx);
}
Command::ShowMessaging => {
self.dock.update(cx, |this, cx| {
this.add_panel(
Arc::new(messaging_relays::init(window, cx)),
DockPlacement::Left,
window,
cx,
);
});
self.add_panel_to_dock(
messaging_relays::init(window, cx),
DockPlacement::Left,
window,
cx,
);
}
Command::RefreshMessagingRelays => {
let chat = ChatRegistry::global(cx);
@@ -316,14 +313,12 @@ impl Workspace {
});
}
Command::ShowRelayList => {
self.dock.update(cx, |this, cx| {
this.add_panel(
Arc::new(relay_list::init(window, cx)),
DockPlacement::Right,
window,
cx,
);
});
self.add_panel_to_dock(
relay_list::init(window, cx),
DockPlacement::Right,
window,
cx,
);
}
Command::RefreshEncryption => {
let device = DeviceRegistry::global(cx);
-558
View File
@@ -1,558 +0,0 @@
# Migrating `crates/ui` to `gpui-base`
`crates/ui` is a fork of an early version of `gpui-component`: 71 files and roughly
21.9k lines that mix behavior, presentation, and application shell. This document is
the plan for moving its behavior half onto the upstream `gpui-base` crate while the
application keeps the design system it has today.
The facts below were checked against `gpui-base 0.6.1` (crates.io), the `gpui-kit`
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).
- **Phase 2: landed.** `input/` runs on base's editing engine. 6,715 lines of
engine and history are gone and 306 are written, taking `crates/ui/src/input`
from 6,573 lines to 321. Six call sites changed, all named in phase 2 below.
- **Phase 3: landed.** `tooltip`, `popover`, `modal`, and `notification` run on base's
overlay and feedback primitives. Those four modules are 1,434 lines where they
were 1,592, and nothing outside `crates/ui` changed. The behavioural differences
are named in phase 3 below; the largest is that base's toast stack replaces the
fork's notification list.
- **Phase 4: landed.** `switch`, `button`, `scroll/`, and `resizable/` run on base's
controls; `avatar` was checked and deliberately left alone. `crates/ui/src` went
from 12,922 lines to 10,830, and again nothing outside `crates/ui` changed. The
only files inside `crates/ui` outside the migrated modules are the two dock files
that consume `resizable`. The differences are named in phase 4 below; the one that
wants an eye on it is a button's inherited line height.
- 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`,
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,
and a dependency graph that contains both does not compile. Zed's crates.io `gpui`
(0.2.2, October 2025) is also far behind the APIs coop already uses. The workspace's
`gpui` entry therefore has to resolve to the `gpui-pre` package; with the `package =`
alias, every `use gpui::…` site stays as it is.
**The fork's external contract is small.** Outside `crates/ui`, the crate is consumed
as 38 imported items plus a single `ui::init(cx)` call, across 13 modules, and never
deeper than `ui::<module>::<Item>`:
| Module | Items | Consumer files |
| --- | --- | --- |
| crate root (`Icon`, `IconName`, `h_flex`, `v_flex`, `divider`, `Root`, `TitleBar`, `Sizable`, `Selectable`, `Disableable`, `StyledExt`, `WindowExtension`, `InteractiveElementExt`) | 13 | 17 |
| `input` (`InputState`, `Input`, `InputEvent`; plus `TextareaState` and `Textarea` after phase 2) | 3, then 5 | 10 |
| `button` (`Button`, `ButtonVariants`) | 2 | 14 |
| `dock` (`Panel`, `PanelView`, `DockArea`, `DockItem`, `DockPlacement`, `PanelEvent`, `ClosePanel`) | 7 | 10 |
| `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` 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
primitives.
## What must not change
- `crates/theme` stays the source of truth: `ThemeColors`, `ThemeFamily`, the registry,
scrollbar mode, platform, font size and radii.
- Behavior comes from `gpui-base`; presentation comes from `theme` plus the `ui` styled
layer. Every migrated component keeps reading `cx.theme()` and keeps its current
spacing, radius, and shadow math, so the rendered result does not move.
- Application code keeps importing `theme::ActiveTheme` and `ui::*` under its current
names. Module paths are part of the contract; internals are not.
- `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 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: `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, 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
here: coop has a single `radius`/`radius_lg`/`font_size` where base has six-point
scales, so any mapping would be invented rather than derived. Two things base would
otherwise paint from its own defaults are instead supplied by `crates/ui` at the call
site, because they are per-element rather than global: `scroll/` passes coop's rail and
thumb geometry through `ScrollbarStyles`, and `resizable/` paints the divider with a
`ResizeHandleRenderer`. `ResizableTheme` and `ScrollbarStyles` are therefore never
projected.
## What each module becomes
LOC is the count before the work; a module whose phase has landed reads
`before → after`.
| `ui` module | LOC | Plan | `gpui-base` counterpart |
| --- | --- | --- | --- |
| `input/` (input, clear_button) | 6,573 | Replace; keep `ui::input::{Input, InputEvent, InputState}` as the import path. 321 lines remain, and the engine paints itself through `InputEditorStyle` | `InputState`/`TextareaState` (`InputBaseState` in two modes) plus the `InputBase` frame |
| `list/` | 1,477 | Delete | GPUI's own `list` (already in use) |
| `checkbox.rs` | 312 | Delete | `Checkbox` |
| `scroll/` (scrollbar, scrollable, scrollable_mask) | 1,332 → 232 | Replace; keep the `ScrollableElement` and `Scrollbar` names. The two scrollbar files are gone and `scrollable.rs` is a thin layer over base | `Scrollbar` (`ScrollableMask` had no callers and is dropped) |
| `resizable/` | 927 → 37 | Replace with a re-export of base's identically named API plus a `ResizeHandleRenderer` for the coop hairline | `Resizable*`, `resize_handle`, `PANEL_MIN_SIZE` |
| `modal.rs` | 540 → 500 | Port onto base parts; `Modal`, `ModalButtonProps`, and `window.open_modal` unchanged. `Root` still owns the stack | `Dialog` — focus trap, Escape/Enter/backdrop dispatch, layer priority, deferred host |
| `notification.rs` | 584 → 663 | Port; `Notification`, `NotificationKind`, and `window.push_notification` unchanged | `ToastManager` (storage, ids, timers, exit), `ToastStack` (geometry, motion), `Toast` (`Role::Alert`) |
| `popover.rs` | 432 → 234 | Coop's builder over base's element; `PopoverState` is base's, re-exported | `Popover`, `Popup`, `Positioner` |
| `tooltip.rs` | 36 → 37 | Coop's view rooted at base's element | `Tooltip` (`Role::Tooltip`) |
| `button.rs` | 626 → 610 | Skin: base behavior plus coop's existing variant tables | `Button`, `StateStyle` |
| `switch.rs` | 287 → 188 | Skin: base owns the toggle and its semantics; coop keeps the geometry, the label and the description | `Switch`, `SwitchTrack`, `SwitchThumb`, `spring` |
| `avatar.rs` | 141 | Keep. Base's `Avatar` is an unstyled `Div` holding an image slot *or* a fallback slot, and its `AvatarImage` exposes neither `grayscale` nor `Img::with_fallback`; the fork needs a runtime load failure to swap in `brand/avatar.png`, which is a different thing from having no image. Nothing base offers is reachable from this module | `Avatar` (unusable here) |
| `history.rs` | 184 | Delete. Base's input keeps its own `UndoManager`, and `UndoHistory` is a separate public utility the input never touches, so nothing has to be re-based. Its only consumer was `input/state.rs` | — |
| `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) |
| `root.rs`, `window_ext.rs`, `title_bar.rs` | 965 | Keep; app shell. `Root` continues to host the dialog and toast layers. Its `focused_input` field and the two `WindowExtension` methods that read it are gone — the only thing that ever set them was the deleted input paint hook, and no crate consumed them | — |
Once phases 14 have landed, `crates/ui/src` is 39 files and 10,830 lines where it
began at 71 and 21,876: roughly 11k lines removed, 2k re-expressed as thin skins,
and the rest kept as the design system.
## Dependency change
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" }
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. 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 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) — landed
Point the workspace's GPUI entries at the published `gpui-pre` crates. There is no GPUI
source to align, patch, or vendor.
**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.
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.
### Phase 1 — Wire base, delete dead weight (no visual change) — landed
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) — landed
`crates/ui/src/input` is three files and 321 lines: a rewritten 299-line `input.rs`, a
7-line `mod.rs` that re-exports base, and the untouched 15-line `clear_button.rs`. Deleted:
`state.rs`, `element.rs`, `display_map/`, `rope_ext.rs`, `mask_pattern.rs`, `movement.rs`,
`selection.rs`, `indent.rs`, `mode.rs`, `change.rs`, `cursor.rs`, `blink_cursor.rs`, and
`history.rs` — 6,715 lines.
The names the application imports are unchanged, but two of them are base's now:
| Coop before | `ui::input` now |
| --- | --- |
| `InputState`, one struct that became multi-line through `auto_grow`/`multi_line` | `InputState` = `InputBaseState<InputMode>` and `TextareaState` = `InputBaseState<TextareaMode>`; multi-line is a property of the state's kind |
| `Input`, one element that rendered whatever kind of state it was given | `Input` for `InputState` and `Textarea` for `TextareaState` — one generic element, two names |
| `InputEvent::{Change, PressEnter, Focus, Blur}` | identical |
| `history::History` and `HistoryItem` | gone; `Change` keeps no trait impl |
The styled element is a frame around base's engine rather than the engine itself. Base's
`InputBaseState::render` registers the key context, the focus handle, every editing
action, the text element and the editor scrollbar, so the coop element no longer carries
any of it. What is left is chrome — background, radius, font size, prefix and suffix
slots, clear button, mask toggle, loading indicator — plus three projections onto the
state:
- `set_editor_style(InputEditorStyle)`, filling `foreground`, `muted_foreground`,
`selection` and `caret` from `text`, `text_muted`, `selection` and `cursor`. Base
resolves any color left transparent from its own palette, and that palette is only a
projection of coop's, so every color coop paints with is named rather than left to
resolve. The remaining fields stay at base's defaults: coop configures no highlighter,
no diagnostics and no gutter.
- `set_editor_paddings(Edges<Pixels>)`, for multi-line only, resolved from the same `Size`
table the single-line frame applies, through the window's rem size. Base puts that
padding on the text element itself so the text, the gutter and the scrollbar share one
inset; putting it on the frame *and* passing it here would double it. Passing the
frame's own value is also what keeps the scrollbar where the fork drew it.
- `set_disabled` and `set_text_align`, replacing the fork's direct writes to `state.size`,
`state.disabled` and `state.text_align`.
`history.rs` folded in as predicted, with one correction: it did not need re-basing at
all. Base's engine owns an `UndoManager`, and `gpui_base::UndoHistory` — the grouped
undo/redo, not the back/forward `History` — is a separate utility the engine never
reaches for. Removing `pub mod history` is safe because nothing outside `crates/ui`
referenced it.
The gaps named before the phase started all resolved in base's favor: `clean_on_escape()`
and `set_loading()` both exist in 0.6.1, and `InputEditorStyle` is the third piece.
**Six call sites changed, and none of them is churn:**
| Call site | Change | Why |
| --- | --- | --- |
| `chat_ui` composer | `InputState``TextareaState`, `Input::new``Textarea::new` | multi-line is the state's kind, not a layout flag |
| `workspace`, profile bio | the same, and `.multi_line(true)` is dropped | the same; `auto_grow(3, 8)` is unchanged |
| `workspace`, sidebar | `set_loading(status, cx)``set_loading(status, window, cx)` | base's signature takes the window |
| `workspace`, sidebar | the `.loading` field read → `.presentation().is_loading()` | `loading` is private; `InputPresentation` is the facade for reading it |
| `ui::window_ext` | `focused_input` and `has_focused_input` are removed | their only implementation was the deleted paint hook, and no crate consumed them |
| `ui::init` | `input::init(cx)` is removed | `gpui_base::init` binds the same keys, and its set is a strict superset |
Three visible differences survive, all of them base's, none of them a color, radius or
spacing value:
- **The mask character is `•`, not `*`.** Base's `MASK_CHAR` is a private constant, so the
fork's `*` cannot be restored. It shows only in masked inputs.
- **The caret is `0.85 × line_height` at every size.** The fork scaled it by `Size` (0.75
at small, 1.0 at large) from a `size` field base does not have.
- **Inputs are tab stops.** Base builds the state's focus handle with `tab_stop(true)`;
the fork's frame was not a tab stop, so Tab skipped text fields and now lands on them.
Two things came along with `InputBase` that the fork's plain `div` did not do: the frame
carries the `TextInput` accessibility role, and a left click anywhere in the frame —
including the padding outside the text element — focuses the input. The second has to be
restated on the frame because base handles its own mouse events on the inner element only.
Surfaces to re-verify by hand: the chat composer (auto-grow, Enter to send, IME), the
profile bio, the subject line, the settings dialog, the relay and messaging lists, the
import/restore/backup dialogs, and the sidebar search field.
### Phase 3 — overlays and feedback — landed
All four modules keep their names, builders, and call sites. The four files go from
1,592 lines to 1,434, and no file outside `crates/ui` changed.
| `ui` module | What stayed coop's | What is base's now |
| --- | --- | --- |
| `tooltip` | the whole look, `Tooltip::new(text, window, cx)` and the `Render` view | the element and `Role::Tooltip` |
| `popover` | every builder, the content styling, the anchor | open lifecycle, dismissal, focus capture and restore, deferred registration, trigger measurement and anchor math |
| `modal` | `Modal`, `ModalButtonProps`, `Root`'s stack, `window.open_modal`, the card, buttons, shadows and animations | focus trap, Escape/Enter/backdrop dispatch with a cancel veto, layer priority, the deferred host, `Role::Dialog` |
| `notification` | `Notification`, `NotificationKind`, `window.push_notification`, the card and the placement from `theme.notification` | id-replacing storage, auto-hide and exit timers, stack geometry and motion, `Role::Alert` |
**`tooltip`.** The view and its `new` are unchanged; the styled box inside is
`gpui_base::Tooltip` instead of a bare `div`. That is what carries the role. Base's
window-level `TooltipOverlay` is deliberately not adopted — gpui's own `.tooltip()`
layer already provides the delay and the placement, and taking the overlay would mean
rewriting every `.tooltip(..)` call site onto `Popup` plus hover state.
**`popover`.** `PopoverState` is `gpui_base::PopoverState`, re-exported so
`ui::popover::PopoverState` still resolves, and the hand-rolled `anchored`/`deferred`
layer, `resolved_corner` and `render_popover` are gone — base's `Popup` measures the
trigger, resolves the anchor and snaps to the window edge. The rest of the file is the
fork's builder, unchanged, including `trigger_style`, which the fork already stored
without ever reading. Two bindings changed hands: `popover::init` (escape → coop's
`Cancel` in the `Popover` context) is deleted, because `gpui_base::init` binds
escape/enter/space in that same context and coop's lone escape binding would have
shadowed base's `Confirm` — the one that opens a popover from its trigger.
**`modal`.** `Modal` still assembles the card, title, close button, footer buttons,
the two shadows and the `fade-in`/`slide-down` animations; `Root` still owns the stack,
the focus restore, and the one-visible-overlay rule, now expressed as base's
`layer(index, topmost)`. What changed underneath:
- Escape, Enter and the backdrop now run through base's `Dialog` decisions, so
`on_cancel`/`on_ok` returning `false` vetoes all three. The fork honored the veto on
the buttons and the backdrop but ignored it on Escape.
- Enter on a modal that has a footer but no `on_ok` now calls `on_close` before closing;
the fork closed silently. No caller combines the two, and `on_close` defaults to a
no-op.
- Tab is trapped inside the modal, and the dialog surface carries `Role::Dialog`.
- `modal::init` (escape/enter in the `Modal` context) is deleted; base binds them in
its own `Dialog` context, which the `Dialog` host installs when `keyboard` is on.
- The dim does not move: coop's backdrop element keeps the `window_paddings` inset and
the `view_size` that the fork used. Its hit area does move — base's host covers the
whole viewport, so a click in the client-side-decoration shadow band now dismisses
the modal instead of starting a window resize.
`AlertDialog` turned out to be unnecessary. Coop's `alert()` and `confirm()` select a
button set, not an ARIA role, and they already opt out of backdrop dismissal, which is
the whole of what `AlertDialog` adds over `Dialog`.
**`notification`.** `Notification` keeps its builder and its card. `closing: bool`
becomes base's `ToastTransitionStatus`, `dismiss` now emits a `DismissRequest` the list
turns into a `ToastManager::dismiss`, and the exit delay is base's 200 ms rather than
the fork's fixed 150 ms. `NotificationList` holds
`ToastManager<NotificationId, Entity<Notification>>` plus one `ToastStackState`; its
`expanded` field and hover handler are gone, and a 50 ms lifecycle tick runs only while
something is mounted. The stack is base's:
- It collapses to three layers with a 14 px peek and a 5% width step per layer, expands
on hover or focus, and pauses auto-hide while expanded.
- The newest notification sits nearest the window edge; the fork's list grew downwards
with the oldest first.
- Motion is `ToastMotion::default()`, base's shadcn/Sonner figures. Coop contributes the
width the fork's card had, the placement and the margins from `theme.notification`.
That stack is the one visible change of the phase, and it is the one to judge by hand.
If it is not wanted, the smaller step is to keep the list's own `v_flex` and use only
`ToastManager` together with `Toast` — base separates the lifecycle from the geometry,
so nothing else has to come back.
Surfaces to re-verify by hand: the settings dialog (its Escape and Enter paths), the
import, restore and screening modals (a modal with a textarea, and one with
`keyboard(false)`), the dropdown menus that ride the popover, and every
`push_notification` site — sending an empty message, a failed upload with its retry
action, and the device-approval notification that never auto-hides.
### Phase 4 — leaf controls, scroll, and resizable (one module per pull request) — landed
Order: `avatar`, `switch`, `button`, `scroll/`, `resizable/`. `button` is the
largest skin: the `ButtonVariants` and `ButtonCustomVariant` tables, the `compact`,
`loading`, and `caret` builders, and the variant names stay as they are, with styling
supplied through base's semantic-state styles. `scroll/` keeps the `ScrollableElement`
trait name so `.vertical_scrollbar(..)` call sites keep compiling, and `resizable/`
becomes a thin re-export of base's identically named API plus a `ResizeHandleRenderer`
for the coop hairline.
Each of these is independently shippable. Acceptance for each: no change outside
`crates/ui`, and the surfaces that use the module are pixel-identical before and after.
What each module turned into:
| Module | LOC | Base now owns | Stayed in `ui` |
| --- | --- | --- | --- |
| `switch.rs` | 287 → 188 | `Role::Switch`, `aria_toggled`, focus tracking, Enter/Space and pointer activation, disabled inertness, the thumb's travel | both slots of the control, the label and description, the sizes and radii |
| `button.rs` | 626 → 610 | `Role::Button`, focus tracking, Enter/Space and pointer activation, the disabled and selected precedence, the disabled `mouse_down` veto | every variant colour, the size and padding table, icon-only mode, `loading`, `caret`, `indicator`, `compact`, `rounded`, the tooltip |
| `scroll/` | 1,332 → 232 | the whole scrollbar: geometry, fade, drag, hover and active states, and the handle traits for `ScrollHandle`, `UniformListScrollHandle` and `ListState` | `ScrollableElement`/`Scrollable` (base has no trait for attaching a scrollbar to an arbitrary element) and the projection of coop's rails and colours |
| `resizable/` | 927 → 37 | the group and panel elements, the state, the drag arithmetic, the minimum-size clamp, the handle's hit area and cursor | the hover-only 1px hairline and its two colours |
`crates/ui` changed in three places outside those modules: `dock/dock.rs` and
`dock/stack_panel.rs`, the two consumers of `resizable` (`resizable` is consumed
nowhere else — it is `dock`'s machinery), and nowhere else. No call site outside
`crates/ui` changed.
Behavioural differences, all of them base's:
- **`switch`**: activation moves from the fork's mouse-down on the whole row to
base's click, Enter and Space on the switch. The label is inside the switch, so
clicking it still toggles, and the control is now a tab stop that announces
itself and its toggled state. It also no longer stops the press from reaching a
parent; base stops it only while disabled. The thumb's 150 ms slide becomes base's
critically damped 0.15 s spring, so the easing differs slightly, and reduced-motion
is now honoured.
- **`button`**: Enter and Space now activate the button and `Role::Button` is set
with the label as its accessible name. Nothing draws a focus ring, which is
unchanged. Base's root carries `line_height(relative(1.))` where the fork inherited
GPUI's default `phi()`: the label already pins `relative(1.)` so it does not move,
but text handed to `.child(..)` now sits tighter. This is the one difference a
screenshot review should look for.
- **`scroll`**: coop's rail and thumb are projected explicitly — a 10px rail, a 6px
thumb inset by 1px with a 3px radius (8/1/4 whenever the mode is not `Scrolling`),
a 48px minimum length, and the thumb's two palette colours — so the resting
appearance does not move. Base's own fade timings replace the fork's
`FADE_OUT_DURATION`/`FADE_OUT_DELAY`. `ui::scroll::ScrollbarState`, `PrepaintState`
and `AxisPrepaintState` are gone; nothing outside `crates/ui/src/scroll` ever named
them. The `is_inspector_picking` guard went with them, as coop never enables the
inspector.
- **`resizable`**: base keeps `sync_panels_count`, `update_panel_size` and
`replace_panel` private, so the dock uses `reset_panel` for the one it needed. Base's
built-in divider is always painted and takes its colours from `ResizableTheme`,
which `sync_base` leaves at the `border`/`ring` fallback, so the divider is drawn by
a `ResizeHandleRenderer` instead: the fork paints nothing at rest and `border` on
hover, `border_selected` while dragging. `ResizeHandle::placement` takes
`gpui_base::Side` rather than `DockPlacement`; the dock maps it, and base only
distinguishes `Left`.
### Phase 5 — dock, tab, and menu (deliberately later)
Base has a full dock, but its contract is "layout is data, and the application
implements the renderer traits", while coop's `Panel`/`PanelView`/`DockArea`/`DockItem`
is an app-specific shell already consumed by `crates/workspace` and `crates/chat_ui`.
Moving it is a project of its own, and it would also retire `tab/` and touch `menu/`.
Keep them local until phases 14 have landed, then plan dock separately. Re-basing
menu positioning and dismissal on base `Popup`/`Positioner` is optional and later still.
## Verification
There is no UI test suite to lean on, so each phase gets the same treatment:
- `cargo check --workspace` and `cargo build` (default members build `desktop`).
`cargo build --workspace` cannot link the web crate's host dylib: `coop_web` is
`crate-type = ["cdylib", "rlib"]` and depends on `wasm-bindgen`, `web-sys`,
`console_log` and `tracing-wasm` unconditionally, so its dylib is a wasm artifact.
That is a property of the manifest rather than of any migrated crate —
`cargo check -p coop_web` passes, and the desktop binary links the same crates.
- `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.
- For phase 4, record before/after screenshots per module. These are still owed: the
module work is compiled and linted but has not been walked by eye. The settings
dialog covers `switch` and `button`, the chat list and the sidebar cover `scroll`,
and any docked panel divider covers `resizable`. For `button`, watch text passed
through `.child(..)` rather than `.label(..)`, which is the one place the inherited
line height changes.
- Keep the call-site diff at zero where the phase claims it — phases 1 and 34 do; if a
call site has to change because base has no equivalent, list it in the pull request.
Phase 2 needed six, tabulated above, and the list is the record of what "no equivalent"
turned out to mean in practice.
### 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
`main` by however long it takes longbridge to cut the next release (a few days). A new
GPUI API is therefore unavailable until then. That is the price of not maintaining a
fork; the escape hatch — vendoring `gpui-base` and patching it onto zed's git
repository — should stay unused.
- **The `gpui` dependency line is load-bearing.** Depending on zed's git `gpui`
alongside `gpui-base` looks harmless and is not: it puts two GPUI crates in the graph
and every window, context, and element crossing between them becomes a type error.
- **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` 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` | 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` and the `ropey`/`sum_tree`/`lsp-types`/`regex`/`unicode-segmentation`/`tree-sitter` pruning | `crates/workspace`, `crates/chat_ui` (six call sites); no manifest outside `crates/ui` | landed |
| 4 | Phase 3: popover, modal, notification, tooltip | none | landed |
| 5 | Phase 4: `switch` | none | landed |
| 6 | Phase 4: `button` | none | landed |
| 7 | Phase 4: `scroll/` | none | landed |
| 8 | Phase 4: `resizable/`, plus its two `dock` consumers | none; `dock/dock.rs` and `dock/stack_panel.rs` are inside `crates/ui` | landed |
| — | Phase 4: `avatar` | none | no change; see phase 4 |
| 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,
focus and IME, drag-resize arithmetic, overlay lifecycle, accessibility semantics — are
maintained upstream instead of in a fork.