From 85644b209a9200fa5f2c538f0e34b548b33ec260 Mon Sep 17 00:00:00 2001 From: Sebastian Benjamin Date: Mon, 3 Aug 2026 17:19:02 -0700 Subject: [PATCH] Pin the scrollbar layer to the viewport instead of the content gpui applies the scroll offset to every child of a scrolled element, and the scrollbar layer was a child of the scrolling container itself (what calling gpui-component's .scrollbar() on the scrolling element builds) - so the whole scrollbar rode away with the content and the thumb tracked nothing. Scroll containers now mirror gpui-component's own Scrollable wrapper: an outer non-scrolling wrapper owns the box styles (size, flex, background, border) and the scrollbar layer, while an inner element scrolls the content and takes the content-shaping styles (direction, padding, gap, alignment). Rendering at rest is pixel-identical; scrolled, the thumb now sits where the scroll offset says it should. Verified interactively: wheel-scrolled a settings screen and watched the thumb hold its proportional position. Co-Authored-By: Claude Fable 5 --- README.md | 84 ++++++----------------------- crates/slag-gpui/src/materialize.rs | 84 +++++++++++++++++++++-------- 2 files changed, 79 insertions(+), 89 deletions(-) diff --git a/README.md b/README.md index 8fc0287..482b1d9 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,10 @@ # slag -A Scheme + gpui UI component framework. Screens are [Steel](https://github.com/mattwparas/steel) -(`.scm`) files that build a plain-data element tree; slag decodes that tree -once per frame and materialises it into [gpui](https://github.com/zed-industries/zed) -/ [gpui-component](https://github.com/longbridge/gpui-component) elements. -Edit a file, watch the screen change — hot reload is mtime-based and takes -~10 ms, with no engine rebuild. - -Smelted out of [microclimate](https://clone.gloom.garden/puregarlic/microclimate), -which remains its dogfooding consumer. +A [Steel](https://github.com/mattwparas/steel) (Scheme) component framework +for [gpui](https://github.com/zed-industries/zed) + +[gpui-component](https://github.com/longbridge/gpui-component). Screens are +`.scm` files that build a plain-data element tree; slag decodes it once per +frame into real gpui elements. Saving a file hot-reloads in ~10 ms. ```scheme (require "slag/ui.scm") @@ -26,65 +22,17 @@ which remains its dogfooding consumer. #:on-click (lambda () (state-set! 'count (+ 1 (state-ref 'count))))))) ``` -See [`examples/hello`](examples/hello) for the complete consumer this comes -from — host module, runtime, config, component, shell — in ~60 lines of Rust. - -## Crates - -- **`slag-core`** — gpui-free: the element model, the Steel runtime (one shared - sandboxed engine; components are isolated by module prefix), the - per-component host-context registry, closure-based effects for tokio→UI - results, pack loading, and fixtures. Tests run headless. -- **`slag-gpui`** — the gpui layer: the component entity (render loop, handler - table, persistent input/slider/select state, hot reload, key capture, error - panel), the materializer, the string-keyed screen `Shell`, and the pack - watcher. - -## The shape of an app - -1. Build a host module: `BuiltInModule::new("/host")`, call - `slag_core::host::register_builtins`, then register your own functions. - Host functions capture nothing (Steel requires `Send + Sync`); they reach - their component through `with_ctx` / `with_app::`. -2. Build a `ScriptRuntime` from a `PackLoader` (on-disk pack directory with a - bundled `include_str!` fallback you supply) and the host module. -3. Build a `UiConfig` (env-var prefix, icon resolver). -4. Create a `SchemeComponent` per screen and a `Shell` over them — screen - names are the routes; scripts navigate with `(navigate! 'name)`. -5. Async results come back as `Send` closures over the component's `Effects` - channel and run on the UI thread: `fx.send(move |ui| ui.set("key", value))`. - External event buses become plain tokio watcher tasks that exit when the - component is gone. - -## Consumer obligations - -Three things a library crate cannot express; every consumer's workspace must -carry them (all demonstrated by this repo's own workspace files): - -1. **Unpinned zed git deps.** Declare `gpui` (and friends) with - `git = "https://github.com/zed-industries/zed"` and *no* branch/rev, so - cargo unifies them with gpui-component's own zed dependency into a single - rev in your lockfile. Diverging means two `gpui::View` types in one build. - Pin by lockfile (`cargo update -p gpui --precise `), and keep your rev - matched to slag's (see this repo's `Cargo.lock`). -2. **The `gpui_windows` patch (Windows).** Copy the - `[patch."https://github.com/zed-industries/zed"]` stanza pointing at a copy - of [`vendor/gpui_windows`](vendor/gpui_windows) — it carries a - DirectComposition first-present fix without which windows intermittently - launch transparent. `[patch]` only applies from the consuming workspace - root. Re-vendor when bumping the zed rev. -3. **Stack size (Windows).** `.cargo/config.toml` must set - `-C link-arg=/STACK:16777216`: the Steel compiler recurses on the Rust - stack in step with script nesting (~70 levels per MB in debug), and gpui - renders on the main thread. +[`examples/hello`](examples/hello) is the full wiring — host module, runtime, +config, component, shell — in ~60 lines of Rust. -## Environment variables +- **`slag-core`** — gpui-free: element model, sandboxed Steel runtime, host + registry, effects, packs, fixtures. Tests run headless. +- **`slag-gpui`** — the gpui layer: component entity, materializer, + string-keyed screen shell, hot reload. -With `UiConfig { env_prefix: "SLAG", .. }` (use your own prefix): +Build notes for consumers (this workspace demonstrates all three): -- `SLAG_PACK_DIR` — explicit pack directory (otherwise `packs/default` is - discovered under the working directory or the executable's ancestors; - bundled sources are the fallback). -- `SLAG_UI_FIXTURE=` — seed canned component state from a fixture - `.scm` (defines `(seed)`), skipping `init` and live data. Built for - screenshot harnesses. +- Declare `gpui` deps as unpinned zed git URLs so cargo unifies them with + gpui-component's, and pin the rev in your lockfile (match this repo's). +- On Windows, carry the `[patch]` for [`vendor/gpui_windows`](vendor/gpui_windows) + and `-C link-arg=/STACK:16777216` in `.cargo/config.toml`. diff --git a/crates/slag-gpui/src/materialize.rs b/crates/slag-gpui/src/materialize.rs index 445ffca..8d53ede 100644 --- a/crates/slag-gpui/src/materialize.rs +++ b/crates/slag-gpui/src/materialize.rs @@ -524,10 +524,10 @@ fn build_container( Dir::Row => h_flex(), Dir::Col => v_flex(), }; - let styled = apply_hover(apply_style(base, &el.style, theme), &el.style, theme); - if let Some(key) = &el.style.scroll_key { - return build_scroll_container(styled, key, kids, el.on_click, ctx); + if el.style.scroll_key.is_some() { + return build_scroll_container(el, dir, kids, theme, ctx); } + let styled = apply_hover(apply_style(base, &el.style, theme), &el.style, theme); let styled = styled.children(kids); // A tooltip needs a stateful element (hover tracking is per-id); the text // doubles as the id, which is unique enough for the labels we hang them on. @@ -583,11 +583,19 @@ fn build_container( styled.into_any_element() } -/// Turn a styled container into a keyed vertical scroll region. The -/// `ScrollHandle` persists per key across frames (scroll position survives -/// re-render); the scrollbar layer comes from gpui-component. Deliberately -/// avoids `Scrollable::new`, whose `ElementId::CodeLocation` ids alias when -/// every container materialises from this one call site. +/// Turn a container into a keyed vertical scroll region. The `ScrollHandle` +/// persists per key across frames (scroll position survives re-render); the +/// scrollbar layer comes from gpui-component. Deliberately avoids +/// `Scrollable::new`, whose `ElementId::CodeLocation` ids alias when every +/// container materialises from this one call site. +/// +/// Structure matters here: gpui applies the scroll offset to *every* child of +/// the scrolled element, so a scrollbar layer nested inside it (what calling +/// `.scrollbar()` on the scrolling element builds) rides away with the content +/// — the thumb tracked nothing. Mirroring gpui-component's `Scrollable` +/// wrapper instead: an outer non-scrolling wrapper owns the box (size, flex, +/// background, border) and the scrollbar layer; an inner element scrolls the +/// content and owns the content layout (direction, padding, gap, alignment). /// /// Unlike the Rhai version the map is borrowed read-only, so the component is /// responsible for interning the handle before render (an `ensure_scrolls` @@ -595,27 +603,61 @@ fn build_container( /// won't retain scroll position across frames. #[inline(never)] fn build_scroll_container( - base: gpui::Div, - key: &str, + el: &Element, + dir: Dir, kids: Vec, - on_click: Option, + theme: &Theme, ctx: &MaterializeCtx, ) -> AnyElement { - let handle = ctx.scrolls.get(key).cloned().unwrap_or_default(); - let el = base - .id(SharedString::from(key.to_string())) + let key = el.style.scroll_key.clone().unwrap_or_default(); + let handle = ctx.scrolls.get(&key).cloned().unwrap_or_default(); + + // Split the declared style: the outer wrapper keeps the box, the inner + // scroll area takes everything that shapes the content. + let mut outer_style = el.style.clone(); + let mut inner_style = Style::default(); + inner_style.gap = outer_style.gap.take(); + inner_style.p = outer_style.p.take(); + inner_style.px = outer_style.px.take(); + inner_style.py = outer_style.py.take(); + inner_style.pt = outer_style.pt.take(); + inner_style.pb = outer_style.pb.take(); + inner_style.pl = outer_style.pl.take(); + inner_style.pr = outer_style.pr.take(); + inner_style.items_center = std::mem::take(&mut outer_style.items_center); + inner_style.items_start = std::mem::take(&mut outer_style.items_start); + inner_style.items_end = std::mem::take(&mut outer_style.items_end); + inner_style.justify_center = std::mem::take(&mut outer_style.justify_center); + inner_style.justify_between = std::mem::take(&mut outer_style.justify_between); + inner_style.justify_end = std::mem::take(&mut outer_style.justify_end); + inner_style.justify_start = std::mem::take(&mut outer_style.justify_start); + inner_style.flex_wrap = std::mem::take(&mut outer_style.flex_wrap); + + let inner_base = match dir { + Dir::Plain => div(), + Dir::Row => h_flex(), + Dir::Col => v_flex(), + }; + let inner = apply_style(inner_base, &inner_style, theme) + .id(SharedString::from(key)) + .size_full() .overflow_y_scroll() .track_scroll(&handle) - .children(kids) + .children(kids); + + let outer = apply_style(div(), &outer_style, theme) + .relative() + .child(inner) .scrollbar(&handle, ScrollbarAxis::Vertical); - if let Some(idx) = on_click { + if let Some(idx) = el.on_click { let weak = ctx.component.clone(); - el.on_mouse_down(gpui::MouseButton::Left, move |_, window, cx| { - run_handler(&weak, idx, window, cx); - }) - .into_any_element() + outer + .on_mouse_down(gpui::MouseButton::Left, move |_, window, cx| { + run_handler(&weak, idx, window, cx); + }) + .into_any_element() } else { - el.into_any_element() + outer.into_any_element() } } -- 2.51.2