From 898bc412e564d258feacbd65cd50a9c5203122cf Mon Sep 17 00:00:00 2001 From: "liminal.rip" Date: Tue, 25 Aug 2026 22:34:45 -0700 Subject: [PATCH] Stick-to-bottom scroll regions and scroll-to-bottom! MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A #:scroll-y container can declare #:stick-bottom #t to follow its bottom edge while content grows (chat logs). The decision happens in ensure_scrolls, before the new layout, so the handle still holds last frame's offsets: a container within ~4px of its bottom — or never laid out, when both offset and max are zero — is re-pinned via gpui's scroll_to_bottom flag, which the element consumes in its next prepaint, after this render's layout, where the new bottom is known. A reader who scrolled up fails the check and stays put until they return to the bottom. The new scroll-to-bottom! builtin (jump-to-latest buttons, snapping a log down after a route change) rides the widget command queue as a one-shot Command::ScrollToBottom; its drain interns the handle rather than looking it up — handles are just shared offsets, no Window needed — so a command aimed at a container that first appears on the very next render still lands. Co-Authored-By: Claude Fable 5 --- crates/slag-core/src/decode.rs | 1 + crates/slag-core/src/element.rs | 6 ++++ crates/slag-core/src/host.rs | 14 +++++++++ crates/slag-core/src/prelude/ui.scm | 8 +++++ crates/slag-core/src/runtime.rs | 39 ++++++++++++++++++++++++- crates/slag-gpui/src/component.rs | 45 +++++++++++++++++++++++++---- 6 files changed, 107 insertions(+), 6 deletions(-) diff --git a/crates/slag-core/src/decode.rs b/crates/slag-core/src/decode.rs index 0845976..8b95775 100644 --- a/crates/slag-core/src/decode.rs +++ b/crates/slag-core/src/decode.rs @@ -316,6 +316,7 @@ fn apply_layout(style: &mut Style, props: &SteelVal) { style.left = Some(0.0); } style.scroll_key = prop_string(props, "#:scroll-y"); + style.stick_bottom = prop_bool(props, "#:stick-bottom"); style.swallow_clicks = prop_bool(props, "#:swallow-clicks"); } diff --git a/crates/slag-core/src/element.rs b/crates/slag-core/src/element.rs index f2c3f82..f803289 100644 --- a/crates/slag-core/src/element.rs +++ b/crates/slag-core/src/element.rs @@ -149,6 +149,12 @@ pub struct Style { /// Makes a container a keyed vertical scroll region (persistent /// `ScrollHandle` per key, scrollbar rendered by gpui-component). pub scroll_key: Option, + /// With `scroll_key`: follow the bottom edge while content grows (chat + /// logs). Only a container that is at — or was never scrolled away from — + /// its bottom gets re-pinned as the layout changes; a reader who scrolled + /// up stays put until they come back down. Meaningless without + /// `scroll_key`. + pub stick_bottom: bool, /// Stop mouse-down here instead of letting it reach anything painted /// underneath. What a popover panel needs so that a full-window dismiss /// scrim below it does not also fire on clicks aimed at the popover. diff --git a/crates/slag-core/src/host.rs b/crates/slag-core/src/host.rs index db005d4..60d5a34 100644 --- a/crates/slag-core/src/host.rs +++ b/crates/slag-core/src/host.rs @@ -256,6 +256,9 @@ pub enum Command { InputSet { key: String, value: String }, /// `(input-clear! key)` — empty the input and sync `input-`. InputClear { key: String }, + /// `(scroll-to-bottom! key)` — pin the `#:scroll-y` container to its + /// bottom edge on the next frame. + ScrollToBottom { key: String }, } /// Append a command to the current component's queue. Commands are stored as @@ -303,6 +306,7 @@ fn parse_cmd(value: &SteelVal) -> Option { value: parts.get(2).and_then(key_of).unwrap_or_default(), }), "input-clear" => Some(Command::InputClear { key }), + "scroll-to-bottom" => Some(Command::ScrollToBottom { key }), _ => None, } } @@ -376,6 +380,16 @@ pub fn register_builtins(module: &mut BuiltInModule) { push_cmd("input-clear", &key, None); }); + // Pin a #:scroll-y container to its bottom edge (a jump-to-latest button, + // or snapping a chat log down after a route change). One-shot; a container + // that should FOLLOW the bottom as content grows declares #:stick-bottom + // instead. The pin lands on the next frame, after the new layout, where + // the real bottom is known. + module.register_fn("scroll-to-bottom!", |key: SteelVal| { + let Some(key) = key_of(&key) else { return }; + push_cmd("scroll-to-bottom", &key, None); + }); + // --- window -------------------------------------------------------------- // Requests window fullscreen (#t) or windowed (#f). One-shot: the component diff --git a/crates/slag-core/src/prelude/ui.scm b/crates/slag-core/src/prelude/ui.scm index c816ecd..16d19c5 100644 --- a/crates/slag-core/src/prelude/ui.scm +++ b/crates/slag-core/src/prelude/ui.scm @@ -120,6 +120,14 @@ (list 'el kind (car ph) (cdr pc) (cdr ph)))) ;; ---- containers ------------------------------------------------------------- +;; All containers take the flat style props. Scroll regions: +;; #:scroll-y "key" a keyed vertical scroll region whose position +;; survives re-renders; pin it to the bottom from a +;; handler with the `scroll-to-bottom!` host builtin +;; #:stick-bottom #t with #:scroll-y: follow the bottom edge while +;; content grows (chat logs) — unless the reader has +;; scrolled up, in which case they stay put until +;; they return to the bottom (define (div . args) (make-el 'div args)) (define (h-flex . args) (make-el 'h-flex args)) diff --git a/crates/slag-core/src/runtime.rs b/crates/slag-core/src/runtime.rs index 8b3e398..aec990b 100644 --- a/crates/slag-core/src/runtime.rs +++ b/crates/slag-core/src/runtime.rs @@ -324,6 +324,41 @@ mod tests { ); } + /// `#:stick-bottom` must survive the keyword plist -> decode trip next to + /// its `#:scroll-y`: a silent miss turns a chat log back into a scroll + /// region that strands the reader at the top as messages arrive. + #[test] + fn container_stick_bottom_decodes() { + let rt = runtime(bundled_only()); + let out = rt + .run(&format!( + r#"(require "{UI_MODULE}") + (list (v-flex #:scroll-y "log" #:stick-bottom #t) + (v-flex #:scroll-y "log") + (v-flex))"# + )) + .expect("container build failed"); + let SteelVal::ListV(containers) = out.last().expect("no output") else { + panic!("expected a list of containers"); + }; + let decoded: Vec<(Option, bool)> = containers + .iter() + .map(|v| { + let style = crate::decode::decode(v).root.style; + (style.scroll_key, style.stick_bottom) + }) + .collect(); + assert_eq!( + decoded, + vec![ + (Some("log".into()), true), + (Some("log".into()), false), + (None, false), + ], + "stick-bottom must decode alongside scroll-y; absent props stay off" + ); + } + /// The widget-command builtins queue onto the reserved state slot in issue /// order, and one drain both parses and empties the queue — that's what /// makes applying commands idempotent across the component's drain points. @@ -342,7 +377,8 @@ mod tests { r#"(require-builtin test/host) (input-set! 'msg "hello") (input-set! "msg" 42) - (input-clear! 'msg)"#, + (input-clear! 'msg) + (scroll-to-bottom! 'log)"#, ) }) .expect("command builtins failed"); @@ -359,6 +395,7 @@ mod tests { value: "42".into() }, Command::InputClear { key: "msg".into() }, + Command::ScrollToBottom { key: "log".into() }, ], "commands must come back parsed, in issue order" ); diff --git a/crates/slag-gpui/src/component.rs b/crates/slag-gpui/src/component.rs index 300beb0..7ecbae5 100644 --- a/crates/slag-gpui/src/component.rs +++ b/crates/slag-gpui/src/component.rs @@ -405,6 +405,7 @@ impl SchemeComponent { self.apply_input_set(&key, &value, window, cx) } host::Command::InputClear { key } => self.apply_input_set(&key, "", window, cx), + host::Command::ScrollToBottom { key } => self.apply_scroll_to_bottom(&key), } } } @@ -433,6 +434,21 @@ impl SchemeComponent { ); } + /// Pin a `#:scroll-y` container to its bottom edge. `scroll_to_bottom` + /// sets a flag gpui consumes in the element's next prepaint — after the + /// upcoming layout, when the real bottom is known — so the pin is deferred + /// one frame and lands seamlessly. The handle is interned rather than + /// looked up: handles are just shared offsets (no `Window` needed, unlike + /// the input entities), so a command aimed at a container that first + /// appears on the very next render — a route change's chat log — still + /// lands instead of dropping. + fn apply_scroll_to_bottom(&mut self, key: &str) { + self.scrolls + .entry(key.to_string()) + .or_default() + .scroll_to_bottom(); + } + // ---- persistent widget entities ---------------------------------------- /// Ensure an `InputState` exists for every `input` node in the tree. Change @@ -764,16 +780,35 @@ impl SchemeComponent { } /// Intern a `ScrollHandle` for every `#:scroll-y` container so scroll - /// positions survive re-renders. + /// positions survive re-renders, and keep `#:stick-bottom` containers + /// following their bottom edge. + /// + /// The stick decision reads the handle *before* this render's layout, so + /// offset and max offset are still last frame's: a container that was at + /// (within ~4px of) its bottom — or was never laid out, when both are + /// still zero — is re-pinned to the NEW bottom via gpui's + /// `scroll_to_bottom` flag, which the element consumes in its next + /// prepaint, after the layout this render produces. A reader who scrolled + /// up fails the check and stays put while content grows, until they come + /// back down. fn ensure_scrolls(&mut self, el: &Element) { - let mut found: Vec = Vec::new(); + let mut found: Vec<(String, bool)> = Vec::new(); walk_elements(el, &mut |e| { if let Some(key) = &e.style.scroll_key { - found.push(key.clone()); + found.push((key.clone(), e.style.stick_bottom)); } }); - for key in found { - self.scrolls.entry(key).or_default(); + for (key, stick_bottom) in found { + let handle = self.scrolls.entry(key).or_default(); + if stick_bottom { + // The offset grows negative as content scrolls down, so the + // distance left to the bottom is max + offset. The ~4px slack + // absorbs fractional-pixel layout drift without capturing a + // reader who deliberately scrolled up. + if handle.max_offset().y + handle.offset().y <= px(4.) { + handle.scroll_to_bottom(); + } + } } } -- knotmirror 0.0.1