blob: 825a96b84b3a2637b478b5f4e91ceeebda8c48df [file]
//! Dock and panel integration: a script contributes a panel to a docked
//! layout, and draws the dock's chrome.
//!
//! `gpui_base::dock` already has the half of a plugin system that is hard to
//! build: a layout that is pure data, a
//! [`PanelRegistry`](gpui_base::dock::PanelRegistry) that rebuilds a panel
//! from a name in a persisted file, and a per-panel `serde_json::Value` that
//! rides along with it. What it lacks is a way for a panel to come from
//! somewhere other than the host binary. This module is that way — see
//! `docs/gpui-shell.md` §15.
//!
//! Three parts, and they are independent of each other:
//!
//! - [`ScriptPanel`] is a [`Panel`] whose body is a [`ScriptView`]. It carries
//! the script's own `serialize()` payload through
//! [`PanelInfo::panel`](gpui_base::dock::PanelInfo::panel), and
//! [`register_panel`] teaches the registry to rebuild it, so a layout
//! written before a restart comes back with the panel in place.
//! - [`ScriptDockSkin`] is the appearance. Base draws no chrome at all — an
//! area with no renderer docks, drags, resizes and persists while painting
//! nothing but the panels — so every tab bar and dock frame a script wants
//! has to come back through the two renderer traits. The
//! skin forwards each one to [`DockChrome`], and a skin with no chrome is
//! still a working dock.
//! - A **dock command** is what a chrome element *does*. A chrome description
//! is cached until its callback or resolved native state changes, so it may
//! not register a handler — cached elements have no script callback lifetime.
//! A command names a container and what to ask it,
//! carries no script value at all, and is resolved against the contexts the
//! last drawn frame recorded, when the pointer arrives.
//!
//! # Engine independence
//!
//! Nothing here knows what a script value is. The script side of the first two
//! is a trait — [`DockChrome`] for the chrome callbacks, [`PanelScript`] for
//! the panel's build and (de)serialize hooks — that the engine implements, and
//! the third is plain data. This module deals only in [`Entity<ScriptView>`],
//! [`AnyElement`], callback ids and `serde_json::Value`, which is what lets the
//! dock work the same under either engine.
use std::{
cell::{Cell, RefCell},
collections::{HashMap, HashSet},
rc::Rc,
sync::{Arc, Mutex, OnceLock},
};
use gpui::{
AnyElement, AnyView, App, AppContext as _, Context, Empty, Entity, EventEmitter, FocusHandle,
Focusable, InteractiveElement as _, IntoElement, ParentElement as _, Render, Styled as _,
Window, div,
};
use gpui_base::dock::{
DockAreaRenderer, DockContext, DockPlacement, DropIndicator, DropPlaceholderBounds, Panel,
PanelBuildContext, PanelEvent, PanelId, PanelInfo, PanelState, PanelView, TabGroupContext,
TabGroupRenderer,
};
use serde_json::{Value, json};
use crate::{
entities::EntityHandle,
scope::{self, ScopePhase},
spec::CallbackId,
view::ScriptView,
};
/// The prefix every script panel name carries.
///
/// It is `shell:` rather than the engine's name because the same layout file
/// must still restore after the host switches engines: what wrote the panel is
/// the shell, not QuickJS.
const NAME_PREFIX: &str = "shell:";
/// The interned, process-wide name for one application's panel:
/// `shell:<application>/<panel>`.
///
/// Namespacing is what keeps a script panel from ever colliding with a host
/// panel — no host name begins with `shell:` — and what keeps two
/// applications that both call their panel `inbox` apart in one layout file.
///
/// The returned pointer is stable: calling this twice with the same pair
/// returns the same `&'static str`, so passing a name back through
/// [`ScriptPanel::new`] leaks nothing further.
pub fn panel_name(application: &str, panel: &str) -> &'static str {
intern(&format!("{NAME_PREFIX}{application}/{panel}"))
}
/// Leaks `name` once and answers with the same pointer forever after.
///
/// [`Panel::panel_name`] must return `&'static str`, and a script's panel name
/// is only known when the application is loaded. There is no way to satisfy
/// the signature without a leak, so the leak is made once per distinct name
/// and bounded by the number of *registered* panels — applications loaded ×
/// panels each, which is a number in the hundreds, tens of bytes apiece.
///
/// Unloading an application does **not** reclaim its name. The design accepts
/// this (§15.4): reclaiming would mean a name could be freed while a persisted
/// layout still refers to it, and the whole point of the name is that it
/// outlives the load that produced it.
fn intern(name: &str) -> &'static str {
static NAMES: OnceLock<Mutex<HashSet<&'static str>>> = OnceLock::new();
let mut names = NAMES
.get_or_init(|| Mutex::new(HashSet::new()))
.lock()
// A poisoned table still holds valid names: the only mutation is an
// insert, and a panic between the leak and the insert would leak one
// name twice at worst.
.unwrap_or_else(|poisoned| poisoned.into_inner());
if let Some(interned) = names.get(name) {
return interned;
}
let interned: &'static str = Box::leak(name.to_owned().into_boxed_str());
names.insert(interned);
interned
}
/// The script side of a panel, implemented by the engine.
///
/// Three hooks, because three things cross the language boundary: building the
/// view a restored panel shows, reading the script's `serialize()`, and handing
/// a persisted payload back to `deserialize(data)`. Everything else about a
/// panel — where it sits, whether it is displayed, what it is called — is the
/// layout's business and never reaches the script.
#[allow(unused_variables)]
pub trait PanelScript: 'static {
/// Whether the restored panel may be closed.
///
/// Panel options belong to the application definition rather than the
/// persisted layout, so the registry asks the script again on every load.
fn closable(&self) -> bool {
true
}
/// Whether the restored panel may fill its dock.
fn zoomable(&self) -> bool {
true
}
/// Whether the restored panel is currently drawn.
fn visible(&self) -> bool {
true
}
/// Builds a fresh script view for this panel.
///
/// Called when [`PanelRegistry`](gpui_base::dock::PanelRegistry) rebuilds
/// the panel from a persisted layout. `None` means the script could not
/// be instantiated — the application is broken, or its entry point threw —
/// and the panel's persisted state is carried forward untouched rather
/// than dropped.
fn build(&self, window: &mut Window, cx: &mut App) -> Option<Entity<ScriptView>>;
/// The script's `serialize()`, or `None` for a panel that has none.
///
/// Note the `&App`: [`Panel::dump`] is a read, so there is no `&mut
/// Window` here and therefore no call scope to open. A
/// script `serialize()` must be a plain value-returning method that calls
/// nothing back into the host.
fn serialize(&self, view: &Entity<ScriptView>, cx: &App) -> Option<Value> {
None
}
/// Hands `data` — whatever [`Self::serialize`] wrote last time — back to
/// the script's `deserialize(data)`.
///
/// Called after [`Self::build`], with a real host call available, so this
/// one may open a scope and touch entities.
fn deserialize(
&self,
view: &Entity<ScriptView>,
data: &Value,
window: &mut Window,
cx: &mut App,
) {
}
/// The panel became, or stopped being, the one its group displays.
///
/// A tab that is not displayed is not rendered at all, so this is the only
/// way a panel learns that it is back on screen — which is when a script
/// refreshes something it stopped keeping up to date while hidden.
fn set_active(
&self,
view: &Entity<ScriptView>,
active: bool,
window: &mut Window,
cx: &mut App,
) {
}
/// The panel's container was zoomed in or out around it.
fn set_zoomed(
&self,
view: &Entity<ScriptView>,
zoomed: bool,
window: &mut Window,
cx: &mut App,
) {
}
/// The panel left the layout and its view is about to be dropped.
///
/// Where [`Self::build`] made something the engine has to hand back — a
/// retained handle, a registration — this is the matching release. A panel
/// whose view the *script* created and still holds is not the engine's to
/// free, so an implementation frees only what its own `build` produced.
fn release(&self, view: &Entity<ScriptView>, window: &mut Window, cx: &mut App) {}
}
/// A dockable panel whose body is a script view.
///
/// Only [`gpui_base::dock::Panel`] — behavior — is implemented, not the
/// presentation trait one layer up. A script panel's title, toolbar and menus
/// are drawn by [`ScriptDockSkin`] from the script's own elements, which is
/// what "the script owns presentation" means here.
pub struct ScriptPanel {
/// Interned, always prefixed. See [`intern`].
name: &'static str,
view: Entity<ScriptView>,
script: Option<Rc<dyn PanelScript>>,
focus_handle: FocusHandle,
closable: bool,
zoomable: bool,
visible: bool,
}
impl ScriptPanel {
/// Wraps `view` as a panel named `name`.
///
/// `name` is normally the value [`panel_name`] or [`register_panel`]
/// returned. A name that does not already carry the `shell:` prefix gets
/// it, so the namespace guarantee holds however the panel was built.
pub fn new(name: &str, view: Entity<ScriptView>, cx: &mut App) -> Self {
let name = if name.starts_with(NAME_PREFIX) {
intern(name)
} else {
intern(&format!("{NAME_PREFIX}{name}"))
};
Self {
name,
view,
script: None,
focus_handle: cx.focus_handle(),
closable: true,
zoomable: true,
visible: true,
}
}
/// Connects the script hooks, without which the panel persists its
/// position and nothing else.
pub fn with_script(mut self, script: Rc<dyn PanelScript>) -> Self {
self.script = Some(script);
self
}
/// Whether the dock may close this panel — the script's
/// `static options.closable`.
pub fn with_closable(mut self, closable: bool) -> Self {
self.closable = closable;
self
}
pub fn with_zoomable(mut self, zoomable: bool) -> Self {
self.zoomable = zoomable;
self
}
pub fn with_visible(mut self, visible: bool) -> Self {
self.visible = visible;
self
}
/// The interned name this panel persists under.
pub fn name(&self) -> &'static str {
self.name
}
pub fn view(&self) -> &Entity<ScriptView> {
&self.view
}
}
impl Panel for ScriptPanel {
fn panel_name(&self) -> &'static str {
self.name
}
fn visible(&self, _: &App) -> bool {
self.visible
}
fn closable(&self, _: &App) -> bool {
self.closable
}
fn zoomable(&self, _: &App) -> bool {
self.zoomable
}
/// The script's own state, under the panel's interned name.
///
/// A panel whose script has no `serialize()` still writes a leaf here, so
/// its place in the tree survives; only the payload is absent.
fn dump(&self, cx: &App) -> PanelState {
let mut state = PanelState::new(self.name);
if let Some(value) = self
.script
.as_ref()
.and_then(|script| script.serialize(&self.view, cx))
{
state.info = PanelInfo::panel(value);
}
state
}
fn set_active(&mut self, active: bool, window: &mut Window, cx: &mut Context<Self>) {
if let Some(script) = self.script.clone() {
script.set_active(&self.view, active, window, cx);
}
}
fn set_zoomed(&mut self, zoomed: bool, window: &mut Window, cx: &mut Context<Self>) {
if let Some(script) = self.script.clone() {
script.set_zoomed(&self.view, zoomed, window, cx);
}
}
/// Base removed the panel, so whatever [`PanelScript::build`] retained for
/// it is released here rather than at the next teardown.
fn on_removed(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if let Some(script) = self.script.clone() {
script.release(&self.view, window, cx);
}
}
}
impl EventEmitter<PanelEvent> for ScriptPanel {}
impl Focusable for ScriptPanel {
fn focus_handle(&self, _: &App) -> FocusHandle {
self.focus_handle.clone()
}
}
impl Render for ScriptPanel {
/// The script's root element, and one focus target around it.
///
/// `size_full` is the only presentation here and it is not a style choice:
/// a tab group draws its displayed panel as an ordinary child, so a
/// wrapper that shrink-wrapped would leave the script's own root — which
/// the script *does* style — with nothing to fill.
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
div()
.track_focus(&self.focus_handle)
.size_full()
.child(self.view.clone())
}
}
/// Teaches [`PanelRegistry`](gpui_base::dock::PanelRegistry) to rebuild
/// `application`'s `panel` from a persisted layout, and answers with the
/// interned name it registered under.
///
/// Call this when the application is activated, before any
/// [`DockArea::load`](gpui_base::dock::DockArea::load) that might mention the
/// panel. The builder runs [`PanelScript::build`], then hands the persisted
/// `serde_json::Value` to [`PanelScript::deserialize`].
///
/// # An uninstalled application still round-trips
///
/// If the application is *not* loaded, nothing is registered under its name
/// and `DockArea::load` finds no builder. It does not drop the panel: it
/// substitutes a draw-nothing placeholder that answers `Panel::dump` with the
/// `PanelState` it was handed, so the next save writes the panel — name,
/// payload and position — back out unchanged. That is verified base behavior
/// (`dock_area.rs`, `PlaceholderPanel`), and it is what makes uninstalling and
/// reinstalling an application put its panels back where they were. This
/// module keeps the same promise one step further in: a registered panel whose
/// [`PanelScript::build`] fails is carried forward the same way rather than
/// losing its state.
pub fn register_panel(
application: &str,
panel: &str,
script: Rc<dyn PanelScript>,
cx: &mut App,
) -> &'static str {
let name = panel_name(application, panel);
gpui_base::dock::register_panel(cx, name, move |context: PanelBuildContext, window, cx| {
build_panel(name, &script, &context, window, cx)
});
name
}
fn build_panel(
name: &'static str,
script: &Rc<dyn PanelScript>,
context: &PanelBuildContext,
window: &mut Window,
cx: &mut App,
) -> Arc<dyn PanelView> {
let Some(view) = script.build(window, cx) else {
return Arc::new(cx.new(|cx| RetainedPanel::new(name, context.state().clone(), cx)));
};
if let PanelInfo::Panel(data) = context.info() {
if !data.is_null() {
script.deserialize(&view, data, window, cx);
}
}
let closable = script.closable();
let zoomable = script.zoomable();
let visible = script.visible();
Arc::new(cx.new(|cx| {
ScriptPanel::new(name, view, cx)
.with_script(script.clone())
.with_closable(closable)
.with_zoomable(zoomable)
.with_visible(visible)
}))
}
/// Stands in for a registered panel whose script would not build.
///
/// It draws nothing and hands back the [`PanelState`] it was given, so a
/// script that throws on load costs the user the panel's *contents* for this
/// session, not its place in the layout or its saved payload. Base does the
/// same for a panel with no builder at all; this covers the case where the
/// builder exists and fails.
struct RetainedPanel {
name: &'static str,
state: PanelState,
focus_handle: FocusHandle,
}
impl RetainedPanel {
fn new(name: &'static str, state: PanelState, cx: &mut Context<Self>) -> Self {
Self {
name,
state,
focus_handle: cx.focus_handle(),
}
}
}
impl Panel for RetainedPanel {
fn panel_name(&self) -> &'static str {
self.name
}
fn dump(&self, _: &App) -> PanelState {
self.state.clone()
}
}
impl EventEmitter<PanelEvent> for RetainedPanel {}
impl Focusable for RetainedPanel {
fn focus_handle(&self, _: &App) -> FocusHandle {
self.focus_handle.clone()
}
}
impl Render for RetainedPanel {
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
Empty
}
}
/// The chrome a script draws for a dock, implemented by the engine.
///
/// One method per place base asks for appearance and a script can plausibly
/// supply it. Every method has a default that reproduces base's own
/// no-chrome behavior, so an application that draws no dock chrome implements
/// none of them and still gets a dock that docks, drags, resizes and persists.
///
/// Each method is handed the *resolved* context. A renderer never sees a drag
/// event, a mouse position or a hit test — base attaches all of that to the
/// elements it gets back — so the script's job is to turn state into elements
/// and to call the context's own callbacks (`select_tab`, `close`,
/// `toggle_zoom`, `resize_to`) rather than reimplement them. [`tab_group_data`]
/// and [`dock_data`] convert the state half into plain JSON, which is the form
/// the engine hands to script code.
#[allow(unused_variables)]
pub trait DockChrome: 'static {
/// The tab bar above a group's displayed panel.
fn tab_bar(&self, group: &TabGroupContext, window: &mut Window, cx: &mut App) -> AnyElement {
Empty.into_any_element()
}
/// What a group with no displayed panel shows.
fn empty_group(
&self,
group: &TabGroupContext,
window: &mut Window,
cx: &mut App,
) -> Option<AnyElement> {
None
}
/// The hint showing where a dragged panel would land.
fn drop_indicator(
&self,
indicator: DropIndicator,
window: &mut Window,
cx: &mut App,
) -> Option<AnyElement> {
None
}
/// One dock's chrome around its content: title strip, collapse
/// affordance, resize handle. Whatever this returns replaces the content,
/// so a chrome that wants both must place `content` itself.
///
/// Chrome only, and the emphasis is earned. The dock's own box -- its
/// extent along its own axis, and the `flex_none` that holds it there --
/// is base's, applied around whatever this returns, so a script cannot
/// misplace a dock by not knowing it had a box to draw. It used to be able
/// to: the extent lived in base's `DockSkin::render_dock`, this hook
/// replaced that method whole, and its default handed the content straight
/// back, so every script-drawn dock came out with no width at all.
fn dock(
&self,
dock: &DockContext,
content: AnyElement,
window: &mut Window,
cx: &mut App,
) -> AnyElement {
content
}
}
/// A [`DockChrome`] that draws nothing, which is also base's own behavior.
struct BareChrome;
impl DockChrome for BareChrome {}
/// Which script handler draws each piece of chrome, right now.
///
/// One field per [`DockChrome`] method, and `None` means the script did not
/// ask to draw that piece — so base's own no-chrome behavior stands.
#[derive(Clone, Copy, Default, PartialEq, Eq, Debug)]
pub(crate) struct DockChromeHooks {
pub(crate) tab_bar: Option<CallbackId>,
pub(crate) empty_group: Option<CallbackId>,
pub(crate) drop_indicator: Option<CallbackId>,
pub(crate) dock: Option<CallbackId>,
}
/// The chrome handlers in force for the frame being drawn.
///
/// The indirection exists because the two ends move at different rates. A skin
/// is installed once, when the area is created, and a
/// [`DockArea`](gpui_base::dock::DockArea) offers no way to replace it
/// afterwards — while the handlers belong to whichever script render is
/// currently published, and a snapshot rebuilt while the dock stands has to be
/// able to replace them without the area being rebuilt around it.
///
/// [`crate::materialize`] writes this as it replays a `dock_area(...)`
/// description, which is once per frame and before base asks the skin for
/// anything; the engine's [`DockChrome`] reads it when base does ask.
#[derive(Default)]
pub(crate) struct DockChromeSlots(Cell<DockChromeHooks>);
impl DockChromeSlots {
pub(crate) fn set(&self, hooks: DockChromeHooks) {
self.0.set(hooks);
}
pub(crate) fn get(&self) -> DockChromeHooks {
self.0.get()
}
}
/// The contexts base handed the chrome while it drew, kept until it draws
/// again.
///
/// A [`TabGroupContext`] carries the callbacks a skin invokes — `select_tab`,
/// `close`, `toggle_zoom` — and lives only for the length of one chrome call.
/// A script's tab, though, reports its click *later*, from an event handler,
/// long after that borrow has ended. So each context is cloned as it goes past
/// (all three are `Clone` over `Rc` handlers) and filed under the id the script
/// was given, and a command arriving afterwards finds the one that belongs to
/// it.
///
/// Cleared once per frame by [`crate::materialize`], before the area is laid
/// out and therefore before anything is recorded again. That is what bounds it:
/// a node that stopped existing is not re-recorded, so its entry is gone by the
/// next frame rather than accumulating for the life of the window.
#[derive(Default)]
pub(crate) struct DockContexts {
/// Keyed by [`NodeId::as_u64`](gpui_base::dock::NodeId::as_u64) rather than
/// by the id itself: base does not publish a way to rebuild a `NodeId` from
/// a number, and a number is what comes back from script.
tab_groups: RefCell<HashMap<u64, TabGroupContext>>,
docks: RefCell<HashMap<DockPlacement, DockContext>>,
}
impl DockContexts {
/// Forgets every context, so that the frame about to be drawn records its
/// own.
pub(crate) fn clear(&self) {
self.tab_groups.borrow_mut().clear();
self.docks.borrow_mut().clear();
}
fn record_tab_group(&self, group: &TabGroupContext) {
self.tab_groups
.borrow_mut()
.insert(group.node().as_u64(), group.clone());
}
fn record_dock(&self, dock: &DockContext) {
self.docks
.borrow_mut()
.insert(dock.placement(), dock.clone());
}
pub(crate) fn tab_group(&self, node: u64) -> Option<TabGroupContext> {
self.tab_groups.borrow().get(&node).cloned()
}
pub(crate) fn dock(&self, placement: DockPlacement) -> Option<DockContext> {
self.docks.borrow().get(&placement).cloned()
}
}
thread_local! {
/// The dock content the chrome handler running right now may place.
///
/// Base hands a dock's content to the chrome as a finished `AnyElement`
/// and keeps whatever comes back, so a chrome that wants both has to place
/// the content itself. An element cannot cross into script, so the engine
/// installs the real one here for the length of the call and the script's
/// `dock_content()` description takes it.
///
/// Thread-local because the two ends are a GPUI layout pass and a script
/// callback inside it, with base's own code in between — there is no value
/// to thread it through. Single-threaded by construction: the VM and
/// GPUI's `App` are both main-thread only.
static DOCK_CONTENT: RefCell<Option<AnyElement>> = const { RefCell::new(None) };
}
/// Installs a dock's content as what a `dock_content()` description resolves
/// to, until the guard drops.
///
/// The previous occupant is put back rather than cleared, so a dock area
/// nested inside a panel of another dock area is no different from one on its
/// own.
pub(crate) struct ContentSlot(Option<AnyElement>);
impl ContentSlot {
pub(crate) fn install(content: AnyElement) -> Self {
Self(DOCK_CONTENT.with(|slot| slot.borrow_mut().replace(content)))
}
/// Whatever the chrome did not place, for a caller that has to draw it
/// anyway.
pub(crate) fn unplaced(&self) -> Option<AnyElement> {
take_dock_content()
}
}
impl Drop for ContentSlot {
fn drop(&mut self) {
DOCK_CONTENT.with(|slot| *slot.borrow_mut() = self.0.take());
}
}
/// The dock content a `dock_content()` description stands for.
///
/// Taken, not cloned — an `AnyElement` is a value that is consumed when it is
/// used. A description with two of them draws the content once and says so.
pub(crate) fn take_dock_content() -> Option<AnyElement> {
DOCK_CONTENT.with(|slot| slot.borrow_mut().take())
}
/// Removes the panel `id` names, wherever it sits.
///
/// Base removes a panel by *entity*, which a script has no way to name — it
/// holds the `PanelId` the area reported. The two are one downcast apart: a
/// panel is registered as an `Arc<dyn PanelView>` over the entity that
/// implements it, and `as_any` gives it back.
///
/// Answers whether anything was removed, so a script asking for a panel that
/// has already gone hears about it.
pub(crate) fn remove_panel(
area: &Entity<gpui_base::dock::DockArea>,
panel: PanelId,
window: &mut Window,
cx: &mut App,
) -> bool {
let view = area.read(cx).panel(panel).cloned();
let Some(view) = view else {
return false;
};
if let Some(entity) = view.as_any().downcast_ref::<Entity<ScriptPanel>>() {
let entity = entity.clone();
area.update(cx, |area, cx| area.remove_panel(entity, window, cx));
return true;
}
// A panel whose script would not build stands in for it, and closing one is
// still closing the panel as far as the layout is concerned.
if let Some(entity) = view.as_any().downcast_ref::<Entity<RetainedPanel>>() {
let entity = entity.clone();
area.update(cx, |area, cx| area.remove_panel(entity, window, cx));
return true;
}
false
}
/// What one chrome element does when it is used.
///
/// A [`TabGroupContext`] carries the callbacks a skin invokes and lives only
/// for the length of one chrome call — but a tab reports its click *later*,
/// from GPUI's event pass. So a chrome element does not hold a callback; it
/// holds one of these, which names the container and what to ask it, and is
/// resolved against [`DockContexts`] when the moment comes.
///
/// That is also why none of these is a script callback. A chrome handler runs
/// once per frame for as long as the dock is on screen, and a handler
/// registered from inside one would pile up exactly the way a virtual list's
/// row handlers would — see [`crate::materialize::components::virtual_list`].
/// A command carries no script value at all, so there is nothing to pile up.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum DockCommand {
/// Display the tab at this position in its group.
SelectTab { node: u64, index: usize },
/// Close this panel, if its group allows it.
ClosePanel { node: u64, panel: u64 },
/// Zoom this group in, or back out.
ToggleGroupZoom { node: u64 },
/// Make this tab a drag source carrying base's own panel payload.
DragTab { node: u64, index: usize },
/// Accept a dragged panel here. `index` is the slot it lands in, or `None`
/// to append.
DropTab { node: u64, index: Option<usize> },
/// Open or close this dock.
ToggleDock { placement: DockPlacement },
/// Drag this dock's edge. Base clamps the size it is given.
ResizeDock { placement: DockPlacement },
}
/// The drag GPUI carries while a dock's edge is being dragged.
///
/// A marker rather than a payload: base already holds the gesture, and all
/// this has to do is tell one dock's `on_drag_move` from another's.
#[derive(Clone, Copy)]
pub(crate) struct ResizingDock(pub(crate) DockPlacement);
impl Render for ResizingDock {
/// A drag GPUI does not paint: the feedback is the dock edge moving under
/// the pointer, which base is already doing.
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
Empty
}
}
/// One command together with the area it belongs to.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct DockAction {
dock: EntityHandle,
command: DockCommand,
}
impl DockAction {
pub(crate) fn new(dock: EntityHandle, command: DockCommand) -> Self {
Self { dock, command }
}
pub(crate) fn dock(&self) -> EntityHandle {
self.dock
}
pub(crate) fn command(&self) -> DockCommand {
self.command
}
}
/// The appearance of a dock area, forwarded to script.
///
/// Install it with
/// [`DockArea::with_renderer`](gpui_base::dock::DockArea::with_renderer). One
/// value implements both renderer traits and hands out clones of itself for
/// the per-container one, because the only thing a container renderer needs is
/// the same chrome handle — and the same context table, so a command from a
/// tab finds the group a *tab group* renderer recorded.
pub struct ScriptDockSkin {
chrome: Rc<dyn DockChrome>,
contexts: Rc<DockContexts>,
slots: Rc<DockChromeSlots>,
}
impl ScriptDockSkin {
pub fn new(chrome: Rc<dyn DockChrome>) -> Self {
Self {
chrome,
contexts: Rc::new(DockContexts::default()),
slots: Rc::new(DockChromeSlots::default()),
}
}
/// Shares the slots the chrome reads, so the frame being described and the
/// frame being drawn agree about which handlers are in force.
pub(crate) fn with_slots(mut self, slots: Rc<DockChromeSlots>) -> Self {
self.slots = slots;
self
}
/// The table this skin files contexts in, for the entity store to hand back
/// to a command.
pub(crate) fn contexts(&self) -> Rc<DockContexts> {
self.contexts.clone()
}
/// Where the next frame's chrome handlers are written.
pub(crate) fn slots(&self) -> Rc<DockChromeSlots> {
self.slots.clone()
}
fn clone_skin(&self) -> Self {
Self {
chrome: self.chrome.clone(),
contexts: self.contexts.clone(),
slots: self.slots.clone(),
}
}
}
impl Default for ScriptDockSkin {
/// A dock with no chrome at all — still draggable, resizable and
/// persistent, drawing only the panels.
fn default() -> Self {
Self::new(Rc::new(BareChrome))
}
}
/// Runs `f` in a nested [`ScopePhase::Layout`] scope.
///
/// Every chrome callback is reached from inside GPUI's layout/prepaint pass
/// rather than from a view's `render`, and there may already be an outer scope
/// around it — a dock area nested in a script view has one. `Layout` is the
/// phase that says so: it forbids `notify`, entity creation and spawning, and
/// runs on the render time budget. A frame is pushed, not reused, so the
/// budget starts fresh for each callback and a stale `cx` captured from an
/// earlier call is still rejected.
///
/// The scope inherits the enclosing view, if any: the chrome is drawn on
/// behalf of whatever view is being rendered and owns no view of its own.
fn in_layout_scope<R>(
window: &mut Window,
cx: &mut App,
f: impl FnOnce(&mut Window, &mut App) -> R,
) -> R {
let (_guard, _generation) = scope::enter(window, cx, ScopePhase::Layout, scope::current_view());
f(window, cx)
}
impl DockAreaRenderer for ScriptDockSkin {
fn render_dock(
&self,
dock: &DockContext,
content: AnyElement,
window: &mut Window,
cx: &mut App,
) -> AnyElement {
self.contexts.record_dock(dock);
in_layout_scope(window, cx, |window, cx| {
self.chrome.dock(dock, content, window, cx)
})
}
fn tab_group_renderer(&self) -> Rc<dyn TabGroupRenderer> {
Rc::new(self.clone_skin())
}
}
impl TabGroupRenderer for ScriptDockSkin {
fn render_tab_bar(
&self,
group: &TabGroupContext,
window: &mut Window,
cx: &mut App,
) -> AnyElement {
// Recorded here rather than in `render_empty`, because this hook runs
// for every group and that one only for a group with nothing to show.
self.contexts.record_tab_group(group);
in_layout_scope(window, cx, |window, cx| {
self.chrome.tab_bar(group, window, cx)
})
}
/// Nothing at all while the group is folded away.
///
/// A collapsed group is a strip of tabs with no content region, and the
/// trait's default still renders the panel into it -- so the pane the user
/// folded away went on paying for itself behind the fold. The host's own
/// `TabPanel` has always drawn a collapsed group empty.
fn render_active_panel(
&self,
panel: AnyView,
group: &TabGroupContext,
_: &mut Window,
_: &mut App,
) -> AnyElement {
if group.is_collapsed() {
return Empty.into_any_element();
}
panel.into_any_element()
}
fn render_empty(
&self,
group: &TabGroupContext,
window: &mut Window,
cx: &mut App,
) -> Option<AnyElement> {
self.contexts.record_tab_group(group);
in_layout_scope(window, cx, |window, cx| {
self.chrome.empty_group(group, window, cx)
})
}
fn render_drop_indicator(
&self,
indicator: DropIndicator,
window: &mut Window,
cx: &mut App,
) -> Option<AnyElement> {
in_layout_scope(window, cx, |window, cx| {
self.chrome.drop_indicator(indicator, window, cx)
})
}
}
/// One tab group's state as plain JSON, the form script code reads:
///
/// ```json
/// {
/// "node": 3,
/// "active_index": 0,
/// "zoomed": false, "collapsed": false, "locked": false,
/// "draggable": true, "droppable": true, "closable": true,
/// "tabs": [
/// { "index": 0, "name": "shell:mail/inbox", "id": 42,
/// "active": true, "visible": true, "closable": true, "zoomable": true }
/// ]
/// }
/// ```
///
/// `index` is the tab's position in the group, which is what the script passes
/// back to [`TabGroupContext::select_tab`]. Hidden panels are included: base
/// keeps them in tab order and a script that wants them gone filters on
/// `visible`, rather than having to re-derive an index into an already
/// filtered list.
///
/// The callbacks are deliberately absent — they cannot be JSON. The engine
/// keeps the [`TabGroupContext`] alongside this value and wires
/// `select_tab`/`close`/`toggle_zoom` onto the elements the script returns.
pub fn tab_group_data(group: &TabGroupContext, cx: &App) -> Value {
let active = group.active_panel().map(|panel| panel.panel_id(cx));
let tabs = group
.panels()
.iter()
.enumerate()
.map(|(index, panel)| {
let id = panel.panel_id(cx);
json!({
"index": index,
"name": panel.panel_name(cx),
"id": id.as_u64(),
"active": Some(id) == active,
"visible": panel.visible(cx),
"closable": panel.closable(cx),
"zoomable": panel.zoomable(cx),
})
})
.collect::<Vec<_>>();
json!({
"node": group.node().as_u64(),
"active_index": group.active_ix(),
"zoomed": group.is_zoomed(),
"collapsed": group.is_collapsed(),
"locked": group.is_locked(),
"draggable": group.is_draggable(),
"droppable": group.is_droppable(),
"closable": group.is_closable(),
"tabs": tabs,
})
}
/// Where a dragged panel would land, as plain JSON:
///
/// ```json
/// {
/// "placement": "left",
/// "bounds": { "x": 0.0, "y": 0.0, "width": 600.0, "height": 400.0 },
/// "from": { "x": 0.0, "y": 0.0, "width": 600.0, "height": 400.0 },
/// "to": { "x": 0.0, "y": 0.0, "width": 300.0, "height": 400.0 }
/// }
/// ```
///
/// `bounds` is the hovered group's content box in window coordinates;
/// `from` and `to` are relative to it, and are the two ends of the placeholder
/// the skin animates between. A `placement` of `null` means the drop merges
/// into the group's tabs rather than splitting beside it.
pub fn drop_indicator_data(indicator: &DropIndicator) -> Value {
let placeholder = |bounds: DropPlaceholderBounds| {
json!({
"x": f32::from(bounds.origin().x),
"y": f32::from(bounds.origin().y),
"width": f32::from(bounds.size().width),
"height": f32::from(bounds.size().height),
})
};
let bounds = indicator.bounds();
json!({
"placement": indicator.placement().map(placement_name),
"bounds": {
"x": f32::from(bounds.origin.x),
"y": f32::from(bounds.origin.y),
"width": f32::from(bounds.size.width),
"height": f32::from(bounds.size.height),
},
"from": placeholder(indicator.from()),
"to": placeholder(indicator.to()),
})
}
/// The word a split placement crosses as. Base's own spelling, lowercased,
/// which is also what a script writes for a dock placement.
fn placement_name(placement: gpui_base::Placement) -> &'static str {
match placement {
gpui_base::Placement::Left => "left",
gpui_base::Placement::Right => "right",
gpui_base::Placement::Top => "top",
gpui_base::Placement::Bottom => "bottom",
}
}
/// One dock's state as plain JSON:
///
/// ```json
/// { "placement": "left", "size": 240.0, "open": true, "collapsible": true }
/// ```
///
/// `placement` uses the same tags as the persisted layout, which are frozen.
/// `size` is the dock's extent along its own axis — width for left and right,
/// height for bottom.
pub fn dock_data(dock: &DockContext) -> Value {
json!({
"placement": serde_json::to_value(dock.placement()).unwrap_or(Value::Null),
"size": f32::from(dock.size()),
"open": dock.is_open(),
"collapsible": dock.is_collapsible(),
})
}
#[cfg(test)]
mod tests {
use std::{cell::RefCell, ops::Deref as _};
use gpui::{TestAppContext, VisualTestContext};
use gpui_base::dock::{DockArea, DockLayout, DockPlacement};
use super::*;
use crate::engine::{ShellRuntime, ViewType};
/// The smallest thing each engine calls a view. The dock tests never look
/// at what it renders — only at what survives a save and a load.
#[cfg(feature = "quickjs")]
const PANEL_SOURCE: &str = r#"
import { View, div } from "gpui-kit";
export default class Inbox extends View {
render() {
return div();
}
}
"#;
#[cfg(not(feature = "quickjs"))]
const PANEL_SOURCE: &str = r#"
local gpui = require("gpui-kit")
local Inbox = gpui.view("Inbox")
function Inbox:render(cx)
return gpui.div()
end
return Inbox
"#;
/// Stands in for the engine's half of [`PanelScript`]: it builds a real
/// script view, hands back a fixed payload as the script's `serialize()`,
/// and records what `deserialize` was given.
struct Probe {
/// Declared before the runtime, because fields drop in declaration
/// order and a script handle released after its runtime aborts the
/// process. `ShellRuntime` orders its own fields for the same reason.
view_type: ViewType,
runtime: Rc<ShellRuntime>,
payload: Option<Value>,
restored: Rc<RefCell<Vec<Value>>>,
closable: bool,
zoomable: bool,
visible: bool,
}
impl PanelScript for Probe {
fn build(&self, window: &mut Window, cx: &mut App) -> Option<Entity<ScriptView>> {
let object = self.runtime.instantiate(&self.view_type, window, cx).ok()?;
let runtime = self.runtime.clone();
Some(cx.new(|_| ScriptView::new(runtime, object)))
}
fn serialize(&self, _: &Entity<ScriptView>, _: &App) -> Option<Value> {
self.payload.clone()
}
fn deserialize(&self, _: &Entity<ScriptView>, data: &Value, _: &mut Window, _: &mut App) {
self.restored.borrow_mut().push(data.clone());
}
fn closable(&self) -> bool {
self.closable
}
fn zoomable(&self) -> bool {
self.zoomable
}
fn visible(&self) -> bool {
self.visible
}
}
fn boot(cx: &mut TestAppContext) -> (Rc<ShellRuntime>, ViewType) {
cx.update(|cx| crate::init(cx));
let runtime = ShellRuntime::new_isolated().expect("runtime");
cx.update(|cx| runtime.set_global(cx));
let view_type = runtime.load_source("inbox", PANEL_SOURCE).expect("load");
(runtime, view_type)
}
fn probe(
runtime: &Rc<ShellRuntime>,
view_type: &ViewType,
payload: Option<Value>,
) -> (Rc<Probe>, Rc<RefCell<Vec<Value>>>) {
let restored = Rc::new(RefCell::new(Vec::new()));
let script = Rc::new(Probe {
view_type: view_type.clone(),
runtime: runtime.clone(),
payload,
restored: restored.clone(),
closable: true,
zoomable: true,
visible: true,
});
(script, restored)
}
#[gpui::test]
fn registered_panel_restores_its_static_options(cx: &mut TestAppContext) {
let (runtime, view_type) = boot(cx);
let window = cx.add_window(|_, _| Empty);
let mut context = VisualTestContext::from_window(*window.deref(), cx);
let (script, _) = probe(&runtime, &view_type, None);
let mut script = Rc::try_unwrap(script).ok().expect("unique probe");
script.closable = false;
script.zoomable = false;
script.visible = false;
let script = Rc::new(script);
let name = context.update(|_, cx| register_panel("mail", "fixed", script.clone(), cx));
let saved = context.update(|window, cx| {
let object = runtime
.instantiate(&view_type, window, cx)
.expect("instance");
let view = cx.new(|_| ScriptView::new(runtime.clone(), object));
let panel = cx.new(|cx| {
ScriptPanel::new(name, view, cx)
.with_script(script.clone())
.with_closable(false)
.with_zoomable(false)
.with_visible(false)
});
let area = cx.new(|cx| DockArea::new("workspace", None, window, cx));
area.update(cx, |area, cx| {
area.set_center(DockLayout::tabs().panel(panel), window, cx);
area.dump(cx)
})
});
let area = context.update(|window, cx| {
let area = cx.new(|cx| DockArea::new("workspace", None, window, cx));
area.update(cx, |area, cx| {
area.load(saved, window, cx).expect("load");
});
area
});
context.read(|cx| {
let panel_id = area
.read(cx)
.layout(DockPlacement::Center)
.expect("center")
.panels()
.next()
.expect("restored panel");
let panel = area.read(cx).panel(panel_id).expect("live panel");
assert!(!panel.closable(cx));
assert!(!panel.zoomable(cx));
assert!(!panel.visible(cx));
});
}
/// The persisted leaf for `name`, wherever it sits in the tree.
fn leaf<'a>(state: &'a PanelState, name: &str) -> Option<&'a PanelState> {
if state.panel_name == name {
return Some(state);
}
state.children.iter().find_map(|child| leaf(child, name))
}
#[test]
fn a_name_is_namespaced_and_interned_once() {
let name = panel_name("mail", "inbox");
assert_eq!(name, "shell:mail/inbox");
assert!(std::ptr::eq(name, panel_name("mail", "inbox")));
}
#[test]
fn the_same_panel_name_in_two_applications_does_not_collide() {
assert_ne!(panel_name("mail", "inbox"), panel_name("chat", "inbox"));
assert_eq!(panel_name("chat", "inbox"), "shell:chat/inbox");
}
#[gpui::test]
fn a_panel_reports_its_interned_name(cx: &mut TestAppContext) {
let (runtime, view_type) = boot(cx);
let window = cx.add_window(|_, _| Empty);
let mut context = VisualTestContext::from_window(*window.deref(), cx);
let panel = context.update(|window, cx| {
let object = runtime
.instantiate(&view_type, window, cx)
.expect("instance");
let view = cx.new(|_| ScriptView::new(runtime.clone(), object));
cx.new(|cx| ScriptPanel::new(panel_name("mail", "inbox"), view, cx))
});
context.read(|cx| {
let name = panel.read(cx).panel_name();
assert_eq!(name, "shell:mail/inbox");
// The panel holds the interned string itself, not a copy of it,
// which is the whole reason the table exists.
assert!(std::ptr::eq(name, panel_name("mail", "inbox")));
});
}
/// A bare name still lands in the namespace, so a panel built without
/// going through `panel_name` cannot shadow a host panel.
#[gpui::test]
fn an_unqualified_name_is_still_namespaced(cx: &mut TestAppContext) {
let (runtime, view_type) = boot(cx);
let window = cx.add_window(|_, _| Empty);
let mut context = VisualTestContext::from_window(*window.deref(), cx);
let panel = context.update(|window, cx| {
let object = runtime
.instantiate(&view_type, window, cx)
.expect("instance");
let view = cx.new(|_| ScriptView::new(runtime.clone(), object));
cx.new(|cx| ScriptPanel::new("TabPanel", view, cx))
});
context.read(|cx| assert_eq!(panel.read(cx).panel_name(), "shell:TabPanel"));
}
#[gpui::test]
fn a_round_trip_preserves_the_scripts_own_payload(cx: &mut TestAppContext) {
let (runtime, view_type) = boot(cx);
let window = cx.add_window(|_, _| Empty);
let mut context = VisualTestContext::from_window(*window.deref(), cx);
let (script, restored) = probe(
&runtime,
&view_type,
Some(json!({ "filter": "unread", "sort": 2 })),
);
let name = context.update(|_, cx| register_panel("mail", "inbox", script.clone(), cx));
let saved = context.update(|window, cx| {
let object = runtime
.instantiate(&view_type, window, cx)
.expect("instance");
let view = cx.new(|_| ScriptView::new(runtime.clone(), object));
let panel = cx.new(|cx| ScriptPanel::new(name, view, cx).with_script(script.clone()));
let area = cx.new(|cx| DockArea::new("workspace", Some(1), window, cx));
area.update(cx, |area, cx| {
area.set_center(DockLayout::tabs().panel(panel), window, cx);
area.dump(cx)
})
});
let written = leaf(&saved.center, name).expect("the panel is in the saved layout");
assert_eq!(
written.info,
PanelInfo::panel(json!({ "filter": "unread", "sort": 2 }))
);
// A fresh area rebuilds the panel through the registry, which is the
// path a restart takes.
let reloaded = context.update(|window, cx| {
let area = cx.new(|cx| DockArea::new("workspace", Some(1), window, cx));
area.update(cx, |area, cx| {
area.load(saved.clone(), window, cx).expect("load");
area.dump(cx)
})
});
assert_eq!(
restored.borrow().as_slice(),
[json!({ "filter": "unread", "sort": 2 })],
"deserialize was handed exactly what serialize wrote"
);
assert_eq!(
leaf(&reloaded.center, name).map(|state| &state.info),
Some(&PanelInfo::panel(json!({ "filter": "unread", "sort": 2 }))),
"and saving again writes it back"
);
}
#[gpui::test]
fn a_panel_without_serialize_still_round_trips_its_position(cx: &mut TestAppContext) {
let (runtime, view_type) = boot(cx);
let window = cx.add_window(|_, _| Empty);
let mut context = VisualTestContext::from_window(*window.deref(), cx);
let (script, restored) = probe(&runtime, &view_type, None);
let name = context.update(|_, cx| register_panel("mail", "outbox", script.clone(), cx));
let saved = context.update(|window, cx| {
let object = runtime
.instantiate(&view_type, window, cx)
.expect("instance");
let view = cx.new(|_| ScriptView::new(runtime.clone(), object));
let panel = cx.new(|cx| ScriptPanel::new(name, view, cx).with_script(script.clone()));
let area = cx.new(|cx| DockArea::new("workspace", None, window, cx));
area.update(cx, |area, cx| {
area.set_dock(
DockPlacement::Bottom,
DockLayout::tabs().panel(panel),
window,
cx,
);
area.dump(cx)
})
});
let bottom = saved.bottom_dock.clone().expect("a bottom dock was saved");
let written = leaf(bottom.panel(), name).expect("the panel is in the saved layout");
assert_eq!(written.info, PanelInfo::panel(Value::Null));
let reloaded = context.update(|window, cx| {
let area = cx.new(|cx| DockArea::new("workspace", None, window, cx));
area.update(cx, |area, cx| {
area.load(saved.clone(), window, cx).expect("load");
area.dump(cx)
})
});
assert!(
restored.borrow().is_empty(),
"an absent payload is not handed to deserialize"
);
assert!(
leaf(
reloaded
.bottom_dock
.expect("the bottom dock came back")
.panel(),
name
)
.is_some(),
"the panel came back in the dock it was saved in"
);
}
/// The layout must survive a script that cannot be built at all — the
/// same promise base makes for a panel it has no builder for.
#[gpui::test]
fn a_panel_whose_script_fails_keeps_its_persisted_state(cx: &mut TestAppContext) {
struct Broken;
impl PanelScript for Broken {
fn build(&self, _: &mut Window, _: &mut App) -> Option<Entity<ScriptView>> {
None
}
}
let (runtime, view_type) = boot(cx);
let window = cx.add_window(|_, _| Empty);
let mut context = VisualTestContext::from_window(*window.deref(), cx);
let (script, _) = probe(&runtime, &view_type, Some(json!({ "filter": "all" })));
let name = context.update(|_, cx| register_panel("mail", "drafts", script.clone(), cx));
let saved = context.update(|window, cx| {
let object = runtime
.instantiate(&view_type, window, cx)
.expect("instance");
let view = cx.new(|_| ScriptView::new(runtime.clone(), object));
let panel = cx.new(|cx| ScriptPanel::new(name, view, cx).with_script(script.clone()));
let area = cx.new(|cx| DockArea::new("workspace", None, window, cx));
area.update(cx, |area, cx| {
area.set_center(DockLayout::tabs().panel(panel), window, cx);
area.dump(cx)
})
});
// The application reloads with a script that throws on construction.
context.update(|_, cx| register_panel("mail", "drafts", Rc::new(Broken), cx));
let reloaded = context.update(|window, cx| {
let area = cx.new(|cx| DockArea::new("workspace", None, window, cx));
area.update(cx, |area, cx| {
area.load(saved.clone(), window, cx).expect("load");
area.dump(cx)
})
});
assert_eq!(
leaf(&reloaded.center, name).map(|state| &state.info),
Some(&PanelInfo::panel(json!({ "filter": "all" }))),
"a failed build carries the payload forward instead of erasing it"
);
}
}