blob: feef633e43b3b1fe3cda0063a64c0bfa68fca376 [file]
//! Turns element descriptions into real GPUI elements.
//!
//! This runs entirely in Rust: it never calls back into the script, which is what
//! makes it possible to benchmark and snapshot-test the render path
//! independently of the VM — and, more importantly, what lets GPUI repaint a
//! script view as often as it likes without entering one.
//!
//! Reading a snapshot leaves it intact, so the same description is replayed by
//! every frame until script state replaces it. The runtime is still needed here,
//! but only to dispatch events: no path through this module calls into the
//! script while an element is being built.
//!
//! # The one exception: the lazy lists
//!
//! A lazy list is the single kind of component whose description is not the
//! whole of what it draws. Its rows are produced by a script callback that
//! GPUI runs from *inside* layout and prepaint — twice per frame, once to
//! measure and once to place — so a frame that contains one does enter the VM,
//! no matter what changed.
//!
//! That is not a leak in the design; it is the trade the design was for. The
//! alternative is describing every row up front, which is exactly the cost
//! virtualization exists to avoid: ten thousand rows described so that twenty
//! can be seen. What the exception buys is that the VM is entered for the
//! *visible window* rather than for the collection, so the script cost of a
//! ten-thousand-row list is the script cost of a twenty-row one.
//!
//! How often it is entered depends on which list, because that is set by the
//! GPUI API each one wraps. [`Component::VirtualList`] and `uniform_list` take
//! a renderer over a range, so one frame is one call however many rows are on
//! screen. `list` — the one that measures each item rather than placing them
//! all by one — takes a renderer over a single index, so one frame is *one
//! call per visible row*, plus the rows in its overdraw band. Both are bounded
//! by the viewport rather than by the collection, which is the property that
//! matters; but a `list` of twenty visible rows costs twenty crossings where a
//! virtual list costs one, and that is the price of not stating heights.
//!
//! A `list` also calls its `get_key` around its own layout: once for the item
//! at the top whenever that item or the render changes, and, when the item
//! count changes, near that item's old index until its key turns up again —
//! one or two calls when items were added or removed on one side of it only,
//! and the whole collection once if it is gone. That is what keeps the
//! reader's place; [`components::list`] says why it needs the key.
//!
//! Three things confine it, and they are worth naming because each is what
//! stops the exception from spreading:
//!
//! * **It is scoped.** Item rendering runs under [`ScopePhase::Layout`], which
//! forbids `cx.notify()` (a re-render requested from inside layout is a loop),
//! forbids creating retained state, and runs on the render-time budget.
//! * **It registers nothing.** Callbacks cannot be registered from an item
//! renderer — see [`components::virtual_list`] for why, and for what a script
//! uses instead.
//! * **It owns no arena.** Each batch describes itself into a temporary
//! [`SpecArena`] that is materialized through [`materialize_subtree`] and
//! dropped before the call returns, so nothing a row described outlives the
//! frame that drew it.
//!
//! The cost lands in `materialize_time` rather than in `script_render_time`;
//! [`crate::metrics`] says what that means for reading the two counters.
//!
//! [`ScopePhase::Layout`]: crate::scope::ScopePhase::Layout
use std::{
cell::Cell,
cell::RefCell,
rc::{Rc, Weak},
time::Duration,
};
// Failures raised by registered components while a subtree is being built.
//
// `materialize_node` returns an element rather than a `Result`, because a
// component that fails still has to leave something on screen for the rest of
// the frame. A caller that materializes a subtree *on behalf of an adapter* —
// a deferred slot factory, a temporary build — needs the failure instead, so it
// opens a frame here and reads back the first error recorded under it. Nothing
// outside this file may push or pop; `with_error_frame` is the only way in.
thread_local! {
static FACTORY_MATERIALIZE_ERRORS: RefCell<Vec<Option<anyhow::Error>>> = const { RefCell::new(Vec::new()) };
}
/// Runs `build`, turning the first registered-component failure inside it into
/// an `Err`.
fn with_error_frame(build: impl FnOnce() -> AnyElement) -> anyhow::Result<AnyElement> {
FACTORY_MATERIALIZE_ERRORS.with(|errors| errors.borrow_mut().push(None));
// Pops the frame if `build` unwinds, so a panic cannot leave the stack one
// frame deep for every later build on this thread.
struct Frame(bool);
impl Drop for Frame {
fn drop(&mut self) {
if !self.0 {
FACTORY_MATERIALIZE_ERRORS.with(|errors| {
errors.borrow_mut().pop();
});
}
}
}
let mut frame = Frame(false);
let element = build();
let error = FACTORY_MATERIALIZE_ERRORS
.with(|errors| errors.borrow_mut().pop().expect("this frame was pushed"));
frame.0 = true;
error.map_or(Ok(element), Err)
}
fn materialize_factory_subtree(
runtime: &Rc<ShellRuntime>,
snapshot: &RenderSnapshot,
root: SpecId,
inherited: gpui::Hsla,
window: &mut Window,
cx: &mut App,
) -> anyhow::Result<AnyElement> {
with_error_frame(|| {
materialize_node(
runtime,
Some(snapshot),
snapshot.arena(),
root,
inherited,
window,
cx,
)
})
}
use smallvec::SmallVec;
use gpui::{
AbsoluteLength, AnyElement, App, Bounds, DefiniteLength, InteractiveElement, IntoElement,
Length, MouseButton, ParentElement, Pixels, Refineable as _, SharedString,
StatefulInteractiveElement, StyleRefinement, Styled, Window, div,
};
use gpui_base::{
Button, Checkbox, CheckboxState, ElementExt as _, Link, ScrollbarAxis, Switch, TextView,
TextViewStyle, Theme,
animation::{ease_in_cubic, ease_in_out_cubic, ease_out_cubic},
h_flex,
input::{Input, InputBase},
motion::{Spring, Transition, spring, transition},
v_flex,
};
mod components;
mod text_view;
#[cfg(test)]
pub(crate) use components::spin::{STEP as SPIN_STEP, take_painted_spin_angles};
pub(crate) use text_view::ScriptTextStyle;
use crate::{
capability::is_openable_url,
engine::ShellRuntime,
scroll::Scrollable,
snapshot::RenderSnapshot,
spec::{CallbackId, Component, SpecArena, SpecId, SpecNode, SpecOp},
style,
value::Bridged,
};
/// The children of one node, inline until there are more than a row's worth.
///
/// Eight because a quote row is six cells and a wrapper, which is the widest
/// ordinary shape; past that the spill is one allocation for a node that was
/// always going to be expensive.
use crate::component_registry::Children;
/// The named slots a node's ops filled, in the order the script filled them.
///
/// A list rather than a set of fields because the slot names belong to the
/// components, not to this module: a `Collapsible` fills one, a popover fills a
/// trigger and a content, a number input fills three. Two inline covers every
/// component bound today and every one on the way; a third is one allocation on
/// a node that already carries whole subtrees.
use crate::component_registry::Slots;
/// The same list before the slots are materialized.
use crate::component_registry::SlotSpecs;
/// The children of a node whose component takes a *typed* child.
///
/// [`materialize_node`] flattens every child into [`Children`], because
/// `ParentElement::child` is what every component bound so far accepts. A few
/// base containers do not: `ResizablePanelGroup::child` takes a
/// `ResizablePanel`, and that type carries `size`, `size_range` and `visible` —
/// constraints with no counterpart on an `AnyElement`. Base will wrap a finished
/// element in a panel, but the panel it produces has already lost all three.
///
/// So those components are handed the *descriptions* instead of the elements.
/// A parent looks at what constructed each child, builds the typed value itself
/// for the ones it owns, and sends everything else back down the ordinary path.
/// A `Tabs` that took `Tab` values, or a dock that takes panels, is the same
/// shape — which is why this is a type rather than five more parameters on one
/// component's function.
#[derive(Clone, Copy)]
struct ChildSpecs<'a> {
runtime: &'a Rc<ShellRuntime>,
snapshot: Option<&'a RenderSnapshot>,
arena: &'a SpecArena,
ids: &'a [SpecId],
/// The text color the parent passes down, already resolved.
inherited: gpui::Hsla,
}
impl<'a> ChildSpecs<'a> {
/// The described children, in the order the script attached them.
fn ids(&self) -> &'a [SpecId] {
self.ids
}
/// Wiring a callback is the one thing a component cannot do from a
/// description alone.
fn runtime(&self) -> &'a Rc<ShellRuntime> {
self.runtime
}
/// What constructed the child at `id`, so a parent can tell the children it
/// owns from the rest.
fn component(&self, id: SpecId) -> Option<&'a Component> {
self.arena.node(id).and_then(SpecNode::component)
}
/// The ordinary path, for every child the parent does not own.
fn element(&self, id: SpecId, window: &mut Window, cx: &mut App) -> AnyElement {
materialize_node(
self.runtime,
self.snapshot,
self.arena,
id,
self.inherited,
window,
cx,
)
}
/// Everything [`materialize_node`] resolves before it decides what to
/// construct. What is left is the construction, which is the part the parent
/// is here to do itself.
///
/// Deliberately narrower than that preamble: a typed child is a container
/// the parent builds, so there is no retained path geometry to keep and no
/// slot to fill. A slot it filled anyway is reported, because filling one
/// detached the element from the tree — silence there is content that
/// vanished.
fn parts(&self, id: SpecId, window: &mut Window, cx: &mut App) -> Option<NodeParts> {
let node = self.arena.node(id)?;
let component = node.component()?;
let (mut refinement, behavior, states, motions, slots) = resolve_ops(self.arena, node);
apply_motion(
motion_element_id(id, behavior.key.clone(), component),
&motions,
&mut refinement,
window,
cx,
);
for (name, _) in slots.iter() {
tracing::warn!(
"{} has no `{name}` slot, so the element given to it is not rendered at all",
component.name()
);
}
let inherited = refinement.text.color.unwrap_or(self.inherited);
let children: Children = node
.children()
.iter()
.map(|child| {
materialize_node(
self.runtime,
self.snapshot,
self.arena,
*child,
inherited,
window,
cx,
)
})
.collect();
Some(NodeParts {
refinement,
behavior,
states,
children,
})
}
}
/// One child resolved but not yet constructed. See [`ChildSpecs::parts`].
struct NodeParts {
refinement: StyleRefinement,
behavior: Behavior,
states: StateStyles,
children: Children,
}
/// Whether this component reads its children as descriptions. See
/// [`ChildSpecs`].
fn takes_typed_children(component: &Component) -> bool {
matches!(component, Component::Resizable(..))
}
/// Behavior collected from a node's ops, applied after styling.
/// Style refinements that apply only in a runtime state.
#[derive(Default)]
struct StateStyles {
hover: Option<StyleRefinement>,
active: Option<StyleRefinement>,
focus: Option<StyleRefinement>,
}
impl StateStyles {
/// `active` and `focus` need a stable element identity; `hover` does not.
fn needs_identity(&self) -> bool {
self.active.is_some() || self.focus.is_some()
}
}
#[derive(Default)]
struct Behavior {
disabled: bool,
selectable: Option<bool>,
text_scrollable: Option<bool>,
on_link_click: Option<CallbackId>,
/// What a `TextView`'s `text_style(...)` calls fold onto the style the
/// theme gives it. Boxed: it holds several refinements, and this struct
/// sits in the frame of a recursive walk.
text_style: Option<Box<text_view::ScriptTextStyle>>,
/// One full turn of an `svg` every this long. See
/// [`components::spin`].
spin: Option<Duration>,
/// Whether a `NumberInput` stacks both step buttons to the right of the
/// text, rather than putting one on each side of it.
controls_right: bool,
selected: bool,
checked: bool,
/// A `Toggle`'s controlled state. Separate from `checked` because that is
/// what base calls it, and because the two announce differently: a pressed
/// toggle is a button in a toggled state, not a checked box.
pressed: bool,
/// A name the script gave this element, used as its GPUI identity.
///
/// Without one, identity falls back to the node's address in the
/// description — which is stable only while the script builds the same tree
/// in the same order. A conditional child earlier in the tree shifts every
/// address after it, and with it the active state, the focus, and anything
/// else GPUI keys by id. `id("toolbar")` is how a script says which element
/// this is, rather than where it happened to land.
key: Option<SharedString>,
/// What a screen reader announces. An icon-only control has no text of its
/// own, so without this it announces nothing.
accessibility_label: Option<SharedString>,
href: Option<SharedString>,
on_click: Option<CallbackId>,
on_change: Option<CallbackId>,
token: Option<CallbackId>,
on_token_click: Option<CallbackId>,
on_token_hover: Option<CallbackId>,
on_mouse_move: Option<CallbackId>,
on_hover: Option<CallbackId>,
/// Reports a key press that reached this element.
///
/// GPUI routes a key event down the focus path, so an element only hears
/// one while it — or something inside it — holds the keyboard. That makes
/// `track_focus(handle)` half of the registration rather than an unrelated
/// call: without it the handler is installed on an element the keyboard
/// never reaches, and nothing arrives.
on_key_down: Option<CallbackId>,
/// Reports the release of a key, on the same focus path as
/// [`Behavior::on_key_down`].
on_key_up: Option<CallbackId>,
/// Reports modifier-only changes on the keyboard focus path.
on_modifiers_changed: Option<CallbackId>,
/// Presses on this element, one entry per button listened for.
///
/// A list rather than one field, because GPUI takes the button as an
/// argument and an element may well want two of them — a left press that
/// selects and a right press that opens a menu are one element's job.
on_mouse_down: SmallVec<[(MouseButton, CallbackId); 1]>,
/// Releases on this element, listed the same way.
on_mouse_up: SmallVec<[(MouseButton, CallbackId); 1]>,
/// A press anywhere outside this element, delivered during the capture
/// phase.
///
/// The one event here that is about somewhere else, and the reason a
/// script can dismiss a surface it drew itself: base's own components close
/// on an outside press through exactly this listener, and until now a
/// script had no way to.
on_mouse_down_out: Option<CallbackId>,
/// Wheel and trackpad scrolling over this element.
on_scroll_wheel: Option<CallbackId>,
/// A pinch-to-zoom gesture over this element.
on_pinch: Option<CallbackId>,
/// Handlers for named actions, one entry per action listened for.
///
/// A list because an element that responds to actions usually responds to
/// several — a pane handling Save, Close and Split is one element's job —
/// and because every script action shares one GPUI type, so the id is what
/// tells them apart.
on_action: SmallVec<[(SharedString, CallbackId); 1]>,
/// A heading's announced level, for an `AccordionHeader`.
aria_level: Option<usize>,
/// Whether a shut `AccordionPanel` stays in the tree.
keep_mounted: bool,
/// The key-binding context this element and its subtree sit in.
///
/// What a keymap's `context` predicate is matched against, so a chord can
/// mean one thing in a list and another in an editor.
key_context: Option<SharedString>,
/// Reports which way a `NumberInput` was asked to step.
///
/// Setting it takes the stepping away from base entirely: the built-in
/// increment, the `min`/`max` clamp and the numeric mask all live in the
/// closure this one replaces, so from here on the script is the only thing
/// that can move the value.
on_step: Option<CallbackId>,
/// Reports the open state of an anchored surface after something other than
/// the script changed it — a click on the trigger, a click outside, Escape.
on_open_change: Option<CallbackId>,
/// Reports Enter on an open `Select` or `Combobox`. It carries no payload:
/// the root holds no value, so what was confirmed is whatever the script
/// had highlighted.
on_confirm: Option<CallbackId>,
/// Reports Escape on an open `Select` or `Combobox`, before the open state
/// is asked to close — which is what lets a script commit a pending value
/// on the way out.
on_dismiss: Option<CallbackId>,
scroll_x: bool,
scroll_y: bool,
scrollbar: bool,
/// A `Scrollbar`'s show mode. `None` keeps the theme's own projection,
/// which is what every bar the shell already paints follows.
scrollbar_mode: Option<gpui_base::ScrollbarMode>,
/// The content size a `Scrollbar` measures its thumb against, when the
/// script knows it and the scroll area does not — a list that paints a
/// window of rows rather than all of them.
scroll_size: Option<gpui::Size<Pixels>>,
/// Whether a `Scrollbar` takes its viewport from its own layout box rather
/// than from the scroll area. The way to keep a bar off a fixed header.
viewport_from_layout: bool,
/// A resizable panel's initial size along its group's axis.
///
/// Recorded under a name no script spells. `ResizablePanel::size` is base's
/// own inherent builder and shadows `Styled::size` for that one type; the
/// prelude reproduces the shadowing with an own property on the panel
/// object, so the script still writes `.size(200)` and every other
/// element's `size` still means width-and-height.
panel_size: Option<Pixels>,
/// How far a resizable panel may be dragged. `None` keeps base's own
/// `PANEL_MIN_SIZE..Pixels::MAX`.
size_range: Option<std::ops::Range<Pixels>>,
/// Whether a resizable panel is rendered at all. `None` keeps base's
/// default, which is that it is.
visible: Option<bool>,
/// Reports the panel sizes of a resizable group once a drag has ended.
on_resize: Option<CallbackId>,
/// Reports which item of a `VirtualList` was clicked.
///
/// One handler for the whole list rather than one per row, and that is a
/// deliberate limit rather than a convenience: see
/// [`components::virtual_list`].
on_item_click: Option<CallbackId>,
/// Reports a secondary press on a virtual list row, with the row's key and
/// the press itself. Registered on the list for the same reason
/// `on_item_click` is.
on_item_secondary_click: Option<CallbackId>,
/// Reports which items a `list` shows and how many it has, after a frame
/// in which either changed.
on_visible_range_change: Option<CallbackId>,
/// Which item a `VirtualList` measures to infer its cross-axis size.
/// `None` keeps base's own default, which is the first.
item_to_measure_index: Option<usize>,
/// Whether a `list` follows its tail. `None` is GPUI's own default,
/// `FollowMode::Normal`.
follow_mode: Option<gpui::FollowMode>,
/// An explicit measured-list end request, consumed once per distinct key.
scroll_to_end: Option<String>,
/// The dock commands a chrome element carries — what base is asked to do
/// when it is clicked or dragged.
///
/// A list, because one element can carry two: a tab both selects and drags.
dock_commands: SmallVec<[crate::dock::DockAction; 2]>,
/// Which script handler draws each piece of a `dock_area`'s chrome.
///
/// Six handlers in one field rather than six fields, because they are
/// written together: they leave here for the [`DockChromeSlots`] the skin
/// reads, and a skin reads all six or none.
///
/// [`DockChromeSlots`]: crate::dock::DockChromeSlots
dock_chrome: crate::dock::DockChromeHooks,
/// The retained scroll position a `VirtualList` was told to drive.
virtual_scroll: Option<crate::entities::EntityHandle>,
/// This item's one-based position in its collection, and the collection's
/// size — "tab 2 of 5". Announced, never drawn.
position_in_set: Option<(usize, usize)>,
/// The announced progress percentage. `None` leaves base's own default,
/// which is 0 — a bar the script never told how far along it is.
value: Option<f32>,
/// Withdraws the value from the accessibility tree instead of changing it:
/// "still working, no idea how far" is a different announcement from
/// "at 40%".
indeterminate: bool,
/// The semantic orientation a grouping container announces. `None` keeps
/// the container's own default, which differs between them: a radio group
/// is vertical, a toolbar horizontal.
axis: Option<gpui::Axis>,
/// The whole table's size, including rows and columns outside the range
/// the script rendered. A table that draws every row it has needs neither.
row_count: Option<usize>,
column_count: Option<usize>,
/// Which thumb of a range slider a `SliderThumb` is. `false`, the
/// default, is the thumb at the end of the range — the only one a
/// single-value slider has.
start: bool,
/// How the filled part of a `SliderIndicator` looks. Only how it looks:
/// where it is comes from the state on every frame, because a fill the
/// script positioned would be frozen at the value the render that
/// positioned it saw.
range_style: Option<StyleRefinement>,
/// How every cell of an `OtpInput` looks, how the cell taking the next
/// digit differs, and what the caret in it looks like.
///
/// Three templates rather than three described elements, for the reason
/// [`Self::range_style`] is one: the cells are rebuilt from the state on
/// every frame, and a cell the script described would still be showing the
/// digit the render that described it saw. `cell_active_style` is layered
/// on top of `cell_style` the way a hover is layered on a base style, so a
/// script declares only what differs.
cell_style: Option<StyleRefinement>,
cell_active_style: Option<StyleRefinement>,
caret_style: Option<StyleRefinement>,
/// Whether a `Collapsible` renders the element in its `content` slot, or
/// whether a `Popover` is showing.
///
/// `None` means the script never said, which is not the same as `false`: a
/// popover that was never given an `open` is uncontrolled and starts from
/// its own `default_open`, while one told `open(false)` is controlled and
/// stays shut until the script says otherwise.
open: Option<bool>,
/// A `Popover`'s starting state, used only while it is uncontrolled.
default_open: bool,
/// Whether pressing outside a `Popover` closes it. `None` keeps base's own
/// default, which is that it does.
overlay_closable: Option<bool>,
/// Which corner of the surface is pinned to the trigger. `None` keeps the
/// component's own default, and the two differ: a popover anchors top-left,
/// a hover card top-center.
anchor: Option<gpui::Anchor>,
frame_budget: Option<Duration>,
/// The pointer button that opens a `Popover`.
mouse_button: Option<MouseButton>,
/// The label a hover shows over this element. A string rather than an
/// element: the overlay rebuilds the content once a frame while it is up,
/// which is the one place a retained script closure would put the VM back
/// on the frame path.
tooltip: Option<SharedString>,
/// How long the pointer rests on a `HoverCard`'s trigger before it appears,
/// and how long it may leave before it goes away.
open_delay: Option<Duration>,
close_delay: Option<Duration>,
/// The handle of a script-created `FocusHandle` this element tracks.
///
/// Stored as the handle rather than the resolved `gpui::FocusHandle`
/// because resolving it needs the runtime, which [`resolve_ops`] does not
/// have — and because a released handle has to be reported once, where it
/// is used, rather than turned into silence here.
focus_handle: Option<crate::entities::EntityHandle>,
/// The handle the keyboard moves to when a `Select` or `Combobox` opens.
///
/// A second handle rather than a second use of [`Self::focus_handle`],
/// because a combobox has two focus targets at once: the trigger, which
/// holds the keyboard while it is shut, and the list, which takes it while
/// it is open.
content_focus_handle: Option<crate::entities::EntityHandle>,
/// Where this element sits in the window's focus traversal order.
tab_index: Option<isize>,
/// Whether Tab can land on it at all. Separate from [`Self::tab_index`]
/// because a container that keeps its place in the order without being
/// reachable is the whole point of `tab_stop(false)`.
tab_stop: Option<bool>,
/// What this element announces itself as.
role: Option<gpui::Role>,
aria_selected: Option<bool>,
/// Announces this element as the focused node while an ancestor holds real
/// focus — the highlighted option of a combobox whose input keeps the
/// keyboard.
aria_active_descendant: bool,
/// `InteractiveElement::window_control_area`: this element is the window's
/// drag area or one of its caption buttons.
window_control_area: Option<gpui::WindowControlArea>,
}
impl Behavior {
/// Whether anything asked for a pointer listener, and so for the box every
/// pointer position is reported against.
fn wants_pointer_geometry(&self) -> bool {
!self.on_mouse_down.is_empty()
|| !self.on_mouse_up.is_empty()
|| self.on_mouse_down_out.is_some()
|| self.on_scroll_wheel.is_some()
|| self.on_pinch.is_some()
}
/// Whether focus or accessibility gave this element a reason to be
/// identified.
///
/// A tab stop that changes identity between renders is a tab stop the
/// keyboard loses on the next repaint, and GPUI produces an accessibility
/// node only for an element that has an id — so both halves need one.
fn needs_identity(&self) -> bool {
self.focus_handle.is_some()
|| self.tooltip.is_some()
|| self.tab_index.is_some()
|| self.tab_stop.is_some()
|| self.role.is_some()
|| self.aria_selected.is_some()
|| self.aria_active_descendant
|| self.on_mouse_move.is_some()
|| self.on_hover.is_some()
|| self.on_key_down.is_some()
|| self.on_key_up.is_some()
|| self.on_modifiers_changed.is_some()
|| !self.on_mouse_down.is_empty()
|| !self.on_mouse_up.is_empty()
|| self.on_mouse_down_out.is_some()
|| self.on_scroll_wheel.is_some()
|| self.on_pinch.is_some()
|| !self.on_action.is_empty()
|| self.key_context.is_some()
|| self.window_control_area.is_some()
}
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum MotionProperty {
Opacity,
Width,
Height,
Left,
Top,
}
impl MotionProperty {
fn parse(value: &str) -> Option<Self> {
match value {
"opacity" => Some(Self::Opacity),
"width" => Some(Self::Width),
"height" => Some(Self::Height),
"left" => Some(Self::Left),
"top" => Some(Self::Top),
_ => None,
}
}
fn channel(self) -> &'static str {
match self {
Self::Opacity => "opacity",
Self::Width => "width",
Self::Height => "height",
Self::Left => "left",
Self::Top => "top",
}
}
}
#[derive(Clone)]
struct Motion {
property: MotionProperty,
policy: MotionPolicy,
}
#[derive(Clone)]
enum MotionPolicy {
Transition {
duration: Duration,
delay: Duration,
easing: String,
},
Spring {
response: Duration,
damping: f32,
epsilon: f32,
},
}
/// Materializes a snapshot's root and every descendant.
///
/// Reading is non-destructive, so this may be called any number of times on the
/// same snapshot and produces the same interface each time. That is the whole
/// point: a hover, a cursor blink or an animation frame repaints through here
/// and never through the VM.
///
/// `window` and `cx` are threaded through even though only the recursion uses
/// them today: entity-backed components (Input, Tree, Table) and tooltips need
/// both at construction time, and they are part of this function's contract
/// rather than an oversight.
pub fn materialize(
runtime: &Rc<ShellRuntime>,
snapshot: &RenderSnapshot,
window: &mut Window,
cx: &mut App,
) -> AnyElement {
let ambient = window.text_style().color;
// Counted and timed because this is the native half of rebuilding a dirty
// script view. Clean window frames reuse ShellRoot's cached subtree and do
// not enter here at all.
let metrics = runtime.metrics();
metrics.time_materialize(|| {
materialize_node(
runtime,
Some(snapshot),
snapshot.arena(),
snapshot.root(),
ambient,
window,
cx,
)
})
}
/// Builds the same eager element tree as a view render, preserving registered
/// component failures for a source check instead of only showing a fallback.
/// Deferred slots, nested views, and layout callbacks are not driven here.
pub(crate) fn try_materialize(
runtime: &Rc<ShellRuntime>,
snapshot: &RenderSnapshot,
window: &mut Window,
cx: &mut App,
) -> anyhow::Result<AnyElement> {
with_error_frame(|| materialize(runtime, snapshot, window, cx))
}
/// Materializes one described subtree from an arena that is not a snapshot's.
///
/// The public entry above takes a [`RenderSnapshot`] because that is what a
/// view has. A virtualized list has something else: a batch of rows the script
/// described a moment ago into a temporary arena that will be dropped as soon
/// as the elements exist. It is the same walk, over descriptions with a shorter
/// life — which is the whole of what separates the two entry points.
///
/// Not timed. The caller times the script call and this walk together, because
/// from a frame's point of view they are one cost.
pub(crate) fn materialize_subtree(
runtime: &Rc<ShellRuntime>,
arena: &SpecArena,
root: SpecId,
window: &mut Window,
cx: &mut App,
) -> AnyElement {
let ambient = window.text_style().color;
materialize_node(runtime, None, arena, root, ambient, window, cx)
}
pub(crate) fn try_materialize_subtree(
runtime: &Rc<ShellRuntime>,
arena: &SpecArena,
root: SpecId,
window: &mut Window,
cx: &mut App,
) -> anyhow::Result<AnyElement> {
with_error_frame(|| materialize_subtree(runtime, arena, root, window, cx))
}
/// Materializes one node, carrying the text color down the description.
///
/// GPUI resolves inherited text color while painting, but an svg needs the
/// color on its *own* style before it will paint at all — and by then the
/// description is gone. So inheritance is resolved here, walking the tree the
/// script built: each node passes down its own `text_color` if it set one, and
/// the ambient color otherwise. That is what makes an icon inside a dark button
/// come out light without the script saying so twice.
fn materialize_node(
runtime: &Rc<ShellRuntime>,
snapshot: Option<&RenderSnapshot>,
arena: &SpecArena,
id: SpecId,
inherited: gpui::Hsla,
window: &mut Window,
cx: &mut App,
) -> AnyElement {
let Some(node) = arena.node(id) else {
return div().into_any_element();
};
let Some(component) = node.component().cloned() else {
return div().into_any_element();
};
let (mut refinement, behavior, states, motions, slot_specs) = resolve_ops(arena, node);
let motion_identity = motion_element_id(id, behavior.key.clone(), &component);
apply_motion(motion_identity, &motions, &mut refinement, window, cx);
let inherited = refinement.text.color.unwrap_or(inherited);
// `SmallVec` rather than `Vec`: this runs per node, per frame, and the
// overwhelming majority of nodes have a handful of children or none. A
// heap allocation for each of them is a cost the snapshot was supposed to
// remove, arriving one layer down.
//
// A component that takes typed children is handed the descriptions instead:
// flattening them here would throw away the very thing it needs. See
// [`ChildSpecs`].
let children: Children = if takes_typed_children(&component)
|| matches!(component, Component::Registered(_))
{
Children::new()
} else {
node.children()
.iter()
.map(|child| materialize_node(runtime, snapshot, arena, *child, inherited, window, cx))
.collect()
};
// A slot's element is built here, beside the children, so it inherits the
// same text color: where the component chooses to put it is the component's
// business, but what it looks like should not depend on that choice.
let slots: Slots = if matches!(component, Component::Registered(_)) {
SmallVec::new()
} else if materializes_slots(&component, &behavior) {
slot_specs
.iter()
.filter(|(name, _)| {
!matches!(component, Component::Popover(_))
|| *name != "content"
|| behavior.open != Some(false)
})
.map(|(name, slot)| {
(
*name,
materialize_node(runtime, snapshot, arena, *slot, inherited, window, cx),
)
})
.collect()
} else {
SmallVec::new()
};
// Every component that reads a slot reports its own leftovers, because only
// it knows which names it read.
if !matches!(
component,
Component::Registered(_)
| Component::Collapsible
| Component::Popover(_)
| Component::HoverCard(_)
| Component::Popup(_)
) {
warn_unread_slots(&slots, component.name());
}
// Reported here rather than in the component, because this is the last
// place the description is addressable: a `Slider` with no
// `SliderIndicator` under it records no geometry, and every pointer
// position it is later asked about divides by a zero-sized box.
if let Component::Slider(handle) = &component {
components::slider::warn_without_indicator(arena, id, *handle);
}
components::tooltip::warn_unhonoured_tooltip(&component, &behavior);
components::spin::warn_unhonoured_spin(&component, &behavior);
text_view::warn_unhonoured_text_style(&component, &behavior);
warn_unhonoured_input(&component, &behavior);
match component {
Component::Registered(component) => materialize_registered_component(
runtime,
snapshot,
arena,
node,
element_id(id, behavior.key.clone()),
component,
inherited,
Box::new(RegisteredMaterializeParts {
refinement,
behavior,
children,
slots,
slot_specs,
}),
window,
cx,
),
component => materialize_component(
runtime, snapshot, arena, node, id, component, inherited, refinement, behavior, states,
children, slots, slot_specs, window, cx,
),
}
}
/// Dispatches an already-materialized node to its concrete component.
///
/// Keeping this large, monomorphization-heavy match out of `materialize_node`
/// is not cosmetic: the latter is recursive, so every byte of its stack frame
/// is multiplied by the depth of the script's element tree. Components can
/// grow independently without making ordinary nested layouts exhaust Rust's
/// default test-thread stack.
struct RegisteredMaterializeParts {
refinement: StyleRefinement,
behavior: Behavior,
children: Children,
slots: Slots,
slot_specs: SlotSpecs,
}
#[allow(clippy::too_many_arguments)]
#[inline(never)]
fn materialize_registered_component(
runtime: &Rc<ShellRuntime>,
snapshot: Option<&RenderSnapshot>,
arena: &SpecArena,
node: &SpecNode,
identity: gpui::ElementId,
component: crate::spec::RegisteredComponentSpec,
inherited: gpui::Hsla,
parts: Box<RegisteredMaterializeParts>,
window: &mut Window,
cx: &mut App,
) -> AnyElement {
let RegisteredMaterializeParts {
refinement,
behavior,
children,
slots,
slot_specs,
} = *parts;
let Some(descriptor) = runtime.component_registry().descriptor(component.id()) else {
tracing::error!(
"registered component `{}` has an unknown registry id {}",
component.name(),
component.id().as_u32()
);
return div()
.child(format!("Unknown component: {}", component.name()))
.into_any_element();
};
let mut resolve_element = |element, window: Option<&mut Window>, cx: Option<&mut App>| {
let window =
window.ok_or_else(|| anyhow::anyhow!("render Window authority is unavailable"))?;
let cx = cx.ok_or_else(|| anyhow::anyhow!("render App authority is unavailable"))?;
Ok(materialize_node(
runtime, snapshot, arena, element, inherited, window, cx,
))
};
let child_specs = node
.children()
.iter()
.map(|child| {
let name = arena
.node(*child)
.and_then(SpecNode::component)
.and_then(|component| match component {
Component::Registered(component) => Some(component.name()),
_ => None,
});
(*child, name)
})
.collect();
// A factory leases the snapshot and allocates an `Rc` per slot, and most
// adapters read their slots eagerly. So the request is handed the recipe
// rather than the product, and builds one only for a slot actually asked
// for by name.
let slot_factory_specs: crate::component_registry::SlotSpecs = match snapshot {
Some(_) => slot_specs.iter().copied().collect(),
None => SmallVec::new(),
};
let make_slot_factory = |slot: SpecId| {
let snapshot = snapshot.cloned();
let runtime = Rc::downgrade(runtime);
crate::ComponentElementFactory::new(move |window, cx| {
let runtime = runtime.upgrade().ok_or_else(|| {
anyhow::anyhow!("component element factory runtime has been released")
})?;
let snapshot = snapshot
.as_ref()
.ok_or_else(|| anyhow::anyhow!("component element factory has no snapshot"))?;
anyhow::ensure!(
snapshot.belongs_to(&runtime),
"component element factory belongs to a different runtime"
);
materialize_factory_subtree(&runtime, snapshot, slot, inherited, window, cx)
})
};
let mut request =
crate::MaterializeRequest::new(crate::component_registry::MaterializeRequestInit {
component_name: component.name(),
element_id: identity,
payload: component.payload(),
operations: node.ops(),
runtime,
resolve_element: &mut resolve_element,
style: refinement,
children,
child_specs,
slots,
slot_factory_specs,
make_slot_factory: &make_slot_factory,
disabled: behavior.disabled,
selected: behavior.selected,
on_click: behavior.on_click,
application_owner: snapshot.and_then(RenderSnapshot::application_owner),
});
request.attach_render_authority(window, cx);
match descriptor.materializer().materialize(request) {
Ok(element) => element,
Err(error) => {
tracing::error!("failed to materialize `{}`: {error:#}", component.name());
FACTORY_MATERIALIZE_ERRORS.with(|frames| {
if let Some(held) = frames.borrow_mut().last_mut()
&& held.is_none()
{
*held = Some(
error.context(format!("failed to materialize `{}`", component.name())),
);
}
});
div()
.child(format!("Failed to render {}", component.name()))
.into_any_element()
}
}
}
#[allow(clippy::too_many_arguments)]
fn materialize_component(
runtime: &Rc<ShellRuntime>,
snapshot: Option<&RenderSnapshot>,
arena: &SpecArena,
node: &SpecNode,
id: SpecId,
component: Component,
inherited: gpui::Hsla,
refinement: StyleRefinement,
behavior: Behavior,
states: StateStyles,
children: Children,
slots: Slots,
slot_specs: SlotSpecs,
window: &mut Window,
cx: &mut App,
) -> AnyElement {
match component {
Component::Registered(_) => {
unreachable!("registered components dispatch before the built-in component match")
}
Component::ChildView(child) => child.view().clone().into_any_element(),
Component::Div => flex_element(
runtime,
div(),
id,
refinement,
behavior,
states,
children,
window,
cx,
),
Component::HFlex => flex_element(
runtime,
h_flex(),
id,
refinement,
behavior,
states,
children,
window,
cx,
),
Component::VFlex => flex_element(
runtime,
v_flex(),
id,
refinement,
behavior,
states,
children,
window,
cx,
),
Component::Module(spec) => {
let registry = spec.policy.modules();
let component = registry
.get(&spec.module)
.and_then(|module| module.resolve_component(&spec.component));
let Ok(component) = component else {
tracing::warn!(
"component `{}.{}` was revoked before materialization",
spec.module,
spec.component
);
return div().into_any_element();
};
component.build(
crate::ComponentArgs {
id: &spec.id,
props: &spec.props,
children: children.into_vec(),
},
window,
cx,
)
}
Component::TextView(spec) => {
let mut style = TextViewStyle::from_theme(&Theme::global(cx));
if let Some(text_style) = behavior.text_style.as_deref() {
style = text_style.fold(style, window.rem_size());
}
let mut view = match spec.format {
crate::spec::TextViewFormat::Html => TextView::html(spec.id, spec.text),
crate::spec::TextViewFormat::Markdown => TextView::markdown(spec.id, spec.text),
}
.style(style);
if let Some(selectable) = behavior.selectable {
view = view.selectable(selectable);
}
if let Some(scrollable) = behavior.text_scrollable {
view = view.scrollable(scrollable);
}
if let Some(callback) = behavior.on_link_click {
let route = crate::script_callback::ScriptCallbackRoute::new(
Rc::downgrade(runtime),
callback,
);
view = view.on_link_activation(move |url, _event, window, cx| {
route.emit(crate::HostValue::from(url.to_string()), window, cx);
gpui_base::text::LinkDecision {
prevent_default: true,
stop_propagation: false,
}
});
} else {
view = view.on_link_activation(|url, event, _, cx| {
// Preserve Base's activation behavior, but apply Shell's URL rules.
let activate = match event {
gpui::ClickEvent::Mouse(click) => {
matches!(click.up.button, MouseButton::Left | MouseButton::Middle)
}
gpui::ClickEvent::Keyboard(_) | gpui::ClickEvent::Accessibility(_) => true,
gpui::ClickEvent::Touch(click) => !click.long_press,
};
if activate && is_openable_url(url) {
cx.open_url(url);
}
gpui_base::text::LinkDecision {
prevent_default: true,
stop_propagation: false,
}
});
}
Styled::style(&mut view).refine(&refinement);
text_view::with_policy(view, spec.policy).into_any_element()
}
Component::Text(value) => {
// A text run, not a `div` holding one. GPUI implements
// `IntoElement` for a string, so `div().child("x")` is one element
// with text in it — and a string only ever reaches here as a child,
// because that is the only way to write text now. Wrapping it would
// put a second box inside every label, which is a layout the script
// did not ask for.
//
// Nothing can be hung on it: it has no identity and no
// interactivity, so a role or a tab stop belongs on the element
// holding it.
warn_unhonoured_a11y(&behavior, "text", &[]);
warn_without_surface("text", &refinement, &states, &children);
SharedString::from(value).into_any_element()
}
Component::Button(id) => {
warn_ignored_key(&behavior, "Button");
warn_unhonoured_a11y(
&behavior,
"Button",
&["track_focus", "tab_index", "tab_stop", "role"],
);
let mut button = Button::new(SharedString::from(id))
.disabled(behavior.disabled)
.selected(behavior.selected);
if let Some(label) = behavior.accessibility_label.clone() {
button = button.accessibility_label(label);
}
// Base's own builders, not GPUI's: `Button::render` builds the
// focus handle it tracks from these three fields, so the trait
// methods of the same name would be overwritten a moment later.
if let Some(focus) = tracked_focus(runtime, &behavior, "Button") {
button = button.track_focus(&focus);
}
if let Some(index) = behavior.tab_index {
button = button.tab_index(index);
}
if let Some(stop) = behavior.tab_stop {
button = button.tab_stop(stop);
}
// `Button::role` overrides the implicit `Role::Button`, which is
// what makes a button that opens a menu announce itself as one.
if let Some(role) = behavior.role {
button = button.role(role);
}
if let Some(callback) = behavior.on_click {
let runtime = Rc::downgrade(runtime);
button = button.on_click(move |event, window, cx| {
dispatch_click(&runtime, callback, event, window, cx);
});
}
let button = with_hover(button, &states);
let button = with_active_and_focus(button, &states);
let button = components::tooltip::with_tooltip(button, &behavior);
let button = with_input_handlers(button, &behavior, runtime);
finish(button, refinement, children)
}
Component::Link(id) => {
warn_ignored_key(&behavior, "Link");
// No `track_focus`: base's `Link` builds its own keyed handle and
// has no builder to replace it, so a script's handle would name a
// focus target the link never focuses.
warn_unhonoured_a11y(&behavior, "Link", &["tab_index", "tab_stop"]);
let mut link = Link::new(SharedString::from(id)).disabled(behavior.disabled);
if let Some(label) = behavior.accessibility_label.clone() {
link = link.accessibility_label(label);
}
if let Some(index) = behavior.tab_index {
link = link.tab_index(index);
}
if let Some(stop) = behavior.tab_stop {
link = link.tab_stop(stop);
}
if let Some(href) = behavior.href.clone() {
link = link
.href(href)
.open_with(|href, _, _, cx| cx.open_url(href));
}
if let Some(callback) = behavior.on_click {
let runtime = Rc::downgrade(runtime);
link = link.on_activate(move |event, window, cx| {
dispatch_click(&runtime, callback, event, window, cx);
});
}
let link = with_hover(link, &states);
let link = with_active_and_focus(link, &states);
let link = with_input_handlers(link, &behavior, runtime);
finish(link, refinement, children)
}
Component::Checkbox(id) => {
warn_ignored_key(&behavior, "Checkbox");
warn_unhonoured_a11y(
&behavior,
"Checkbox",
&["track_focus", "tab_index", "tab_stop", "role"],
);
let mut checkbox = Checkbox::new(SharedString::from(id))
.disabled(behavior.disabled)
.checked(behavior.checked);
if let Some(label) = behavior.accessibility_label.clone() {
checkbox = checkbox.accessibility_label(label);
}
if let Some(focus) = tracked_focus(runtime, &behavior, "Checkbox") {
checkbox = checkbox.track_focus(&focus);
}
if let Some(index) = behavior.tab_index {
checkbox = checkbox.tab_index(index);
}
if let Some(stop) = behavior.tab_stop {
checkbox = checkbox.tab_stop(stop);
}
// The override is what turns a checkbox into a menu item that
// carries a check, which is a different announcement.
if let Some(role) = behavior.role {
checkbox = checkbox.role(role);
}
if let Some(callback) = behavior.on_change {
let runtime = Rc::downgrade(runtime);
checkbox = checkbox.on_change(move |state, _, window, cx| {
dispatch_change(
&runtime,
callback,
matches!(state, CheckboxState::Checked),
window,
cx,
);
});
}
let checkbox = with_hover(checkbox, &states);
let checkbox = with_active_and_focus(checkbox, &states);
let checkbox = with_input_handlers(checkbox, &behavior, runtime);
finish(checkbox, refinement, children)
}
Component::Switch(id) => {
warn_ignored_key(&behavior, "Switch");
// Like `Link`, `Switch` builds its own keyed focus handle and
// announces `Role::Switch` itself.
warn_unhonoured_a11y(&behavior, "Switch", &["tab_index", "tab_stop"]);
let mut switch = Switch::new(SharedString::from(id))
.disabled(behavior.disabled)
.checked(behavior.checked);
if let Some(index) = behavior.tab_index {
switch = switch.tab_index(index);
}
if let Some(stop) = behavior.tab_stop {
switch = switch.tab_stop(stop);
}
if let Some(callback) = behavior.on_change {
let runtime = Rc::downgrade(runtime);
switch = switch.on_change(move |checked, _, window, cx| {
dispatch_change(&runtime, callback, checked, window, cx);
});
}
// `Switch` itself is not interactive — `SwitchTrack` is — so a
// state style on the switch root has nowhere to land. Saying so is
// better than dropping it without a word.
if states.hover.is_some() || states.active.is_some() || states.focus.is_some() {
tracing::warn!(
"state styles on a Switch are ignored; style the row around it instead"
);
}
let switch = with_input_handlers(switch, &behavior, runtime);
finish(switch, refinement, children)
}
Component::Tabs(id) => {
components::tabs::tab_list(runtime, id, refinement, behavior, states, children)
}
Component::Tab(id) => {
components::tabs::tab(runtime, id, refinement, behavior, states, children)
}
Component::Progress(id) => {
components::progress::progress(id, refinement, behavior, states, children)
}
Component::ProgressTrack => {
components::progress::progress_track(refinement, behavior, states, children)
}
Component::ProgressIndicator => {
components::progress::progress_indicator(refinement, behavior, states, children)
}
Component::FpsMonitor => {
components::fps::fps_monitor(refinement, behavior, states, children, window, cx)
}
Component::Radio(id) => {
components::radio::radio(runtime, id, refinement, behavior, states, children)
}
Component::Toggle(id) => {
components::toggle::toggle(runtime, id, refinement, behavior, states, children)
}
Component::RadioGroup(id) => {
components::group::radio_group(id, refinement, behavior, states, children)
}
Component::ToggleGroup(id) => {
components::group::toggle_group(id, refinement, behavior, states, children)
}
Component::Table(id) => {
components::table::table(runtime, id, refinement, behavior, states, children)
}
Component::TableHeader(id) => {
components::table::table_header(runtime, id, refinement, behavior, states, children)
}
Component::TableBody(id) => {
components::table::table_body(runtime, id, refinement, behavior, states, children)
}
Component::TableRow(id, row_index) => components::table::table_row(
runtime, id, row_index, refinement, behavior, states, children,
),
Component::TableHead(id, column_index) => components::table::table_head(
runtime,
id,
column_index,
refinement,
behavior,
states,
children,
),
Component::TableCell(id, column_index) => components::table::table_cell(
runtime,
id,
column_index,
refinement,
behavior,
states,
children,
),
Component::TableCaption(id) => {
components::table::table_caption(runtime, id, refinement, behavior, states, children)
}
Component::Resizable(id, axis) => components::resizable::panel_group(
ChildSpecs {
runtime,
snapshot,
arena,
ids: node.children(),
inherited,
},
id,
axis,
refinement,
behavior,
states,
window,
cx,
),
// Only reachable when the panel never made it into a group, which base
// cannot render at all.
Component::ResizablePanel => {
components::resizable::orphan_panel(refinement, behavior, states, children)
}
Component::Slider(handle) => {
components::slider::slider(runtime, handle, refinement, behavior, states, children)
}
Component::SliderTrack(handle) => components::slider::slider_track(
runtime, handle, refinement, behavior, states, children,
),
Component::SliderIndicator(handle) => components::slider::slider_indicator(
runtime, handle, refinement, behavior, states, children, cx,
),
Component::SliderThumb(handle) => components::slider::slider_thumb(
runtime, handle, refinement, behavior, states, children, cx,
),
Component::Collapsible => {
components::collapsible::collapsible(refinement, behavior, states, slots, children)
}
Component::Popover(id) => {
components::popover::popover(runtime, id, refinement, behavior, states, slots, children)
}
Component::HoverCard(id) => components::popover::hover_card(
runtime, id, refinement, behavior, states, slots, children,
),
Component::Popup(id) => {
components::popover::popup(runtime, id, refinement, behavior, states, slots, children)
}
Component::Select(id) => {
components::select::select(runtime, id, refinement, behavior, states, children)
}
Component::Combobox(id) => {
components::select::combobox(runtime, id, refinement, behavior, states, children)
}
Component::DatePicker(id, focus) => components::select::date_picker(
runtime, id, focus, refinement, behavior, states, children,
),
Component::Svg(path) => {
// GPUI paints an svg only when the element's own style carries a
// text color, and an inherited color reaches children as a text
// style rather than as this element's style — so an icon with no
// color of its own silently draws nothing. `inherited` already
// accounts for this node's own `text_color`, so an explicit color
// still wins and an icon in a dark button comes out light.
warn_unhonoured_a11y(&behavior, "svg", &[]);
let mut image = gpui::svg().path(SharedString::from(path));
image.style().refine(&refinement);
image.style().text.color = Some(inherited);
match behavior.spin {
Some(period) => {
components::spin::SpinningSvg::new(image, period).into_any_element()
}
None => image.into_any_element(),
}
}
Component::Accordion(id) => {
components::accordion::accordion(&id, refinement, behavior, children)
}
Component::AccordionItem => components::accordion::accordion_item(
runtime, snapshot, arena, inherited, refinement, behavior, states, slot_specs,
children, window, cx,
),
Component::AccordionHeader => {
components::accordion::orphan("AccordionHeader", "an AccordionItem's `header` slot")
}
Component::AccordionPanel => {
components::accordion::orphan("AccordionPanel", "an AccordionItem's `panel` slot")
}
Component::AccordionTrigger(_) => components::accordion::orphan(
"AccordionTrigger",
"an AccordionHeader.new(trigger) call",
),
Component::Pagination(id) => {
components::pagination::pagination(&id, refinement, behavior, states, children)
}
Component::Avatar => components::avatar::avatar(
runtime, snapshot, arena, inherited, refinement, behavior, states, slot_specs,
children, window, cx,
),
Component::AvatarImage(_) => components::avatar::orphan("AvatarImage"),
Component::AvatarFallback => components::avatar::orphan("AvatarFallback"),
Component::Image(path) => {
warn_unhonoured_a11y(&behavior, "image", &[]);
let mut image = gpui::img(SharedString::from(path));
image.style().refine(&refinement);
image.into_any_element()
}
Component::Path {
fill,
background,
stroke_width,
} => {
warn_unhonoured_a11y(&behavior, "path", &[]);
crate::path::NativePath::new(
fill,
background,
stroke_width,
node.ops().to_vec(),
refinement,
)
.into_any_element()
}
Component::Scrollbar(id) => {
components::scrollbar::scrollbar(id, refinement, behavior, states, children, window, cx)
}
Component::DockArea(handle) => {
let hooks = behavior.dock_chrome;
components::dock::dock_area(
runtime, handle, hooks, refinement, behavior, states, children, window, cx,
)
}
Component::DockContent => components::dock::dock_content(refinement, behavior, children),
Component::VirtualList(spec) => components::virtual_list::virtual_list(
runtime, &spec, refinement, behavior, states, children, window, cx,
),
Component::List(spec) => components::list::list(
runtime, &spec, refinement, behavior, states, children, window, cx,
),
Component::Input(handle) => {
// An input's focus belongs to its `InputState`, which is what
// `on_mouse_down` below hands it. A second handle on the frame
// would be a focus target the caret never follows.
warn_unhonoured_a11y(&behavior, "Input", &[]);
let Some(state) = runtime.entities().input(handle) else {
tracing::error!("input handle {handle} is no longer live");
return div().into_any_element();
};
// `InputBase` rather than a bare `div`: it is the foundational input
// frame, so it carries the input semantics, the focused state style
// and the accessibility role that a `div` around a text run does
// not. `Input` itself draws the text and nothing else.
//
// Three defaults are applied before the script's own styling, so a
// script can override any of them but does not have to remember
// them:
//
// * a centered row — otherwise the text sits at the top of whatever
// height the frame was given, which is what a missing `h` looks
// like on screen;
// * full width, so the editable area is the frame rather than the
// width of the text already in it;
// * a click anywhere in the frame focuses the input, because the
// padding is part of the control as far as a user is concerned.
let focus_target = state.clone();
let mut frame = InputBase::new(("gpui-shell-input", handle))
.flex()
.items_center()
.w_full()
.on_mouse_down(MouseButton::Left, move |_, window, cx| {
focus_target.update(cx, |state, cx| state.focus(window, cx));
});
frame.style().refine(&refinement);
frame.extend(children);
let frame = with_hover(frame, &states);
let frame = with_active_and_focus(frame, &states);
let callbacks = crate::InlineTokenCallbacks::new(
&state,
behavior
.token
.map(|id| crate::ComponentElementCallback::from_runtime(runtime, id)),
behavior
.on_token_click
.map(|id| crate::ComponentCallback::from_runtime(runtime, id)),
)
.with_hover(
&state,
behavior
.on_token_hover
.map(|id| crate::ComponentCallback::from_runtime(runtime, id)),
);
let input = callbacks.apply_hover(
callbacks.apply(
Input::new(&state),
|input, render| input.token(move |token, window, cx| render(token, window, cx)),
|input, listen| {
input.on_token_click(move |event, window, cx| listen(event, window, cx))
},
),
|input, listen| {
input.on_token_hover(move |event, window, cx| listen(event, window, cx))
},
);
frame.child(input).into_any_element()
}
Component::OtpInput(handle) => components::otp_input::otp_input(
runtime, handle, refinement, behavior, states, children, window, cx,
),
Component::Textarea(handle) => {
components::textarea::textarea(runtime, handle, refinement, behavior, states, children)
}
Component::NumberInput(handle) => components::number_input::number_input(
runtime, snapshot, arena, handle, inherited, refinement, behavior, states, slot_specs,
children, window, cx,
),
}
}
fn materializes_slots(component: &Component, behavior: &Behavior) -> bool {
// A `NumberInput` skips them for the opposite reason a closed `Collapsible`
// does: not because they might not be drawn, but because it reads the
// descriptions itself. Building them here would be the same subtree built
// twice, under the same element id.
// An `Avatar` is here for the same reason: `Avatar::image` takes an
// `AvatarImage`, not an element, so the slot has to be read back for its
// path rather than handed over already built.
// An `AccordionItem` is here for the strongest version of the reason: its
// header and panel are concrete types, and the header's own trigger is a
// third one under that. The whole subtree is read back rather than built.
if matches!(
component,
Component::NumberInput(_) | Component::Avatar | Component::AccordionItem
) {
return false;
}
// A `Popover` or a `HoverCard` is not here, and not by oversight: its
// `trigger` slot is what is on screen while it is shut, so skipping its
// slots would remove the one element that has to stay. Skipping only the
// content needs a per-slot predicate and, for the uncontrolled case, an
// open state this side does not hold — base owns it.
!matches!(component, Component::Collapsible) || behavior.open.unwrap_or(false)
}
fn finish<E>(mut element: E, refinement: StyleRefinement, children: Children) -> AnyElement
where
E: Styled + ParentElement + IntoElement + 'static,
{
element.style().refine(&refinement);
element.extend(children);
element.into_any_element()
}
/// Applies the state refinements a component supports.
///
/// `active` needs a stable element identity, which only a `Stateful` element
/// has; `div()` becomes stateful lazily and Button already is, so the two arms
/// differ. Components that are not interactive silently ignore state styles
/// rather than failing the render — the script gets a `tracing` warning.
fn with_hover<E: InteractiveElement>(element: E, states: &StateStyles) -> E {
match states.hover.clone() {
Some(hover) => element.hover(move |mut style| {
style.refine(&hover);
style
}),
None => element,
}
}
fn with_active_and_focus<E: StatefulInteractiveElement>(element: E, states: &StateStyles) -> E {
let mut element = element;
if let Some(active) = states.active.clone() {
element = element.active(move |mut style| {
style.refine(&active);
style
});
}
if let Some(focus) = states.focus.clone() {
element = element.focus(move |mut style| {
style.refine(&focus);
style
});
}
element
}
/// The focus handle a script gave this element, if it is still live.
///
/// A released handle is reported rather than substituted: an element that
/// quietly fell back to base's own keyed focus would still be focusable, so
/// nothing on screen would say that `handle.is_focused()` is now asking about
/// somebody else.
fn tracked_focus(
runtime: &Rc<ShellRuntime>,
behavior: &Behavior,
component: &str,
) -> Option<gpui::FocusHandle> {
resolve_focus(runtime, behavior.focus_handle, component, "track_focus")
}
/// The handle a `Select` or `Combobox` moves the keyboard to when it opens.
fn content_focus(
runtime: &Rc<ShellRuntime>,
behavior: &Behavior,
component: &str,
) -> Option<gpui::FocusHandle> {
resolve_focus(
runtime,
behavior.content_focus_handle,
component,
"content_focus_handle",
)
}
fn resolve_focus(
runtime: &Rc<ShellRuntime>,
handle: Option<crate::entities::EntityHandle>,
component: &str,
method: &str,
) -> Option<gpui::FocusHandle> {
let handle = handle?;
let resolved = runtime.entities().focus(handle);
if resolved.is_none() {
tracing::error!(
"the focus handle {handle} given to `{method}` on a {component} has been released; \
the element keeps its own focus instead"
);
}
resolved
}
/// GPUI's own focus builders, for elements that carry no focus wiring of their
/// own.
///
/// Base's controls do — `Button`, `Checkbox`, `Radio`, `Toggle`, `Switch` and
/// `Link` each have an inherent `tab_index` and `tab_stop` feeding the handle
/// they build in their own `render` — so those call theirs instead. Using this
/// on one of them would set a field that `render` overwrites a moment later.
fn with_gpui_focus<E: InteractiveElement>(
element: E,
behavior: &Behavior,
focus: Option<&gpui::FocusHandle>,
) -> E {
let mut element = element;
match focus {
// Once an element tracks a handle, `Interactivity`'s own `tab_index`
// and `tab_stop` are never read again: GPUI applies them only on the
// path where it has to create a handle itself (`div.rs`, "ensure we
// store a focus handle in our element state"). Setting them on the
// element instead of the handle would leave a tab stop that is not one
// — the exact silence this method exists to remove — so they go on the
// handle, which is what base's own controls do with theirs.
Some(handle) => {
let mut handle = handle.clone();
if let Some(index) = behavior.tab_index {
// A tab index implies a tab stop, as GPUI's own does.
handle = handle.tab_index(index).tab_stop(true);
}
// Second, so an explicit `tab_stop(false)` still wins.
if let Some(stop) = behavior.tab_stop {
handle = handle.tab_stop(stop);
}
element = element.track_focus(&handle);
}
None => {
if let Some(index) = behavior.tab_index {
element = element.tab_index(index);
}
if let Some(stop) = behavior.tab_stop {
element = element.tab_stop(stop);
}
}
}
element
}
/// GPUI's own input listeners, for the components that can carry them.
///
/// One function for the whole family rather than one per kind. The alternative
/// was tried: the keyboard was factored out here while the pointer stayed
/// inline in the `div` arm, so every component this was applied to answered
/// keys and silently ignored presses — while the table saying which components
/// honour "input" claimed all of it.
///
/// The bound is `InteractiveElement + ParentElement`: the listeners need the
/// first and the bounds capture needs the second, because `on_prepaint` is a
/// canvas child.
///
/// Wired is not the same as reachable. A key event travels the focus path and
/// a pointer event travels the hitbox, so a component that accepts no script
/// focus handle — `Tab` — hears presses and never hears keys, however well
/// both are wired here. That is a property of the component, reported where
/// focus is.
fn with_input_handlers<E>(element: E, behavior: &Behavior, runtime: &Rc<ShellRuntime>) -> E
where
E: InteractiveElement + ParentElement + IntoElement,
{
let mut element = element;
if let Some(callback) = behavior.on_key_down {
let runtime = Rc::downgrade(runtime);
element = element.on_key_down(move |event, window, cx| {
if let Some(runtime) = runtime.upgrade() {
runtime.dispatch_key(callback, &event.keystroke, Some(event.is_held), window, cx);
}
});
}
if let Some(callback) = behavior.on_key_up {
let runtime = Rc::downgrade(runtime);
element = element.on_key_up(move |event, window, cx| {
if let Some(runtime) = runtime.upgrade() {
runtime.dispatch_key(callback, &event.keystroke, None, window, cx);
}
});
}
if let Some(callback) = behavior.on_modifiers_changed {
let runtime = Rc::downgrade(runtime);
element = element.on_modifiers_changed(move |event, window, cx| {
if let Some(runtime) = runtime.upgrade() {
runtime.dispatch_modifiers_changed(callback, event, window, cx);
}
});
}
// The pointer half. It needs the element's box for `local_position`, and
// captures it only when something asked.
if behavior.wants_pointer_geometry() {
let bounds = Rc::new(Cell::new(None::<Bounds<Pixels>>));
let writer = Rc::clone(&bounds);
element = element.on_prepaint(move |value, _, _| writer.set(Some(value)));
for (button, callback) in behavior.on_mouse_down.iter().copied() {
let runtime = Rc::downgrade(runtime);
let bounds = Rc::clone(&bounds);
element = element.on_mouse_down(button, move |event, window, cx| {
dispatch_mouse_button(&runtime, callback, event, bounds.get(), window, cx);
});
}
for (button, callback) in behavior.on_mouse_up.iter().copied() {
let runtime = Rc::downgrade(runtime);
let bounds = Rc::clone(&bounds);
element = element.on_mouse_up(button, move |event, window, cx| {
dispatch_mouse_button(&runtime, callback, event, bounds.get(), window, cx);
});
}
if let Some(callback) = behavior.on_mouse_down_out {
let runtime = Rc::downgrade(runtime);
let bounds = Rc::clone(&bounds);
element = element.on_mouse_down_out(move |event, window, cx| {
dispatch_mouse_button(&runtime, callback, event, bounds.get(), window, cx);
});
}
if let Some(callback) = behavior.on_scroll_wheel {
let runtime = Rc::downgrade(runtime);
let bounds = Rc::clone(&bounds);
element = element.on_scroll_wheel(move |event, window, cx| {
let Some(runtime) = runtime.upgrade() else {
return;
};
// A line delta is converted here rather than in the script,
// because the line height it needs is the window's and a
// script has no way to ask for it. Both delta shapes therefore
// arrive as pixels, and `delta_lines` keeps the original when
// there was one.
let line_height = window.line_height();
runtime.dispatch_scroll_wheel(
callback,
event,
line_height,
bounds.get(),
window,
cx,
);
});
}
if let Some(callback) = behavior.on_pinch {
let runtime = Rc::downgrade(runtime);
let bounds = Rc::clone(&bounds);
element = element.on_pinch(move |event, window, cx| {
if let Some(runtime) = runtime.upgrade() {
runtime.dispatch_pinch(callback, event, bounds.get(), window, cx);
}
});
}
}
// The context has to be installed before the listeners for the same reason
// GPUI's own components do it in that order: a keymap predicate is matched
// against the context stack of the element the dispatch reached, so an
// element that names its context after registering a listener is an
// element whose own bindings do not see it.
if let Some(context) = behavior.key_context.as_deref() {
// Parsed rather than taken: a key context is a predicate grammar, not
// a name, and `"my pane"` is not one. Reported rather than refused,
// because the element is otherwise fine and losing a whole frame to a
// typo in a context string is the worse trade.
match gpui::KeyContext::try_from(context) {
Ok(context) => element = element.key_context(context),
Err(error) => {
tracing::error!("`key_context(\"{context}\")` is not a valid key context: {error}")
}
}
}
// One GPUI listener for every action this element handles, rather than one
// per action, and the difference is not tidiness.
//
// GPUI matches an action listener by `TypeId` and, during the bubble
// phase, stops the dispatch after the first listener it finds: an action
// is handled once, by the innermost element that claims it. That is right
// when every action is its own Rust type, which is how GPUI's own actions
// work. Every script action is one `ShellAction`, so two registrations on
// one element would be two listeners with the same `TypeId` — and the
// first would swallow every action, including the ones meant for the
// second, which would then never run at all.
//
// So the routing that GPUI does by type is done here by id, in one
// listener. An action this element does not handle re-opens propagation,
// which is what lets it carry on to an element further out — the behavior
// a per-action listener would have had.
if !behavior.on_action.is_empty() {
let runtime = Rc::downgrade(runtime);
let handlers: SmallVec<[(SharedString, CallbackId); 1]> = behavior.on_action.clone();
element = element.on_action::<crate::action::ShellAction>(move |action, window, cx| {
let Some((_, callback)) = handlers
.iter()
.find(|(wanted, _)| wanted.as_ref() == action.id())
else {
cx.propagate();
return;
};
if let Some(runtime) = runtime.upgrade() {
runtime.dispatch_action(*callback, action.id(), window, cx);
}
});
}
element
}
/// One dispatch for the three press-and-release builders.
///
/// `MouseDownEvent` and `MouseUpEvent` are different types carrying the same
/// four fields, and `on_mouse_down_out` produces the first — so one small
/// trait over both beats writing the same six lines three times.
fn dispatch_mouse_button<E: MouseButtonEventFields>(
runtime: &std::rc::Weak<ShellRuntime>,
callback: CallbackId,
event: &E,
bounds: Option<Bounds<Pixels>>,
window: &mut Window,
cx: &mut App,
) {
let Some(runtime) = runtime.upgrade() else {
return;
};
runtime.dispatch_mouse_button(
callback,
event.button(),
event.position(),
event.click_count(),
event.modifiers(),
bounds,
window,
cx,
);
}
/// The four fields a press and a release both carry.
trait MouseButtonEventFields {
fn button(&self) -> MouseButton;
fn position(&self) -> gpui::Point<Pixels>;
fn click_count(&self) -> usize;
fn modifiers(&self) -> gpui::Modifiers;
}
macro_rules! mouse_button_event_fields {
($event:ty) => {
impl MouseButtonEventFields for $event {
fn button(&self) -> MouseButton {
self.button
}
fn position(&self) -> gpui::Point<Pixels> {
self.position
}
fn click_count(&self) -> usize {
self.click_count
}
fn modifiers(&self) -> gpui::Modifiers {
self.modifiers
}
}
};
}
mouse_button_event_fields!(gpui::MouseDownEvent);
mouse_button_event_fields!(gpui::MouseUpEvent);
/// Reports the input behaviors a component does not wire.
///
/// The same problem `tooltip` has, and the same answer: these are GPUI's own
/// `InteractiveElement` builders, and the shell installs them on the one
/// element it fully owns — a plain `div`, `h_flex` or `v_flex`. Every other
/// component builds its own base type and hangs its own listeners on it, so a
/// handler written there would be recorded in the description and never reach
/// GPUI.
///
/// Saying so is not a consolation prize. `Button.new("save").on_key_down(...)`
/// is a reasonable thing for a script author to write, it reads as though it
/// works, and the failure is a key that does nothing — which looks like a bug
/// in their own code for as long as it takes them to find this. A line in the
/// log names the cause and the workaround in one place.
///
/// Widening this is one call per component rather than a new mechanism, the
/// same as [`components::tooltip::warn_unhonoured_tooltip`] says of its own.
fn warn_unhonoured_input(component: &Component, behavior: &Behavior) {
if honours_input(component) {
return;
}
let asked = [
("on_key_down", behavior.on_key_down.is_some()),
("on_key_up", behavior.on_key_up.is_some()),
(
"on_modifiers_changed",
behavior.on_modifiers_changed.is_some(),
),
("on_mouse_down", !behavior.on_mouse_down.is_empty()),
("on_mouse_up", !behavior.on_mouse_up.is_empty()),
("on_mouse_down_out", behavior.on_mouse_down_out.is_some()),
("on_scroll_wheel", behavior.on_scroll_wheel.is_some()),
("on_pinch", behavior.on_pinch.is_some()),
("on_action", !behavior.on_action.is_empty()),
("key_context", behavior.key_context.is_some()),
];
for (method, called) in asked {
if called {
tracing::warn!(
"`{method}` is not wired on a {}: the shell installs GPUI's input listeners on the element it owns outright, which is a plain `div`, `h_flex` or `v_flex`. Wrap it and write `{method}` on the wrapper",
component.name()
);
}
}
}
/// The components [`with_input_handlers`] is applied to.
///
/// Every one implements `InteractiveElement` and `ParentElement`, which is what
/// the listeners and the bounds capture need, and every one is something a
/// script puts input on. The rest either build no interactive base
/// (`Collapsible`, `Progress`) or own the keyboard themselves (`Input`,
/// `Select`, `OtpInput`), where a second set of listeners would fight the
/// first. Widening this is one call per component, not a new mechanism.
///
/// Wired is not the same as reachable. A key event travels the focus path, so
/// a component that accepts no script focus handle — `Tab` — hears presses and
/// never hears keys. That is a property of the component, reported where focus
/// is, not something this list can express.
fn honours_input(component: &Component) -> bool {
matches!(
component,
Component::Div
| Component::HFlex
| Component::VFlex
| Component::Button(_)
| Component::Link(_)
| Component::Checkbox(_)
| Component::Switch(_)
| Component::Radio(_)
| Component::Toggle(_)
| Component::Tabs(_)
| Component::Tab(_)
)
}
/// The accessibility semantics GPUI reads off the element itself.
fn with_aria<E: StatefulInteractiveElement>(element: E, behavior: &Behavior) -> E {
let mut element = element;
if let Some(role) = behavior.role {
element = element.role(role);
}
if let Some(selected) = behavior.aria_selected {
element = element.aria_selected(selected);
}
if behavior.aria_active_descendant {
element = element.aria_active_descendant();
}
element
}
/// Marks the element as a window control area, the way gpui-component's
/// `TitleBar` does.
///
/// GPUI's `window_control_area` is a hit-test answer for the platform, and only
/// Windows asks it. The host opens its window with an application-drawn title
/// bar on every platform, so a drag area also moves the window itself: a left
/// press dragged across it starts the platform move, and a double-click does
/// what a native title bar's would — `titlebar_double_click` on macOS, a zoom on
/// Linux. Windows keeps its native behaviour through the hit test.
fn with_window_control<E: StatefulInteractiveElement>(element: E, behavior: &Behavior) -> E {
let Some(area) = behavior.window_control_area else {
return element;
};
let element = element.window_control_area(area);
if area != gpui::WindowControlArea::Drag || cfg!(target_os = "windows") {
return element;
}
element
// `on_mouse_move` reaches only the hovered element, so a move with the
// left button held is a press that is being dragged across this area.
.on_mouse_move(|event, window, _| {
if event.pressed_button == Some(MouseButton::Left) {
window.start_window_move();
}
})
.on_mouse_down(MouseButton::Left, |event, window, _| {
if event.click_count == 2 {
if cfg!(target_os = "macos") {
window.titlebar_double_click();
} else {
window.zoom_window();
}
}
})
}
/// Reports focus and accessibility calls a component cannot honour.
///
/// Each bound component wires the subset base gives it a builder for; the rest
/// would be set on an `Interactivity` the component's own `render` overwrites,
/// which is a method that looks bound and does nothing. `honoured` lists what
/// this one does wire, so the warning names only what was actually lost.
fn warn_unhonoured_a11y(behavior: &Behavior, component: &str, honoured: &[&str]) {
let asked = [
("track_focus", behavior.focus_handle.is_some()),
(
"content_focus_handle",
behavior.content_focus_handle.is_some(),
),
("tab_index", behavior.tab_index.is_some()),
("tab_stop", behavior.tab_stop.is_some()),
("role", behavior.role.is_some()),
("aria_selected", behavior.aria_selected.is_some()),
("aria_active_descendant", behavior.aria_active_descendant),
(
"window_control_area",
behavior.window_control_area.is_some(),
),
];
for (method, called) in asked {
if called && !honoured.contains(&method) {
tracing::warn!(
"`{method}` is not wired on a {component}: base's {component} owns this part \
of its own focus and accessibility. Put it on an element around it"
);
}
}
}
/// A plain `div` becomes stateful when a state style needs an identity, or when
/// the script named it.
///
/// A script-given name wins, because it is the only identity that survives the
/// script reordering its own tree. Without one the identity is the node's
/// address in the description, which is stable for as long as the snapshot lives
/// and across rebuilds only while the tree keeps its shape.
// Nine arguments, because a scroll area now also has to reach window element
// state for the position it shares with an explicit `Scrollbar`. Bundling them
// into a struct would move the same nine values one line up and no further.
#[allow(clippy::too_many_arguments)]
fn flex_element(
runtime: &Rc<ShellRuntime>,
element: gpui::Div,
id: SpecId,
refinement: StyleRefinement,
behavior: Behavior,
states: StateStyles,
children: Children,
window: &mut Window,
cx: &mut App,
) -> AnyElement {
let element = with_hover(element, &states);
if behavior.key.is_none()
&& !states.needs_identity()
&& !behavior.needs_identity()
&& behavior.on_click.is_none()
&& behavior.on_mouse_move.is_none()
&& behavior.on_hover.is_none()
&& behavior.on_key_down.is_none()
&& behavior.on_key_up.is_none()
&& behavior.on_modifiers_changed.is_none()
&& behavior.on_mouse_down.is_empty()
&& behavior.on_mouse_up.is_empty()
&& behavior.on_mouse_down_out.is_none()
&& behavior.on_scroll_wheel.is_none()
&& behavior.on_pinch.is_none()
&& behavior.on_action.is_empty()
&& behavior.key_context.is_none()
&& !behavior.scroll_x
&& !behavior.scroll_y
{
return finish(element, refinement, children);
}
let scroll_x = behavior.scroll_x;
let scroll_y = behavior.scroll_y;
// A plain element has no focus or role of its own, so GPUI's builders are
// the whole of what it can carry — and they are exactly what a script
// needs to build the thing base has no component for: a listbox option, a
// toolbar, a composite whose keyboard focus stays on its container.
let focus = tracked_focus(runtime, &behavior, "div");
let identity = element_id(id, behavior.key.clone());
let stateful = element.id(identity.clone());
let stateful = with_gpui_focus(stateful, &behavior, focus.as_ref());
let stateful = with_input_handlers(stateful, &behavior, runtime);
let stateful = components::dock::with_commands(stateful, &behavior, runtime, cx);
let stateful = with_aria(stateful, &behavior);
let stateful = with_window_control(stateful, &behavior);
let mut stateful = with_active_and_focus(stateful, &states);
if !behavior.disabled
&& let Some(callback) = behavior.on_click
{
let runtime = Rc::downgrade(runtime);
stateful = stateful.on_click(move |event, window, cx| {
dispatch_click(&runtime, callback, event, window, cx);
});
}
let bounds = Rc::new(Cell::new(None::<Bounds<Pixels>>));
let bounds_writer = Rc::clone(&bounds);
let mut stateful = stateful.on_prepaint(move |value, _, _| bounds_writer.set(Some(value)));
if let Some(callback) = behavior.on_mouse_move {
let runtime = Rc::downgrade(runtime);
let bounds = Rc::clone(&bounds);
stateful = stateful.on_mouse_move(move |event, window, cx| {
let Some(bounds) = bounds.get() else { return };
let local = event.position - bounds.origin;
if let Some(runtime) = runtime.upgrade() {
runtime.dispatch_mouse_move(callback, event, local, bounds, window, cx);
}
});
}
if let Some(callback) = behavior.on_hover {
let runtime = Rc::downgrade(runtime);
let bounds = Rc::clone(&bounds);
stateful = stateful.on_hover(move |hovered, window, cx| {
if !should_dispatch_hover(*hovered, bounds.get(), window.mouse_position()) {
return;
}
if let Some(runtime) = runtime.upgrade() {
runtime.dispatch_change(callback, *hovered, window, cx);
}
});
}
let stateful = components::tooltip::with_tooltip(stateful, &behavior);
if behavior.scrollbar {
let mut stateful = stateful;
stateful.style().refine(&refinement);
stateful.extend(children);
return match (scroll_x, scroll_y) {
(true, true) => Scrollable::new(stateful, ScrollbarAxis::Both)
.id(identity)
.into_any_element(),
(true, false) => Scrollable::new(stateful, ScrollbarAxis::Horizontal)
.id(identity)
.into_any_element(),
(false, true) => Scrollable::new(stateful, ScrollbarAxis::Vertical)
.id(identity)
.into_any_element(),
(false, false) => stateful.into_any_element(),
};
}
// The scroll position lives in window element state under this element's
// own identity, which is the slot an explicit `Scrollbar` elsewhere in the
// tree looks in when the script gives it the same name. The
// `overflow_*_scrollbar` path above is deliberately left out of this: its
// `Scrollable` owns a position of its own, on the inner area it builds.
let stateful = match (scroll_x, scroll_y) {
(true, true) => {
components::scrollbar::track_scroll_position(stateful, &identity, window, cx)
.overflow_scroll()
}
(true, false) => {
components::scrollbar::track_scroll_position(stateful, &identity, window, cx)
.overflow_x_scroll()
}
(false, true) => {
components::scrollbar::track_scroll_position(stateful, &identity, window, cx)
.overflow_y_scroll()
}
(false, false) => stateful,
};
finish(stateful, refinement, children)
}
/// Whether a hover transition from this snapshot still describes the pointer.
///
/// GPUI can deliver an outgoing snapshot's `false` after a callback rebuilt
/// the element under a stationary pointer. The outgoing bounds still describe
/// that pointer, so only that exit is stale; entries and exits outside the box
/// remain real transitions.
fn should_dispatch_hover(
hovered: bool,
bounds: Option<Bounds<Pixels>>,
mouse_position: gpui::Point<Pixels>,
) -> bool {
hovered || !bounds.is_some_and(|bounds| bounds.contains(&mouse_position))
}
/// Materializes the children of a described node.
///
/// For a slot a component resolves itself rather than receiving as a finished
/// element: the node is read back for its own type and styles, and its
/// children still have to be built the ordinary way.
pub(in crate::materialize) fn materialize_children(
runtime: &Rc<ShellRuntime>,
snapshot: Option<&RenderSnapshot>,
arena: &SpecArena,
id: SpecId,
inherited: gpui::Hsla,
window: &mut Window,
cx: &mut App,
) -> Children {
let Some(node) = arena.node(id) else {
return Children::new();
};
node.children()
.iter()
.map(|child| materialize_node(runtime, snapshot, arena, *child, inherited, window, cx))
.collect()
}
/// A slot node's own styles, with its motion sampled and its leftovers named.
///
/// # Not covered by a test, and why
///
/// The sampling is the part that matters and the part nothing here can assert.
/// It shows up only in the refinement after materialize, which this suite
/// cannot read — it reads description trees — and it cannot be called from a
/// unit test either: the motion sampler keys its state off the element being
/// laid out and refuses to run outside `request_layout`, `prepaint` or `paint`.
/// Reading the resulting geometry would need every script element to carry a
/// `debug_bounds` marker, which is a debugging feature rather than a fix.
///
/// So what stands in for a test is that there is one of these rather than five
/// copies. The bug this replaced was five resolvers that each read the
/// refinement and ignored the motion beside it; a single function cannot drift
/// from itself.
///
/// The two things a slot resolver would otherwise silently drop. Motion is the
/// one that bites: `AccordionPanel.new().transition("height", ...)` is the most
/// ordinary thing anyone writes on an accordion, and a resolver that read only
/// the refinement would record it and animate nothing. It is sampled against
/// the same identity an ordinary node's is, so a slot animates exactly as it
/// would anywhere else.
///
/// State styles are reported rather than applied. Every type these resolvers
/// rebuild is a `RenderOnce` the shell hands finished values to, so there is no
/// `Interactivity` on this side to attach a hover to — the same position
/// `Collapsible` and `Switch` are already in, and the same answer: say so.
pub(in crate::materialize) fn resolve_slot(
arena: &SpecArena,
slot: SpecId,
part: &str,
window: &mut Window,
cx: &mut App,
) -> (StyleRefinement, Behavior, SlotSpecs) {
let Some(node) = arena.node(slot) else {
return (
StyleRefinement::default(),
Behavior::default(),
SlotSpecs::new(),
);
};
let (mut refinement, behavior, states, motions, inner) = resolve_ops(arena, node);
apply_motion(
element_id(slot, behavior.key.clone()),
&motions,
&mut refinement,
window,
cx,
);
if states.hover.is_some() || states.active.is_some() || states.focus.is_some() {
tracing::warn!(
"state styles on a {part} are ignored: it is rebuilt from its description as a \
value, so there is no interactive element here for a hover or a press to land \
on. Put them on an element around it"
);
}
(refinement, behavior, inner)
}
/// Takes the `SpecId` filling `name`, for a component that resolves its own
/// slots rather than receiving them materialized.
pub(in crate::materialize) fn take_slot_spec(slots: &mut SlotSpecs, name: &str) -> Option<SpecId> {
slots
.iter()
.position(|(slot, _)| *slot == name)
.map(|index| slots.remove(index).1)
}
/// Takes the element filling `name`, leaving any other slot for its own reader.
fn take_slot(slots: &mut Slots, name: &str) -> Option<AnyElement> {
slots
.iter()
.position(|(slot, _)| *slot == name)
.map(|index| slots.remove(index).1)
}
/// Slots nothing rendered.
///
/// Filling a slot detaches the element from the tree, so a slot the component
/// does not read is not content in the wrong place — it is content that
/// disappears, which is worth more than silence.
fn warn_unread_slots(slots: &Slots, component: &str) {
for (name, _) in slots.iter() {
tracing::warn!(
"{component} has no `{name}` slot, so the element given to it is not rendered \
at all: a slot element is not drawn as an ordinary child"
);
}
}
/// A control that already takes an identity from `new(id)` has nowhere to put a
/// second one. Saying so beats dropping it without a word.
fn warn_ignored_key(behavior: &Behavior, component: &str) {
if let Some(key) = &behavior.key {
tracing::warn!(
"id(\"{key}\") is ignored on a {component}: it is already identified by the id \
passed to {component}.new(...)"
);
}
}
/// The script's name for an element, or its address in the description.
fn element_id(id: SpecId, key: Option<SharedString>) -> gpui::ElementId {
match key {
Some(key) => gpui::ElementId::Name(key),
None => gpui::ElementId::NamedInteger("gpui-shell".into(), id as u64),
}
}
/// Resolves the identity used by native retained motion.
///
/// Controls and retained inputs already carry an identity that survives tree
/// reordering. Falling back to the snapshot position for them would make the
/// visual track jump to another control whenever a conditional sibling shifts
/// its `SpecId`.
fn motion_element_id(
id: SpecId,
key: Option<SharedString>,
component: &Component,
) -> gpui::ElementId {
match component {
Component::Button(id)
| Component::Link(id)
| Component::Checkbox(id)
| Component::Switch(id)
| Component::Tabs(id)
| Component::Tab(id)
| Component::Progress(id)
| Component::Radio(id)
| Component::Toggle(id)
| Component::RadioGroup(id)
| Component::ToggleGroup(id)
| Component::Popover(id)
| Component::HoverCard(id)
| Component::Popup(id)
| Component::Select(id)
| Component::Combobox(id)
// An or-pattern has to agree on the bindings it makes, not on how many
// fields each variant carries, so the picker's focus handle costs it
// nothing to sit here.
| Component::DatePicker(id, _) => gpui::ElementId::Name(id.clone().into()),
// One arm for all seven: an or-pattern only has to agree on the
// bindings it makes, not on how many fields each variant carries, so
// the indexed three sit here beside the rest.
Component::Table(id)
| Component::TableHeader(id)
| Component::TableBody(id)
| Component::TableCaption(id)
| Component::TableRow(id, _)
| Component::TableHead(id, _)
| Component::TableCell(id, _) => gpui::ElementId::Name(id.clone().into()),
Component::Scrollbar(id) => gpui::ElementId::Name(id.clone().into()),
// The list's name is also the name a `Scrollbar` pairs with and the
// key its scroll position is filed under, so motion has to follow the
// same name rather than a tree position.
Component::VirtualList(spec) => gpui::ElementId::Name(spec.id().to_owned().into()),
Component::List(spec) => gpui::ElementId::Name(spec.id().to_owned().into()),
// The group's id is also where base files the panel sizes, so motion
// has to key off the same name rather than a tree position.
Component::Resizable(id, _) => gpui::ElementId::Name(id.clone().into()),
// One state, four parts, so the handle alone would give all four the
// same motion channel. `SliderThumb` is left out on purpose: a range
// slider has two of them on one state, so the thumb keeps the script's
// `id(...)` and falls through to the address below.
Component::Slider(handle) => {
gpui::ElementId::NamedInteger("gpui-shell-slider".into(), *handle)
}
Component::SliderTrack(handle) => {
gpui::ElementId::NamedInteger("gpui-shell-slider-track".into(), *handle)
}
Component::SliderIndicator(handle) => {
gpui::ElementId::NamedInteger("gpui-shell-slider-indicator".into(), *handle)
}
Component::Input(handle) => {
gpui::ElementId::NamedInteger("gpui-shell-input".into(), *handle)
}
Component::Textarea(handle) => {
gpui::ElementId::NamedInteger("gpui-shell-textarea".into(), *handle)
}
Component::NumberInput(handle) => {
gpui::ElementId::NamedInteger("gpui-shell-number-input".into(), *handle)
}
Component::OtpInput(handle) => {
gpui::ElementId::NamedInteger("gpui-shell-otp-input".into(), *handle)
}
_ => element_id(id, key),
}
}
pub(in crate::materialize) fn resolve_ops(
arena: &SpecArena,
node: &SpecNode,
) -> (
StyleRefinement,
Behavior,
StateStyles,
SmallVec<[Motion; 2]>,
SlotSpecs,
) {
let mut refinement = StyleRefinement::default();
let mut behavior = Behavior::default();
let mut states = StateStyles::default();
let mut motions = SmallVec::new();
let mut slots = SlotSpecs::new();
for op in node.ops() {
match op {
SpecOp::NullaryStyle(index) => {
refinement = style::apply_nullary(*index, refinement);
}
SpecOp::ParamStyle(name, args) => {
match style::apply_param(name, args, refinement.clone()) {
Ok(next) => refinement = next,
Err(error) => {
// Argument coercion already ran when the op was
// recorded, so reaching here means a host bug rather
// than a script error. Keep the frame renderable.
tracing::error!("style `{name}` failed during materialize: {error}");
}
}
}
SpecOp::Method("transition", args) => {
if let [
Bridged::Str(property),
Bridged::Number(duration),
Bridged::Number(delay),
Bridged::Str(easing),
] = args.as_slice()
&& let Some(property) = MotionProperty::parse(property)
{
set_motion(
&mut motions,
Motion {
property,
policy: MotionPolicy::Transition {
duration: checked_milliseconds(f64::from(*duration))
.unwrap_or(Duration::ZERO),
delay: checked_milliseconds(f64::from(*delay))
.unwrap_or(Duration::ZERO),
easing: easing.clone(),
},
},
);
}
}
SpecOp::Method("spring", args) => {
if let [
Bridged::Str(property),
Bridged::Number(response),
Bridged::Number(damping),
Bridged::Number(epsilon),
] = args.as_slice()
&& let Some(property) = MotionProperty::parse(property)
{
set_motion(
&mut motions,
Motion {
property,
policy: MotionPolicy::Spring {
response: checked_milliseconds(f64::from(*response))
.unwrap_or(Duration::ZERO),
damping: *damping as f32,
epsilon: *epsilon as f32,
},
},
);
}
}
SpecOp::Method(name, args) => {
if !matches!(
*name,
"move_to"
| "line_to"
| "curve_to"
| "cubic_bezier_to"
| "arc_to"
| "close"
| "dash_array"
) {
apply_behavior(&mut behavior, name, args)
}
}
SpecOp::StateStyle(name, node) => {
let resolved = resolve_state(arena, *node);
match *name {
"hover" => states.hover = Some(resolved),
"active" => states.active = Some(resolved),
"focus" => states.focus = Some(resolved),
// Not a runtime state at all: the one style from which
// a component draws a box of its own. It rides the same op
// because what it collects is a refinement built from the
// ordinary style methods, which is what a state style is.
"range_style" => behavior.range_style = Some(resolved),
// The three an `OtpInput` draws its own boxes from. Same
// op, same reason: what they collect is a refinement built
// from the ordinary style methods.
"cell_style" => behavior.cell_style = Some(resolved),
"cell_active_style" => behavior.cell_active_style = Some(resolved),
"caret_style" => behavior.caret_style = Some(resolved),
other => tracing::error!("unhandled state style `{other}`"),
}
}
SpecOp::Callback(name, id) => match *name {
"on_click" => behavior.on_click = Some(*id),
"on_link_click" => behavior.on_link_click = Some(*id),
"on_mouse_move" => behavior.on_mouse_move = Some(*id),
"on_hover" => behavior.on_hover = Some(*id),
"on_key_down" => behavior.on_key_down = Some(*id),
"on_key_up" => behavior.on_key_up = Some(*id),
"on_modifiers_changed" => behavior.on_modifiers_changed = Some(*id),
// The button is carried in the op name rather than beside it:
// `SpecOp::Callback` is a `&'static str` and a handle, and
// three fixed names cost nothing next to widening every op to
// carry an argument only these four use.
"on_mouse_down_left" => behavior.on_mouse_down.push((MouseButton::Left, *id)),
"on_mouse_down_right" => behavior.on_mouse_down.push((MouseButton::Right, *id)),
"on_mouse_down_middle" => behavior.on_mouse_down.push((MouseButton::Middle, *id)),
"on_mouse_up_left" => behavior.on_mouse_up.push((MouseButton::Left, *id)),
"on_mouse_up_right" => behavior.on_mouse_up.push((MouseButton::Right, *id)),
"on_mouse_up_middle" => behavior.on_mouse_up.push((MouseButton::Middle, *id)),
"on_mouse_down_out" => behavior.on_mouse_down_out = Some(*id),
"on_scroll_wheel" => behavior.on_scroll_wheel = Some(*id),
"on_pinch" => behavior.on_pinch = Some(*id),
"on_resize" => behavior.on_resize = Some(*id),
"on_change" => behavior.on_change = Some(*id),
"token" => behavior.token = Some(*id),
"on_token_click" => behavior.on_token_click = Some(*id),
"on_token_hover" => behavior.on_token_hover = Some(*id),
"on_step" => behavior.on_step = Some(*id),
"on_open_change" => behavior.on_open_change = Some(*id),
"on_confirm" => behavior.on_confirm = Some(*id),
"on_dismiss" => behavior.on_dismiss = Some(*id),
"on_item_click" => behavior.on_item_click = Some(*id),
"on_item_secondary_click" => behavior.on_item_secondary_click = Some(*id),
"on_visible_range_change" => behavior.on_visible_range_change = Some(*id),
"tab_bar" => behavior.dock_chrome.tab_bar = Some(*id),
"empty_group" => behavior.dock_chrome.empty_group = Some(*id),
"drop_indicator" => behavior.dock_chrome.drop_indicator = Some(*id),
"dock" => behavior.dock_chrome.dock = Some(*id),
other => tracing::error!("unhandled callback `{other}` reached materialize"),
},
SpecOp::ActionCallback(id, callback) => {
behavior.on_action.push((id.clone(), *callback))
}
SpecOp::RegisteredMethod(_) => {}
// Filling the same slot twice replaces it, the way a second
// `open(...)` replaces the first: the last call in the chain is
// what the script meant.
SpecOp::Slot(name, slot) => match slots.iter_mut().find(|(held, _)| held == name) {
Some(existing) => existing.1 = *slot,
None => slots.push((*name, *slot)),
},
}
}
(refinement, behavior, states, motions, slots)
}
fn set_motion(motions: &mut SmallVec<[Motion; 2]>, motion: Motion) {
if let Some(existing) = motions
.iter_mut()
.find(|existing| existing.property == motion.property)
{
*existing = motion;
} else {
motions.push(motion);
}
}
fn apply_motion(
identity: gpui::ElementId,
motions: &[Motion],
refinement: &mut StyleRefinement,
window: &mut Window,
cx: &mut App,
) {
for motion in motions {
let channel = motion.property.channel();
match motion.property {
MotionProperty::Opacity => {
if let Some(target) = refinement.opacity {
refinement.opacity = Some(sample_f32(
identity.clone(),
channel,
target,
&motion.policy,
window,
cx,
));
}
}
MotionProperty::Width => animate_length(
&mut refinement.size.width,
identity.clone(),
channel,
&motion.policy,
window,
cx,
),
MotionProperty::Height => animate_length(
&mut refinement.size.height,
identity.clone(),
channel,
&motion.policy,
window,
cx,
),
MotionProperty::Left => animate_length(
&mut refinement.inset.left,
identity.clone(),
channel,
&motion.policy,
window,
cx,
),
MotionProperty::Top => animate_length(
&mut refinement.inset.top,
identity.clone(),
channel,
&motion.policy,
window,
cx,
),
}
}
}
fn animate_length(
target: &mut Option<Length>,
identity: gpui::ElementId,
channel: &'static str,
policy: &MotionPolicy,
window: &mut Window,
cx: &mut App,
) {
let Some(Length::Definite(DefiniteLength::Absolute(AbsoluteLength::Pixels(pixels)))) = *target
else {
return;
};
let sampled = sample_pixels(identity, channel, pixels, policy, window, cx);
*target = Some(sampled.into());
}
fn transition_policy(duration: Duration, delay: Duration, easing: &str) -> Transition {
let policy = Transition::new(duration).delay(delay);
match easing {
"linear" => policy.ease(|value| value),
"ease-in" => policy.ease(ease_in_cubic),
"ease-in-out" => policy.ease(ease_in_out_cubic),
_ => policy.ease(ease_out_cubic),
}
}
fn sample_f32(
identity: gpui::ElementId,
channel: &'static str,
target: f32,
policy: &MotionPolicy,
window: &mut Window,
cx: &mut App,
) -> f32 {
match policy {
MotionPolicy::Transition {
duration,
delay,
easing,
} => transition(
(identity, channel),
target,
transition_policy(*duration, *delay, easing),
window,
cx,
),
MotionPolicy::Spring {
response,
damping,
epsilon,
} => spring(
(identity, channel),
target,
Spring::new(*response)
.with_damping(*damping)
.with_epsilon(*epsilon),
window,
cx,
),
}
}
fn sample_pixels(
identity: gpui::ElementId,
channel: &'static str,
target: Pixels,
policy: &MotionPolicy,
window: &mut Window,
cx: &mut App,
) -> Pixels {
match policy {
MotionPolicy::Transition {
duration,
delay,
easing,
} => transition(
(identity, channel),
target,
transition_policy(*duration, *delay, easing),
window,
cx,
),
MotionPolicy::Spring {
response,
damping,
epsilon,
} => spring(
(identity, channel),
target,
Spring::new(*response)
.with_damping(*damping)
.with_epsilon(*epsilon),
window,
cx,
),
}
}
/// Resolves a detached state node into a refinement. Only style ops are
/// meaningful there; anything else is a script mistake already reported at the
/// call site.
fn resolve_state(arena: &SpecArena, node: SpecId) -> StyleRefinement {
let Some(node) = arena.node(node) else {
return StyleRefinement::default();
};
let mut refinement = StyleRefinement::default();
for op in node.ops() {
match op {
SpecOp::NullaryStyle(index) => refinement = style::apply_nullary(*index, refinement),
SpecOp::ParamStyle(name, args) => {
if let Ok(next) = style::apply_param(name, args, refinement.clone()) {
refinement = next;
}
}
_ => {}
}
}
refinement
}
/// A count of rows or columns, clamped at zero.
///
/// A negative total is not a table one row shorter than it says; it is a
/// number the script computed wrongly, and announcing "row 3 of 0" is the
/// least confusing thing to do with it.
fn whole_count(args: &[Bridged]) -> Option<usize> {
args.first()
.and_then(|value| value.as_f32().ok())
.map(|count| count.max(0.0) as usize)
}
/// A duration a script gave in milliseconds, clamped at zero.
///
/// Milliseconds because that is what `transition` and a toast's `timeout`
/// already take, and a script that has to remember which host call wants
/// seconds has been given two grammars for one idea.
fn milliseconds(args: &[Bridged]) -> Option<Duration> {
args.first()
.and_then(|value| value.as_f32().ok())
.and_then(|ms| checked_milliseconds(f64::from(ms)))
}
fn checked_milliseconds(ms: f64) -> Option<Duration> {
ms.is_finite()
.then(|| Duration::try_from_secs_f64(ms.max(0.0) / 1000.0).ok())
.flatten()
}
/// Every anchor name a script may pass to `anchor(...)`, in the order
/// `gpui::Anchor` declares them.
///
/// Written out rather than derived: GPUI has no name table for `Anchor`, and
/// the script API is the snake_case spelling of the variant. One list serves
/// the parser below, the check the prelude makes at the call site, and the
/// union `gpui-kit.d.ts` declares — so the three cannot drift.
pub(crate) const ANCHOR_NAMES: [&str; 8] = [
"top_left",
"top_right",
"bottom_left",
"bottom_right",
"top_center",
"bottom_center",
"left_center",
"right_center",
];
/// The anchor a script named, or `None` if no variant spells it.
pub(crate) fn anchor_from_name(name: &str) -> Option<gpui::Anchor> {
match name {
"top_left" => Some(gpui::Anchor::TopLeft),
"top_right" => Some(gpui::Anchor::TopRight),
"bottom_left" => Some(gpui::Anchor::BottomLeft),
"bottom_right" => Some(gpui::Anchor::BottomRight),
"top_center" => Some(gpui::Anchor::TopCenter),
"bottom_center" => Some(gpui::Anchor::BottomCenter),
"left_center" => Some(gpui::Anchor::LeftCenter),
"right_center" => Some(gpui::Anchor::RightCenter),
_ => None,
}
}
/// Reports the styling and the children a component with no surface of its own
/// cannot take.
///
/// `Popover` and `HoverCard` are behavior, not boxes: neither implements
/// `Styled` or `ParentElement`, because what is on screen is the trigger and
/// the content and the script owns both. A style call here is not a style in
/// the wrong place — it is a style that disappears.
fn warn_without_surface(
component: &str,
refinement: &StyleRefinement,
states: &StateStyles,
children: &Children,
) {
if *refinement != StyleRefinement::default() {
tracing::warn!(
"styles on a {component} are ignored: it has no box of its own. Style the element \
you gave to `trigger`, or the one you gave to `content`"
);
}
if states.hover.is_some() || states.active.is_some() || states.focus.is_some() {
tracing::warn!(
"state styles on a {component} are ignored; put them on the element you gave to \
`trigger`"
);
}
if !children.is_empty() {
tracing::warn!(
"a {component} renders its `trigger` and `content` slots and nothing else, so the \
{} ordinary children given to it are dropped",
children.len()
);
}
}
/// Reports builder methods a component does not have.
///
/// Every element shares one prototype, so a delay reaches a popover and an
/// overlay reaches a hover card. Dropping the call in silence would leave a
/// popover that never opens on the right button looking like a positioning bug.
fn warn_unsupported(component: &str, methods: &[(&str, bool)]) {
for (method, called) in methods {
if *called {
tracing::warn!(
"`{method}` is not a {component} method: base's {component} has no such builder, \
so the call is dropped"
);
}
}
}
/// The dock command one chrome method names, or `None` for a method that is
/// not one.
///
/// Every one of them takes the dock handle first, because a command is resolved
/// against the contexts of *that* area — the script passes the container object
/// it was handed, and the prelude unpacks the handle out of it. What follows
/// names the container inside the area: a group's node or a dock's placement.
fn is_dock_command(name: &str) -> bool {
matches!(
name,
"select_tab"
| "close_panel"
| "toggle_zoom"
| "drag_tab"
| "drop_tab"
| "toggle_dock"
| "resize_dock"
)
}
fn dock_action(name: &str, args: &[Bridged]) -> Option<crate::dock::DockAction> {
use crate::dock::{DockAction, DockCommand};
let number = |index: usize| args.get(index).and_then(|value| value.as_f32().ok());
let handle = |index: usize| {
args.get(index).and_then(|value| match value {
Bridged::Number(raw) if *raw >= 0.0 => Some(*raw as crate::entities::EntityHandle),
_ => None,
})
};
let whole = |index: usize| {
number(index)
.filter(|value| *value >= 0.0)
.map(|value| value as usize)
};
let text = |index: usize| args.get(index).and_then(|value| value.as_str().ok());
let dock = handle(0)?;
let node = || handle(1);
let command = match name {
"select_tab" => DockCommand::SelectTab {
node: node()?,
index: whole(2)?,
},
"close_panel" => DockCommand::ClosePanel {
node: node()?,
panel: handle(2)?,
},
"toggle_zoom" => DockCommand::ToggleGroupZoom { node: node()? },
"drag_tab" => DockCommand::DragTab {
node: node()?,
index: whole(2)?,
},
// The one optional argument in the set: a tab bar that names no slot
// means "append", which is what a drop past the last tab is.
"drop_tab" => DockCommand::DropTab {
node: node()?,
index: whole(2),
},
"toggle_dock" => DockCommand::ToggleDock {
placement: dock_placement(text(1)?)?,
},
"resize_dock" => DockCommand::ResizeDock {
placement: dock_placement(text(1)?)?,
},
_ => return None,
};
Some(DockAction::new(dock, command))
}
/// The four regions of an area, spelled as the persisted layout spells them.
pub(crate) fn dock_placement(name: &str) -> Option<gpui_base::dock::DockPlacement> {
use gpui_base::dock::DockPlacement;
match name {
"center" => Some(DockPlacement::Center),
"left" => Some(DockPlacement::Left),
"right" => Some(DockPlacement::Right),
"bottom" => Some(DockPlacement::Bottom),
_ => {
tracing::error!(
"`{name}` is not a dock placement; expected \"center\", \"left\", \"right\" or \"bottom\""
);
None
}
}
}
fn apply_behavior(behavior: &mut Behavior, name: &str, args: &[Bridged]) {
let flag = args.first().map(Bridged::is_truthy);
// Before the table below, and with its own early return: a dock command
// that failed to parse must not fall through to a name-keyed match whose
// last arm is `accessibility_label`.
if is_dock_command(name) {
match dock_action(name, args) {
Some(action) => behavior.dock_commands.push(action),
None => tracing::error!(
"`{name}` did not name a container in a dock area; the command is dropped"
),
}
return;
}
match name {
"accessibility_label" => {
behavior.accessibility_label = args
.first()
.and_then(|value| value.as_str().ok())
.map(SharedString::from);
}
"aria_level" => {
behavior.aria_level = args
.first()
.and_then(|value| value.as_f32().ok())
.map(|level| level.max(1.0) as usize);
}
"keep_mounted" => behavior.keep_mounted = flag.unwrap_or(true),
"key_context" => {
behavior.key_context = args
.first()
.and_then(|value| value.as_str().ok())
.map(SharedString::from);
}
"id" => {
behavior.key = args
.first()
.and_then(|value| value.as_str().ok())
.map(SharedString::from);
}
"href" => {
behavior.href = args
.first()
.and_then(|value| value.as_str().ok())
.map(SharedString::from);
}
"disabled" => behavior.disabled = flag.unwrap_or(true),
"selectable" => behavior.selectable = Some(flag.unwrap_or(true)),
"scrollable" => behavior.text_scrollable = Some(flag.unwrap_or(true)),
"selected" => behavior.selected = flag.unwrap_or(true),
"checked" => behavior.checked = flag.unwrap_or(true),
"value" => behavior.value = args.first().and_then(|value| value.as_f32().ok()),
"indeterminate" => behavior.indeterminate = flag.unwrap_or(true),
"pressed" => behavior.pressed = flag.unwrap_or(true),
"start" => behavior.start = flag.unwrap_or(true),
"overflow_scroll" => {
behavior.scroll_x = true;
behavior.scroll_y = true;
}
"overflow_x_scroll" => behavior.scroll_x = true,
"overflow_y_scroll" => behavior.scroll_y = true,
"overflow_scrollbar" => {
behavior.scroll_x = true;
behavior.scroll_y = true;
behavior.scrollbar = true;
}
"overflow_x_scrollbar" => {
behavior.scroll_x = true;
behavior.scrollbar = true;
}
"overflow_y_scrollbar" => {
behavior.scroll_y = true;
behavior.scrollbar = true;
}
"mode" => {
// Only an explicit mode is recorded: unset means the theme's own
// projection, which is what the rest of the application follows.
behavior.scrollbar_mode =
args.first()
.and_then(|value| value.as_str().ok())
.and_then(|value| match value {
"scrolling" => Some(gpui_base::ScrollbarMode::Scrolling),
"hover" => Some(gpui_base::ScrollbarMode::Hover),
"always" => Some(gpui_base::ScrollbarMode::Always),
_ => None,
});
}
"scroll_size" => {
// Both halves or neither: one axis sized by the script and the
// other by the scroll area is a thumb that lies about one of them.
let width = args.first().and_then(|value| value.as_f32().ok());
let height = args.get(1).and_then(|value| value.as_f32().ok());
if let (Some(width), Some(height)) = (width, height) {
behavior.scroll_size = Some(gpui::size(gpui::px(width), gpui::px(height)));
}
}
"viewport_from_layout" => behavior.viewport_from_layout = true,
"controls_right" => behavior.controls_right = true,
"panel_visible" => behavior.visible = Some(flag.unwrap_or(true)),
"panel_size" => {
behavior.panel_size = args
.first()
.and_then(|value| value.as_f32().ok())
.map(gpui::px);
}
"size_range" => {
// The floor is required and the ceiling optional, because that is
// the shape of the constraint: a panel always has a minimum — base's
// own is `PANEL_MIN_SIZE` — and usually no maximum at all.
if let Some(min) = args.first().and_then(|value| value.as_f32().ok()) {
let max = args.get(1).and_then(|value| value.as_f32().ok());
behavior.size_range = Some(gpui::px(min)..max.map_or(Pixels::MAX, gpui::px));
}
}
"set_position" => {
// Both halves or neither: "tab 2 of" announces nothing a reader
// can place, so a malformed pair is dropped rather than halved.
let position = args.first().and_then(|value| value.as_f32().ok());
let size = args.get(1).and_then(|value| value.as_f32().ok());
if let (Some(position), Some(size)) = (position, size) {
behavior.position_in_set = Some((position as usize, size as usize));
}
}
"axis" => {
// Only an explicit value is recorded: each grouping container has
// its own default orientation, and substituting one of ours would
// be a choice base did not make.
behavior.axis = args
.first()
.and_then(|value| value.as_str().ok())
.and_then(|value| match value {
"horizontal" => Some(gpui::Axis::Horizontal),
"vertical" => Some(gpui::Axis::Vertical),
_ => None,
});
}
// A handle crosses the bridge as a JavaScript number, which is exact
// to 53 bits — the layout `EntityHandle` was chosen for. Anything else
// reaching here is a host bug, and dropping it is what makes the
// element fall back to base's own keyed focus rather than track a
// handle nobody named.
// Same shape as `track_focus` below, and same reasoning: a handle
// crosses as a JavaScript number, and anything else reaching here is a
// host bug. Dropping it leaves the list on the scroll position keyed by
// its own name, which still scrolls — it just cannot be driven from the
// script.
"track_scroll" => {
behavior.virtual_scroll = match args.first() {
Some(Bridged::Number(handle)) if *handle >= 0.0 && handle.fract() == 0.0 => {
Some(*handle as crate::entities::EntityHandle)
}
_ => None,
};
}
"with_item_to_measure_index" => {
behavior.item_to_measure_index = args
.first()
.and_then(|value| value.as_f32().ok())
.filter(|index| *index >= 0.0 && index.fract() == 0.0)
.map(|index| index as usize);
}
"with_follow_mode" => {
behavior.follow_mode =
args.first()
.and_then(|value| value.as_str().ok())
.and_then(|mode| match mode {
"normal" => Some(gpui::FollowMode::Normal),
"tail" => Some(gpui::FollowMode::Tail),
_ => None,
});
}
"scroll_to_end" => {
behavior.scroll_to_end = args
.first()
.and_then(|value| value.as_str().ok())
.map(str::to_owned);
}
"track_focus" => {
behavior.focus_handle = match args.first() {
Some(Bridged::Number(handle)) if *handle >= 0.0 && handle.fract() == 0.0 => {
Some(*handle as crate::entities::EntityHandle)
}
_ => None,
};
}
"content_focus_handle" => {
behavior.content_focus_handle = match args.first() {
Some(Bridged::Number(handle)) if *handle >= 0.0 && handle.fract() == 0.0 => {
Some(*handle as crate::entities::EntityHandle)
}
_ => None,
};
}
"tab_index" => {
behavior.tab_index = args
.first()
.and_then(|value| value.as_f32().ok())
.map(|index| index as isize);
}
"tab_stop" => behavior.tab_stop = Some(flag.unwrap_or(true)),
"role" => {
behavior.role = args
.first()
.and_then(|value| value.as_str().ok())
.and_then(crate::a11y::role_from_name);
}
"aria_selected" => behavior.aria_selected = Some(flag.unwrap_or(true)),
"aria_active_descendant" => behavior.aria_active_descendant = true,
"window_control_area" => {
behavior.window_control_area = match args.first().and_then(|value| value.as_str().ok())
{
Some("drag") => Some(gpui::WindowControlArea::Drag),
Some("close") => Some(gpui::WindowControlArea::Close),
Some("max") => Some(gpui::WindowControlArea::Max),
Some("min") => Some(gpui::WindowControlArea::Min),
other => {
tracing::error!(
"`window_control_area` takes \"drag\", \"close\", \"max\" or \"min\", not {other:?}; the area is dropped"
);
None
}
};
}
"row_count" => behavior.row_count = whole_count(args),
"column_count" => behavior.column_count = whole_count(args),
"open" => behavior.open = Some(flag.unwrap_or(true)),
"default_open" => behavior.default_open = flag.unwrap_or(true),
"overlay_closable" => behavior.overlay_closable = Some(flag.unwrap_or(true)),
// Only an explicit value is recorded, as with `axis`: a popover anchors
// top-left and a hover card top-center, and substituting one of ours
// would be a choice base did not make.
"anchor" => {
behavior.anchor = args
.first()
.and_then(|value| value.as_str().ok())
.and_then(|value| anchor_from_name(value));
}
"frame_budget" => behavior.frame_budget = milliseconds(args),
"mouse_button" => {
behavior.mouse_button =
args.first()
.and_then(|value| value.as_str().ok())
.and_then(|value| match value {
"left" => Some(MouseButton::Left),
"right" => Some(MouseButton::Right),
"middle" => Some(MouseButton::Middle),
_ => None,
});
}
"tooltip" => {
behavior.tooltip = args
.first()
.and_then(|value| value.as_str().ok())
.map(SharedString::from);
}
"open_delay" => behavior.open_delay = milliseconds(args),
"close_delay" => behavior.close_delay = milliseconds(args),
"spin" => behavior.spin = milliseconds(args).filter(|period| !period.is_zero()),
// Each call folds onto the ones before it, in chain order. The pairs
// were checked when they were recorded, so a failure here is a host bug.
"text_style" => {
if let Err(error) = behavior.text_style.get_or_insert_default().apply(args) {
tracing::error!("`text_style` failed during materialize: {error}");
}
}
_ => tracing::error!("unhandled component method `{name}` reached materialize"),
}
}
#[cfg(test)]
mod motion_identity_tests {
use super::*;
#[test]
fn a_stale_hover_exit_is_suppressed_but_a_real_exit_is_dispatched() {
use gpui::{point, px};
let bounds = Bounds::from_corners(point(px(10.), px(20.)), point(px(110.), px(100.)));
assert!(
!should_dispatch_hover(false, Some(bounds), point(px(50.), px(60.))),
"an outgoing snapshot must not clear hover while the pointer remains in its box"
);
assert!(
should_dispatch_hover(false, Some(bounds), point(px(120.), px(60.))),
"moving outside the box is a real hover exit"
);
assert!(
should_dispatch_hover(true, Some(bounds), point(px(50.), px(60.))),
"hover entries remain dispatchable"
);
}
#[test]
fn a_closed_collapsible_does_not_materialize_its_content_slot() {
let mut behavior = Behavior::default();
assert!(!materializes_slots(&Component::Collapsible, &behavior));
behavior.open = Some(true);
assert!(materializes_slots(&Component::Collapsible, &behavior));
assert!(materializes_slots(&Component::Div, &Behavior::default()));
}
#[test]
fn control_motion_uses_the_constructor_identity_instead_of_spec_position() {
assert_eq!(
motion_element_id(41, None, &Component::Button("save".into())),
gpui::ElementId::Name("save".into())
);
assert_eq!(
motion_element_id(7, None, &Component::Link("authorize".into())),
gpui::ElementId::Name("authorize".into())
);
assert_eq!(
motion_element_id(99, None, &Component::Checkbox("remember".into())),
gpui::ElementId::Name("remember".into())
);
}
#[test]
fn retained_input_motion_uses_its_entity_handle() {
assert_eq!(
motion_element_id(41, None, &Component::Input(23)),
gpui::ElementId::NamedInteger("gpui-shell-input".into(), 23)
);
}
#[test]
fn the_last_motion_policy_for_a_property_wins() {
let mut motions = SmallVec::new();
set_motion(
&mut motions,
Motion {
property: MotionProperty::Left,
policy: MotionPolicy::Transition {
duration: Duration::from_millis(100),
delay: Duration::ZERO,
easing: "linear".to_owned(),
},
},
);
set_motion(
&mut motions,
Motion {
property: MotionProperty::Left,
policy: MotionPolicy::Spring {
response: Duration::from_millis(300),
damping: 0.8,
epsilon: 0.001,
},
},
);
assert_eq!(motions.len(), 1);
assert!(matches!(motions[0].policy, MotionPolicy::Spring { .. }));
}
/// The accessibility calls land on the fields GPUI reads them back from.
///
/// Asserted on the concrete element rather than end to end, because
/// `AnyElement` does not forward `a11y_role` or `write_a11y_info` — it
/// captures them while prepainting into a tree a test outside GPUI cannot
/// reach. This is the level base tests its own controls at, and for the
/// same reason.
#[test]
fn accessibility_calls_land_where_gpui_reads_them() {
use gpui::Element as _;
let mut behavior = Behavior::default();
apply_behavior(
&mut behavior,
"role",
&[Bridged::Str("list_box_option".to_owned())],
);
apply_behavior(&mut behavior, "aria_selected", &[Bridged::Bool(true)]);
apply_behavior(&mut behavior, "aria_active_descendant", &[]);
let element = with_aria(div().id("option"), &behavior);
assert_eq!(element.a11y_role(), Some(gpui::Role::ListBoxOption));
let mut node = gpui::accesskit::Node::new(gpui::Role::Unknown);
element.write_a11y_info(&mut node);
assert_eq!(node.is_selected(), Some(true));
}
/// A tracked handle carries the tab order, because the element stops
/// carrying it the moment there is a handle to carry it instead.
#[gpui::test]
fn a_tracked_handle_carries_the_tab_order_the_element_would_have(
cx: &mut gpui::TestAppContext,
) {
let handle = cx.update(|cx| cx.focus_handle());
let mut behavior = Behavior::default();
apply_behavior(&mut behavior, "tab_index", &[Bridged::Number(3.0)]);
let _ = with_gpui_focus(div().id("custom"), &behavior, Some(&handle));
// Cloning re-reads the window's focus map, which is where the builders
// write and where the tab order is read from.
assert_eq!(handle.clone().tab_index, 3);
assert!(
handle.clone().tab_stop,
"a tab index implies a tab stop, as GPUI's own does"
);
// And an explicit refusal still wins over that implication.
let mut behavior = Behavior::default();
apply_behavior(&mut behavior, "tab_index", &[Bridged::Number(3.0)]);
apply_behavior(&mut behavior, "tab_stop", &[Bridged::Bool(false)]);
let _ = with_gpui_focus(div().id("custom"), &behavior, Some(&handle));
assert!(!handle.clone().tab_stop);
}
/// One written list of anchors serves the parser, the check the prelude
/// makes at the call site and the union in `gpui-kit.d.ts`. A declared name
/// that does not parse is a name a script could type and the runtime would
/// silently drop.
#[test]
fn every_declared_anchor_name_parses_back_to_a_variant() {
for name in ANCHOR_NAMES {
assert!(
anchor_from_name(name).is_some(),
"`{name}` is declared but does not parse"
);
}
assert_eq!(anchor_from_name("top_left"), Some(gpui::Anchor::TopLeft));
assert_eq!(
anchor_from_name("right_center"),
Some(gpui::Anchor::RightCenter)
);
assert_eq!(anchor_from_name("topLeft"), None);
assert_eq!(anchor_from_name(""), None);
}
/// `open` is three-valued because a popover is uncontrolled until the
/// script says otherwise, and "never said" has to be distinguishable from
/// "said false" for `default_open` to mean anything.
#[test]
fn an_unsaid_open_state_is_not_a_closed_one() {
let mut behavior = Behavior::default();
assert_eq!(behavior.open, None);
apply_behavior(&mut behavior, "open", &[Bridged::Bool(false)]);
assert_eq!(behavior.open, Some(false));
}
#[test]
fn a_delay_crosses_the_bridge_in_milliseconds() {
let mut behavior = Behavior::default();
apply_behavior(&mut behavior, "open_delay", &[Bridged::Number(250.0)]);
assert_eq!(behavior.open_delay, Some(Duration::from_millis(250)));
// A negative delay is a number the script computed wrongly, not a
// request to open before the pointer arrives.
apply_behavior(&mut behavior, "close_delay", &[Bridged::Number(-5.0)]);
assert_eq!(behavior.close_delay, Some(Duration::ZERO));
}
#[test]
fn boolean_behaviors_use_javascript_truthiness() {
for falsy in [
Bridged::Nil,
Bridged::Bool(false),
Bridged::Number(0.0),
Bridged::Number(f64::NAN),
Bridged::Str(String::new()),
] {
let mut behavior = Behavior::default();
apply_behavior(&mut behavior, "disabled", &[falsy]);
assert!(!behavior.disabled);
}
for truthy in [
Bridged::Bool(true),
Bridged::Number(1.0),
Bridged::Str("false".to_owned()),
] {
let mut behavior = Behavior::default();
apply_behavior(&mut behavior, "disabled", &[truthy]);
assert!(behavior.disabled);
}
}
}
/// Hands a resizable group's panel sizes, in pixels, to the script.
///
/// Base's own callback is given the `ResizableState` entity; a script has no
/// handle for one, and the sizes are the whole of what that entity is read for
/// here.
fn dispatch_resize(
runtime: &Weak<ShellRuntime>,
callback: CallbackId,
sizes: Vec<f32>,
window: &mut Window,
cx: &mut App,
) {
let Some(runtime) = runtime.upgrade() else {
return;
};
runtime.dispatch_resize(callback, sizes, window, cx);
}
fn dispatch_change(
runtime: &Weak<ShellRuntime>,
callback: CallbackId,
checked: bool,
window: &mut Window,
cx: &mut App,
) {
let Some(runtime) = runtime.upgrade() else {
return;
};
runtime.dispatch_change(callback, checked, window, cx);
}
/// A callback with nothing to report but that it happened.
///
/// `on_confirm` and `on_dismiss` carry no payload: the combobox root holds no
/// value, so the only news is the action itself. The script still receives the
/// `(payload, cx)` pair every other handler takes, with an empty payload, so
/// that a handler written as `(_, cx) => …` reads the same everywhere.
fn dispatch_signal(
runtime: &Weak<ShellRuntime>,
callback: CallbackId,
window: &mut Window,
cx: &mut App,
) {
let Some(runtime) = runtime.upgrade() else {
return;
};
runtime.dispatch_signal(callback, window, cx);
}
fn dispatch_click(
runtime: &Weak<ShellRuntime>,
callback: CallbackId,
event: &gpui::ClickEvent,
window: &mut Window,
cx: &mut App,
) {
let Some(runtime) = runtime.upgrade() else {
return;
};
runtime.dispatch_click(callback, event, window, cx);
}