Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
186 changes: 148 additions & 38 deletions crates/shell/src/engine/quickjs/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1023,6 +1023,10 @@ pub(crate) mod exports {
"div",
"svg",
"image",
// GPUI's own lazy lists. Base's virtual lists live in `gpui-base`;
// these are GPUI's, and are exported where `div` is.
"list",
"uniform_list",
"PathBuilder",
"Background",
];
Expand Down Expand Up @@ -5330,21 +5334,27 @@ globalThis.__gpui = (() => {
// The argument checks are here rather than only on the Rust side because a
// list built with the pieces in the wrong order — a render function where the
// sizes go — would otherwise fail as a type error naming neither.
const virtualList = (build, name) => (id, item_count, item_sizes, get_key, render) => {
const shape = name + "(id, item_count, item_sizes, get_key, render)";
// The three checks every lazy list makes. Only the render hint differs:
// `list` is called per item, the other two per visible range.
const checkListArgs = (shape, item_count, get_key, render, renderHint) => {
if (!Number.isInteger(item_count) || item_count < 0) {
throw new TypeError(shape + " needs a whole, non-negative item_count");
}
if (typeof render !== "function") {
throw new TypeError(
shape + " needs a render function; it is called once per visible range, not once per item",
);
}
if (typeof get_key !== "function") {
throw new TypeError(
shape + " needs get_key(index) to return each item's stable string key",
);
}
if (typeof render !== "function") {
throw new TypeError(shape + " needs a render function; it is called " + renderHint);
}
};

const RANGE_HINT = "once per visible range, not once per item";

const virtualList = (build, name) => (id, item_count, item_sizes, get_key, render) => {
const shape = name + "(id, item_count, item_sizes, get_key, render)";
checkListArgs(shape, item_count, get_key, render, RANGE_HINT);
if (Array.isArray(item_sizes) && item_sizes.length !== item_count) {
throw new TypeError(
shape + " was given " + item_sizes.length + " item sizes for " + item_count +
Expand All @@ -5354,6 +5364,31 @@ globalThis.__gpui = (() => {
return element(build(String(id), item_count, item_sizes, get_key, render));
};

// `list` and `uniform_list`: GPUI's own lazy lists. Both cross the boundary
// the way a virtual list does -- one renderer per visible range -- so a
// `list` renderer written per item is folded into a range here, once, rather
// than teaching the host a second calling convention.
const lazyList = (build, name, perItem) => (id, item_count, get_key, render) => {
const shape = name + "(id, item_count, get_key, render)";
checkListArgs(
shape,
item_count,
get_key,
render,
perItem ? "once per item on screen, with the item's index" : RANGE_HINT,
);
const describe = perItem
? (range, cx) => {
const items = [];
for (let index = range.start; index < range.end; index++) {
items.push(render(index, cx));
}
return items;
}
: render;
return element(build(String(id), item_count, get_key, describe));
};

const finiteNonNegative = (value, name) => {
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
throw new TypeError(name + " must be a finite non-negative number");
Expand Down Expand Up @@ -6551,6 +6586,8 @@ globalThis.__gpui = (() => {
// would put one number per row across the boundary on every render.
v_virtual_list: virtualList(__v_virtual_list, "v_virtual_list"),
h_virtual_list: virtualList(__h_virtual_list, "h_virtual_list"),
list: lazyList(__list, "list", true),
uniform_list: lazyList(__uniform_list, "uniform_list", false),
VirtualListScrollHandle: { new: () => virtualScrollHandle(__virtual_scroll_new()) },
Scrollbar: {
new: (id) => element(__scrollbar(String(id))),
Expand Down Expand Up @@ -7059,6 +7096,18 @@ impl ShellRuntime {
runtime.clone(),
gpui::Axis::Horizontal,
)?;
list_constructor(
&globals,
"__list",
runtime.clone(),
crate::spec::ListKind::Measured,
)?;
list_constructor(
&globals,
"__uniform_list",
runtime.clone(),
crate::spec::ListKind::Uniform,
)?;
text_constructor(&globals, "__popup", runtime.clone(), Component::Popup)?;
text_constructor(&globals, "__select", runtime.clone(), Component::Select)?;
text_constructor(&globals, "__combobox", runtime.clone(), Component::Combobox)?;
Expand Down Expand Up @@ -8466,6 +8515,61 @@ impl<'js> FromJs<'js> for ItemKeyResolver {
/// lists cannot bypass it.
const MAX_VIRTUAL_ITEMS_PER_RENDER: usize = 1_000_000;

/// The guard both lazy-list constructors run before they allocate anything.
///
/// The phase check is why an item renderer cannot build a list: callbacks
/// belong to the snapshot that registered them, and by the time a renderer
/// runs that generation is closed, so a callback pushed there is one no lookup
/// could ever match. The budget claim has to come before the size table,
/// because a count the script fat-fingered is an allocation measured in
/// gigabytes.
fn guard_lazy_list(
ctx: &Ctx<'_>,
runtime: &Weak<ShellRuntime>,
count: usize,
) -> JsResult<Rc<ShellRuntime>> {
if scope::current_phase() == Some(ScopePhase::Layout) {
return Err(Exception::throw_type(
ctx,
"a list cannot be built from inside another list's item renderer: its own \
renderer would belong to no render pass and would never be called. Describe \
the nested list from the view's render() instead",
));
}
let store = upgrade(runtime, ctx)?;
if !store
.arena
.borrow_mut()
.claim_virtual_items(count, MAX_VIRTUAL_ITEMS_PER_RENDER)
{
return Err(Exception::throw_type(
ctx,
&format!(
"the lists in one render may describe at most \
{MAX_VIRTUAL_ITEMS_PER_RENDER} items in total"
),
));
}
Ok(store)
}

/// Files a lazy list's two script functions against the open generation.
fn register_item_callbacks(
store: &Rc<ShellRuntime>,
get_key: ItemKeyResolver,
render: ItemRenderer,
) -> (CallbackId, CallbackId) {
let entry = |value| {
store.callbacks.borrow_mut().push(CallbackEntry {
value,
view: scope::current_view().map(|view| view.downgrade()),
application: scope::current_application_generation(),
registered_in: scope::current_generation(),
})
};
(entry(get_key.0), entry(render.0))
}

/// `v_virtual_list` and `h_virtual_list`.
///
/// The item renderer is registered as an ordinary callback, so it belongs to
Expand All @@ -8489,24 +8593,7 @@ fn virtual_list_constructor(
get_key: ItemKeyResolver,
render: ItemRenderer|
-> JsResult<SpecId> {
if scope::current_phase() == Some(ScopePhase::Layout) {
return Err(Exception::throw_type(
&ctx,
"a virtual list cannot be built from inside another list's item renderer: its own renderer would belong to no render pass and would never be called. Describe the nested list from the view's render() instead",
));
}
if !upgrade(&runtime, &ctx)?
.arena
.borrow_mut()
.claim_virtual_items(count, MAX_VIRTUAL_ITEMS_PER_RENDER)
{
return Err(Exception::throw_type(
&ctx,
&format!(
"the virtual lists in one render may describe at most {MAX_VIRTUAL_ITEMS_PER_RENDER} items in total"
),
));
}
let store = guard_lazy_list(&ctx, &runtime, count)?;

let extent = |value: f64| -> JsResult<gpui::Size<gpui::Pixels>> {
if !value.is_finite() || value < 0.0 {
Expand Down Expand Up @@ -8560,19 +8647,7 @@ fn virtual_list_constructor(
}
};

let store = upgrade(&runtime, &ctx)?;
let get_key = store.callbacks.borrow_mut().push(CallbackEntry {
value: get_key.0,
view: scope::current_view().map(|view| view.downgrade()),
application: scope::current_application_generation(),
registered_in: scope::current_generation(),
});
let callback = store.callbacks.borrow_mut().push(CallbackEntry {
value: render.0,
view: scope::current_view().map(|view| view.downgrade()),
application: scope::current_application_generation(),
registered_in: scope::current_generation(),
});
let (get_key, callback) = register_item_callbacks(&store, get_key, render);
Ok(store.push_node(Component::VirtualList(Rc::new(
crate::spec::VirtualListSpec::new(
id,
Expand All @@ -8587,6 +8662,41 @@ fn virtual_list_constructor(
)
}

/// `list` and `uniform_list`.
///
/// The same registration as a virtual list's, and confined for the same
/// reasons: the renderer belongs to the snapshot being built, and cannot be
/// registered from inside another list's item renderer. The item budget is
/// claimed too, because `gpui::list` keeps one entry per item whether or not
/// the item is ever drawn.
fn list_constructor(
globals: &Object<'_>,
name: &str,
runtime: Weak<ShellRuntime>,
kind: crate::spec::ListKind,
) -> JsResult<()> {
globals.set(
name,
Func::from(
move |ctx: Ctx<'_>,
id: String,
count: usize,
get_key: ItemKeyResolver,
render: ItemRenderer|
-> JsResult<SpecId> {
let store = guard_lazy_list(&ctx, &runtime, count)?;

let (get_key, callback) = register_item_callbacks(&store, get_key, render);
Ok(
store.push_node(Component::List(Rc::new(crate::spec::ListSpec::new(
id, kind, count, get_key, callback,
)))),
)
},
),
)
}

/// The spec a `render` returned.
///
/// A retained child view counts, the way an `Entity<V>` is itself renderable in
Expand Down
22 changes: 18 additions & 4 deletions crates/shell/src/materialize.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,13 @@
//! but only to dispatch events: no path through this module calls into the
//! script while an element is being built.
//!
//! # The one exception: `VirtualList`
//! # The one exception: the lazy lists
//!
//! A virtualized list is the single component whose description is not the
//! 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 a virtual list does
//! enter the VM, once per list, no matter what changed.
//! 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
Expand All @@ -25,6 +25,16 @@
//! *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.
//!
//! Three things confine it, and they are worth naming because each is what
//! stops the exception from spreading:
//!
Expand Down Expand Up @@ -1422,6 +1432,9 @@ fn materialize_component(
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
Expand Down Expand Up @@ -2288,6 +2301,7 @@ fn motion_element_id(
// 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()),
Expand Down
1 change: 1 addition & 0 deletions crates/shell/src/materialize/components.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ pub(super) mod collapsible;
pub(super) mod dock;
pub(super) mod fps;
pub(super) mod group;
pub(super) mod list;
pub(super) mod number_input;
pub(super) mod otp_input;
pub(super) mod pagination;
Expand Down
Loading
Loading