From e1e3221acc2fdc28c3cf91175f5c4a04cd9091f9 Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Fri, 4 Sep 2026 21:08:50 +0800 Subject: [PATCH] shell: Expose GPUI's list and uniform_list to scripts A v_virtual_list places rows by a size table the script states, which a column of panels that size to their own content cannot supply. GPUI's own lazy lists measure instead: `uniform_list` measures one row and places the rest by it, `list` measures every item it draws and keeps the sizes. Both now reach script from the `gpui-kit` module, where `div` and `svg` are, as `list(id, item_count, get_key, render)` and `uniform_list(id, item_count, get_key, render)`, through the same confined item-renderer path the virtual list uses, with `on_item_click` and `on_item_secondary_click` unchanged. The phase guard, the shared item budget and the two callback registrations are now one pair of helpers both constructors call, as are the three argument checks on the JavaScript side. The scroll position a name carries is no longer only a ScrollHandle: the shared slot a Scrollbar pairs with holds a ScrollHandle, a UniformListScrollHandle or a ListState and answers as the bar's handle itself, so `Scrollbar.vertical(id)` drives either list by name. A scroll area keeps its own handle under a key of its own rather than reading it back out of that slot, because a lazy list of the same name rewrites the slot every frame and the area would otherwise be handed a fresh, unscrolled handle each time; a name claimed by both now reports itself once instead of once a frame. A `list` keeps a 160px overdraw band so it always has measured ground to scroll into without paying for items a screen away. What `list` costs is one host crossing per visible item per frame, where the range-based lists cost one per frame however many rows are on screen. The materialize module doc and the typings say so rather than leaving the old "once per list" claim to cover both shapes. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01KWyH4iQb8w6q62ZbMMDcJu --- crates/shell/src/engine/quickjs/mod.rs | 186 +++++++++--- crates/shell/src/materialize.rs | 22 +- crates/shell/src/materialize/components.rs | 1 + .../shell/src/materialize/components/list.rs | 166 +++++++++++ .../src/materialize/components/scrollbar.rs | 122 +++++++- .../materialize/components/virtual_list.rs | 52 ++-- crates/shell/src/spec.rs | 77 +++++ crates/shell/src/tests/render.rs | 282 +++++++++++++----- crates/shell/src/typings.rs | 79 ++++- website/shell/api.md | 20 +- website/zh-CN/shell/api.md | 20 +- 11 files changed, 869 insertions(+), 158 deletions(-) create mode 100644 crates/shell/src/materialize/components/list.rs diff --git a/crates/shell/src/engine/quickjs/mod.rs b/crates/shell/src/engine/quickjs/mod.rs index 1d772a6af7..2fe9341d82 100644 --- a/crates/shell/src/engine/quickjs/mod.rs +++ b/crates/shell/src/engine/quickjs/mod.rs @@ -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", ]; @@ -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 + @@ -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"); @@ -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))), @@ -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)?; @@ -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, + count: usize, +) -> JsResult> { + 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, + 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 @@ -8489,24 +8593,7 @@ fn virtual_list_constructor( get_key: ItemKeyResolver, render: ItemRenderer| -> JsResult { - 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> { if !value.is_finite() || value < 0.0 { @@ -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, @@ -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, + kind: crate::spec::ListKind, +) -> JsResult<()> { + globals.set( + name, + Func::from( + move |ctx: Ctx<'_>, + id: String, + count: usize, + get_key: ItemKeyResolver, + render: ItemRenderer| + -> JsResult { + 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` is itself renderable in diff --git a/crates/shell/src/materialize.rs b/crates/shell/src/materialize.rs index 65189089fa..791d1d8b45 100644 --- a/crates/shell/src/materialize.rs +++ b/crates/shell/src/materialize.rs @@ -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 @@ -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: //! @@ -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 @@ -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()), diff --git a/crates/shell/src/materialize/components.rs b/crates/shell/src/materialize/components.rs index ed8f137956..8005fddde7 100644 --- a/crates/shell/src/materialize/components.rs +++ b/crates/shell/src/materialize/components.rs @@ -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; diff --git a/crates/shell/src/materialize/components/list.rs b/crates/shell/src/materialize/components/list.rs new file mode 100644 index 0000000000..29ea4582f7 --- /dev/null +++ b/crates/shell/src/materialize/components/list.rs @@ -0,0 +1,166 @@ +//! `list` and `uniform_list`: GPUI's own lazy lists, driven from script. +//! +//! A [`VirtualList`](crate::spec::Component::VirtualList) is base's: the script +//! states every item's extent and base places the items by the table. These +//! two are GPUI's, and the difference is who measures. `uniform_list` measures +//! one item and places every row by it; `list` measures each item it draws and +//! keeps the sizes, so rows of unequal, unstated height still scroll as one +//! collection. Both draw only what is on screen, and both reach the script the +//! way the virtual list does — one renderer, called with the visible range from +//! inside layout — so the confinement recorded in [`crate::materialize`] holds +//! for them unchanged. +//! +//! Neither takes a `VirtualListScrollHandle`: the position is GPUI's own state, +//! kept under the id the list was built with. That id is also the name a +//! `Scrollbar` pairs with, through the same shared slot a scroll area uses. + +use std::rc::Rc; + +use gpui::{ + AnyElement, App, ElementId, Empty, IntoElement, ListAlignment, ListState, Refineable as _, + SharedString, StyleRefinement, Styled as _, UniformListScrollHandle, Window, list as gpui_list, + px, uniform_list, +}; + +use crate::{ + engine::ShellRuntime, + materialize::{ + Behavior, Children, StateStyles, + components::{ + scrollbar::{SharedScroll, shared_scroll_position}, + virtual_list::{ItemHandlers, render_range, warn_lazy_list_misuse}, + }, + warn_ignored_key, warn_unhonoured_a11y, + }, + spec::{ListKind, ListSpec}, +}; + +/// How far past the viewport a `list` draws and measures, in pixels. +/// +/// GPUI's list can only scroll into what it has measured, and it measures by +/// drawing: with nothing drawn past the viewport, a list whose last drawn row +/// ends exactly at the bottom edge has nowhere to scroll to and never asks +/// for more. A band below the fold keeps a wheel notch or a bar drag inside +/// measured ground, and each frame it moves measures the next band. Kept to +/// a few rows rather than the screenful GPUI's own callers use: every item in +/// the band is a script render per frame, and the whole point of the list is +/// to leave an item a screen away undrawn. +const LIST_OVERDRAW: gpui::Pixels = px(160.); + +/// The retained side of a `list`: GPUI's measurements, and the count they were +/// taken for, so a collection that grew or shrank is spliced rather than +/// re-measured from nothing. +struct MeasuredItems { + state: ListState, + item_count: usize, +} + +#[allow(clippy::too_many_arguments)] +pub(in crate::materialize) fn list( + runtime: &Rc, + spec: &ListSpec, + refinement: StyleRefinement, + behavior: Behavior, + states: StateStyles, + children: Children, + window: &mut Window, + cx: &mut App, +) -> AnyElement { + let name = match spec.kind() { + ListKind::Measured => "list", + ListKind::Uniform => "uniform_list", + }; + warn_ignored_key(&behavior, name); + warn_unhonoured_a11y(&behavior, name, &[]); + warn_lazy_list_misuse(name, &children, &states); + if behavior.virtual_scroll.is_some() { + tracing::warn!( + "track_scroll is ignored on a {name}: its scroll position is GPUI's own, filed \ + under the id it was built with, which is where a Scrollbar of that name finds it" + ); + } + + let identity = ElementId::Name(SharedString::from(spec.id().to_owned())); + let weak = Rc::downgrade(runtime); + let get_key = spec.get_key(); + let render_items = spec.render_items(); + let handlers = ItemHandlers { + click: behavior.on_item_click, + secondary_click: behavior.on_item_secondary_click, + }; + let item_count = spec.item_count(); + + match spec.kind() { + ListKind::Uniform => { + let scroll = window + .use_keyed_state((identity.clone(), "uniform-list-scroll"), cx, |_, _| { + UniformListScrollHandle::new() + }) + .read(cx) + .clone(); + shared_scroll_position(&identity.clone(), window, cx).update(cx, |shared, _| { + *shared = SharedScroll::Uniform(scroll.clone()) + }); + + let mut list = uniform_list(identity.clone(), item_count, move |range, window, cx| { + render_range(&weak, get_key, render_items, handlers, range, window, cx) + }) + .track_scroll(&scroll) + // Base's virtual list fills its box unless told otherwise; the + // same default here, so a list dropped into a sized column shows + // rows rather than a zero-height strip. The refinement may say + // otherwise. + .size_full(); + if let Some(index) = behavior.item_to_measure_index { + list = list.with_width_from_item(Some(index)); + } + list.style().refine(&refinement); + list.into_any_element() + } + ListKind::Measured => { + if behavior.item_to_measure_index.is_some() { + tracing::warn!( + "with_item_to_measure_index is ignored on a list: it measures every item \ + it draws, so there is no one item the rest are sized from. It is \ + uniform_list that takes one" + ); + } + let retained = window.use_keyed_state((identity.clone(), "list-state"), cx, |_, _| { + MeasuredItems { + state: ListState::new(item_count, ListAlignment::Top, LIST_OVERDRAW), + item_count, + } + }); + let state = retained.update(cx, |retained, _| { + if retained.item_count != item_count { + // Every item is a new one as far as the measurements go; + // what survives is the scroll position, which `reset` + // would throw away. + retained.state.splice(0..retained.item_count, item_count); + retained.item_count = item_count; + } + retained.state.clone() + }); + shared_scroll_position(&identity.clone(), window, cx) + .update(cx, |shared, _| *shared = SharedScroll::List(state.clone())); + + let mut list = gpui_list(state, move |index, window, cx| { + render_range( + &weak, + get_key, + render_items, + handlers, + index..index + 1, + window, + cx, + ) + .into_iter() + .next() + .unwrap_or_else(|| Empty.into_any_element()) + }) + .size_full(); + list.style().refine(&refinement); + list.into_any_element() + } + } +} diff --git a/crates/shell/src/materialize/components/scrollbar.rs b/crates/shell/src/materialize/components/scrollbar.rs index f674db9ccf..02c2213d58 100644 --- a/crates/shell/src/materialize/components/scrollbar.rs +++ b/crates/shell/src/materialize/components/scrollbar.rs @@ -26,10 +26,11 @@ //! sitting there hit-testable and refusing to move. use gpui::{ - AnyElement, App, ElementId, IntoElement, ParentElement, Refineable as _, ScrollHandle, - SharedString, StatefulInteractiveElement, StyleRefinement, Styled, Window, div, px, + AnyElement, App, Bounds, ElementId, IntoElement, ListState, ParentElement, Pixels, Point, + Refineable as _, ScrollHandle, SharedString, Size, StatefulInteractiveElement, StyleRefinement, + Styled, UniformListScrollHandle, Window, div, px, }; -use gpui_base::{Scrollbar, ScrollbarAxis}; +use gpui_base::{Scrollbar, ScrollbarAxis, ScrollbarHandle}; use crate::materialize::{Behavior, Children, StateStyles, warn_ignored_key, warn_unhonoured_a11y}; @@ -58,7 +59,9 @@ pub(in crate::materialize) fn scrollbar( } let target = SharedString::from(id); - let handle = scroll_position(&ElementId::Name(target.clone()), window, cx); + let handle = shared_scroll_position(&ElementId::Name(target.clone()), window, cx) + .read(cx) + .clone(); warn_if_unclaimed(&target, &handle, window, cx); // `new(...)` is both axes, and `Scrollbar.horizontal`/`.vertical` narrow it @@ -116,9 +119,108 @@ pub(in crate::materialize) fn track_scroll_position ScrollHandle { - shared_scroll_position(identity, window, cx) + // The area keeps its position under a key of its own rather than in the + // shared slot. A lazy list of the same name overwrites that slot on every + // frame, and an area that read its handle back out of it would be handed a + // fresh, unscrolled one every frame -- it would stop scrolling entirely + // rather than merely lose the bar. + let handle = window + .use_keyed_state((identity.clone(), "scroll-area"), cx, |_, _| { + ScrollHandle::default() + }) .read(cx) - .clone() + .clone(); + let shared = shared_scroll_position(identity, window, cx); + if !matches!(shared.read(cx), SharedScroll::Handle(_)) { + warn_about_two_scrollers(identity, window, cx); + } + // Last one described wins the bar, which is what two scroll areas sharing + // a name have always done. + shared.update(cx, |shared, _| { + *shared = SharedScroll::Handle(handle.clone()) + }); + handle +} + +/// Reports one name claimed by two things that cannot share a position. +/// +/// Once per name, not once per frame: both claimants rewrite the shared slot +/// every time they are materialized, so the condition is true for as long as +/// the description stands. +fn warn_about_two_scrollers(identity: &ElementId, window: &mut Window, cx: &mut App) { + let reported = + window.use_keyed_state((identity.clone(), "scroll-name-collision"), cx, |_, _| { + false + }); + if *reported.read(cx) { + return; + } + reported.update(cx, |reported, _| *reported = true); + tracing::warn!( + "{identity:?} names both a scroll area and a lazy list; each keeps scrolling, but a \ + Scrollbar of that name drives whichever was described last. Give one of them a name \ + of its own" + ); +} + +/// The scroll position one name can carry. +/// +/// A scroll area and a virtual list scroll through a [`ScrollHandle`]; GPUI's +/// own lazy lists keep their position in a handle of their own. A `Scrollbar` +/// pairs by name and does not know which it was given, so the shared slot +/// holds any of them and answers as the bar's handle itself. +#[derive(Clone)] +pub(in crate::materialize) enum SharedScroll { + Handle(ScrollHandle), + Uniform(UniformListScrollHandle), + List(ListState), +} + +impl Default for SharedScroll { + fn default() -> Self { + Self::Handle(ScrollHandle::default()) + } +} + +impl SharedScroll { + /// The handle this name carries, as the bar's own trait. + /// + /// Base's `Scrollbar` erases its handle to `Rc` + /// anyway; the enum exists so a scroll area can still get its concrete + /// `ScrollHandle` back, and one match is the whole of what the bar needs. + fn inner(&self) -> &dyn ScrollbarHandle { + match self { + Self::Handle(handle) => handle, + Self::Uniform(handle) => handle, + Self::List(state) => state, + } + } +} + +impl ScrollbarHandle for SharedScroll { + fn viewport_bounds(&self) -> Bounds { + self.inner().viewport_bounds() + } + + fn offset(&self) -> Point { + self.inner().offset() + } + + fn set_offset(&self, offset: Point) { + self.inner().set_offset(offset) + } + + fn content_size(&self) -> Size { + self.inner().content_size() + } + + fn start_drag(&self) { + self.inner().start_drag() + } + + fn end_drag(&self) { + self.inner().end_drag() + } } /// The slot itself, for the one caller that has to *write* it. @@ -131,8 +233,8 @@ pub(in crate::materialize) fn shared_scroll_position( identity: &ElementId, window: &mut Window, cx: &mut App, -) -> gpui::Entity { - window.use_keyed_state(identity.clone(), cx, |_, _| ScrollHandle::default()) +) -> gpui::Entity { + window.use_keyed_state(identity.clone(), cx, |_, _| SharedScroll::default()) } /// Distinguishes the bars over one scroll area. They share a scroll position, @@ -168,11 +270,11 @@ struct ScrollTarget { /// Reported once. A warning repeated every frame is a warning nobody reads. fn warn_if_unclaimed( target: &SharedString, - handle: &ScrollHandle, + handle: &SharedScroll, window: &mut Window, cx: &mut App, ) { - let viewport = handle.bounds().size; + let viewport = handle.viewport_bounds().size; let unclaimed = viewport.width <= px(0.) || viewport.height <= px(0.); let state = window.use_keyed_state( (ElementId::Name(target.clone()), "scroll-target"), diff --git a/crates/shell/src/materialize/components/virtual_list.rs b/crates/shell/src/materialize/components/virtual_list.rs index bf3490d38a..0d656bb9ed 100644 --- a/crates/shell/src/materialize/components/virtual_list.rs +++ b/crates/shell/src/materialize/components/virtual_list.rs @@ -69,7 +69,8 @@ use gpui_base::{VirtualListScrollHandle, h_virtual_list, v_virtual_list}; use crate::{ engine::ShellRuntime, materialize::{ - Behavior, Children, StateStyles, components::scrollbar::shared_scroll_position, + Behavior, Children, StateStyles, + components::scrollbar::{SharedScroll, shared_scroll_position}, materialize_subtree, warn_ignored_key, warn_unhonoured_a11y, }, spec::{CallbackId, VirtualListSpec}, @@ -87,6 +88,29 @@ use crate::{ /// of the view from inside GPUI's layout pass, on behalf of a closure that /// never reads it — a borrow whose safety the shell would then have to keep /// arguing for every time a list ended up nested inside something. +/// The two things a script can put on any lazy list that it cannot honour. +/// +/// Shared with [`super::list`], which has the same two: what a list draws is +/// whatever its item renderer returns, and the list itself has no hit state. +pub(in crate::materialize) fn warn_lazy_list_misuse( + name: &str, + children: &Children, + states: &StateStyles, +) { + if !children.is_empty() { + tracing::warn!( + "children are dropped on a {name}: its contents are whatever the item renderer \ + returns" + ); + } + if states.hover.is_some() || states.active.is_some() || states.focus.is_some() { + tracing::warn!( + "state styles are ignored on a {name}: it has no interactive state of its own. \ + Put them on the rows the item renderer returns, or on an element around the list" + ); + } +} + struct VirtualItems; impl Render for VirtualItems { @@ -115,18 +139,7 @@ pub(in crate::materialize) fn virtual_list( // `Interactivity` of its own is reachable, so there is no focus handle, // role or hit state for any of this to land on. warn_unhonoured_a11y(&behavior, name, &[]); - if !children.is_empty() { - tracing::warn!( - "children are dropped on a {name}: its contents are whatever the item renderer \ - returns, one element per item in the range it is given" - ); - } - if states.hover.is_some() || states.active.is_some() || states.focus.is_some() { - tracing::warn!( - "state styles are ignored on a {name}: it has no interactive state of its own. \ - Put them on the rows the item renderer returns, or on an element around the list" - ); - } + warn_lazy_list_misuse(name, &children, &states); let identity = ElementId::Name(SharedString::from(spec.id().to_owned())); let scroll = scroll_position(runtime, &behavior, &identity, window, cx); @@ -202,8 +215,9 @@ fn scroll_position( .clone() }); - shared_scroll_position(identity, window, cx) - .update(cx, |shared, _| *shared = scroll.base_handle().clone()); + shared_scroll_position(identity, window, cx).update(cx, |shared, _| { + *shared = SharedScroll::Handle(scroll.base_handle().clone()) + }); scroll } @@ -212,7 +226,7 @@ fn scroll_position( /// /// Both halves are timed together because from a frame's point of view they are /// one cost, and both are the frame's: see [`crate::metrics`]. -fn render_range( +pub(in crate::materialize) fn render_range( runtime: &Weak, get_key: CallbackId, render_items: CallbackId, @@ -266,11 +280,11 @@ fn render_range( /// list that asked for neither gets its rows exactly as the renderer built /// them. #[derive(Clone, Copy, Default)] -struct ItemHandlers { +pub(in crate::materialize) struct ItemHandlers { /// `on_item_click`: the key, on a click. - click: Option, + pub(in crate::materialize) click: Option, /// `on_item_secondary_click`: the key and the press, on a right press. - secondary_click: Option, + pub(in crate::materialize) secondary_click: Option, } impl ItemHandlers { diff --git a/crates/shell/src/spec.rs b/crates/shell/src/spec.rs index a51514ae0c..eb2ddd63c2 100644 --- a/crates/shell/src/spec.rs +++ b/crates/shell/src/spec.rs @@ -414,6 +414,11 @@ pub(crate) enum Component { /// so this node carries only the list itself. See [`VirtualListSpec`] and /// the exception recorded in [`crate::materialize`]. VirtualList(Rc), + /// GPUI's own lazy lists, driven the same way as [`Component::VirtualList`]: + /// the items come from a callback run during layout. `list` measures every + /// item it draws, so rows need not state a height; `uniform_list` measures + /// one and places the rest by it. See [`ListSpec`]. + List(Rc), } #[derive(Clone, Debug, PartialEq)] @@ -551,6 +556,71 @@ impl VirtualListSpec { } } +/// Which of GPUI's lazy lists a [`ListSpec`] describes. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ListKind { + /// `gpui::list`: every drawn item is measured, so heights may differ. + Measured, + /// `gpui::uniform_list`: one item is measured and every row takes its height. + Uniform, +} + +/// The parameters of a `list` or `uniform_list` call, held for the frame. +/// +/// The same shape as a [`VirtualListSpec`] without the size table: what +/// distinguishes these lists is that GPUI measures the items itself, so the +/// script says how many there are and nothing about how tall. +#[derive(Clone, Debug, PartialEq)] +pub struct ListSpec { + id: String, + kind: ListKind, + item_count: usize, + get_key: CallbackId, + render_items: CallbackId, +} + +impl ListSpec { + pub fn new( + id: String, + kind: ListKind, + item_count: usize, + get_key: CallbackId, + render_items: CallbackId, + ) -> Self { + Self { + id, + kind, + item_count, + get_key, + render_items, + } + } + + /// The identity the script gave, also the name a `Scrollbar` pairs with. + pub fn id(&self) -> &str { + &self.id + } + + pub fn kind(&self) -> ListKind { + self.kind + } + + /// How many items the collection has, visible or not. + pub fn item_count(&self) -> usize { + self.item_count + } + + /// Resolves the stable domain key for one current item index. + pub fn get_key(&self) -> CallbackId { + self.get_key + } + + /// The handler that describes one window of items. + pub fn render_items(&self) -> CallbackId { + self.render_items + } +} + impl Component { /// What this node contributes to a [`StructureFingerprint`]: which /// constructor produced it, and nothing it carries. @@ -644,6 +714,10 @@ impl Component { gpui::Axis::Vertical => "v_virtual_list", gpui::Axis::Horizontal => "h_virtual_list", }, + Component::List(spec) => match spec.kind() { + ListKind::Measured => "list", + ListKind::Uniform => "uniform_list", + }, } } } @@ -1276,6 +1350,9 @@ impl SpecArena { Component::VirtualList(spec) => { out.push_str(&format!(" {:?} \u{d7}{}", spec.id(), spec.sizes().len())) } + Component::List(spec) => { + out.push_str(&format!(" {:?} \u{d7}{}", spec.id(), spec.item_count())) + } Component::ChildView(spec) => out.push_str(&format!(" #{}", spec.handle())), Component::Slider(handle) | Component::SliderTrack(handle) diff --git a/crates/shell/src/tests/render.rs b/crates/shell/src/tests/render.rs index 51849be6a5..3e1425b1b1 100644 --- a/crates/shell/src/tests/render.rs +++ b/crates/shell/src/tests/render.rs @@ -9780,31 +9780,7 @@ fn mount_virtual_list( gpui::Entity, VisualTestContext, ) { - cx.update(crate::init); - let runtime = ShellRuntime::new_isolated().expect("runtime"); - cx.update(|cx| runtime.set_global(cx)); - let view_type = runtime - .load_source("rows.js", &virtual_list_source(extra)) - .expect("load"); - - // The view has to be the window's own root. A helper that draws it once - // into a throwaway element would leave every later frame going to the real - // root instead, and a virtual list only says anything once it has been laid - // out more than once. - let runtime_for_view = Rc::clone(&runtime); - let window = cx.add_window(move |window, cx| { - let view = runtime_for_view - .instantiate_view(&view_type, window, cx) - .expect("instantiate"); - RootedScriptView(view) - }); - let mut context = VisualTestContext::from_window(*window.deref(), cx); - context.update(|window, cx| window.draw(cx).clear(cx)); - let view = window - .root(&mut context) - .expect("view") - .read_with(&context, |root, _| root.0.clone()); - (runtime, window, view, context) + mount_list_source(cx, &virtual_list_source(extra)) } fn scroll_by(context: &mut VisualTestContext, dy: f32) { @@ -9820,30 +9796,7 @@ fn scroll_by(context: &mut VisualTestContext, dy: f32) { fn a_virtual_list_describes_only_the_visible_window_and_follows_the_scroll( cx: &mut TestAppContext, ) { - let (_runtime, _window, view, mut context) = mount_virtual_list(cx, ""); - - let (start, end) = reported_range(&redraw_and_read(&mut context, &view)); - assert_eq!(start, 0, "an unscrolled list starts at its first item"); - assert!( - (10..=13).contains(&end), - "a 200px box of 20px rows shows about ten of five hundred, not {end}" - ); - - // Ten rows down. The script has to be asked again, with a different range: - // that it is asked at all is the whole of what separates this component - // from every other one, and that the range moves is what makes it a list - // rather than a window onto the first screenful. - scroll_by(&mut context, -200.); - - let (scrolled_start, scrolled_end) = reported_range(&redraw_and_read(&mut context, &view)); - assert_eq!( - scrolled_start, 10, - "200px of 20px rows is ten items; the window must start there" - ); - assert!( - scrolled_end > end, - "the window must have moved down the collection: {scrolled_start}..{scrolled_end}" - ); + assert_the_visible_window_follows_the_scroll(cx, &virtual_list_source("")); } #[gpui::test] @@ -10001,23 +9954,7 @@ export default class Rows extends View { #[gpui::test] fn a_virtual_list_reports_which_row_was_clicked(cx: &mut TestAppContext) { - let (_runtime, _window, view, mut context) = mount_virtual_list(cx, ""); - - // Rows are twenty pixels tall and the list starts at the top of the window, - // so the third one covers 40..60. - context.simulate_click(point(px(150.), px(50.)), Modifiers::default()); - context.update(|window, cx| window.draw(cx).clear(cx)); - - let tree = context.update(|_, cx| { - view.read(cx) - .snapshot() - .map(crate::RenderSnapshot::debug_tree) - .unwrap_or_default() - }); - assert!( - tree.contains("clicked 2"), - "the click must arrive with the item's stable key: {tree}" - ); + assert_a_click_reports_the_row_key(cx, &virtual_list_source(""), 50.); } /// The hit box belongs to the item it was painted for, not to the position the @@ -10210,7 +10147,9 @@ export default class LargeLists extends View { "#, ); assert!( - message.contains("virtual list") && message.contains("render"), + // "lists", not "virtual lists": one budget covers every lazy list in a + // render, `list` and `uniform_list` included. + message.contains("lists in one render"), "the error must identify the aggregate host allocation boundary: {message}" ); } @@ -10423,3 +10362,212 @@ fn retiring_an_application_generation_runs_its_app_effect_cleanups(cx: &mut Test ); let _ = std::fs::remove_dir_all(&directory); } + +// --------------------------------------------------------------------------- +// `list` and `uniform_list`: GPUI's own lazy lists, driven from script. + +fn uniform_list_source() -> &'static str { + r#" +import { div, View, uniform_list } from "gpui-kit"; +import { v_flex } from "gpui-base"; + +export default class Rows extends View { + init() { + this.range = [0, 0]; + this.clicked = -1; + } + + render(cx) { + return v_flex() + .w(300) + .h(400) + .child( + v_flex() + .h(200) + .child( + uniform_list("rows", 500, (index) => String(index), (range) => { + this.range = [range.start, range.end]; + const items = []; + for (let index = range.start; index < range.end; index++) { + items.push(div().h(20).child(`row ${index}`)); + } + return items; + }).on_item_click((key, cx) => { + this.clicked = key; + cx.notify(); + }), + ), + ) + .child(`range ${this.range[0]}..${this.range[1]} clicked ${this.clicked}`); + } +} +"# +} + +/// Rows of two heights, so the list has to measure each one: a uniform guess +/// from the first row would place every later row wrong. +fn measured_list_source() -> &'static str { + r#" +import { div, View, list } from "gpui-kit"; +import { v_flex } from "gpui-base"; + +export default class Rows extends View { + init() { + this.lo = -1; + this.hi = -1; + this.shown = [-1, -1]; + this.clicked = -1; + } + + render(cx) { + // What the previous frame's layout asked for; the item renderer runs after + // this render, from inside layout, so the report is always one frame old. + this.shown = [this.lo, this.hi]; + this.lo = -1; + this.hi = -1; + return v_flex() + .w(300) + .h(400) + .child( + v_flex() + .h(200) + .child( + list("rows", 500, (index) => String(index), (index) => { + if (this.lo < 0 || index < this.lo) this.lo = index; + if (index > this.hi) this.hi = index; + return div().h(index % 2 === 0 ? 20 : 40).child(`row ${index}`); + }).on_item_click((key, cx) => { + this.clicked = key; + cx.notify(); + }), + ), + ) + .child(`range ${this.shown[0]}..${this.shown[1] + 1} clicked ${this.clicked}`); + } +} +"# +} + +/// A 200px box of 20px rows shows about ten of them, and scrolling moves which +/// ten the script is asked for. Shared by the two lists that take a range. +fn assert_the_visible_window_follows_the_scroll(cx: &mut TestAppContext, source: &str) { + let (_runtime, _window, view, mut context) = mount_list_source(cx, source); + + let (start, end) = reported_range(&redraw_and_read(&mut context, &view)); + assert_eq!(start, 0, "an unscrolled list starts at its first item"); + assert!( + (10..=13).contains(&end), + "a 200px box of 20px rows shows about ten of five hundred, not {end}" + ); + + // Ten rows down. The script has to be asked again, with a different range: + // that it is asked at all is the whole of what separates these components + // from every other one, and that the range moves is what makes them lists + // rather than a window onto the first screenful. + scroll_by(&mut context, -200.); + + let (scrolled_start, scrolled_end) = reported_range(&redraw_and_read(&mut context, &view)); + assert_eq!( + scrolled_start, 10, + "200px of 20px rows is ten items; the window must start there" + ); + assert!( + scrolled_end > end, + "the window must have moved down the collection: {scrolled_start}..{scrolled_end}" + ); +} + +/// Rows are twenty pixels tall and the list starts at the top of the window, so +/// the third one covers 40..60 and its stable key is `2`. +fn assert_a_click_reports_the_row_key(cx: &mut TestAppContext, source: &str, y: f32) { + let (_runtime, _window, view, mut context) = mount_list_source(cx, source); + + context.simulate_click(point(px(150.), px(y)), Modifiers::default()); + context.update(|window, cx| window.draw(cx).clear(cx)); + + let tree = redraw_and_read(&mut context, &view); + assert!( + tree.contains("clicked 2"), + "the click must arrive with the item's stable key: {tree}" + ); +} + +/// Loads one script source as the window's own root view and draws it once. +/// +/// The view has to be the window's own root. A helper that drew it once into a +/// throwaway element would leave every later frame going to the real root +/// instead, and a lazy list only says anything once it has been laid out more +/// than once. +fn mount_list_source( + cx: &mut TestAppContext, + source: &str, +) -> ( + Rc, + gpui::WindowHandle, + gpui::Entity, + VisualTestContext, +) { + cx.update(crate::init); + let runtime = ShellRuntime::new_isolated().expect("runtime"); + cx.update(|cx| runtime.set_global(cx)); + let view_type = runtime.load_source("rows.js", source).expect("load"); + + let runtime_for_view = Rc::clone(&runtime); + let window = cx.add_window(move |window, cx| { + let view = runtime_for_view + .instantiate_view(&view_type, window, cx) + .expect("instantiate"); + RootedScriptView(view) + }); + let mut context = VisualTestContext::from_window(*window.deref(), cx); + context.update(|window, cx| window.draw(cx).clear(cx)); + let view = window + .root(&mut context) + .expect("view") + .read_with(&context, |root, _| root.0.clone()); + (runtime, window, view, context) +} + +#[gpui::test] +fn a_uniform_list_describes_only_the_visible_window_and_follows_the_scroll( + cx: &mut TestAppContext, +) { + assert_the_visible_window_follows_the_scroll(cx, uniform_list_source()); +} + +#[gpui::test] +fn a_uniform_list_reports_which_row_was_clicked(cx: &mut TestAppContext) { + assert_a_click_reports_the_row_key(cx, uniform_list_source(), 50.); +} + +#[gpui::test] +fn a_list_measures_each_item_and_follows_the_scroll(cx: &mut TestAppContext) { + let (_runtime, _window, view, mut context) = mount_list_source(cx, measured_list_source()); + + let (start, end) = reported_range(&redraw_and_read(&mut context, &view)); + assert_eq!(start, 0, "an unscrolled list starts at its first item"); + // 20 + 40 + 20 + 40 + 20 + 40 + 20 fills the 200px box with seven rows, and + // the list draws a short band past the fold so it has measured ground to + // scroll into. A list that placed every row by the first one's 20px would + // put eighteen in the same space. + assert!( + (7..=13).contains(&end), + "a 200px box of alternating 20px and 40px rows shows about seven plus the \ + overdraw band, not {end}" + ); + + scroll_by(&mut context, -200.); + + let (_, scrolled_end) = reported_range(&redraw_and_read(&mut context, &view)); + assert!( + scrolled_end > end, + "the window must have moved down the collection: ends at {scrolled_end}, was {end}" + ); +} + +#[gpui::test] +fn a_list_reports_which_item_was_clicked(cx: &mut TestAppContext) { + // Alternating heights: row 0 covers 0..20, row 1 covers 20..60, row 2 + // covers 60..80. + assert_a_click_reports_the_row_key(cx, measured_list_source(), 70.); +} diff --git a/crates/shell/src/typings.rs b/crates/shell/src/typings.rs index 25d8a96597..653c6b9c7d 100644 --- a/crates/shell/src/typings.rs +++ b/crates/shell/src/typings.rs @@ -2039,6 +2039,80 @@ const ELEMENTS: &str = r#" */ export function image(path: string): Element; + /** The visible items, as a half-open `[start, end)` interval. */ + export interface ItemRange { + start: number; + end: number; + } + + /** + * GPUI's own lazy list: rows of any height, measured as they are drawn. + * + * Where `v_virtual_list` places rows by the sizes the script states, `list` + * asks nothing about size. `render(index, cx)` is called for each item that + * is on screen, from inside layout as a virtual list's renderer is, and the + * element it returns is measured; the list keeps those measurements and + * estimates the rest, so a collection of panels that size to their own + * content scrolls as one and costs the script only what is visible. The + * rules of a virtual list's renderer apply unchanged: no handlers and no + * retained state inside it, and `cx.notify()` is refused there. + * + * The list scrolls itself and paints no scrollbar; pair one with it by name, + * as with a scroll area: + * + * ```js + * v_flex().relative().flex_1().min_h(0) + * .child(list("panels", this.panels.length, + * (index) => this.panels[index].id, + * (index) => this.panel(this.panels[index]))) + * .child(Scrollbar.vertical("panels").absolute().inset_0()); + * ``` + * + * The measuring is what it costs: the host is entered once per visible item + * per frame, where `v_virtual_list` and `uniform_list` are entered once per + * frame however many rows are on screen. Reach for this when heights are + * genuinely unequal and unknown — a column of panels, a feed of mixed + * cards — and for a long run of same-height rows reach for one of the + * others. + * + * One consequence of the per-item call: `get_key`'s uniqueness is checked + * within a call, so a `list` cannot see that two items share a key, where + * the other two throw. A duplicate key there quietly gives both items one + * identity, and `on_item_click` reports it for either. + * + * @param id Identity, and the name a `Scrollbar` pairs with. + * @param item_count How many items the collection has, visible or not. + * @param get_key An item's stable domain key, from its current index; the + * row's element identity and what `on_item_click` reports. + * @param render Called with one index; returns that item's element. + */ + export function list( + id: string | number, + item_count: number, + get_key: (index: number) => string, + render: (index: number, cx: Context) => Element, + ): Element; + + /** + * GPUI's own uniform list: one row is measured and every row takes its + * height. + * + * The same contract as `v_virtual_list` with a single size, without the + * size: the first row (or the one `with_item_to_measure_index` names) is + * measured and the rest are placed by it, so a row's height may come from + * its content rather than a number in the script. `render(range, cx)` is + * called with the visible interval and returns one element per item in it, + * so one frame is one call however many rows are on screen — the same + * bargain `v_virtual_list` makes, and the reason to prefer this over `list` + * whenever the rows really are the same height. + */ + export function uniform_list( + id: string | number, + item_count: number, + get_key: (index: number) => string, + render: (range: ItemRange, cx: Context) => Element[], + ): Element; + /** Immutable native GPUI geometry produced by `PathBuilder.build()`. */ export interface Path {} export interface PathBuilder { @@ -2789,10 +2863,7 @@ const BASE: &str = r#" /** A row. */ }; /** The visible items, as a half-open `[start, end)` interval. */ - export interface ItemRange { - start: number; - end: number; - } + export type ItemRange = import("gpui-kit").ItemRange; /** * A list that describes only what is on screen. diff --git a/website/shell/api.md b/website/shell/api.md index 064dc8ff1b..09c47b549d 100644 --- a/website/shell/api.md +++ b/website/shell/api.md @@ -36,17 +36,21 @@ API shape follows the Rust original: a method on `App` is a method on `cx`, a me ### Elements -| Name | What it is | -| ------------- | ---------------------------------------------------------------------------------------------------- | -| `Element` | A render-pass-owned description built by chaining methods | -| `div()` | An element with no layout of its own | -| `svg(path)` | A vector image from the application root, tinted by the surrounding text color | -| `image(path)` | A full-color image from the application root, colors preserved | -| `PathBuilder` | The GPUI path-builder type and its factory: `fill()` and `stroke(width)` each return a `PathBuilder` | -| `Background` | `solid`, `stop`, `linear_gradient`, `pattern_slash`, `checkerboard` | +| Name | What it is | +| ----------------- | ---------------------------------------------------------------------------------------------------- | +| `Element` | A render-pass-owned description built by chaining methods | +| `div()` | An element with no layout of its own | +| `svg(path)` | A vector image from the application root, tinted by the surrounding text color | +| `image(path)` | A full-color image from the application root, colors preserved | +| `list(…)` | GPUI's lazy list: rows of any height, measured as they are drawn | +| `uniform_list(…)` | GPUI's uniform list: one row measured, every row placed by it | +| `PathBuilder` | The GPUI path-builder type and its factory: `fill()` and `stroke(width)` each return a `PathBuilder` | +| `Background` | `solid`, `stop`, `linear_gradient`, `pattern_slash`, `checkerboard` | `PathBuilder.fill()` and `.stroke(width)` return a handle that chains `move_to`, `line_to`, `curve_to`, `cubic_bezier_to`, `arc_to`, `add_polygon`, `close` and `dash_array`, and ends in `build()`. Paint the result with `window.paint_path(path, background)` — the one element constructor reached through an object, because the thing it mirrors is a method on the window. +`list` and `uniform_list` are GPUI's own lazy lists and take `(id, item_count, get_key, render)`, the shape of `gpui-base`'s `v_virtual_list` without its `item_sizes`: no sizes, because GPUI measures the items itself. `uniform_list` measures one row and places every row by it, so `render(range, cx)` returns one element per item in the range as a virtual list's does. `list` measures each item it draws and keeps the sizes, so `render(index, cx)` returns one element for one item, and rows — or panels — of unequal height need not say how tall they are. Both draw only what is on screen plus a short band past the fold, scroll themselves, and pair with a `Scrollbar` by name; neither takes a `VirtualListScrollHandle`. + A string is an element too, exactly as `&str` implements `IntoElement` in GPUI: `.child("hello")` is how text is written, and the style comes from the element holding it. ### Views diff --git a/website/zh-CN/shell/api.md b/website/zh-CN/shell/api.md index b80e62bf77..6115de61f4 100644 --- a/website/zh-CN/shell/api.md +++ b/website/zh-CN/shell/api.md @@ -35,17 +35,21 @@ API 形态跟随 Rust 原型:`App` 上的方法放在 `cx`,`Window` 上的 ### 元素 -| 名称 | 说明 | -| ------------- | ----------------------------------------------------------------------------------- | -| `Element` | 通过链式方法构建、只属于当前 render pass 的描述 | -| `div()` | 自身不带布局的元素 | -| `svg(path)` | 来自应用根目录的矢量图,按周围的文字颜色着色 | -| `image(path)` | 来自应用根目录的全彩图片,保留原色 | -| `PathBuilder` | GPUI 的路径构建器类型及其工厂;`fill()` 与 `stroke(width)` 都返回一个 `PathBuilder` | -| `Background` | `solid`、`stop`、`linear_gradient`、`pattern_slash`、`checkerboard` | +| 名称 | 说明 | +| ----------------- | ----------------------------------------------------------------------------------- | +| `Element` | 通过链式方法构建、只属于当前 render pass 的描述 | +| `div()` | 自身不带布局的元素 | +| `svg(path)` | 来自应用根目录的矢量图,按周围的文字颜色着色 | +| `image(path)` | 来自应用根目录的全彩图片,保留原色 | +| `list(…)` | GPUI 的惰性列表:行高任意,边画边测量 | +| `uniform_list(…)` | GPUI 的等高列表:测量一行,其余按它排布 | +| `PathBuilder` | GPUI 的路径构建器类型及其工厂;`fill()` 与 `stroke(width)` 都返回一个 `PathBuilder` | +| `Background` | `solid`、`stop`、`linear_gradient`、`pattern_slash`、`checkerboard` | `PathBuilder.fill()` 与 `.stroke(width)` 返回一个句柄,可链式调用 `move_to`、`line_to`、`curve_to`、`cubic_bezier_to`、`arc_to`、`add_polygon`、`close` 与 `dash_array`,最后以 `build()` 收尾。用 `window.paint_path(path, background)` 把结果画出来——它是唯一一个通过对象取到的元素构造器,因为它镜像的东西在 Rust 侧就是窗口上的一个方法。 +`list` 和 `uniform_list` 是 GPUI 自己的惰性列表,参数是 `(id, item_count, get_key, render)`,即 `gpui-base` 的 `v_virtual_list` 去掉 `item_sizes` 的形状:没有尺寸表,因为 GPUI 自己测量各项。`uniform_list` 测量一行,其余各行按它排布,`render(range, cx)` 和虚拟列表一样按区间返回元素数组。`list` 会测量画出的每一项并记住尺寸,`render(index, cx)` 为一项返回一个元素,所以高度不等的行或面板不必事先说明有多高。两者都只绘制屏幕内的内容外加折叠线下方的一小段,自己处理滚动,并按名字与 `Scrollbar` 配对;都不接受 `VirtualListScrollHandle`。 + 字符串本身也是元素,和 GPUI 里 `&str` 实现 `IntoElement` 完全一样:`.child("hello")` 就是写文本的方式,样式来自持有它的那个元素。 ### View