char-slop/berry
multipurpose self-modifiable agentic harness
git clone https://git.t4t.associates/char-slop/berry
ba4e2f4
main
1import type { Scope } from "./ui.ts" ; 2 3const BOTTOM_MARGIN = 64 ; 4 5/** What a scrolling pane offers the content that draws itself on demand as the reader reaches it. */ 6export type Viewport = { 7readonly clientHeight :number ; 8scrollTop :number ; 9}; 10 11/** Scrolls to follow the newest output, unless the reader has scrolled up. */ 12export function scroller ( 13scope :Scope , 14content :Element , 15last :() => Element | null = () => content . lastElementChild , 16/** Draws more of `content` as the reader reaches what is drawn, and asks for nothing once it is all there. */ 17grow :( pane :Viewport ) => void = () => {}, 18) :HTMLDivElement { 19const jump = ( 20< div class = "jump-to-bottom" hidden > 21< button 22type = "button" 23aria-label = "Jump to the newest output" 24title = "Jump to the newest output" 25// keep the keyboard up 26_onmousedown = {( event ) => event . preventDefault ()} 27_onclick = {() => { 28pinned = true ; 29snap (); 30jump . hidden = true ; 31}} 32/> 33< / div> 34) as HTMLDivElement ; 35const element = < div class = "scroller" >{ content }{ jump }< / div > as HTMLDivElement; 36// whether the reader follows the bottom; layout changes never decide this, only the reader's own scrolling does 37let pinned = true; 38// a gesture whose scroll event has not arrived yet 39let scrolling = false; 40let gestureFrame = 0; 41// the layout the scroll position was last settled against; a different one means a scroll event is layout's, not the reader's 42let width = element.clientWidth; 43let viewport = element.clientHeight; 44let height = element.scrollHeight; 45let top = element.scrollTop; 46const distance = () = > element . scrollHeight - element . clientHeight - element . scrollTop ; 47const snap = () => { 48// a hidden pane has no place to keep 49if ( element . clientHeight === 0 ) return ; 50element . scrollTop = element . scrollHeight ; 51top = element . scrollTop ; 52settle (); 53}; 54const settle = () => { 55width = element . clientWidth ; 56viewport = element . clientHeight ; 57height = element . scrollHeight ; 58}; 59const onScroll = () => { 60const laidOut = element . clientWidth !== width || element . clientHeight !== viewport || 61element . scrollHeight !== height ; 62if ( scrolling || ! laidOut ) { 63pinned = distance () <= BOTTOM_MARGIN ; 64settle (); 65} 66scrolling = false ; 67top = element . scrollTop ; 68jump . hidden = pinned ; 69grow ( element ); 70}; 71const onGesture = () => { 72scrolling = true ; 73cancelAnimationFrame ( gestureFrame ); 74gestureFrame = requestAnimationFrame (() => scrolling = false ); 75}; 76const onKeyDown = ( event : KeyboardEvent ) => { 77if ( 78[ "ArrowUp" , "ArrowDown" , "PageUp" , "PageDown" , "Home" , "End" , " " ]. includes ( event . key ) 79) onGesture (); 80}; 81element . addEventListener ( "scroll" , onScroll ); 82element . addEventListener ( "wheel" , onGesture , { passive : true }); 83element . addEventListener ( "pointerdown" , onGesture ); 84element . addEventListener ( "keydown" , onKeyDown ); 85let bottom = last (); 86let loaded = false ; 87const initial = () => { 88if ( 89loaded || element . clientHeight === 0 || 90element . scrollHeight <= element . clientHeight || last () === null 91) return false ; 92loaded = true ; 93return true ; 94}; 95const mutations = new MutationObserver (( records ) => { 96const next = last (); 97const changed = next !== null && 98( next !== bottom || records . some (({ target }) => next . contains ( target ))); 99bottom = next ; 100// the reader may have moved before their scroll event arrives 101const moved = scrolling && element . scrollTop !== top ; 102if ( initial ()) snap (); 103else if ( changed && pinned && ! moved ) snap (); 104else if ( moved || ! pinned ) settle (); 105}); 106mutations . observe ( content , { 107subtree : true , 108childList : true , 109characterData : true , 110attributes : true , 111}); 112const resizes = new ResizeObserver (() => { 113settle (); 114if ( element . clientHeight === 0 ) return ; 115if ( initial () || pinned ) snap (); 116jump . hidden = pinned ; 117// after the initial scroll, which is to the newest entries, so that a transcript that has just opened is not taken 118// for one the reader has gone above 119grow ( element ); 120}); 121resizes . observe ( element ); 122resizes . observe ( content ); 123scope . add (() => { 124mutations . disconnect (); 125resizes . disconnect (); 126cancelAnimationFrame ( gestureFrame ); 127element . removeEventListener ( "scroll" , onScroll ); 128element . removeEventListener ( "wheel" , onGesture ); 129element . removeEventListener ( "pointerdown" , onGesture ); 130element . removeEventListener ( "keydown" , onKeyDown ); 131}); 132return element ; 133}