//! The Signed appearance for a tiles canvas. //! //! `gpui_base::dock::TilesState` owns the geometry — snapping, the resize //! arithmetic, the undo stack, the zoom flag — and draws none of it. The tile //! frame, its title bar and its resize affordances are here, ported from //! gpui-component's `TilesSkin` (the vendored dock had no tiles canvas, so //! there is no local look to preserve). use std::rc::Rc; use gpui::prelude::FluentBuilder as _; use gpui::{ AnyElement, App, AppContext as _, Context, Div, DragMoveEvent, Empty, InteractiveElement as _, IntoElement, MouseButton, MouseDownEvent, ParentElement as _, Pixels, Render, ScrollHandle, Size, Stateful, StatefulInteractiveElement as _, Styled as _, Window, div, px, }; use gpui_base::dock::{ DRAG_BAR_HEIGHT, HANDLE_SIZE, NodeId, ResizeSide, TileContext, TilesRenderer, }; use gpui_component::button::{Button, ButtonVariants as _}; use gpui_component::menu::{DropdownMenu as _, PopupMenuItem}; use gpui_component::scroll::Scrollbar; use gpui_component::{ ActiveTheme as _, Icon, IconName, Selectable as _, Sizable as _, h_flex, v_flex, }; use crate::dock_area::SkinShared; use crate::tab_panel::panel_title; use crate::{PanelHandle, t}; /// How far a resize handle sticks out past the tile's edge. const HANDLE_OFFSET: Pixels = px(-4.); /// The payload a tile drag carries, so one canvas ignores another's drags. #[derive(Clone)] struct DragMoving(NodeId); impl Render for DragMoving { fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { Empty } } /// The payload a tile resize carries, for the same reason. #[derive(Clone)] struct DragResizing(NodeId); impl Render for DragResizing { fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { Empty } } /// One tiles canvas's appearance. /// /// Built per canvas — `DockAreaRenderer::tiles_renderer` is called once per /// container — so the scroll position belongs to the canvas it scrolls. pub(crate) struct SignedTilesSkin { shared: Rc, scroll_handle: ScrollHandle, } impl SignedTilesSkin { pub(crate) fn new(shared: Rc) -> Self { Self { shared, scroll_handle: ScrollHandle::default(), } } /// One edge or corner handle. fn resize_handle( &self, tile: &TileContext, id: &'static str, side: ResizeSide, build: impl FnOnce(Stateful
) -> Stateful
, ) -> Stateful
{ let node = tile.node(); build(div().id(id).absolute()) .on_mouse_down(MouseButton::Left, { let tile = tile.clone(); move |event: &MouseDownEvent, window, cx| { tile.begin_resize(side, event.position, window, cx); cx.stop_propagation(); } }) .on_drag(DragResizing(node), |drag, _, _, cx| { cx.stop_propagation(); cx.new(|_| drag.clone()) }) .on_drag_move({ let tile = tile.clone(); move |event: &DragMoveEvent, window, cx| { if event.drag(cx).0 != node { return; } tile.resize_to(event.event.position, window, cx); } }) } /// The trailing controls of a tile's title bar. /// /// A tile has no tab bar to hang a toolbar off, so this is where its zoom, /// close and ellipsis menu live. The entries use click handlers rather /// than the [`ToggleZoom`](crate::ToggleZoom) and /// [`ClosePanel`](crate::ClosePanel) actions: those are dispatched to a /// focused tab group, and a tile is not one. fn render_tile_controls( &self, tile: &TileContext, window: &mut Window, cx: &mut App, ) -> impl IntoElement { let handle = PanelHandle::of(tile.panel()); let control = handle.and_then(|handle| handle.zoom_control(cx)); let zoomed = tile.is_zoomed(); let toolbar_zoom = tile.is_zoomable() && control.is_some_and(|control| control.toolbar_visible()); let menu_zoom = tile.is_zoomable() && control.is_some_and(|control| control.menu_visible()); let closable = tile.is_closable(); let buttons = handle.and_then(|handle| handle.toolbar_buttons(window, cx)); let panel = handle.map(|handle| handle.panel()); h_flex() .gap_1() .flex_shrink_0() .occlude() .when_some(buttons, |this, buttons| { this.children( buttons .into_iter() .map(|button| button.xsmall().ghost().tab_stop(false)), ) }) .when_some( match (zoomed, toolbar_zoom) { (true, _) => Some(("zoom-out", IconName::Minimize, t("Dock.Zoom Out"))), (false, true) => Some(("zoom-in", IconName::Maximize, t("Dock.Zoom In"))), (false, false) => None, }, |this, (id, icon, tooltip)| { this.child( Button::new(id) .icon(icon) .xsmall() .ghost() .tab_stop(false) .tooltip(tooltip) .selected(zoomed) .on_click({ let tile = tile.clone(); move |_, window, cx| tile.toggle_zoom(window, cx) }), ) }, ) .child( Button::new("menu") .icon(IconName::Ellipsis) .xsmall() .ghost() .tab_stop(false) .dropdown_menu({ let tile = tile.clone(); move |menu, window, cx| { menu.when_some(panel.clone(), |menu, panel| { panel.dropdown_menu(menu, window, cx) }) .separator() .item( PopupMenuItem::new(match zoomed { true => t("Dock.Zoom Out"), false => t("Dock.Zoom In"), }) .disabled(!menu_zoom && !zoomed) .on_click({ let tile = tile.clone(); move |_, window, cx| tile.toggle_zoom(window, cx) }), ) .when(closable, |menu| { menu.separator() .item(PopupMenuItem::new(t("Dock.Close")).on_click({ let tile = tile.clone(); move |_, window, cx| tile.close(window, cx) })) }) } }) .anchor(gpui::Anchor::TopRight), ) } } impl TilesRenderer for SignedTilesSkin { fn frame(&self, _: &mut Window, cx: &mut App) -> Stateful
{ div() .id("tiles") .relative() .size_full() .bg(cx.theme().tokens.tiles) .track_scroll(&self.scroll_handle) .overflow_scroll() } fn tile_frame(&self, tile: &TileContext, _: &mut Window, cx: &mut App) -> Stateful
{ v_flex() .id(("tile", tile.panel_id().as_u64())) .occlude() .overflow_hidden() .bg(cx.theme().tokens.background) .border_1() .border_color(cx.theme().border) .rounded(cx.theme().tile_radius) // Room for the title bar, which is positioned over the padding so // the panel below it is never covered. Base draws the panel view // as a plain child, so this is the only way to keep the two from // overlapping. .pt(DRAG_BAR_HEIGHT) // Base installs the stored bounds on an ordinary tile and nothing // at all on a zoomed one — how a zoomed tile fills the dock is // this skin's decision. .when(tile.is_zoomed(), |this| this.size_full()) .on_mouse_down(MouseButton::Left, { let tile = tile.clone(); move |_, window, cx| tile.bring_to_front(window, cx) }) // A gesture can end with the pointer anywhere, so both halves are // needed; each is a no-op unless this tile is the one moving. .on_mouse_up(MouseButton::Left, { let tile = tile.clone(); move |_, window, cx| { tile.end_move(window, cx); tile.end_resize(window, cx); } }) .on_mouse_up_out(MouseButton::Left, { let tile = tile.clone(); move |_, window, cx| { tile.end_move(window, cx); tile.end_resize(window, cx); } }) } fn render_drag_bar(&self, tile: &TileContext, window: &mut Window, cx: &mut App) -> AnyElement { let node = tile.node(); let handle = PanelHandle::of(tile.panel()); let title_style = handle.and_then(|handle| handle.title_style(cx)); h_flex() .id("drag-bar") .absolute() .top_0() .left_0() .w_full() .h(DRAG_BAR_HEIGHT) .items_center() .gap_1() .pl_3() .pr_2() .when_some(title_style, |this, style| { this.bg(style.background).text_color(style.foreground) }) .child( div() .flex_1() .min_w_16() .overflow_hidden() .text_ellipsis() .whitespace_nowrap() .child(panel_title(tile.panel(), window, cx)), ) .children(handle.and_then(|handle| handle.title_suffix(window, cx))) .child(self.render_tile_controls(tile, window, cx)) // A zoomed tile is not at its stored bounds, so there is nothing // for a move to mean; base refuses the gesture too. .when(!tile.is_zoomed(), |this| { this.cursor_grab() .on_mouse_down(MouseButton::Left, { let tile = tile.clone(); move |event: &MouseDownEvent, window, cx| { tile.begin_move(event.position, window, cx); } }) .on_drag(DragMoving(node), |drag, _, _, cx| { cx.stop_propagation(); cx.new(|_| drag.clone()) }) .on_drag_move({ let tile = tile.clone(); move |event: &DragMoveEvent, window, cx| { if event.drag(cx).0 != node { return; } tile.move_to(event.event.position, window, cx); } }) }) .into_any_element() } fn render_resize_handles( &self, tile: &TileContext, _: &mut Window, cx: &mut App, ) -> AnyElement { let bounds = tile.bounds(); // A passive full-tile box so each handle is positioned against the // tile rather than against whatever the flow put it next to. It // registers no interaction of its own, so it does not shadow the panel // underneath. div() .absolute() .top_0() .left_0() .size_full() .child( self.resize_handle(tile, "left-resize-handle", ResizeSide::Left, |this| { this.cursor_ew_resize() .top_0() .left(HANDLE_OFFSET) .w(HANDLE_SIZE) .h(bounds.size.height) }), ) .child( self.resize_handle(tile, "right-resize-handle", ResizeSide::Right, |this| { this.cursor_ew_resize() .top_0() .right(HANDLE_OFFSET) .w(HANDLE_SIZE) .h(bounds.size.height) }), ) .child( self.resize_handle(tile, "top-resize-handle", ResizeSide::Top, |this| { this.cursor_ns_resize() .left_0() .top(HANDLE_OFFSET) .w(bounds.size.width) .h(HANDLE_SIZE) }), ) .child( self.resize_handle(tile, "bottom-resize-handle", ResizeSide::Bottom, |this| { this.cursor_ns_resize() .left_0() .bottom(HANDLE_OFFSET) .w(bounds.size.width) .h(HANDLE_SIZE) }), ) .child( Icon::new(IconName::ResizeCorner) .size_3() .absolute() .right(px(1.)) .bottom(px(1.)) .text_color(cx.theme().muted_foreground.opacity(0.5)), ) .child(self.resize_handle( tile, "corner-resize-handle", ResizeSide::BottomRight, |this| { this.cursor_nwse_resize() .right(HANDLE_OFFSET) .bottom(HANDLE_OFFSET) .size_3() }, )) .into_any_element() } /// The panel of a tile gets `size_full` here; base draws the panel as a /// plain child, so without it a panel that does not size itself has no /// size. fn panel_frame(&self, tile: &TileContext, _: &mut Window, _: &mut App) -> Stateful
{ h_flex() .id(("tile-panel", tile.panel_id().as_u64())) .overflow_hidden() .size_full() } /// The canvas scrollbar. /// /// It has to be an overlay rather than one of the frame's own children: /// the frame is the scroll container and base appends the tiles after /// whatever the frame carries, so a scrollbar placed there would paint and /// hit-test underneath every tile. fn render_overlay( &self, content: Size, _: &mut Window, _: &mut App, ) -> Option { Some( Scrollbar::new(&self.scroll_handle) .scroll_size(content) .when_some(self.shared.tiles_scrollbar_mode(), |this, mode| { this.mode(mode) }) .into_any_element(), ) } fn grid_size(&self, cx: &App) -> Pixels { cx.theme().tile_grid_size } }