From 48b806bef5e13302cda7573b9535e211995255dd Mon Sep 17 00:00:00 2001 From: tannevaled Date: Thu, 22 Oct 2026 09:32:04 +0200 Subject: [PATCH 1/2] feat: PageStrip, a virtualised strip of page thumbnails A vertical or horizontal strip of page cards with numbers, a current-page outline and a scrollbar: the slide panel of PowerPoint / Keynote / Google Slides, the thumbnail sidebar of pdf.js. Generic: a page is a bitmap handed in with SetImage or painted by the DrawPage callback; NeedImage asks the host once per page when it comes within a page of the window; Invalidate keeps the stale bitmap on screen until the replacement arrives, so an edited document re-renders only its changed pages; MaxResident bounds the bitmaps kept in memory (the farthest are dropped and asked for again). Layout is a prefix-sum over per-page aspects, the window a binary search: the cost of a Draw does not depend on the page count (measured 181 us at 10 pages, 155 us at 500, 142 us at 100000). Current() is an Observable (click, keys Arrow/Page/Home/End, or the host); the strip scrolls to keep it visible. The selected outline keeps >= 3:1 against the strip in light and dark. Co-Authored-By: Claude Opus 5.5 --- pagestrip.go | 629 ++++++++++++++++++++++++++++++++++++++++++++++ pagestrip_test.go | 431 +++++++++++++++++++++++++++++++ 2 files changed, 1060 insertions(+) create mode 100644 pagestrip.go create mode 100644 pagestrip_test.go diff --git a/pagestrip.go b/pagestrip.go new file mode 100644 index 0000000..be1249a --- /dev/null +++ b/pagestrip.go @@ -0,0 +1,629 @@ +// Copyright (c) 2026 the go-widgets/toolkit authors. All rights reserved. +// Use of this source code is governed by a BSD-3-Clause license that can be +// found in the LICENSE file at the root of this repository. + +package toolkit + +import ( + "sort" + "strconv" + + "github.com/go-widgets/mvvm" + "github.com/go-widgets/painter" +) + +// PageStrip is a strip of page thumbnails — the slide panel of PowerPoint, +// Keynote and Google Slides, the thumbnail sidebar of pdf.js and every PDF viewer +// — vertical (the default) or horizontal. It is generic: a page is whatever the +// host says, shown from a bitmap handed in with [PageStrip.SetImage] or painted by +// the [PageStrip.DrawPage] callback. +// +// The strip is virtualised the way pdf.js' thumbnail sidebar is: only the pages +// in (and one either side of) the window are laid out, requested and painted, so +// a 500-page document costs what a 10-page one does. A page that scrolls into +// view without an image is asked for ONCE through [PageStrip.NeedImage]; the host +// renders it (synchronously or later, on its own schedule) and answers with +// SetImage; pdf.js likewise keeps a per-view rendering state (initial / running / +// finished) and renders what is visible first. A bounded number of images stays +// resident ([PageStrip.MaxResident]); the ones farthest from the window are +// dropped and asked for again if they come back. [PageStrip.Invalidate] marks one +// page stale — the old bitmap stays on screen until its replacement arrives — so +// when a document is edited only the changed pages are re-rendered. +// +// The current page is the [PageStrip.Current] Observable (0-based, -1 for none): +// a click or a key sets it, a host sets it to follow its own view, and the strip +// scrolls to keep it visible either way. Pages are laid out at a common aspect +// (PageW:PageH, 16:9 by default) unless [PageStrip.SetAspects] says otherwise. +// A scrollbar in the toolkit's shared style shows while the pages overflow. +type PageStrip struct { + Base + focusState + + // Horizontal lays the pages out left to right instead of top to bottom. Set + // once, before the first Draw. + Horizontal bool + + // ThumbSize is the logical width of a thumbnail in a vertical strip (its + // height in a horizontal one); 0 means [PageStripThumbSize]. The card shrinks + // to fit a narrower strip. + ThumbSize int + + // PageW and PageH give the default page aspect (0 means 16:9). + PageW, PageH int + + // MaxResident bounds the bitmaps kept in memory; 0 means + // [PageStripMaxResident]. + MaxResident int + + // NeedImage is called once when page i first comes within a page of the window + // with no bitmap (and again after Invalidate). The host answers, now or later, + // with SetImage(i, …). Nil: the strip never asks. + NeedImage func(i int) + + // DrawPage, when set, paints page i into r — the card's inner rectangle, the + // painter clipped to it — for pages that have no bitmap. Nil: a placeholder + // with the page number. + DrawPage func(p painter.Painter, theme *Theme, r Rect, i int) + + count int + aspects []pageAspect // optional per-page aspects, len == count when set + imgs []*stripImage + asked []bool + nres int // resident bitmaps + + cur *mvvm.Observable[int] + reveal bool // scroll Current into view at the next Draw + scroll int // scroll offset along the strip, device pixels + drag scrollDrag + + // offs[i] is the start of page i along the strip's axis, offs[count] the end, + // for the cross size crossUsed; rebuilt when either changes. + offs []int + crossUsed int +} + +type pageAspect struct{ w, h int } + +type stripImage struct { + pix []byte + w, h int +} + +const ( + // PageStripThumbSize is the default logical thumbnail width — pdf.js' + // THUMBNAIL_WIDTH is 126, Keynote's navigator about 130. + PageStripThumbSize = 120 + // PageStripMaxResident is the default bound on resident bitmaps. + PageStripMaxResident = 96 + + stripGap = 10 // logical px between cards + stripPad = 8 // logical px around the strip's content + stripPrefetch = 1 // pages requested beyond each end of the window +) + +// NewPageStrip builds a strip of n pages with no current page. +func NewPageStrip(n int) *PageStrip { + s := &PageStrip{} + s.SetCount(n) + return s +} + +// Current is the selected page, 0-based, -1 for none, as a bindable Observable. +func (s *PageStrip) Current() *mvvm.Observable[int] { + if s.cur == nil { + s.cur = mvvm.NewObservable(-1) + s.cur.Subscribe(func(int) { s.reveal = true }) + } + return s.cur +} + +// Count is the number of pages. +func (s *PageStrip) Count() int { return s.count } + +// SetCount sets the number of pages, keeping the bitmaps of the pages that stay +// and clamping Current. Per-page aspects set by SetAspects are dropped when the +// count no longer matches. +func (s *PageStrip) SetCount(n int) { + if n < 0 { + n = 0 + } + if n < s.count { + for i := n; i < s.count; i++ { + if s.imgs[i] != nil { + s.nres-- + } + } + } + for len(s.imgs) < n { + s.imgs = append(s.imgs, nil) + s.asked = append(s.asked, false) + } + s.imgs, s.asked = s.imgs[:n], s.asked[:n] + if len(s.aspects) != n { + s.aspects = nil + } + s.count = n + s.offs = nil + if c := s.Current(); c.Get() >= n { + c.Set(n - 1) + } +} + +// SetAspects gives every page its own width and height (their ratio is what +// matters); its length must equal Count or it is ignored. +func (s *PageStrip) SetAspects(sizes [][2]int) { + if len(sizes) != s.count { + return + } + s.aspects = make([]pageAspect, len(sizes)) + for i, z := range sizes { + s.aspects[i] = pageAspect{z[0], z[1]} + } + s.offs = nil +} + +// SetImage supplies page i's bitmap: w*h RGBA pixels (len >= w*h*4), nearest- +// neighbour scaled to the card — render it at the card's pixel size for an exact +// picture. The bytes are used as is, not copied. Out-of-range i or a short +// buffer is ignored. It evicts the farthest resident bitmaps when over budget. +func (s *PageStrip) SetImage(i int, pix []byte, w, h int) { + if i < 0 || i >= s.count || w <= 0 || h <= 0 || len(pix) < w*h*4 { + return + } + if s.imgs[i] == nil { + s.nres++ + } + s.imgs[i] = &stripImage{pix: pix, w: w, h: h} + s.asked[i] = true + s.evict(i) +} + +// Invalidate marks page i stale: its bitmap (if any) stays shown until SetImage +// replaces it, and NeedImage is called again as soon as the page is in view. +func (s *PageStrip) Invalidate(i int) { + if i >= 0 && i < s.count { + s.asked[i] = false + } +} + +// InvalidateAll marks every page stale (a new theme, a new document). +func (s *PageStrip) InvalidateAll() { + for i := range s.asked { + s.asked[i] = false + } +} + +// Resident is the number of bitmaps currently held. +func (s *PageStrip) Resident() int { return s.nres } + +func (s *PageStrip) maxResident() int { + if s.MaxResident > 0 { + return s.MaxResident + } + return PageStripMaxResident +} + +// evict drops the resident bitmaps farthest from keep until under budget; a +// dropped page is asked for again when it returns to view. +func (s *PageStrip) evict(keep int) { + for s.nres > s.maxResident() { + far, dist := -1, -1 + for i, im := range s.imgs { + if im == nil || i == keep { + continue + } + d := i - keep + if d < 0 { + d = -d + } + if d > dist { + far, dist = i, d + } + } + if far < 0 { + return + } + s.imgs[far], s.asked[far] = nil, false + s.nres-- + } +} + +// --- layout ----------------------------------------------------------------- + +func (s *PageStrip) thumb() int { + t := s.ThumbSize + if t <= 0 { + t = PageStripThumbSize + } + return scaled(t) +} + +// numW is the room the page number takes beside (vertical) or under +// (horizontal) a card. +func (s *PageStrip) numW() int { + if s.Horizontal { + return s.glyphHeight() + scaled(6) + } + return s.textWidth(strconv.Itoa(s.count)) + scaled(8) +} + +// cross is the card's size across the strip: ThumbSize, shrunk to fit. +func (s *PageStrip) cross() int { + r := s.Bounds() + avail := r.W + if s.Horizontal { + avail = r.H + } + avail -= 2*scaled(stripPad) + scrollbarTrack() + s.numW() + c := s.thumb() + if avail < c { + c = avail + } + if c < 1 { + c = 1 + } + return c +} + +func (s *PageStrip) aspect(i int) (w, h int) { + if s.aspects != nil && s.aspects[i].w > 0 && s.aspects[i].h > 0 { + return s.aspects[i].w, s.aspects[i].h + } + w, h = s.PageW, s.PageH + if w <= 0 || h <= 0 { + return 16, 9 + } + return w, h +} + +// layout (re)builds the offsets for the current cross size. +func (s *PageStrip) layout() { + c := s.cross() + if s.offs != nil && s.crossUsed == c && len(s.offs) == s.count+1 { + return + } + s.crossUsed = c + s.offs = make([]int, s.count+1) + pos := scaled(stripPad) + gap := scaled(stripGap) + for i := 0; i < s.count; i++ { + s.offs[i] = pos + w, h := s.aspect(i) + main := c * h / w + if s.Horizontal { // the card is as tall as the cross size says: main is its width + main = c * w / h + } + if main < 1 { + main = 1 + } + pos += main + gap + } + s.offs[s.count] = pos // end of the last card plus the gap after it +} + +// contentLen is the strip's whole length: every card, the gaps between them and +// the padding at both ends. +func (s *PageStrip) contentLen() int { + s.layout() + if s.count == 0 { + return 0 + } + return s.offs[s.count] - scaled(stripGap) + scaled(stripPad) +} + +// cardMain is page i's size along the strip. +func (s *PageStrip) cardMain(i int) int { + return s.offs[i+1] - s.offs[i] - scaled(stripGap) +} + +func (s *PageStrip) viewLen() int { + r := s.Bounds() + if s.Horizontal { + return r.W + } + return r.H +} + +func (s *PageStrip) maxScroll() int { + m := s.contentLen() - s.viewLen() + if m < 0 { + m = 0 + } + return m +} + +func (s *PageStrip) clampedScroll() int { + sc := s.scroll + if sc < 0 { + sc = 0 + } + if m := s.maxScroll(); sc > m { + sc = m + } + return sc +} + +// window is the range [first, last] of pages intersecting the viewport, and +// ok false when there are none. O(log n). +func (s *PageStrip) window() (first, last int, ok bool) { + s.layout() + if s.count == 0 || s.viewLen() <= 0 { + return 0, 0, false + } + sc := s.clampedScroll() + first = sort.Search(s.count, func(i int) bool { return s.offs[i+1] > sc }) + last = sort.Search(s.count, func(i int) bool { return s.offs[i] >= sc+s.viewLen() }) - 1 + // The scroll is clamped, so the first page ends after it and starts before the + // window's end: first <= last < count always holds here. + return first, last, true +} + +// Visible returns the first and last page intersecting the window (0, -1 when +// none) — what a host can use to prioritise its own rendering. +func (s *PageStrip) Visible() (first, last int) { + f, l, ok := s.window() + if !ok { + return 0, -1 + } + return f, l +} + +// ScrollTo scrolls so that page i is fully in the window (as little as needed). +func (s *PageStrip) ScrollTo(i int) { + if i < 0 || i >= s.count { + return + } + s.layout() + start, end := s.offs[i]-scaled(stripPad), s.offs[i+1]-scaled(stripGap)+scaled(stripPad) + sc := s.clampedScroll() + if start < sc { + sc = start + } else if end > sc+s.viewLen() { + sc = end - s.viewLen() + } + s.scroll = sc + s.scroll = s.clampedScroll() +} + +// cardRect is page i's card, widget-local (the picture, not its number). +func (s *PageStrip) cardRect(i int) Rect { + s.layout() + sc := s.clampedScroll() + main := s.cardMain(i) + c := s.crossUsed + if s.Horizontal { + return Rect{X: s.offs[i] - sc, Y: scaled(stripPad), W: main, H: c} + } + return Rect{X: scaled(stripPad) + s.numW(), Y: s.offs[i] - sc, W: c, H: main} +} + +// PageAt returns the page under widget-local (x, y), -1 for none. +func (s *PageStrip) PageAt(x, y int) int { + first, last, ok := s.window() + if !ok { + return -1 + } + for i := first; i <= last; i++ { + r := s.cardRect(i) + if s.Horizontal { + if x >= r.X && x < r.X+r.W+scaled(stripGap)/2 && y >= 0 && y < s.Bounds().H { + return i + } + } else if y >= r.Y && y < r.Y+r.H+scaled(stripGap)/2 && x >= 0 && x < s.Bounds().W-scrollbarTrack() { + return i + } + } + return -1 +} + +// --- scrollbar -------------------------------------------------------------- + +func (s *PageStrip) barGeom() (sbGeom, bool) { + s.layout() + r := s.Bounds() + if s.maxScroll() == 0 { + return sbGeom{}, false + } + tr := scrollbarTrack() + if s.Horizontal { + return scrollGeom(true, r.H-tr, tr, 0, r.W, s.contentLen(), r.W, s.clampedScroll()), true + } + return scrollGeom(false, r.W-tr, tr, 0, r.H, s.contentLen(), r.H, s.clampedScroll()), true +} + +// --- draw ------------------------------------------------------------------- + +// Draw paints the visible cards (and their numbers), the current page outlined, +// and the scrollbar. Requests the images the window and its neighbours lack. +func (s *PageStrip) Draw(p painter.Painter, theme *Theme) { + r := s.Bounds() + if r.W <= 0 || r.H <= 0 { + return + } + fillRect(p, r.X, r.Y, r.W, r.H, theme.Surface) + if s.reveal { + s.reveal = false + s.ScrollTo(s.Current().Get()) + } + first, last, ok := s.window() + if ok { + s.request(first, last) + cur := s.Current().Get() + sel := ensureContrast(theme.Accent, theme.Surface, theme.OnSurface, 3) + withClip(p, r, func() { + for i := first; i <= last; i++ { + s.drawCard(p, theme, r, i, i == cur, sel) + } + }) + } + if g, shown := s.barGeom(); shown { + if s.Horizontal { + paintScrollTrack(p, theme, r.X+g.trackStart, r.Y+g.cross0, g.trackLen, g.crossW) + paintScrollThumb(p, theme, r.X+g.thumbStart, r.Y+g.cross0, g.thumbLen, g.crossW) + } else { + paintScrollTrack(p, theme, r.X+g.cross0, r.Y+g.trackStart, g.crossW, g.trackLen) + paintScrollThumb(p, theme, r.X+g.cross0, r.Y+g.thumbStart, g.crossW, g.thumbLen) + } + } + s.drawFocusRing(p, theme, r) +} + +// request asks the host, once, for each bitmap-less page in the window and one +// page beyond each end of it. +func (s *PageStrip) request(first, last int) { + if s.NeedImage == nil { + return + } + lo, hi := first-stripPrefetch, last+stripPrefetch + if lo < 0 { + lo = 0 + } + if hi >= s.count { + hi = s.count - 1 + } + for i := first; i <= last; i++ { // the window first, then the neighbours + s.ask(i) + } + for i := lo; i < first; i++ { + s.ask(i) + } + for i := last + 1; i <= hi; i++ { + s.ask(i) + } +} + +func (s *PageStrip) ask(i int) { + if !s.asked[i] { + s.asked[i] = true + s.NeedImage(i) + } +} + +func (s *PageStrip) drawCard(p painter.Painter, theme *Theme, r Rect, i int, selected bool, sel RGBA) { + cr := s.cardRect(i) + cr.X += r.X + cr.Y += r.Y + // number + num := strconv.Itoa(i + 1) + ink := dimInk(theme) + if selected { + ink = theme.OnSurface + } + if s.Horizontal { + s.drawText(p, cr.X+(cr.W-s.textWidth(num))/2, cr.Y+cr.H+scaled(3), num, ink) + } else { + s.drawText(p, r.X+scaled(stripPad), cr.Y+(cr.H-s.glyphHeight())/2, num, ink) + } + if im := s.imgs[i]; im != nil { + fillRect(p, cr.X, cr.Y, cr.W, cr.H, theme.Background) + blitImage(p, cr, cr, im.pix, im.w, im.h) + } else if s.DrawPage != nil { + fillRect(p, cr.X, cr.Y, cr.W, cr.H, theme.Background) + withClip(p, cr, func() { s.DrawPage(p, theme, cr, i) }) + } else { + fillRect(p, cr.X, cr.Y, cr.W, cr.H, theme.SurfaceAlt) + s.drawText(p, cr.X+(cr.W-s.textWidth(num))/2, cr.Y+(cr.H-s.glyphHeight())/2, num, dimInk(theme)) + } + if selected { + o := atLeast1(scaled(2)) + for k := 0; k < o; k++ { + strokeRect(p, cr.X-o+k, cr.Y-o+k, cr.W+2*(o-k), cr.H+2*(o-k), sel) + } + } else { + strokeRect(p, cr.X, cr.Y, cr.W, cr.H, theme.Border) + } +} + +// --- events ----------------------------------------------------------------- + +// OnEvent: a press on the scrollbar grabs or pages it, a click on a card selects +// that page, the wheel scrolls, and (focused) the arrow, Page, Home and End keys +// move the selection. +func (s *PageStrip) OnEvent(ev Event) { + if s.Disabled().Get() { + return + } + switch ev.Kind { + case EventClick: + g, ok := s.barGeom() + if s.drag.press(g, ok, ev, s.viewLen(), s.scrollBy) { + return + } + if i := s.PageAt(ev.X, ev.Y); i >= 0 { + s.Current().Set(i) + } + case EventMouseDrag: + g, ok := s.barGeom() + s.drag.drag(g, ok, ev, func(v int) { s.scroll = v }) + case EventMouseUp: + s.drag.release() + case EventScroll: + d := ev.Delta + if s.Horizontal && d == 0 { + d = ev.DeltaX + } + s.scrollBy(d * s.step()) + case EventKeyDown: + if s.focused { + s.onKey(ev.Code) + } + } +} + +// step is one wheel row: a third of a card. +func (s *PageStrip) step() int { + s.layout() + if s.count == 0 { + return 1 + } + return atLeast1((s.cardMain(0) + scaled(stripGap)) / 3) +} + +func (s *PageStrip) scrollBy(d int) { + s.scroll = s.clampedScroll() + d + s.scroll = s.clampedScroll() +} + +func (s *PageStrip) onKey(code string) { + cur, n := s.Current().Get(), s.count + if n == 0 { + return + } + first, last, _ := s.window() + page := last - first + if page < 1 { + page = 1 + } + switch code { + case "ArrowDown", "ArrowRight": + cur++ + case "ArrowUp", "ArrowLeft": + cur-- + case "PageDown": + cur += page + case "PageUp": + cur -= page + case "Home": + cur = 0 + case "End": + cur = n - 1 + default: + return + } + if cur < 0 { + cur = 0 + } + if cur >= n { + cur = n - 1 + } + s.Current().Set(cur) +} + +// A11y reports the strip as a listbox whose value is "page N of M". +func (s *PageStrip) A11y() A11yInfo { + v := "" + if c := s.Current().Get(); c >= 0 && c < s.count { + v = "page " + strconv.Itoa(c+1) + " of " + strconv.Itoa(s.count) + } + return A11yInfo{Role: RoleListbox, Value: v} +} diff --git a/pagestrip_test.go b/pagestrip_test.go new file mode 100644 index 0000000..d1c43b5 --- /dev/null +++ b/pagestrip_test.go @@ -0,0 +1,431 @@ +// Copyright (c) 2026 the go-widgets/toolkit authors. All rights reserved. +// Use of this source code is governed by a BSD-3-Clause license that can be +// found in the LICENSE file at the root of this repository. + +package toolkit + +import ( + "testing" + + "github.com/go-widgets/painter" +) + +func newStrip(n int) *PageStrip { + s := NewPageStrip(n) + s.SetBounds(Rect{X: 3, Y: 4, W: 160, H: 400}) + return s +} + +func solid(w, h int, c RGBA) []byte { + b := make([]byte, w*h*4) + for i := 0; i < len(b); i += 4 { + b[i], b[i+1], b[i+2], b[i+3] = c.R, c.G, c.B, c.A + } + return b +} + +func TestPageStripVirtualisesTheWindow(t *testing.T) { + s := newStrip(500) + first, last := s.Visible() + if first != 0 || last < 1 || last > 12 { + t.Fatalf("window = [%d,%d] on 500 pages", first, last) + } + asked := map[int]int{} + s.NeedImage = func(i int) { asked[i]++ } + th := DefaultLight() + buf := makeSurface(170, 420) + s.Draw(newP(buf, 170), th) + s.Draw(newP(buf, 170), th) + if len(asked) > last+1+stripPrefetch+1 { + t.Fatalf("asked for %d pages with a window of %d: not virtualised", len(asked), last-first+1) + } + for i := 0; i <= last+stripPrefetch; i++ { + if asked[i] != 1 { + t.Fatalf("page %d asked %d times, want exactly once", i, asked[i]) + } + } + if asked[last+stripPrefetch+1] != 0 || asked[499] != 0 { + t.Fatal("pages far outside the window were requested") + } + // Scroll to the end: the last pages are the window, the first are not asked again. + s.scrollBy(1 << 30) + f2, l2 := s.Visible() + if l2 != 499 || f2 < 480 { + t.Fatalf("end window = [%d,%d]", f2, l2) + } + s.Draw(newP(buf, 170), th) + if asked[499] != 1 { + t.Fatal("the last page was not requested") + } +} + +func TestPageStripImageDrawPageAndPlaceholder(t *testing.T) { + th := DefaultLight() + s := newStrip(3) + s.Current().Set(-1) + card := s.cardRect(0) + probe := func(buf []byte) RGBA { + return pixelAt(buf, 170, s.Bounds().X+card.X+card.W/2, s.Bounds().Y+card.Y+3) + } + red := RGB(200, 20, 20) + // placeholder first + buf := makeSurface(170, 420) + s.Draw(newP(buf, 170), th) + if got := probe(buf); got != th.SurfaceAlt { + t.Fatalf("placeholder = %v, want SurfaceAlt", got) + } + // DrawPage hook + s.DrawPage = func(p painter.Painter, _ *Theme, r Rect, i int) { fillRect(p, r.X, r.Y, r.W, r.H, RGB(1, 2, 3)) } + buf = makeSurface(170, 420) + s.Draw(newP(buf, 170), th) + if got := probe(buf); got != RGB(1, 2, 3) { + t.Fatalf("DrawPage = %v", got) + } + // bitmap wins + s.SetImage(0, solid(16, 9, red), 16, 9) + buf = makeSurface(170, 420) + s.Draw(newP(buf, 170), th) + if got := probe(buf); got != red { + t.Fatalf("bitmap = %v, want red", got) + } + // bad inputs are ignored + s.SetImage(-1, solid(2, 2, red), 2, 2) + s.SetImage(3, solid(2, 2, red), 2, 2) + s.SetImage(1, solid(2, 2, red)[:4], 2, 2) + s.SetImage(1, nil, 0, 0) + if s.Resident() != 1 { + t.Fatalf("Resident = %d", s.Resident()) + } +} + +func TestPageStripInvalidateKeepsStaleUntilReplaced(t *testing.T) { + s := newStrip(4) + asked := 0 + s.NeedImage = func(i int) { + if i == 1 { + asked++ + } + } + buf := makeSurface(170, 420) + th := DefaultDark() + s.Draw(newP(buf, 170), th) + s.SetImage(1, solid(16, 9, RGB(9, 9, 9)), 16, 9) + s.Invalidate(1) + s.Invalidate(-5) + s.Invalidate(99) + s.Draw(newP(buf, 170), th) + if asked != 2 { + t.Fatalf("Invalidate must ask again: asked %d times", asked) + } + if s.imgs[1] == nil { + t.Fatal("the stale bitmap must stay until its replacement arrives") + } + s.InvalidateAll() + s.Draw(newP(buf, 170), th) + if asked != 3 { + t.Fatalf("InvalidateAll must ask again: %d", asked) + } +} + +func TestPageStripEvictsTheFarthestResident(t *testing.T) { + s := newStrip(300) + s.MaxResident = 10 + for i := 0; i < 30; i++ { + s.SetImage(i, solid(4, 3, RGB(1, 1, 1)), 4, 3) + } + if s.Resident() != 10 { + t.Fatalf("Resident = %d, want the cap 10", s.Resident()) + } + if s.imgs[29] == nil || s.imgs[0] != nil { + t.Fatal("the farthest pages (from the newest) must go first") + } + if s.asked[0] { + t.Fatal("an evicted page must be asked for again") + } + // default cap + d := newStrip(300) + if d.maxResident() != PageStripMaxResident { + t.Fatal("default MaxResident") + } + // every other page is the page being kept: nothing to evict, no loop + one := newStrip(3) + one.MaxResident = 0 + one.nres = 200 + one.evict(0) +} + +func TestPageStripSelectionClickKeysAndFollow(t *testing.T) { + s := newStrip(50) + var got []int + s.Current().Subscribe(func(i int) { got = append(got, i) }) + c2 := s.cardRect(2) + s.OnEvent(Event{Kind: EventClick, X: c2.X + 5, Y: c2.Y + 5}) + if s.Current().Get() != 2 { + t.Fatalf("click selected %d", s.Current().Get()) + } + // keys need focus + s.OnEvent(Event{Kind: EventKeyDown, Code: "ArrowDown"}) + if s.Current().Get() != 2 { + t.Fatal("an unfocused strip must ignore keys") + } + s.SetFocused(true) + for _, k := range []struct { + code string + want int + }{{"ArrowDown", 3}, {"ArrowRight", 4}, {"ArrowUp", 3}, {"ArrowLeft", 2}, {"End", 49}, {"ArrowDown", 49}, {"Home", 0}, {"ArrowUp", 0}} { + s.OnEvent(Event{Kind: EventKeyDown, Code: k.code}) + if s.Current().Get() != k.want { + t.Fatalf("%s -> %d, want %d", k.code, s.Current().Get(), k.want) + } + } + s.OnEvent(Event{Kind: EventKeyDown, Code: "PageDown"}) + pd := s.Current().Get() + if pd < 2 { + t.Fatalf("PageDown -> %d", pd) + } + s.OnEvent(Event{Kind: EventKeyDown, Code: "PageUp"}) + if s.Current().Get() != 0 { + t.Fatalf("PageUp -> %d", s.Current().Get()) + } + s.OnEvent(Event{Kind: EventKeyDown, Code: "q"}) + // The window follows the selection on the next Draw, both ways. + buf := makeSurface(170, 420) + s.Current().Set(40) + s.Draw(newP(buf, 170), DefaultLight()) + if f, l := s.Visible(); 40 < f || 40 > l { + t.Fatalf("selected page 40 not scrolled into view: [%d,%d]", f, l) + } + s.Current().Set(1) + s.Draw(newP(buf, 170), DefaultLight()) + if f, l := s.Visible(); 1 < f || 1 > l { + t.Fatalf("selected page 1 not scrolled into view: [%d,%d]", f, l) + } + // a click on nothing keeps the selection; clicks past the gap map to no page + s.OnEvent(Event{Kind: EventClick, X: 2, Y: 100000}) + if s.Current().Get() != 1 { + t.Fatal("a click outside every card changed the selection") + } + s.SetCount(0) + s.OnEvent(Event{Kind: EventKeyDown, Code: "End"}) + if s.PageAt(5, 5) != -1 { + t.Fatal("PageAt on an empty strip") + } + if len(got) == 0 { + t.Fatal("subscribers must see selection changes") + } + dis := newStrip(5) + dis.Disabled().Set(true) + dis.OnEvent(Event{Kind: EventClick, X: 40, Y: 20}) + if dis.Current().Get() != -1 { + t.Fatal("a disabled strip must not select") + } +} + +func TestPageStripScrollbarDragPageAndWheel(t *testing.T) { + s := newStrip(500) + g, ok := s.barGeom() + if !ok { + t.Fatal("no bar over 500 pages") + } + if g.thumbLen < scaled(scrollbarMinThumb) { + t.Fatalf("thumb %d below the minimum", g.thumbLen) + } + x := g.cross0 + 1 + s.OnEvent(Event{Kind: EventClick, X: x, Y: g.thumbStart + 2}) + s.OnEvent(Event{Kind: EventMouseDrag, X: x, Y: g.trackStart + g.trackLen + 40}) + if s.clampedScroll() != s.maxScroll() { + t.Fatalf("dragged to the end: scroll %d of %d", s.clampedScroll(), s.maxScroll()) + } + s.OnEvent(Event{Kind: EventMouseUp}) + // Track click pages by one window. + s.scroll = 0 + g, _ = s.barGeom() + s.OnEvent(Event{Kind: EventClick, X: x, Y: g.trackStart + g.trackLen - 3}) + if s.clampedScroll() != s.viewLen() { + t.Fatalf("track click scrolled %d, want a window (%d)", s.clampedScroll(), s.viewLen()) + } + // Wheel + s.scroll = 0 + s.OnEvent(Event{Kind: EventScroll, Delta: 3}) + if s.clampedScroll() != 3*s.step() { + t.Fatalf("wheel scrolled %d, want %d", s.clampedScroll(), 3*s.step()) + } + s.OnEvent(Event{Kind: EventScroll, Delta: -100}) + if s.clampedScroll() != 0 { + t.Fatal("wheel must clamp at 0") + } + // A click on the bar column selects nothing. + s.Current().Set(-1) + s.OnEvent(Event{Kind: EventClick, X: x, Y: g.trackStart + g.trackLen - 3}) + if s.Current().Get() != -1 { + t.Fatal("a scrollbar click selected a page") + } + // No bar when everything fits. + if _, ok := newStrip(2).barGeom(); ok { + t.Fatal("a bar over two pages") + } +} + +func TestPageStripHorizontal(t *testing.T) { + s := NewPageStrip(200) + s.Horizontal = true + s.SetBounds(Rect{X: 0, Y: 0, W: 400, H: 110}) + c0, c1 := s.cardRect(0), s.cardRect(1) + if c1.X <= c0.X+c0.W || c1.Y != c0.Y { + t.Fatalf("cards not laid left to right: %v %v", c0, c1) + } + if c0.W <= c0.H { + t.Fatalf("16:9 card should be wider than tall: %v", c0) + } + g, ok := s.barGeom() + if !ok || !g.horizontal { + t.Fatal("expected a horizontal bar") + } + buf := makeSurface(400, 110) + s.Draw(newP(buf, 400), DefaultDark()) + s.OnEvent(Event{Kind: EventScroll, DeltaX: 2}) + if s.clampedScroll() == 0 { + t.Fatal("DeltaX must scroll a horizontal strip") + } + s.OnEvent(Event{Kind: EventScroll, Delta: 1}) + f, _ := s.Visible() + cr := s.cardRect(f + 1) + s.OnEvent(Event{Kind: EventClick, X: cr.X + 3, Y: 20}) + if s.Current().Get() != f+1 { + t.Fatalf("clicked card %d, selected %d", f+1, s.Current().Get()) + } +} + +func TestPageStripAspectsAndCount(t *testing.T) { + s := newStrip(3) + s.SetAspects([][2]int{{1, 1}}) // wrong length: ignored + s.SetAspects([][2]int{{1, 1}, {1, 2}, {0, 0}}) + a, b, c := s.cardRect(0), s.cardRect(1), s.cardRect(2) + if a.H != a.W { + t.Fatalf("square page: %v", a) + } + if b.H != 2*b.W { + t.Fatalf("portrait page: %v", b) + } + if c.H*16 != c.W*9 && c.H != c.W*9/16 { + t.Fatalf("an invalid aspect falls back to 16:9: %v", c) + } + s.PageW, s.PageH = 4, 3 + s.aspects = nil + s.offs = nil + if r := s.cardRect(0); r.H != r.W*3/4 { + t.Fatalf("PageW:PageH = 4:3, card %v", r) + } + s.Current().Set(2) + s.SetCount(2) + if s.Current().Get() != 1 || s.Count() != 2 { + t.Fatalf("SetCount must clamp Current: %d", s.Current().Get()) + } + s.SetImage(1, solid(2, 2, RGB(1, 1, 1)), 2, 2) + s.SetCount(1) // drops the bitmap of the removed page + if s.Resident() != 0 { + t.Fatalf("Resident = %d after dropping its page", s.Resident()) + } + s.SetCount(-4) + if s.Count() != 0 { + t.Fatal("negative count") + } + buf := makeSurface(170, 420) + s.Draw(newP(buf, 170), DefaultLight()) + s.SetBounds(Rect{}) + s.Draw(newP(buf, 170), DefaultLight()) +} + +func TestPageStripEdgeCases(t *testing.T) { + e := NewPageStrip(0) + e.SetBounds(Rect{W: 100, H: 100}) + if f, l := e.Visible(); f != 0 || l != -1 { + t.Fatalf("Visible on an empty strip = %d,%d", f, l) + } + e.ScrollTo(3) + e.ScrollTo(-1) + e.OnEvent(Event{Kind: EventScroll, Delta: 2}) // step() on an empty strip + if e.step() != 1 { + t.Fatal("step of an empty strip") + } + // A hairline aspect still gets a one-pixel card. + h := newStrip(2) + h.SetAspects([][2]int{{5000, 1}, {1, 1}}) + if r := h.cardRect(0); r.H != 1 { + t.Fatalf("hairline card %v", r) + } + // One page per window: PageDown still moves by one. + one := NewPageStrip(5) + one.SetBounds(Rect{W: 160, H: 60}) + one.SetFocused(true) + one.Current().Set(0) + one.OnEvent(Event{Kind: EventKeyDown, Code: "PageDown"}) + if one.Current().Get() != 1 { + t.Fatalf("PageDown on a one-page window: %d", one.Current().Get()) + } +} + +func TestPageStripNarrowShrinksTheCard(t *testing.T) { + s := NewPageStrip(10) + s.SetBounds(Rect{W: 60, H: 300}) + if s.cross() >= s.thumb() { + t.Fatalf("card %d did not shrink below %d in a 60px strip", s.cross(), s.thumb()) + } + s.SetBounds(Rect{W: 4, H: 300}) + if s.cross() < 1 { + t.Fatal("card collapsed") + } + s.ThumbSize = 40 + if s.thumb() != scaled(40) { + t.Fatal("ThumbSize ignored") + } +} + +func TestPageStripSelectedOutlineAndNumbersContrast(t *testing.T) { + for name, th := range popupThemes() { + s := newStrip(5) + s.Current().Set(1) + s.SetFocused(false) + buf := makeSurface(170, 420) + for i := 0; i+3 < len(buf); i += 4 { + buf[i], buf[i+1], buf[i+2], buf[i+3] = th.Surface.R, th.Surface.G, th.Surface.B, 255 + } + s.Draw(newP(buf, 170), th) + cr := s.cardRect(1) + out := pixelAt(buf, 170, s.Bounds().X+cr.X+cr.W/2, s.Bounds().Y+cr.Y-1) + if r := contrastRatio(out, th.Surface); r < 3 { + t.Errorf("%s: selected outline %v vs strip = %.2f:1, want >= 3:1", name, out, r) + } + } +} + +func TestPageStripA11y(t *testing.T) { + s := newStrip(7) + if s.A11y().Role != RoleListbox || s.A11y().Value != "" { + t.Fatalf("A11y = %+v", s.A11y()) + } + s.Current().Set(2) + if s.A11y().Value != "page 3 of 7" { + t.Fatalf("A11y value = %q", s.A11y().Value) + } +} + +// The cost of a Draw does not depend on the number of pages. +func BenchmarkPageStripDraw(b *testing.B) { + for _, n := range []int{10, 500, 100000} { + b.Run(itoa(n), func(b *testing.B) { + s := NewPageStrip(n) + s.SetBounds(Rect{W: 160, H: 600}) + s.NeedImage = func(i int) { s.SetImage(i, solid(120, 68, RGB(9, 9, 9)), 120, 68) } + buf := makeSurface(160, 600) + p := newP(buf, 160) + th := DefaultDark() + s.Draw(p, th) + b.ResetTimer() + for i := 0; i < b.N; i++ { + s.scroll = (i * 37) % 5000 + s.Draw(p, th) + } + }) + } +} From 19f04dc60d09e998606994d5769195bd6d93a554 Mon Sep 17 00:00:00 2001 From: tannevaled Date: Thu, 22 Oct 2026 09:39:19 +0200 Subject: [PATCH 2/2] PageStrip: Scroll() Observable and CardRect() for hosts overlaying content on the cards Co-Authored-By: Claude Opus 5.5 --- pagestrip.go | 39 +++++++++++++++++++++++++++++++-------- pagestrip_test.go | 29 ++++++++++++++++++++++++++--- 2 files changed, 57 insertions(+), 11 deletions(-) diff --git a/pagestrip.go b/pagestrip.go index be1249a..d5e1c20 100644 --- a/pagestrip.go +++ b/pagestrip.go @@ -72,8 +72,8 @@ type PageStrip struct { nres int // resident bitmaps cur *mvvm.Observable[int] - reveal bool // scroll Current into view at the next Draw - scroll int // scroll offset along the strip, device pixels + reveal bool // scroll Current into view at the next Draw + scroll *mvvm.Observable[int] // scroll offset along the strip, device pixels drag scrollDrag // offs[i] is the start of page i along the strip's axis, offs[count] the end, @@ -117,6 +117,29 @@ func (s *PageStrip) Current() *mvvm.Observable[int] { return s.cur } +// Scroll is the scroll offset along the strip, in device pixels, as a bindable +// Observable: a host that overlays something on the cards (an HTML thumbnail +// layer the canvas cannot paint) subscribes to keep it aligned, and may set it. +// Reads clamp on the fly. +func (s *PageStrip) Scroll() *mvvm.Observable[int] { + if s.scroll == nil { + s.scroll = mvvm.NewObservable(0) + } + return s.scroll +} + +// CardRect is page i's card — the picture, not its number — in the SAME +// coordinates as Bounds (the surface's), at the current scroll offset; the zero +// Rect for an out-of-range page. A host overlaying its own content on the cards +// reads it (with Scroll) to place them. +func (s *PageStrip) CardRect(i int) Rect { + if i < 0 || i >= s.count { + return Rect{} + } + cr, r := s.cardRect(i), s.Bounds() + return Rect{X: r.X + cr.X, Y: r.Y + cr.Y, W: cr.W, H: cr.H} +} + // Count is the number of pages. func (s *PageStrip) Count() int { return s.count } @@ -333,7 +356,7 @@ func (s *PageStrip) maxScroll() int { } func (s *PageStrip) clampedScroll() int { - sc := s.scroll + sc := s.Scroll().Get() if sc < 0 { sc = 0 } @@ -381,8 +404,8 @@ func (s *PageStrip) ScrollTo(i int) { } else if end > sc+s.viewLen() { sc = end - s.viewLen() } - s.scroll = sc - s.scroll = s.clampedScroll() + s.Scroll().Set(sc) + s.Scroll().Set(s.clampedScroll()) } // cardRect is page i's card, widget-local (the picture, not its number). @@ -554,7 +577,7 @@ func (s *PageStrip) OnEvent(ev Event) { } case EventMouseDrag: g, ok := s.barGeom() - s.drag.drag(g, ok, ev, func(v int) { s.scroll = v }) + s.drag.drag(g, ok, ev, func(v int) { s.Scroll().Set(v) }) case EventMouseUp: s.drag.release() case EventScroll: @@ -580,8 +603,8 @@ func (s *PageStrip) step() int { } func (s *PageStrip) scrollBy(d int) { - s.scroll = s.clampedScroll() + d - s.scroll = s.clampedScroll() + s.Scroll().Set(s.clampedScroll() + d) + s.Scroll().Set(s.clampedScroll()) } func (s *PageStrip) onKey(code string) { diff --git a/pagestrip_test.go b/pagestrip_test.go index d1c43b5..24e5a77 100644 --- a/pagestrip_test.go +++ b/pagestrip_test.go @@ -238,14 +238,14 @@ func TestPageStripScrollbarDragPageAndWheel(t *testing.T) { } s.OnEvent(Event{Kind: EventMouseUp}) // Track click pages by one window. - s.scroll = 0 + s.Scroll().Set(0) g, _ = s.barGeom() s.OnEvent(Event{Kind: EventClick, X: x, Y: g.trackStart + g.trackLen - 3}) if s.clampedScroll() != s.viewLen() { t.Fatalf("track click scrolled %d, want a window (%d)", s.clampedScroll(), s.viewLen()) } // Wheel - s.scroll = 0 + s.Scroll().Set(0) s.OnEvent(Event{Kind: EventScroll, Delta: 3}) if s.clampedScroll() != 3*s.step() { t.Fatalf("wheel scrolled %d, want %d", s.clampedScroll(), 3*s.step()) @@ -336,6 +336,29 @@ func TestPageStripAspectsAndCount(t *testing.T) { s.Draw(newP(buf, 170), DefaultLight()) } +// A host overlaying content on the cards places it from CardRect and follows +// Scroll: the rectangle is in surface coordinates and moves with the scroll. +func TestPageStripCardRectAndScrollObservable(t *testing.T) { + s := newStrip(100) // X 3, Y 4 + r0, r1 := s.CardRect(0), s.CardRect(1) + if r0.X < s.Bounds().X || r0.Y < s.Bounds().Y || r1.Y <= r0.Y+r0.H { + t.Fatalf("CardRect not in surface coordinates / stacked: %v %v", r0, r1) + } + if s.CardRect(-1) != (Rect{}) || s.CardRect(100) != (Rect{}) { + t.Fatal("out-of-range CardRect must be the zero Rect") + } + var seen []int + s.Scroll().Subscribe(func(v int) { seen = append(seen, v) }) + s.OnEvent(Event{Kind: EventScroll, Delta: 3}) + if len(seen) == 0 || seen[len(seen)-1] != s.clampedScroll() { + t.Fatalf("Scroll observers saw %v, scroll is %d", seen, s.clampedScroll()) + } + moved := s.CardRect(0) + if moved.Y != r0.Y-s.clampedScroll() { + t.Fatalf("card did not follow the scroll: %v vs %v at scroll %d", moved, r0, s.clampedScroll()) + } +} + func TestPageStripEdgeCases(t *testing.T) { e := NewPageStrip(0) e.SetBounds(Rect{W: 100, H: 100}) @@ -423,7 +446,7 @@ func BenchmarkPageStripDraw(b *testing.B) { s.Draw(p, th) b.ResetTimer() for i := 0; i < b.N; i++ { - s.scroll = (i * 37) % 5000 + s.Scroll().Set((i * 37) % 5000) s.Draw(p, th) } })