feat: out-of-box experience (#2)
Reviewed-on: https://git.reya.su/reya/signed/pulls/2
This commit was merged in pull request #2.
This commit is contained in:
@@ -0,0 +1,416 @@
|
||||
//! 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.
|
||||
///
|
||||
/// 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> {
|
||||
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 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<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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user