Files
signed/crates/dock/src/tiles.rs
T

412 lines
15 KiB
Rust

//! 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<Self>) -> 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<Self>) -> 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<SkinShared>,
scroll_handle: ScrollHandle,
}
impl SignedTilesSkin {
pub(crate) fn new(shared: Rc<SkinShared>) -> 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<Div>) -> Stateful<Div>,
) -> Stateful<Div> {
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<DragResizing>, 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: zoom, close and the
/// ellipsis menu. They use click handlers rather than the
/// [`ToggleZoom`](crate::ToggleZoom)/[`ClosePanel`](crate::ClosePanel)
/// actions, which are dispatched to a focused tab group — 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> {
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<Div> {
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<DragMoving>, 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<Div> {
h_flex()
.id(("tile-panel", tile.panel_id().as_u64()))
.overflow_hidden()
.size_full()
}
/// The canvas scrollbar. It must be an overlay: the frame is the scroll
/// container and base appends the tiles after it, so a scrollbar placed
/// inside would paint and hit-test underneath every tile.
fn render_overlay(
&self,
content: Size<Pixels>,
_: &mut Window,
_: &mut App,
) -> Option<AnyElement> {
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
}
}