Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e9b5441104 | ||
|
|
2faba843a7 | ||
|
|
a7baa03c64 | ||
|
|
84e8ad29bc | ||
|
|
b5d959748f | ||
|
|
3949644597 |
@@ -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.
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -8,7 +8,6 @@ pub use window_ext::*;
|
||||
|
||||
pub use crate::Disableable;
|
||||
|
||||
pub mod actions;
|
||||
pub mod animation;
|
||||
pub mod avatar;
|
||||
pub mod button;
|
||||
|
||||
@@ -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)),
|
||||
),
|
||||
))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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(),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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,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::*;
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user