6 Commits
Author SHA1 Message Date
reya e9b5441104 update dock 2026-09-17 18:44:26 +07:00
reya 2faba843a7 record phase 4 in the migration plan 2026-09-17 16:59:50 +07:00
reya a7baa03c64 migrate resizable onto gpui-base 2026-09-17 16:58:35 +07:00
reya 84e8ad29bc migrate scroll onto gpui-base 2026-09-17 16:55:57 +07:00
reya b5d959748f migrate button onto gpui-base 2026-09-17 16:53:43 +07:00
reya 3949644597 migrate switch onto gpui-base 2026-09-17 16:53:43 +07:00
23 changed files with 1193 additions and 6256 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]);
+24 -40
View File
@@ -2,15 +2,16 @@ use std::rc::Rc;
use gpui::prelude::FluentBuilder as _;
use gpui::{
AnyElement, App, ClickEvent, Div, ElementId, Hsla, InteractiveElement, IntoElement,
ParentElement, RenderOnce, SharedString, Stateful, StatefulInteractiveElement as _,
StyleRefinement, Styled, Window, div, relative,
AnyElement, App, ClickEvent, ElementId, Hsla, InteractiveElement, IntoElement, MouseButton,
ParentElement, RenderOnce, SharedString, StatefulInteractiveElement as _, StyleRefinement,
Styled, Window, div, relative,
};
use gpui_base::Button as BaseButton;
use theme::ActiveTheme;
use crate::indicator::Indicator;
use crate::tooltip::Tooltip;
use crate::{Disableable, Icon, IconName, Selectable, Sizable, Size, StyledExt, h_flex};
use crate::{Disableable, Icon, IconName, Selectable, Sizable, Size, h_flex};
#[derive(Clone, Copy, PartialEq, Eq)]
pub struct ButtonCustomVariant {
@@ -114,9 +115,7 @@ pub trait ButtonVariants: Sized {
#[derive(IntoElement)]
#[allow(clippy::type_complexity)]
pub struct Button {
id: ElementId,
base: Stateful<Div>,
style: StyleRefinement,
base: BaseButton,
icon: Option<Icon>,
label: Option<SharedString>,
@@ -151,12 +150,8 @@ impl From<Button> for AnyElement {
impl Button {
pub fn new(id: impl Into<ElementId>) -> Self {
let id = id.into();
Self {
id: id.clone(),
base: div().flex_shrink_0().id(id),
style: StyleRefinement::default(),
base: BaseButton::new(id),
icon: None,
label: None,
variant: ButtonVariant::default(),
@@ -301,7 +296,7 @@ impl ButtonVariants for Button {
impl Styled for Button {
fn style(&mut self) -> &mut StyleRefinement {
&mut self.style
self.base.style()
}
}
@@ -318,7 +313,7 @@ impl InteractiveElement for Button {
}
impl RenderOnce for Button {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
let style: ButtonVariant = self.variant;
let clickable = self.clickable();
let hoverable = self.hoverable();
@@ -329,18 +324,21 @@ impl RenderOnce for Button {
_ => self.size,
};
let focus_handle = window
.use_keyed_state(self.id.clone(), cx, |_window, cx| cx.focus_handle())
.read(cx)
.clone();
self.base
.when(!self.disabled, |this| {
this.track_focus(
&focus_handle
.tab_index(self.tab_index)
.tab_stop(self.tab_stop),
)
.tab_index(self.tab_index)
.tab_stop(self.tab_stop)
.disabled(self.disabled)
.when_some(self.on_click.clone(), |this, on_click| {
this.on_click(move |event, window, cx| {
// Stop handle any click event when disabled.
// To avoid handle dropdown menu open when button is disabled.
if !clickable {
cx.stop_propagation();
return;
}
on_click(event, window, cx);
})
})
.relative()
.flex_shrink_0()
@@ -349,7 +347,6 @@ impl RenderOnce for Button {
.justify_center()
.cursor_default()
.overflow_hidden()
.refine_style(&self.style)
.map(|this| match self.rounded {
false => this.rounded(cx.theme().radius),
true => this.rounded_full(),
@@ -399,8 +396,7 @@ impl RenderOnce for Button {
}
}
})
.refine_style(&self.style)
.on_mouse_down(gpui::MouseButton::Left, move |_, window, cx| {
.on_mouse_down(MouseButton::Left, move |_, window, cx| {
// Stop handle any click event when disabled.
// To avoid handle dropdown menu open when button is disabled.
if self.disabled {
@@ -410,18 +406,6 @@ impl RenderOnce for Button {
// Avoid focus on mouse down.
window.prevent_default();
})
.when_some(self.on_click, |this, on_click| {
this.on_click(move |event, window, cx| {
// Stop handle any click event when disabled.
// To avoid handle dropdown menu open when button is disabled.
if !clickable {
cx.stop_propagation();
return;
}
on_click(event, window, cx);
})
})
.when_some(self.on_hover.filter(|_| hoverable), |this, on_hover| {
this.on_hover(move |hovered, window, cx| {
(on_hover)(hovered, window, cx);
-438
View File
@@ -1,438 +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 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};
#[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)
}
}
/// 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)
.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
}
}
-387
View File
@@ -1,387 +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, ResizablePanelState, ResizableState,
resizable_panel,
};
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.replace_panel(ix, ResizablePanelState::default(), 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)
.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)
+28 -288
View File
@@ -1,294 +1,34 @@
use std::ops::Range;
use std::rc::Rc;
use gpui::{
Along, App, Axis, Bounds, Context, ElementId, EventEmitter, IsZero, Pixels, Window, px,
use gpui::prelude::FluentBuilder as _;
use gpui::{App, InteractiveElement as _, IntoElement, Pixels, Styled as _, Window, div, px};
pub(crate) use gpui_base::resize_handle;
pub use gpui_base::{
ResizablePanel, ResizablePanelEvent, ResizablePanelGroup, ResizableState, h_resizable,
resizable_panel, v_resizable,
};
use gpui_base::{ResizeHandleContext, ResizeHandleRenderer};
use theme::{ActiveTheme as _, AxisExt as _};
mod panel;
mod resize_handle;
pub use panel::*;
pub(crate) use resize_handle::*;
const HANDLE_SIZE: Pixels = px(1.);
pub(crate) const PANEL_MIN_SIZE: Pixels = px(100.);
pub(crate) fn resize_handle_appearance() -> ResizeHandleRenderer {
Rc::new(
|context: &ResizeHandleContext, _: &mut Window, cx: &mut App| {
let color = if context.is_active() {
cx.theme().border_selected
} else {
cx.theme().border
};
let axis = context.axis();
/// Create a [`ResizablePanelGroup`] with horizontal resizing
pub fn h_resizable(id: impl Into<ElementId>) -> ResizablePanelGroup {
ResizablePanelGroup::new(id).axis(Axis::Horizontal)
}
/// Create a [`ResizablePanelGroup`] with vertical resizing
pub fn v_resizable(id: impl Into<ElementId>) -> ResizablePanelGroup {
ResizablePanelGroup::new(id).axis(Axis::Vertical)
}
/// Create a [`ResizablePanel`].
pub fn resizable_panel() -> ResizablePanel {
ResizablePanel::new()
}
/// State for a [`ResizablePanel`]
#[derive(Debug, Clone)]
pub struct ResizableState {
/// The `axis` will sync to actual axis of the ResizablePanelGroup in use.
axis: Axis,
panels: Vec<ResizablePanelState>,
sizes: Vec<Pixels>,
pub(crate) resizing_panel_ix: Option<usize>,
bounds: Bounds<Pixels>,
}
impl Default for ResizableState {
fn default() -> Self {
Self {
axis: Axis::Horizontal,
panels: vec![],
sizes: vec![],
resizing_panel_ix: None,
bounds: Bounds::default(),
}
}
}
impl ResizableState {
/// Get the size of the panels.
pub fn sizes(&self) -> &Vec<Pixels> {
&self.sizes
}
pub(crate) fn insert_panel(
&mut self,
size: Option<Pixels>,
ix: Option<usize>,
cx: &mut Context<Self>,
) {
let panel_state = ResizablePanelState {
size,
..Default::default()
};
let size = size.unwrap_or(PANEL_MIN_SIZE);
// We make sure that the size always sums up to the container size
// by reducing the size of all other panels first.
let container_size = self.container_size().max(px(1.));
let total_leftover_size = (container_size - size).max(px(1.));
for (i, panel) in self.panels.iter_mut().enumerate() {
let ratio = self.sizes[i] / container_size;
self.sizes[i] = total_leftover_size * ratio;
panel.size = Some(self.sizes[i]);
}
if let Some(ix) = ix {
self.panels.insert(ix, panel_state);
self.sizes.insert(ix, size);
} else {
self.panels.push(panel_state);
self.sizes.push(size);
};
cx.notify();
}
pub(crate) fn sync_panels_count(
&mut self,
axis: Axis,
panels_count: usize,
cx: &mut Context<Self>,
) {
let mut changed = self.axis != axis;
self.axis = axis;
if panels_count > self.panels.len() {
let diff = panels_count - self.panels.len();
self.panels
.extend(vec![ResizablePanelState::default(); diff]);
self.sizes.extend(vec![PANEL_MIN_SIZE; diff]);
changed = true;
}
if panels_count < self.panels.len() {
self.panels.truncate(panels_count);
self.sizes.truncate(panels_count);
changed = true;
}
if changed {
// We need to make sure the total size is in line with the container size.
self.adjust_to_container_size(cx);
}
}
pub(crate) fn update_panel_size(
&mut self,
panel_ix: usize,
bounds: Bounds<Pixels>,
size_range: Range<Pixels>,
cx: &mut Context<Self>,
) {
let size = bounds.size.along(self.axis);
// This check is only necessary to stop the very first panel from resizing on its own
// it needs to be passed when the panel is freshly created so we get the initial size,
// but its also fine when it sometimes passes later.
if self.sizes[panel_ix].to_f64() == PANEL_MIN_SIZE.to_f64() {
self.sizes[panel_ix] = size;
self.panels[panel_ix].size = Some(size);
}
self.panels[panel_ix].bounds = bounds;
self.panels[panel_ix].size_range = size_range;
cx.notify();
}
pub(crate) fn remove_panel(&mut self, panel_ix: usize, cx: &mut Context<Self>) {
self.panels.remove(panel_ix);
self.sizes.remove(panel_ix);
if let Some(resizing_panel_ix) = self.resizing_panel_ix
&& resizing_panel_ix > panel_ix
{
self.resizing_panel_ix = Some(resizing_panel_ix - 1);
}
self.adjust_to_container_size(cx);
}
pub(crate) fn replace_panel(
&mut self,
panel_ix: usize,
panel: ResizablePanelState,
cx: &mut Context<Self>,
) {
let old_size = self.sizes[panel_ix];
self.panels[panel_ix] = panel;
self.sizes[panel_ix] = old_size;
self.adjust_to_container_size(cx);
}
pub(crate) fn clear(&mut self) {
self.panels.clear();
self.sizes.clear();
}
#[inline]
pub(crate) fn container_size(&self) -> Pixels {
self.bounds.size.along(self.axis)
}
pub(crate) fn done_resizing(&mut self, cx: &mut Context<Self>) {
self.resizing_panel_ix = None;
cx.emit(ResizablePanelEvent::Resized);
}
fn panel_size_range(&self, ix: usize) -> Range<Pixels> {
let Some(panel) = self.panels.get(ix) else {
return PANEL_MIN_SIZE..Pixels::MAX;
};
panel.size_range.clone()
}
fn sync_real_panel_sizes(&mut self, _: &App) {
for (i, panel) in self.panels.iter().enumerate() {
self.sizes[i] = panel.bounds.size.along(self.axis);
}
}
/// The `ix`` is the index of the panel to resize,
/// and the `size` is the new size for the panel.
fn resize_panel(&mut self, ix: usize, size: Pixels, _: &mut Window, cx: &mut Context<Self>) {
let old_sizes = self.sizes.clone();
let mut ix = ix;
// Only resize the left panels.
if ix >= old_sizes.len() - 1 {
return;
}
let container_size = self.container_size();
self.sync_real_panel_sizes(cx);
let move_changed = size - old_sizes[ix];
if move_changed == px(0.) {
return;
}
let size_range = self.panel_size_range(ix);
let new_size = size.clamp(size_range.start, size_range.end);
let is_expand = move_changed > px(0.);
let main_ix = ix;
let mut new_sizes = old_sizes.clone();
if is_expand {
let mut changed = new_size - old_sizes[ix];
new_sizes[ix] = new_size;
while changed > px(0.) && ix < old_sizes.len() - 1 {
ix += 1;
let size_range = self.panel_size_range(ix);
let available_size = (new_sizes[ix] - size_range.start).max(px(0.));
let to_reduce = changed.min(available_size);
new_sizes[ix] -= to_reduce;
changed -= to_reduce;
}
} else {
let mut changed = new_size - size;
new_sizes[ix] = new_size;
while changed > px(0.) && ix > 0 {
ix -= 1;
let size_range = self.panel_size_range(ix);
let available_size = (new_sizes[ix] - size_range.start).max(px(0.));
let to_reduce = changed.min(available_size);
changed -= to_reduce;
new_sizes[ix] -= to_reduce;
}
new_sizes[main_ix + 1] += old_sizes[main_ix] - size - changed;
}
let total_size: Pixels = new_sizes.iter().map(|s| s.to_f64()).sum::<f64>().into();
// If total size exceeds container size, adjust the main panel
if total_size > container_size {
let overflow = total_size - container_size;
new_sizes[main_ix] = (new_sizes[main_ix] - overflow).max(size_range.start);
}
for (i, _) in old_sizes.iter().enumerate() {
let size = new_sizes[i];
self.panels[i].size = Some(size);
}
self.sizes = new_sizes;
cx.notify();
}
/// Adjust panel sizes according to the container size.
///
/// When the container size changes, the panels should take up the same percentage as they did before.
fn adjust_to_container_size(&mut self, cx: &mut Context<Self>) {
if self.container_size().is_zero() {
return;
}
let container_size = self.container_size();
let total_size = px(self.sizes.iter().map(f32::from).sum::<f32>());
for i in 0..self.panels.len() {
let size = self.sizes[i];
let ratio = size / total_size;
let new_size = container_size * ratio;
self.sizes[i] = new_size;
self.panels[i].size = Some(new_size);
}
cx.notify();
}
}
impl EventEmitter<ResizablePanelEvent> for ResizableState {}
#[derive(Debug, Clone, Default)]
pub(crate) struct ResizablePanelState {
pub size: Option<Pixels>,
pub size_range: Range<Pixels>,
bounds: Bounds<Pixels>,
Some(
div()
.group_hover("handle", move |this| this.bg(color))
.when(axis.is_horizontal(), |this| this.h_full().w(HANDLE_SIZE))
.when(axis.is_vertical(), |this| this.w_full().h(HANDLE_SIZE))
.into_any_element(),
)
},
)
}
-408
View File
@@ -1,408 +0,0 @@
use std::ops::{Deref, Range};
use std::rc::Rc;
use gpui::prelude::FluentBuilder;
use gpui::{
Along, AnyElement, App, AppContext, Axis, Bounds, Context, Element, ElementId, Empty, Entity,
EventEmitter, InteractiveElement as _, IntoElement, IsZero as _, MouseMoveEvent, MouseUpEvent,
ParentElement, Pixels, Render, RenderOnce, Style, Styled, Window, div,
};
use theme::AxisExt;
use super::{ResizableState, resizable_panel, resize_handle};
use crate::resizable::PANEL_MIN_SIZE;
use crate::{ElementExt, h_flex, v_flex};
pub enum ResizablePanelEvent {
Resized,
}
#[derive(Clone)]
pub(crate) struct DragPanel;
impl Render for DragPanel {
fn render(&mut self, _: &mut Window, _: &mut Context<'_, Self>) -> impl IntoElement {
Empty
}
}
/// A group of resizable panels.
#[allow(clippy::type_complexity)]
#[derive(IntoElement)]
pub struct ResizablePanelGroup {
id: ElementId,
state: Option<Entity<ResizableState>>,
axis: Axis,
size: Option<Pixels>,
children: Vec<ResizablePanel>,
on_resize: Rc<dyn Fn(&Entity<ResizableState>, &mut Window, &mut App)>,
}
impl ResizablePanelGroup {
/// Create a new resizable panel group.
pub fn new(id: impl Into<ElementId>) -> Self {
Self {
id: id.into(),
axis: Axis::Horizontal,
children: vec![],
state: None,
size: None,
on_resize: Rc::new(|_, _, _| {}),
}
}
/// Bind yourself to a resizable state entity.
///
/// If not provided, it will handle its own state internally.
pub fn with_state(mut self, state: &Entity<ResizableState>) -> Self {
self.state = Some(state.clone());
self
}
/// Set the axis of the resizable panel group, default is horizontal.
pub fn axis(mut self, axis: Axis) -> Self {
self.axis = axis;
self
}
/// Add a panel to the group.
///
/// - The `axis` will be set to the same axis as the group.
/// - The `initial_size` will be set to the average size of all panels if not provided.
/// - The `group` will be set to the group entity.
pub fn child(mut self, panel: impl Into<ResizablePanel>) -> Self {
self.children.push(panel.into());
self
}
/// Add multiple panels to the group.
pub fn children<I>(mut self, panels: impl IntoIterator<Item = I>) -> Self
where
I: Into<ResizablePanel>,
{
self.children = panels.into_iter().map(|panel| panel.into()).collect();
self
}
/// Set size of the resizable panel group
///
/// - When the axis is horizontal, the size is the height of the group.
/// - When the axis is vertical, the size is the width of the group.
pub fn size(mut self, size: Pixels) -> Self {
self.size = Some(size);
self
}
/// Set the callback to be called when the panels are resized.
///
/// ## Callback arguments
///
/// - Entity<ResizableState>: The state of the ResizablePanelGroup.
pub fn on_resize(
mut self,
on_resize: impl Fn(&Entity<ResizableState>, &mut Window, &mut App) + 'static,
) -> Self {
self.on_resize = Rc::new(on_resize);
self
}
}
impl<T> From<T> for ResizablePanel
where
T: Into<AnyElement>,
{
fn from(value: T) -> Self {
resizable_panel().child(value.into())
}
}
impl From<ResizablePanelGroup> for ResizablePanel {
fn from(value: ResizablePanelGroup) -> Self {
resizable_panel().child(value)
}
}
impl EventEmitter<ResizablePanelEvent> for ResizablePanelGroup {}
impl RenderOnce for ResizablePanelGroup {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let state = self.state.unwrap_or(
window.use_keyed_state(self.id.clone(), cx, |_, _| ResizableState::default()),
);
let container = if self.axis.is_horizontal() {
h_flex()
} else {
v_flex()
};
// Sync panels to the state
let panels_count = self.children.len();
state.update(cx, |state, cx| {
state.sync_panels_count(self.axis, panels_count, cx);
});
container
.id(self.id)
.size_full()
.children(
self.children
.into_iter()
.enumerate()
.map(|(ix, mut panel)| {
panel.panel_ix = ix;
panel.axis = self.axis;
panel.state = Some(state.clone());
panel
}),
)
.on_prepaint({
let state = state.clone();
move |bounds, _, cx| {
state.update(cx, |state, cx| {
let size_changed =
state.bounds.size.along(self.axis) != bounds.size.along(self.axis);
state.bounds = bounds;
if size_changed {
state.adjust_to_container_size(cx);
}
})
}
})
.child(ResizePanelGroupElement {
state: state.clone(),
axis: self.axis,
on_resize: self.on_resize.clone(),
})
}
}
/// A resizable panel inside a [`ResizablePanelGroup`].
#[derive(IntoElement)]
pub struct ResizablePanel {
axis: Axis,
panel_ix: usize,
state: Option<Entity<ResizableState>>,
/// Initial size is the size that the panel has when it is created.
initial_size: Option<Pixels>,
/// size range limit of this panel.
size_range: Range<Pixels>,
children: Vec<AnyElement>,
visible: bool,
}
impl ResizablePanel {
/// Create a new resizable panel.
pub(super) fn new() -> Self {
Self {
panel_ix: 0,
initial_size: None,
state: None,
size_range: (PANEL_MIN_SIZE..Pixels::MAX),
axis: Axis::Horizontal,
children: vec![],
visible: true,
}
}
/// Set the visibility of the panel, default is true.
pub fn visible(mut self, visible: bool) -> Self {
self.visible = visible;
self
}
/// Set the initial size of the panel.
pub fn size(mut self, size: impl Into<Pixels>) -> Self {
self.initial_size = Some(size.into());
self
}
/// Set the size range to limit panel resize.
///
/// Default is [`PANEL_MIN_SIZE`] to [`Pixels::MAX`].
pub fn size_range(mut self, range: impl Into<Range<Pixels>>) -> Self {
self.size_range = range.into();
self
}
}
impl ParentElement for ResizablePanel {
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
self.children.extend(elements);
}
}
impl RenderOnce for ResizablePanel {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
if !self.visible {
return div().id(("resizable-panel", self.panel_ix));
}
let state = self
.state
.expect("BUG: The `state` in ResizablePanel should be present.");
let panel_state = state
.read(cx)
.panels
.get(self.panel_ix)
.expect("BUG: The `index` of ResizablePanel should be one of in `state`.");
let size_range = self.size_range.clone();
div()
.id(("resizable-panel", self.panel_ix))
.flex()
.flex_grow_1()
.size_full()
.relative()
.when(self.axis.is_vertical(), |this| {
this.min_h(size_range.start).max_h(size_range.end)
})
.when(self.axis.is_horizontal(), |this| {
this.min_w(size_range.start).max_w(size_range.end)
})
// 1. initial_size is None, to use auto size.
// 2. initial_size is Some and size is none, to use the initial size of the panel for first time render.
// 3. initial_size is Some and size is Some, use `size`.
.when(self.initial_size.is_none(), |this| this.flex_shrink_1())
.when_some(self.initial_size, |this, initial_size| {
// The `self.size` is None, that mean the initial size for the panel,
// so we need set `flex_shrink_0` To let it keep the initial size.
this.when(
panel_state.size.is_none() && !initial_size.is_zero(),
|this| this.flex_none(),
)
.flex_basis(initial_size)
})
.map(|this| match panel_state.size {
Some(size) => this.flex_basis(size.min(size_range.end).max(size_range.start)),
None => this,
})
.on_prepaint({
let state = state.clone();
move |bounds, _, cx| {
state.update(cx, |state, cx| {
state.update_panel_size(self.panel_ix, bounds, self.size_range, cx)
})
}
})
.children(self.children)
.when(self.panel_ix > 0, |this| {
let ix = self.panel_ix - 1;
this.child(resize_handle(("resizable-handle", ix), self.axis).on_drag(
DragPanel,
move |drag_panel, _, _, cx| {
cx.stop_propagation();
// Set current resizing panel ix
state.update(cx, |state, _| {
state.resizing_panel_ix = Some(ix);
});
cx.new(|_| drag_panel.deref().clone())
},
))
})
}
}
#[allow(clippy::type_complexity)]
struct ResizePanelGroupElement {
state: Entity<ResizableState>,
on_resize: Rc<dyn Fn(&Entity<ResizableState>, &mut Window, &mut App)>,
axis: Axis,
}
impl IntoElement for ResizePanelGroupElement {
type Element = Self;
fn into_element(self) -> Self::Element {
self
}
}
impl Element for ResizePanelGroupElement {
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 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>,
_: Bounds<Pixels>,
_: &mut Self::RequestLayoutState,
_window: &mut Window,
_cx: &mut App,
) -> Self::PrepaintState {
}
fn paint(
&mut self,
_: Option<&gpui::GlobalElementId>,
_: Option<&gpui::InspectorElementId>,
_: Bounds<Pixels>,
_: &mut Self::RequestLayoutState,
_: &mut Self::PrepaintState,
window: &mut Window,
cx: &mut App,
) {
window.on_mouse_event({
let state = self.state.clone();
let axis = self.axis;
let current_ix = state.read(cx).resizing_panel_ix;
move |e: &MouseMoveEvent, phase, window, cx| {
if !phase.bubble() {
return;
}
let Some(ix) = current_ix else { return };
state.update(cx, |state, cx| {
let panel = state.panels.get(ix).expect("BUG: invalid panel index");
match axis {
Axis::Horizontal => {
state.resize_panel(ix, e.position.x - panel.bounds.left(), window, cx)
}
Axis::Vertical => {
state.resize_panel(ix, e.position.y - panel.bounds.top(), window, cx);
}
}
cx.notify();
})
}
});
// When any mouse up, stop dragging
window.on_mouse_event({
let state = self.state.clone();
let current_ix = state.read(cx).resizing_panel_ix;
let on_resize = self.on_resize.clone();
move |_: &MouseUpEvent, phase, window, cx| {
if current_ix.is_none() {
return;
}
if phase.bubble() {
state.update(cx, |state, cx| state.done_resizing(cx));
on_resize(&state, window, cx);
}
}
})
}
}
-225
View File
@@ -1,225 +0,0 @@
use std::cell::Cell;
use std::rc::Rc;
use gpui::prelude::FluentBuilder as _;
use gpui::{
AnyElement, App, Axis, Element, ElementId, Entity, GlobalElementId, InteractiveElement,
IntoElement, MouseDownEvent, MouseUpEvent, ParentElement as _, Pixels, Point, Render,
StatefulInteractiveElement, Styled as _, Window, div, px,
};
use theme::{ActiveTheme, AxisExt};
use crate::dock::DockPlacement;
pub(crate) const HANDLE_PADDING: Pixels = px(4.);
pub(crate) const HANDLE_SIZE: Pixels = px(1.);
/// Create a resize handle for a resizable panel.
pub(crate) fn resize_handle<T: 'static, E: 'static + Render>(
id: impl Into<ElementId>,
axis: Axis,
) -> ResizeHandle<T, E> {
ResizeHandle::new(id, axis)
}
#[allow(clippy::type_complexity)]
pub(crate) struct ResizeHandle<T: 'static, E: 'static + Render> {
id: ElementId,
axis: Axis,
drag_value: Option<Rc<T>>,
placement: Option<DockPlacement>,
on_drag: Option<Rc<dyn Fn(&Point<Pixels>, &mut Window, &mut App) -> Entity<E>>>,
}
impl<T: 'static, E: 'static + Render> ResizeHandle<T, E> {
fn new(id: impl Into<ElementId>, axis: Axis) -> Self {
let id = id.into();
Self {
id: id.clone(),
on_drag: None,
drag_value: None,
placement: None,
axis,
}
}
pub(crate) fn on_drag(
mut self,
value: T,
f: impl Fn(Rc<T>, &Point<Pixels>, &mut Window, &mut App) -> Entity<E> + 'static,
) -> Self {
let value = Rc::new(value);
self.drag_value = Some(value.clone());
self.on_drag = Some(Rc::new(move |p, window, cx| {
f(value.clone(), p, window, cx)
}));
self
}
#[allow(dead_code)]
pub(crate) fn placement(mut self, placement: DockPlacement) -> Self {
self.placement = Some(placement);
self
}
}
#[derive(Default, Debug, Clone)]
struct ResizeHandleState {
active: Cell<bool>,
}
impl ResizeHandleState {
fn set_active(&self, active: bool) {
self.active.set(active);
}
fn is_active(&self) -> bool {
self.active.get()
}
}
impl<T: 'static, E: 'static + Render> IntoElement for ResizeHandle<T, E> {
type Element = ResizeHandle<T, E>;
fn into_element(self) -> Self::Element {
self
}
}
impl<T: 'static, E: 'static + Render> Element for ResizeHandle<T, E> {
type PrepaintState = ();
type RequestLayoutState = AnyElement;
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<&GlobalElementId>,
_: Option<&gpui::InspectorElementId>,
window: &mut Window,
cx: &mut App,
) -> (gpui::LayoutId, Self::RequestLayoutState) {
let neg_offset = -HANDLE_PADDING;
let axis = self.axis;
window.with_element_state(id.unwrap(), |state, window| {
let state = state.unwrap_or(ResizeHandleState::default());
let bg_color = if state.is_active() {
cx.theme().border_selected
} else {
cx.theme().border
};
let mut ele = div()
.id(self.id.clone())
.occlude()
.absolute()
.flex_shrink_0()
.group("handle")
.when_some(self.on_drag.clone(), |this, on_drag| {
this.on_drag(
self.drag_value.clone().unwrap(),
move |_, position, window, cx| on_drag(&position, window, cx),
)
})
.map(|this| match self.placement {
Some(DockPlacement::Left) => {
// Special for Left Dock
// FIXME: Improve this to let the scroll bar have px(HANDLE_PADDING)
this.cursor_col_resize()
.top_0()
.right(px(1.))
.h_full()
.w(HANDLE_SIZE)
.pl(HANDLE_PADDING)
}
_ => this
.when(axis.is_horizontal(), |this| {
this.cursor_col_resize()
.top_0()
.left(neg_offset)
.h_full()
.w(HANDLE_SIZE)
.px(HANDLE_PADDING)
})
.when(axis.is_vertical(), |this| {
this.cursor_row_resize()
.top(neg_offset)
.left_0()
.w_full()
.h(HANDLE_SIZE)
.py(HANDLE_PADDING)
}),
})
.child(
div()
.group_hover("handle", |this| this.bg(bg_color))
.when(axis.is_horizontal(), |this| this.h_full().w(HANDLE_SIZE))
.when(axis.is_vertical(), |this| this.w_full().h(HANDLE_SIZE)),
)
.into_any_element();
let layout_id = ele.request_layout(window, cx);
((layout_id, ele), state)
})
}
fn prepaint(
&mut self,
_: Option<&GlobalElementId>,
_: Option<&gpui::InspectorElementId>,
_: gpui::Bounds<Pixels>,
request_layout: &mut Self::RequestLayoutState,
window: &mut Window,
cx: &mut App,
) -> Self::PrepaintState {
request_layout.prepaint(window, cx);
}
fn paint(
&mut self,
id: Option<&GlobalElementId>,
_: Option<&gpui::InspectorElementId>,
bounds: gpui::Bounds<Pixels>,
request_layout: &mut Self::RequestLayoutState,
_: &mut Self::PrepaintState,
window: &mut Window,
cx: &mut App,
) {
request_layout.paint(window, cx);
window.with_element_state(id.unwrap(), |state: Option<ResizeHandleState>, window| {
let state = state.unwrap_or_default();
window.on_mouse_event({
let state = state.clone();
move |ev: &MouseDownEvent, phase, window, _| {
if bounds.contains(&ev.position) && phase.bubble() {
state.set_active(true);
window.refresh();
}
}
});
window.on_mouse_event({
let state = state.clone();
move |_: &MouseUpEvent, _, window, _| {
if state.is_active() {
state.set_active(false);
window.refresh();
}
}
});
((), state)
});
}
}
+1 -4
View File
@@ -1,7 +1,4 @@
mod scrollable;
mod scrollable_mask;
mod scrollbar;
pub use gpui_base::{Scrollbar, ScrollbarAxis, ScrollbarHandle};
pub use scrollable::*;
pub use scrollable_mask::*;
pub use scrollbar::*;
+72 -55
View File
@@ -1,15 +1,14 @@
use std::panic::Location;
use std::rc::Rc;
use gpui::prelude::FluentBuilder;
use gpui::{
App, Div, Element, ElementId, InteractiveElement, IntoElement, ParentElement, RenderOnce,
ScrollHandle, Stateful, StatefulInteractiveElement, StyleRefinement, Styled, Window, div,
ScrollHandle, Stateful, StatefulInteractiveElement, StyleRefinement, Styled, Window, div, px,
};
use gpui_base::{Scrollbar, ScrollbarAxis, ScrollbarHandle};
use theme::ActiveTheme as _;
use super::{Scrollbar, ScrollbarAxis};
use crate::StyledExt;
use crate::scroll::ScrollbarHandle;
use crate::StyledExt as _;
/// A trait for elements that can be made scrollable with scrollbars.
pub trait ScrollableElement: InteractiveElement + Styled + ParentElement + Element {
@@ -23,7 +22,7 @@ pub trait ScrollableElement: InteractiveElement + Styled + ParentElement + Eleme
self.child(ScrollbarLayer {
id: "scrollbar_layer".into(),
axis: axis.into(),
scroll_handle: Rc::new(scroll_handle.clone()),
scroll_handle: scroll_handle.clone(),
})
}
@@ -57,6 +56,72 @@ pub trait ScrollableElement: InteractiveElement + Styled + ParentElement + Eleme
}
}
/// The scrollbar the application's theme describes: a 10px rail holding a 6px
/// rounded thumb that grows to 8px under the pointer.
fn scrollbar<H: ScrollbarHandle + Clone>(
scroll_handle: &H,
axis: ScrollbarAxis,
cx: &App,
) -> Scrollbar {
let theme = cx.theme();
let (thumb_width, thumb_radius) = if theme.scrollbar_mode.is_scrolling() {
(px(6.), px(3.))
} else {
(px(8.), px(4.))
};
Scrollbar::new(scroll_handle).axis(axis).styles(|styles| {
styles
.track(|track| {
track
.bg(theme.scrollbar_track_background)
.border_color(theme.scrollbar_thumb_border)
.width(px(10.))
})
.track_hover(|track| track.bg(theme.scrollbar_thumb_background).width(px(10.)))
.thumb(|thumb| {
thumb
.bg(theme.scrollbar_thumb_background)
.width(thumb_width)
.inset(px(1.))
.radius(thumb_radius)
.min_length(px(48.))
})
.thumb_hover(|thumb| {
thumb
.bg(theme.scrollbar_thumb_hover_background)
.width(px(8.))
.inset(px(1.))
.radius(px(4.))
})
.thumb_active(|thumb| {
thumb
.bg(theme.scrollbar_thumb_hover_background)
.width(px(8.))
.inset(px(1.))
.radius(px(4.))
})
})
}
/// A scrollbar child that resolves the theme while rendering, which the
/// [`ScrollableElement`] helpers cannot do at the call site.
#[derive(IntoElement)]
struct ScrollbarLayer<H: ScrollbarHandle + Clone> {
id: ElementId,
axis: ScrollbarAxis,
scroll_handle: H,
}
impl<H> RenderOnce for ScrollbarLayer<H>
where
H: ScrollbarHandle + Clone + 'static,
{
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
scrollbar(&self.scroll_handle, self.axis, cx).id(self.id)
}
}
/// A scrollable element wrapper that adds scrollbars to an interactive element.
#[derive(IntoElement)]
pub struct Scrollable<E: InteractiveElement + Styled + ParentElement + Element> {
@@ -149,13 +214,7 @@ where
.flex_1(),
),
)
.child(render_scrollbar(
"scrollbar",
&scroll_handle,
self.axis,
window,
cx,
))
.child(scrollbar(&scroll_handle, self.axis, cx).id("scrollbar"))
}
}
@@ -167,45 +226,3 @@ where
Self: InteractiveElement,
{
}
#[derive(IntoElement)]
struct ScrollbarLayer<H: ScrollbarHandle + Clone> {
id: ElementId,
axis: ScrollbarAxis,
scroll_handle: Rc<H>,
}
impl<H> RenderOnce for ScrollbarLayer<H>
where
H: ScrollbarHandle + Clone + 'static,
{
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
render_scrollbar(self.id, self.scroll_handle.as_ref(), self.axis, window, cx)
}
}
#[inline]
#[track_caller]
fn render_scrollbar<H: ScrollbarHandle + Clone>(
id: impl Into<ElementId>,
scroll_handle: &H,
axis: ScrollbarAxis,
window: &mut Window,
cx: &mut App,
) -> Div {
// Do not render scrollbar when inspector is picking elements,
// to allow us to pick the background elements.
let is_inspector_picking = window.is_inspector_picking(cx);
if is_inspector_picking {
return div();
}
div()
.absolute()
.top_0()
.left_0()
.right_0()
.bottom_0()
.child(Scrollbar::new(scroll_handle).id(id).axis(axis))
}
-169
View File
@@ -1,169 +0,0 @@
use gpui::{
App, Axis, BorderStyle, Bounds, ContentMask, Corners, Edges, Element, ElementId, EntityId,
GlobalElementId, Hitbox, Hsla, IntoElement, IsZero as _, LayoutId, PaintQuad, Pixels, Point,
Position, ScrollHandle, ScrollWheelEvent, Size, Style, Window, px, relative,
};
use theme::AxisExt;
/// Make a scrollable mask element to cover the parent view with the mouse wheel event listening.
///
/// When the mouse wheel is scrolled, will move the `scroll_handle` scrolling with the `axis` direction.
/// You can use this `scroll_handle` to control what you want to scroll.
/// This is only can handle once axis scrolling.
pub struct ScrollableMask {
view_id: EntityId,
axis: Axis,
scroll_handle: ScrollHandle,
debug: Option<Hsla>,
}
impl ScrollableMask {
/// Create a new scrollable mask element.
pub fn new(view_id: EntityId, axis: Axis, scroll_handle: &ScrollHandle) -> Self {
Self {
view_id,
scroll_handle: scroll_handle.clone(),
axis,
debug: None,
}
}
/// Enable the debug border, to show the mask bounds.
#[allow(dead_code)]
pub fn debug(mut self) -> Self {
self.debug = Some(gpui::yellow());
self
}
}
impl IntoElement for ScrollableMask {
type Element = Self;
fn into_element(self) -> Self::Element {
self
}
}
impl Element for ScrollableMask {
type PrepaintState = Hitbox;
type RequestLayoutState = ();
fn id(&self) -> Option<ElementId> {
None
}
fn source_location(&self) -> Option<&'static std::panic::Location<'static>> {
None
}
fn request_layout(
&mut self,
_: Option<&GlobalElementId>,
_: Option<&gpui::InspectorElementId>,
window: &mut Window,
cx: &mut App,
) -> (LayoutId, Self::RequestLayoutState) {
let style = Style {
position: Position::Absolute,
flex_grow: 1.0,
flex_shrink: 1.0,
size: Size {
width: relative(1.).into(),
height: relative(1.).into(),
},
..Default::default()
};
(window.request_layout(style, None, cx), ())
}
fn prepaint(
&mut self,
_: Option<&GlobalElementId>,
_: Option<&gpui::InspectorElementId>,
bounds: Bounds<Pixels>,
_: &mut Self::RequestLayoutState,
window: &mut Window,
_: &mut App,
) -> Self::PrepaintState {
// Move y to bounds height to cover the parent view.
let cover_bounds = Bounds {
origin: Point {
x: bounds.origin.x,
y: bounds.origin.y - bounds.size.height,
},
size: bounds.size,
};
window.insert_hitbox(cover_bounds, gpui::HitboxBehavior::Normal)
}
fn paint(
&mut self,
_: Option<&GlobalElementId>,
_: Option<&gpui::InspectorElementId>,
_: Bounds<Pixels>,
_: &mut Self::RequestLayoutState,
hitbox: &mut Self::PrepaintState,
window: &mut Window,
_: &mut App,
) {
let line_height = window.line_height();
let bounds = hitbox.bounds;
window.with_content_mask(Some(ContentMask { bounds }), |window| {
if let Some(color) = self.debug {
window.paint_quad(PaintQuad {
bounds,
border_widths: Edges::all(px(1.0)),
border_color: color,
background: gpui::transparent_white().into(),
corner_radii: Corners::all(px(0.)),
border_style: BorderStyle::default(),
});
}
window.on_mouse_event({
let view_id = self.view_id;
let is_horizontal = self.axis.is_horizontal();
let scroll_handle = self.scroll_handle.clone();
let hitbox = hitbox.clone();
let mouse_position = window.mouse_position();
let last_offset = scroll_handle.offset();
move |event: &ScrollWheelEvent, phase, window, cx| {
if bounds.contains(&mouse_position)
&& phase.bubble()
&& hitbox.is_hovered(window)
{
let mut offset = scroll_handle.offset();
let mut delta = event.delta.pixel_delta(line_height);
// Limit for only one way scrolling at same time.
// When use MacBook touchpad we may get both x and y delta,
// only allows the one that more to scroll.
if !delta.x.is_zero() && !delta.y.is_zero() {
if delta.x.abs() > delta.y.abs() {
delta.y = px(0.);
} else {
delta.x = px(0.);
}
}
if is_horizontal {
offset.x += delta.x;
} else {
offset.y += delta.y;
}
if last_offset != offset {
scroll_handle.set_offset(offset);
cx.notify(view_id);
cx.stop_propagation();
}
}
}
});
});
}
}
-945
View File
@@ -1,945 +0,0 @@
use std::cell::Cell;
use std::ops::Deref;
use std::panic::Location;
use std::rc::Rc;
use gpui::{
Anchor, App, Axis, BorderStyle, Bounds, ContentMask, CursorStyle, Edges, Element, ElementId,
GlobalElementId, Hitbox, HitboxBehavior, Hsla, InspectorElementId, IntoElement, IsZero,
LayoutId, ListState, MouseDownEvent, MouseMoveEvent, MouseUpEvent, PaintQuad, Pixels, Point,
Position, ScrollHandle, ScrollWheelEvent, Size, Style, UniformListScrollHandle, Window, fill,
point, px, relative, size,
};
use instant::{Duration, Instant};
use theme::{ActiveTheme, AxisExt, ScrollbarMode};
/// The width of the scrollbar (THUMB_ACTIVE_INSET * 2 + THUMB_ACTIVE_WIDTH)
const WIDTH: Pixels = px(1. * 2. + 8.);
const MIN_THUMB_SIZE: f32 = 48.;
const THUMB_WIDTH: Pixels = px(6.);
const THUMB_RADIUS: Pixels = px(6. / 2.);
const THUMB_INSET: Pixels = px(1.);
const THUMB_ACTIVE_WIDTH: Pixels = px(8.);
const THUMB_ACTIVE_RADIUS: Pixels = px(8. / 2.);
const THUMB_ACTIVE_INSET: Pixels = px(1.);
const FADE_OUT_DURATION: f32 = 3.0;
const FADE_OUT_DELAY: f32 = 2.0;
/// A trait for scroll handles that can get and set offset.
pub trait ScrollbarHandle: 'static {
/// Get the current offset of the scroll handle.
fn offset(&self) -> Point<Pixels>;
/// Set the offset of the scroll handle.
fn set_offset(&self, offset: Point<Pixels>);
/// The full size of the content, including padding.
fn content_size(&self) -> Size<Pixels>;
/// Called when start dragging the scrollbar thumb.
fn start_drag(&self) {}
/// Called when end dragging the scrollbar thumb.
fn end_drag(&self) {}
}
impl ScrollbarHandle for ScrollHandle {
fn offset(&self) -> Point<Pixels> {
self.offset()
}
fn set_offset(&self, offset: Point<Pixels>) {
self.set_offset(offset);
}
fn content_size(&self) -> Size<Pixels> {
Size::from(self.max_offset()) + self.bounds().size
}
}
impl ScrollbarHandle for UniformListScrollHandle {
fn offset(&self) -> Point<Pixels> {
self.0.borrow().base_handle.offset()
}
fn set_offset(&self, offset: Point<Pixels>) {
self.0.borrow_mut().base_handle.set_offset(offset)
}
fn content_size(&self) -> Size<Pixels> {
let base_handle = &self.0.borrow().base_handle;
Size::from(base_handle.max_offset()) + base_handle.bounds().size
}
}
impl ScrollbarHandle for ListState {
fn offset(&self) -> Point<Pixels> {
self.scroll_px_offset_for_scrollbar()
}
fn set_offset(&self, offset: Point<Pixels>) {
self.set_offset_from_scrollbar(offset);
}
fn content_size(&self) -> Size<Pixels> {
Size::from(self.max_offset_for_scrollbar()) + self.viewport_bounds().size
}
fn start_drag(&self) {
self.scrollbar_drag_started();
}
fn end_drag(&self) {
self.scrollbar_drag_ended();
}
}
#[doc(hidden)]
#[derive(Debug, Clone)]
struct ScrollbarState(Rc<Cell<ScrollbarStateInner>>);
#[doc(hidden)]
#[derive(Debug, Clone, Copy)]
struct ScrollbarStateInner {
hovered_axis: Option<Axis>,
hovered_on_thumb: Option<Axis>,
dragged_axis: Option<Axis>,
drag_pos: Point<Pixels>,
last_scroll_offset: Point<Pixels>,
last_scroll_time: Option<Instant>,
// Last update offset
last_update: Instant,
idle_timer_scheduled: bool,
}
impl Default for ScrollbarState {
fn default() -> Self {
Self(Rc::new(Cell::new(ScrollbarStateInner {
hovered_axis: None,
hovered_on_thumb: None,
dragged_axis: None,
drag_pos: point(px(0.), px(0.)),
last_scroll_offset: point(px(0.), px(0.)),
last_scroll_time: None,
last_update: Instant::now(),
idle_timer_scheduled: false,
})))
}
}
impl Deref for ScrollbarState {
type Target = Rc<Cell<ScrollbarStateInner>>;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl ScrollbarStateInner {
fn with_drag_pos(&self, axis: Axis, pos: Point<Pixels>) -> Self {
let mut state = *self;
if axis.is_vertical() {
state.drag_pos.y = pos.y;
} else {
state.drag_pos.x = pos.x;
}
state.dragged_axis = Some(axis);
state
}
fn with_unset_drag_pos(&self) -> Self {
let mut state = *self;
state.dragged_axis = None;
state
}
fn with_hovered(&self, axis: Option<Axis>) -> Self {
let mut state = *self;
state.hovered_axis = axis;
if axis.is_some() {
state.last_scroll_time = Some(instant::Instant::now());
}
state
}
fn with_hovered_on_thumb(&self, axis: Option<Axis>) -> Self {
let mut state = *self;
state.hovered_on_thumb = axis;
if self.is_scrollbar_visible() && axis.is_some() {
state.last_scroll_time = Some(instant::Instant::now());
}
state
}
fn with_last_scroll(
&self,
last_scroll_offset: Point<Pixels>,
last_scroll_time: Option<Instant>,
) -> Self {
let mut state = *self;
state.last_scroll_offset = last_scroll_offset;
state.last_scroll_time = last_scroll_time;
state
}
fn with_last_scroll_time(&self, t: Option<Instant>) -> Self {
let mut state = *self;
state.last_scroll_time = t;
state
}
fn with_last_update(&self, t: Instant) -> Self {
let mut state = *self;
state.last_update = t;
state
}
fn with_idle_timer_scheduled(&self, scheduled: bool) -> Self {
let mut state = *self;
state.idle_timer_scheduled = scheduled;
state
}
fn is_scrollbar_visible(&self) -> bool {
// On drag
if self.dragged_axis.is_some() {
return true;
}
if let Some(last_time) = self.last_scroll_time {
let elapsed = Instant::now().duration_since(last_time).as_secs_f32();
elapsed < FADE_OUT_DURATION
} else {
false
}
}
}
/// Scrollbar axis.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ScrollbarAxis {
/// Vertical scrollbar.
Vertical,
/// Horizontal scrollbar.
Horizontal,
/// Show both vertical and horizontal scrollbars.
Both,
}
impl From<Axis> for ScrollbarAxis {
fn from(axis: Axis) -> Self {
match axis {
Axis::Vertical => Self::Vertical,
Axis::Horizontal => Self::Horizontal,
}
}
}
impl ScrollbarAxis {
/// Return true if the scrollbar axis is vertical.
#[inline]
pub fn is_vertical(&self) -> bool {
matches!(self, Self::Vertical)
}
/// Return true if the scrollbar axis is horizontal.
#[inline]
pub fn is_horizontal(&self) -> bool {
matches!(self, Self::Horizontal)
}
/// Return true if the scrollbar axis is both vertical and horizontal.
#[inline]
pub fn is_both(&self) -> bool {
matches!(self, Self::Both)
}
/// Return true if the scrollbar has vertical axis.
#[inline]
pub fn has_vertical(&self) -> bool {
matches!(self, Self::Vertical | Self::Both)
}
/// Return true if the scrollbar has horizontal axis.
#[inline]
pub fn has_horizontal(&self) -> bool {
matches!(self, Self::Horizontal | Self::Both)
}
#[inline]
fn all(&self) -> Vec<Axis> {
match self {
Self::Vertical => vec![Axis::Vertical],
Self::Horizontal => vec![Axis::Horizontal],
// This should keep Horizontal first, Vertical is the primary axis
// if Vertical not need display, then Horizontal will not keep right margin.
Self::Both => vec![Axis::Horizontal, Axis::Vertical],
}
}
}
/// Scrollbar control for scroll-area or a uniform-list.
pub struct Scrollbar {
pub(crate) id: ElementId,
axis: ScrollbarAxis,
scrollbar_mode: Option<ScrollbarMode>,
scroll_handle: Rc<dyn ScrollbarHandle>,
scroll_size: Option<Size<Pixels>>,
/// Maximum frames per second for scrolling by drag. Default is 120 FPS.
///
/// This is used to limit the update rate of the scrollbar when it is
/// being dragged for some complex interactions for reducing CPU usage.
max_fps: usize,
}
impl Scrollbar {
/// Create a new scrollbar.
///
/// This will have both vertical and horizontal scrollbars.
#[track_caller]
pub fn new<H: ScrollbarHandle + Clone>(scroll_handle: &H) -> Self {
let caller = Location::caller();
Self {
id: ElementId::CodeLocation(*caller),
axis: ScrollbarAxis::Both,
scrollbar_mode: None,
scroll_handle: Rc::new(scroll_handle.clone()),
max_fps: 120,
scroll_size: None,
}
}
/// Create with horizontal scrollbar.
#[track_caller]
pub fn horizontal<H: ScrollbarHandle + Clone>(scroll_handle: &H) -> Self {
Self::new(scroll_handle).axis(ScrollbarAxis::Horizontal)
}
/// Create with vertical scrollbar.
#[track_caller]
pub fn vertical<H: ScrollbarHandle + Clone>(scroll_handle: &H) -> Self {
Self::new(scroll_handle).axis(ScrollbarAxis::Vertical)
}
/// Set a specific element id, default is the [`Location::caller`].
///
/// NOTE: In most cases, you don't need to set a specific id for scrollbar.
pub fn id(mut self, id: impl Into<ElementId>) -> Self {
self.id = id.into();
self
}
/// Set the scrollbar show mode [`ScrollbarShow`], if not set use the `cx.theme().scrollbar_show`.
pub fn scrollbar_mode(mut self, mode: ScrollbarMode) -> Self {
self.scrollbar_mode = Some(mode);
self
}
/// Set a special scroll size of the content area, default is None.
///
/// Default will sync the `content_size` from `scroll_handle`.
pub fn scroll_size(mut self, scroll_size: Size<Pixels>) -> Self {
self.scroll_size = Some(scroll_size);
self
}
/// Set scrollbar axis.
pub fn axis(mut self, axis: impl Into<ScrollbarAxis>) -> Self {
self.axis = axis.into();
self
}
/// Set maximum frames per second for scrolling by drag. Default is 120 FPS.
///
/// If you have very high CPU usage, consider reducing this value to improve performance.
///
/// Available values: 30..120
#[allow(dead_code)]
pub(crate) fn max_fps(mut self, max_fps: usize) -> Self {
self.max_fps = max_fps.clamp(30, 120);
self
}
// Get the width of the scrollbar.
#[allow(dead_code)]
pub(crate) const fn width() -> Pixels {
WIDTH
}
fn style_for_active(cx: &App) -> (Hsla, Hsla, Hsla, Pixels, Pixels, Pixels) {
(
cx.theme().scrollbar_thumb_hover_background,
cx.theme().scrollbar_thumb_background,
cx.theme().scrollbar_thumb_border,
THUMB_ACTIVE_WIDTH,
THUMB_ACTIVE_INSET,
THUMB_ACTIVE_RADIUS,
)
}
fn style_for_hovered_thumb(cx: &App) -> (Hsla, Hsla, Hsla, Pixels, Pixels, Pixels) {
(
cx.theme().scrollbar_thumb_hover_background,
cx.theme().scrollbar_thumb_background,
cx.theme().scrollbar_thumb_border,
THUMB_ACTIVE_WIDTH,
THUMB_ACTIVE_INSET,
THUMB_ACTIVE_RADIUS,
)
}
fn style_for_hovered_bar(cx: &App) -> (Hsla, Hsla, Hsla, Pixels, Pixels, Pixels) {
(
cx.theme().scrollbar_thumb_background,
cx.theme().scrollbar_thumb_border,
gpui::transparent_black(),
THUMB_ACTIVE_WIDTH,
THUMB_ACTIVE_INSET,
THUMB_ACTIVE_RADIUS,
)
}
fn style_for_normal(&self, cx: &App) -> (Hsla, Hsla, Hsla, Pixels, Pixels, Pixels) {
let scrollbar_mode = self.scrollbar_mode.unwrap_or(cx.theme().scrollbar_mode);
let (width, inset, radius) = match scrollbar_mode {
ScrollbarMode::Scrolling => (THUMB_WIDTH, THUMB_INSET, THUMB_RADIUS),
_ => (THUMB_ACTIVE_WIDTH, THUMB_ACTIVE_INSET, THUMB_ACTIVE_RADIUS),
};
(
cx.theme().scrollbar_thumb_background,
cx.theme().scrollbar_track_background,
gpui::transparent_black(),
width,
inset,
radius,
)
}
fn style_for_idle(&self, _cx: &App) -> (Hsla, Hsla, Hsla, Pixels, Pixels, Pixels) {
let scrollbar_mode = self.scrollbar_mode.unwrap_or(ScrollbarMode::Always);
let (width, inset, radius) = match scrollbar_mode {
ScrollbarMode::Scrolling => (THUMB_WIDTH, THUMB_INSET, THUMB_RADIUS),
_ => (THUMB_ACTIVE_WIDTH, THUMB_ACTIVE_INSET, THUMB_ACTIVE_RADIUS),
};
(
gpui::transparent_black(),
gpui::transparent_black(),
gpui::transparent_black(),
width,
inset,
radius,
)
}
}
impl IntoElement for Scrollbar {
type Element = Self;
fn into_element(self) -> Self::Element {
self
}
}
#[doc(hidden)]
pub struct PrepaintState {
hitbox: Hitbox,
scrollbar_state: ScrollbarState,
states: Vec<AxisPrepaintState>,
}
#[doc(hidden)]
pub struct AxisPrepaintState {
axis: Axis,
bar_hitbox: Hitbox,
bounds: Bounds<Pixels>,
radius: Pixels,
bg: Hsla,
border: Hsla,
thumb_bounds: Bounds<Pixels>,
// Bounds of thumb to be rendered.
thumb_fill_bounds: Bounds<Pixels>,
thumb_bg: Hsla,
scroll_size: Pixels,
container_size: Pixels,
thumb_size: Pixels,
margin_end: Pixels,
}
impl Element for Scrollbar {
type PrepaintState = PrepaintState;
type RequestLayoutState = ();
fn id(&self) -> Option<gpui::ElementId> {
Some(self.id.clone())
}
fn source_location(&self) -> Option<&'static std::panic::Location<'static>> {
None
}
fn request_layout(
&mut self,
_: Option<&GlobalElementId>,
_: Option<&InspectorElementId>,
window: &mut Window,
cx: &mut App,
) -> (LayoutId, Self::RequestLayoutState) {
let style = Style {
position: Position::Absolute,
flex_grow: 1.0,
flex_shrink: 1.0,
size: Size {
width: relative(1.).into(),
height: relative(1.).into(),
},
..Default::default()
};
(window.request_layout(style, None, cx), ())
}
fn prepaint(
&mut self,
_: Option<&GlobalElementId>,
_: Option<&InspectorElementId>,
bounds: Bounds<Pixels>,
_: &mut Self::RequestLayoutState,
window: &mut Window,
cx: &mut App,
) -> Self::PrepaintState {
let hitbox = window.with_content_mask(Some(ContentMask { bounds }), |window| {
window.insert_hitbox(bounds, HitboxBehavior::Normal)
});
let state = window
.use_state(cx, |_, _| ScrollbarState::default())
.read(cx)
.clone();
let mut states = vec![];
let mut has_both = self.axis.is_both();
let scroll_size = self
.scroll_size
.unwrap_or(self.scroll_handle.content_size());
for axis in self.axis.all().into_iter() {
let is_vertical = axis.is_vertical();
let (scroll_area_size, container_size, scroll_position) = if is_vertical {
(
scroll_size.height,
hitbox.size.height,
self.scroll_handle.offset().y,
)
} else {
(
scroll_size.width,
hitbox.size.width,
self.scroll_handle.offset().x,
)
};
// The horizontal scrollbar is set avoid overlapping with the vertical scrollbar, if the vertical scrollbar is visible.
let margin_end = if has_both && !is_vertical {
WIDTH
} else {
px(0.)
};
// Hide scrollbar, if the scroll area is smaller than the container.
if scroll_area_size <= container_size {
has_both = false;
continue;
}
let thumb_length =
(container_size / scroll_area_size * container_size).max(px(MIN_THUMB_SIZE));
let thumb_start = -(scroll_position / (scroll_area_size - container_size)
* (container_size - margin_end - thumb_length));
let thumb_end = (thumb_start + thumb_length).min(container_size - margin_end);
let bounds = Bounds {
origin: if is_vertical {
point(hitbox.origin.x + hitbox.size.width - WIDTH, hitbox.origin.y)
} else {
point(
hitbox.origin.x,
hitbox.origin.y + hitbox.size.height - WIDTH,
)
},
size: gpui::Size {
width: if is_vertical {
WIDTH
} else {
hitbox.size.width
},
height: if is_vertical {
hitbox.size.height
} else {
WIDTH
},
},
};
let scrollbar_show = self.scrollbar_mode.unwrap_or(cx.theme().scrollbar_mode);
let is_always_to_show = scrollbar_show.is_always();
let is_hover_to_show = scrollbar_show.is_hover();
let is_hovered_on_bar = state.get().hovered_axis == Some(axis);
let is_hovered_on_thumb = state.get().hovered_on_thumb == Some(axis);
let is_offset_changed = state.get().last_scroll_offset != self.scroll_handle.offset();
let (thumb_bg, bar_bg, bar_border, thumb_width, inset, radius) =
if state.get().dragged_axis == Some(axis) {
Self::style_for_active(cx)
} else if is_hover_to_show && (is_hovered_on_bar || is_hovered_on_thumb) {
if is_hovered_on_thumb {
Self::style_for_hovered_thumb(cx)
} else {
Self::style_for_hovered_bar(cx)
}
} else if is_offset_changed {
self.style_for_normal(cx)
} else if is_always_to_show {
if is_hovered_on_thumb {
Self::style_for_hovered_thumb(cx)
} else {
Self::style_for_hovered_bar(cx)
}
} else {
let mut idle_state = self.style_for_idle(cx);
// Delay 2s to fade out the scrollbar thumb (in 1s)
if let Some(last_time) = state.get().last_scroll_time {
let elapsed = Instant::now().duration_since(last_time).as_secs_f32();
if is_hovered_on_bar {
state.set(state.get().with_last_scroll_time(Some(Instant::now())));
idle_state = if is_hovered_on_thumb {
Self::style_for_hovered_thumb(cx)
} else {
Self::style_for_hovered_bar(cx)
};
} else if elapsed < FADE_OUT_DELAY {
idle_state.0 = cx.theme().scrollbar_thumb_background;
if !state.get().idle_timer_scheduled {
let state = state.clone();
state.set(state.get().with_idle_timer_scheduled(true));
let current_view = window.current_view();
let next_delay = Duration::from_secs_f32(FADE_OUT_DELAY - elapsed);
window
.spawn(cx, async move |cx| {
cx.background_executor().timer(next_delay).await;
state.set(state.get().with_idle_timer_scheduled(false));
cx.update(|_, cx| cx.notify(current_view)).ok();
})
.detach();
}
} else if elapsed < FADE_OUT_DURATION {
let opacity = 1.0 - (elapsed - FADE_OUT_DELAY).powi(10);
idle_state.0 = cx.theme().scrollbar_thumb_background.opacity(opacity);
window.request_animation_frame();
}
}
idle_state
};
// The clickable area of the thumb
let thumb_length = thumb_end - thumb_start - inset * 2;
let thumb_bounds = if is_vertical {
Bounds::from_anchor_and_size(
Anchor::TopRight,
bounds.top_right() + point(-inset, inset + thumb_start),
size(WIDTH, thumb_length),
)
} else {
Bounds::from_anchor_and_size(
Anchor::BottomLeft,
bounds.bottom_left() + point(inset + thumb_start, -inset),
size(thumb_length, WIDTH),
)
};
// The actual render area of the thumb
let thumb_fill_bounds = if is_vertical {
Bounds::from_anchor_and_size(
Anchor::TopRight,
bounds.top_right() + point(-inset, inset + thumb_start),
size(thumb_width, thumb_length),
)
} else {
Bounds::from_anchor_and_size(
Anchor::BottomLeft,
bounds.bottom_left() + point(inset + thumb_start, -inset),
size(thumb_length, thumb_width),
)
};
let bar_hitbox = window.with_content_mask(Some(ContentMask { bounds }), |window| {
window.insert_hitbox(bounds, gpui::HitboxBehavior::Normal)
});
states.push(AxisPrepaintState {
axis,
bar_hitbox,
bounds,
radius,
bg: bar_bg,
border: bar_border,
thumb_bounds,
thumb_fill_bounds,
thumb_bg,
scroll_size: scroll_area_size,
container_size,
thumb_size: thumb_length,
margin_end,
})
}
PrepaintState {
hitbox,
states,
scrollbar_state: state,
}
}
fn paint(
&mut self,
_: Option<&GlobalElementId>,
_: Option<&InspectorElementId>,
_: Bounds<Pixels>,
_: &mut Self::RequestLayoutState,
prepaint: &mut Self::PrepaintState,
window: &mut Window,
cx: &mut App,
) {
let scrollbar_state = &prepaint.scrollbar_state;
let scrollbar_show = self.scrollbar_mode.unwrap_or(cx.theme().scrollbar_mode);
let view_id = window.current_view();
let hitbox_bounds = prepaint.hitbox.bounds;
let is_visible = scrollbar_state.get().is_scrollbar_visible() || scrollbar_show.is_always();
let is_hover_to_show = scrollbar_show.is_hover();
// Update last_scroll_time when offset is changed.
if self.scroll_handle.offset() != scrollbar_state.get().last_scroll_offset {
scrollbar_state.set(
scrollbar_state
.get()
.with_last_scroll(self.scroll_handle.offset(), Some(Instant::now())),
);
cx.notify(view_id);
}
window.with_content_mask(
Some(ContentMask {
bounds: hitbox_bounds,
}),
|window| {
for state in prepaint.states.iter() {
let axis = state.axis;
let mut radius = state.radius;
if cx.theme().radius.is_zero() {
radius = px(0.);
}
let bounds = state.bounds;
let thumb_bounds = state.thumb_bounds;
let scroll_area_size = state.scroll_size;
let container_size = state.container_size;
let thumb_size = state.thumb_size;
let margin_end = state.margin_end;
let is_vertical = axis.is_vertical();
window.set_cursor_style(CursorStyle::default(), &state.bar_hitbox);
window.paint_layer(hitbox_bounds, |cx| {
cx.paint_quad(fill(state.bounds, state.bg));
cx.paint_quad(PaintQuad {
bounds,
corner_radii: (0.).into(),
background: gpui::transparent_black().into(),
border_widths: Edges {
top: px(0.),
right: px(0.),
bottom: px(0.),
left: px(0.),
},
border_color: state.border,
border_style: BorderStyle::default(),
});
cx.paint_quad(
fill(state.thumb_fill_bounds, state.thumb_bg).corner_radii(radius),
);
});
window.on_mouse_event({
let state = scrollbar_state.clone();
let scroll_handle = self.scroll_handle.clone();
move |event: &ScrollWheelEvent, phase, _, cx| {
if phase.bubble()
&& hitbox_bounds.contains(&event.position)
&& scroll_handle.offset() != state.get().last_scroll_offset
{
state.set(state.get().with_last_scroll(
scroll_handle.offset(),
Some(Instant::now()),
));
cx.notify(view_id);
}
}
});
let safe_range = (-scroll_area_size + container_size)..px(0.);
if is_hover_to_show || is_visible {
window.on_mouse_event({
let state = scrollbar_state.clone();
let scroll_handle = self.scroll_handle.clone();
move |event: &MouseDownEvent, phase, _, cx| {
if phase.bubble() && bounds.contains(&event.position) {
cx.stop_propagation();
if thumb_bounds.contains(&event.position) {
// click on the thumb bar, set the drag position
let pos = event.position - thumb_bounds.origin;
scroll_handle.start_drag();
state.set(state.get().with_drag_pos(axis, pos));
cx.notify(view_id);
} else {
// click on the scrollbar, jump to the position
// Set the thumb bar center to the click position
let offset = scroll_handle.offset();
let percentage = if is_vertical {
(event.position.y - thumb_size / 2. - bounds.origin.y)
/ (bounds.size.height - thumb_size)
} else {
(event.position.x - thumb_size / 2. - bounds.origin.x)
/ (bounds.size.width - thumb_size)
}
.min(1.);
if is_vertical {
scroll_handle.set_offset(point(
offset.x,
(-scroll_area_size * percentage)
.clamp(safe_range.start, safe_range.end),
));
} else {
scroll_handle.set_offset(point(
(-scroll_area_size * percentage)
.clamp(safe_range.start, safe_range.end),
offset.y,
));
}
}
}
}
});
}
window.on_mouse_event({
let scroll_handle = self.scroll_handle.clone();
let state = scrollbar_state.clone();
let max_fps_duration = Duration::from_millis((1000 / self.max_fps) as u64);
move |event: &MouseMoveEvent, _, _, cx| {
let mut notify = false;
// When is hover to show mode or it was visible,
// we need to update the hovered state and increase the last_scroll_time.
let need_hover_to_update = is_hover_to_show || is_visible;
// Update hovered state for scrollbar
if bounds.contains(&event.position) && need_hover_to_update {
state.set(state.get().with_hovered(Some(axis)));
if state.get().hovered_axis != Some(axis) {
notify = true;
}
} else if state.get().hovered_axis == Some(axis) {
state.set(state.get().with_hovered(None));
notify = true;
}
// Update hovered state for scrollbar thumb
if thumb_bounds.contains(&event.position) {
if state.get().hovered_on_thumb != Some(axis) {
state.set(state.get().with_hovered_on_thumb(Some(axis)));
notify = true;
}
} else if state.get().hovered_on_thumb == Some(axis) {
state.set(state.get().with_hovered_on_thumb(None));
notify = true;
}
// Move thumb position on dragging
if state.get().dragged_axis == Some(axis) && event.dragging() {
// Stop the event propagation to avoid selecting text or other side effects.
cx.stop_propagation();
// drag_pos is the position of the mouse down event
// We need to keep the thumb bar still at the origin down position
let drag_pos = state.get().drag_pos;
let percentage = (if is_vertical {
(event.position.y - drag_pos.y - bounds.origin.y)
/ (bounds.size.height - thumb_size)
} else {
(event.position.x - drag_pos.x - bounds.origin.x)
/ (bounds.size.width - thumb_size - margin_end)
})
.clamp(0., 1.);
let offset = if is_vertical {
point(
scroll_handle.offset().x,
(-(scroll_area_size - container_size) * percentage)
.clamp(safe_range.start, safe_range.end),
)
} else {
point(
(-(scroll_area_size - container_size) * percentage)
.clamp(safe_range.start, safe_range.end),
scroll_handle.offset().y,
)
};
if (scroll_handle.offset().y - offset.y).abs() > px(1.)
|| (scroll_handle.offset().x - offset.x).abs() > px(1.)
{
// Limit update rate
if state.get().last_update.elapsed() > max_fps_duration {
scroll_handle.set_offset(offset);
state.set(state.get().with_last_update(Instant::now()));
notify = true;
}
}
}
if notify {
cx.notify(view_id);
}
}
});
window.on_mouse_event({
let state = scrollbar_state.clone();
let scroll_handle = self.scroll_handle.clone();
move |_event: &MouseUpEvent, phase, _, cx| {
if phase.bubble() {
scroll_handle.end_drag();
state.set(state.get().with_unset_drag_pos());
cx.notify(view_id);
}
}
});
}
},
);
}
}
+92 -191
View File
@@ -1,19 +1,19 @@
use std::cell::RefCell;
use std::rc::Rc;
use instant::Duration;
use std::time::Duration;
use gpui::prelude::FluentBuilder as _;
use gpui::{
Animation, AnimationExt as _, AnyElement, App, Element, ElementId, GlobalElementId,
InteractiveElement, IntoElement, LayoutId, ParentElement as _, SharedString, Styled as _,
Window, div, px, white,
App, ElementId, IntoElement, ParentElement as _, RenderOnce, SharedString, Styled as _, Window,
div, px, white,
};
use gpui_base::{Spring, Switch as BaseSwitch, SwitchThumb, SwitchTrack, spring};
use theme::{ActiveTheme, Side};
use crate::{Disableable, Sizable, Size};
type OnClick = Option<Rc<dyn Fn(&bool, &mut Window, &mut App)>>;
#[derive(IntoElement)]
pub struct Switch {
id: ElementId,
checked: bool,
@@ -84,204 +84,105 @@ impl Disableable for Switch {
}
}
impl IntoElement for Switch {
type Element = Self;
impl RenderOnce for Switch {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let checked = self.checked;
let on_click = self.on_click.clone();
fn into_element(self) -> Self::Element {
self
}
}
let (bg, toggle_bg) = match checked {
true => (cx.theme().element_background, white()),
false => (cx.theme().elevated_surface_background, white()),
};
#[derive(Default)]
pub struct SwitchState {
prev_checked: Rc<RefCell<Option<bool>>>,
}
let (bg, toggle_bg) = match self.disabled {
true => (bg.opacity(0.3), toggle_bg.opacity(0.8)),
false => (bg, toggle_bg),
};
impl Element for Switch {
type PrepaintState = ();
type RequestLayoutState = AnyElement;
let (bg_width, bg_height) = match self.size {
Size::XSmall | Size::Small => (px(28.), px(16.)),
_ => (px(36.), px(20.)),
};
fn id(&self) -> Option<ElementId> {
Some(self.id.clone())
}
let bar_width = match self.size {
Size::XSmall | Size::Small => px(12.),
_ => px(16.),
};
fn source_location(&self) -> Option<&'static std::panic::Location<'static>> {
None
}
let inset = px(2.);
fn request_layout(
&mut self,
global_id: Option<&GlobalElementId>,
_: Option<&gpui::InspectorElementId>,
window: &mut Window,
cx: &mut App,
) -> (LayoutId, Self::RequestLayoutState) {
window.with_element_state::<SwitchState, _>(global_id.unwrap(), move |state, window| {
let state = state.unwrap_or_default();
let thumb_left = spring(
(self.id.clone(), "thumb"),
if checked {
bg_width - bar_width - inset * 2.
} else {
px(0.)
},
Spring::new(Duration::from_secs_f64(0.15)),
window,
cx,
);
let theme = cx.theme();
let checked = self.checked;
let on_click = self.on_click.clone();
let accessibility_label = self.label.clone();
let label = self.label;
let (bg, toggle_bg) = match self.checked {
true => (theme.element_background, white()),
false => (theme.elevated_surface_background, white()),
};
let (bg, toggle_bg) = match self.disabled {
true => (bg.opacity(0.3), toggle_bg.opacity(0.8)),
false => (bg, toggle_bg),
};
let (bg_width, bg_height) = match self.size {
Size::XSmall | Size::Small => (px(28.), px(16.)),
_ => (px(36.), px(20.)),
};
let bar_width = match self.size {
Size::XSmall | Size::Small => px(12.),
_ => px(16.),
};
let inset = px(2.);
let mut element = div()
div().child(
BaseSwitch::new(self.id.clone())
.checked(checked)
.disabled(self.disabled)
.when_some(accessibility_label, |this, label| {
this.accessibility_label(label)
})
.when_some(on_click, |this, on_click| {
this.on_change(move |next, _event, window, cx| on_click(&next, window, cx))
})
.when(self.label_side.is_left(), |this| this.flex_row_reverse())
.child(
div()
.id(self.id.clone())
.when(self.label_side.is_left(), |this| this.flex_row_reverse())
.child(
div()
.w_full()
.flex()
.justify_between()
.items_center()
.gap_4()
.when_some(self.label.clone(), |this, label| {
// Label
this.child(
div().text_sm().text_color(cx.theme().text).child(label),
)
})
.child(
// Switch Bar
div()
.id(self.id.clone())
.flex_shrink_0()
.w(bg_width)
.h(bg_height)
.rounded(bg_height / 2.)
.flex()
.items_center()
.border(inset)
.border_color(theme.border_transparent)
.bg(bg)
.when(!self.disabled, |this| this.cursor_pointer())
.child(
// Switch Toggle
div()
.rounded_full()
.when(cx.theme().shadow, |this| this.shadow_sm())
.bg(toggle_bg)
.size(bar_width)
.map(|this| {
let prev_checked = state.prev_checked.clone();
if !self.disabled
&& prev_checked
.borrow()
.is_some_and(|prev| prev != checked)
{
let dur = Duration::from_secs_f64(0.15);
cx.spawn(async move |cx| {
cx.background_executor()
.timer(dur)
.await;
*prev_checked.borrow_mut() =
Some(checked);
})
.detach();
this.with_animation(
ElementId::NamedInteger(
"move".into(),
checked as u64,
),
Animation::new(dur),
move |this, delta| {
let max_x = bg_width
- bar_width
- inset * 2;
let x = if checked {
max_x * delta
} else {
max_x - max_x * delta
};
this.left(x)
},
)
.into_any_element()
} else {
let max_x =
bg_width - bar_width - inset * 2;
let x =
if checked { max_x } else { px(0.) };
this.left(x).into_any_element()
}
}),
),
),
)
.when_some(self.description.clone(), |this, description| {
this.child(
div()
.pr_3()
.text_xs()
.text_color(cx.theme().text_muted)
.child(description),
)
.w_full()
.flex()
.justify_between()
.items_center()
.gap_4()
.when_some(label, |this, label| {
// Label
this.child(div().text_sm().text_color(cx.theme().text).child(label))
})
.when_some(
on_click
.as_ref()
.map(|c| c.clone())
.filter(|_| !self.disabled),
|this, on_click| {
let prev_checked = state.prev_checked.clone();
this.on_mouse_down(gpui::MouseButton::Left, move |_, window, cx| {
cx.stop_propagation();
*prev_checked.borrow_mut() = Some(checked);
on_click(&!checked, window, cx);
})
},
.child(
// Switch Bar
SwitchTrack::new((self.id.clone(), "track"))
.checked(checked)
.disabled(self.disabled)
.flex_shrink_0()
.w(bg_width)
.h(bg_height)
.rounded(bg_height / 2.)
.flex()
.items_center()
.border(inset)
.border_color(cx.theme().border_transparent)
.bg(bg)
.when(!self.disabled, |this| this.cursor_pointer())
.child(
// Switch Toggle
SwitchThumb::new(checked)
.rounded_full()
.when(cx.theme().shadow, |this| this.shadow_sm())
.bg(toggle_bg)
.size(bar_width)
.left(thumb_left),
),
),
)
.into_any_element();
((element.request_layout(window, cx), element), state)
})
}
fn prepaint(
&mut self,
_: Option<&gpui::GlobalElementId>,
_: Option<&gpui::InspectorElementId>,
_: gpui::Bounds<gpui::Pixels>,
element: &mut Self::RequestLayoutState,
window: &mut Window,
cx: &mut App,
) {
element.prepaint(window, cx);
}
fn paint(
&mut self,
_: Option<&gpui::GlobalElementId>,
_: Option<&gpui::InspectorElementId>,
_: gpui::Bounds<gpui::Pixels>,
element: &mut Self::RequestLayoutState,
_: &mut Self::PrepaintState,
window: &mut Window,
cx: &mut App,
) {
element.paint(window, cx)
.when_some(self.description.clone(), |this, description| {
this.child(
div()
.pr_3()
.text_xs()
.text_color(cx.theme().text_muted)
.child(description),
)
}),
)
}
}
+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);
-492
View File
@@ -1,492 +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.
- 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, 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
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 | Replace; keep the `ScrollableElement` and `Scrollbar` names | `Scrollbar`, `ScrollableMask` |
| `resizable/` | 927 | Replace; base exports the same names (`h_resizable`, `v_resizable`, `resizable_panel`, `PANEL_MIN_SIZE`, `resize_handle`) | `Resizable` + `ResizeHandleRenderer` for the coop hairline |
| `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 | 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` | 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 | — |
Roughly 10k lines are removed, 3k are re-expressed as thin skins, and 8k are kept.
## 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)
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.
### 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 1–4 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.
- Keep the call-site diff at zero where the phase claims it — phases 1 and 3–4 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–10 | Phase 4: one leaf module each | none | not started |
| later | Phase 5: dock, as its own plan | `crates/workspace`, `crates/chat_ui` | not started |
The end state: the application keeps its design system and its call sites, `crates/ui`
shrinks by roughly half, and the parts that are genuinely hard — text editing,
focus and IME, drag-resize arithmetic, overlay lifecycle, accessibility semantics — are
maintained upstream instead of in a fork.