diff --git a/crates/shell/src/engine/quickjs/mod.rs b/crates/shell/src/engine/quickjs/mod.rs index 1d772a6af..2fe9341d8 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 65189089f..791d1d8b4 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 ed8f13795..8005fddde 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 000000000..29ea4582f --- /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 f674db9cc..02c2213d5 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 bf3490d38..0d656bb9e 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 a51514ae0..eb2ddd63c 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 51849be6a..3e1425b1b 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 25d8a9659..653c6b9c7 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 064dc8ff1..09c47b549 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 b80e62bf7..6115de61f 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