diff --git a/src/ui/widgets/Menu.ts b/src/ui/widgets/Menu.ts index c95174e2..1a059aed 100644 --- a/src/ui/widgets/Menu.ts +++ b/src/ui/widgets/Menu.ts @@ -109,7 +109,7 @@ export class Menu extends Popup { const size = this.list.measure(); const room = this.roomFor(this.scroll); const scrolls = size.h > room; - this.scroll.minWidth = size.w + (scrolls ? Theme.scrollbarWidth + 4 : 0); + this.scroll.minWidth = size.w + (scrolls ? Theme.scrollbarWidth : 0); this.scroll.minHeight = Math.min(size.h, room); this.scroll.scrollTo(0); this.refreshLayout(); diff --git a/src/ui/widgets/ScrollContainer.ts b/src/ui/widgets/ScrollContainer.ts index 756447f4..cccb4df9 100644 --- a/src/ui/widgets/ScrollContainer.ts +++ b/src/ui/widgets/ScrollContainer.ts @@ -3,7 +3,11 @@ import { clamp } from '../math'; import { Theme } from '../Theme'; import { Widget } from '../Widget'; -/** Vertical scroll of one content widget, which gets the container's width and its own height. */ +/** + * Vertical scroll of one content widget, which gets the container's width and its own height. + * While the content scrolls it is narrowed by `Theme.scrollbarWidth`, so the bar sits beside it + * rather than on top of its right edge. + */ export class ScrollContainer extends Widget { private readonly maskShape = new Graphics(); private readonly scrollbar = new Graphics(); @@ -82,10 +86,11 @@ export class ScrollContainer extends Widget { if (!this.content) return; const size = this.content.measure(); const height = Math.max(size.h, this.h); + const width = Math.max(0, this.w - (size.h > this.h ? Theme.scrollbarWidth : 0)); // Content shorter than the viewport is pinned to the viewport height either way, so `resize` // would see no change and skip the layout -- leaving rows added since stacked at the origin. - const unchanged = this.content.w === this.w && this.content.h === height; - this.content.resize(this.w, height); + const unchanged = this.content.w === width && this.content.h === height; + this.content.resize(width, height); if (unchanged) this.content.layout(); this.scrollY = clamp(this.scrollY, 0, this.maxScroll); this.applyScroll(); diff --git a/tests/ui/scroll-container.test.ts b/tests/ui/scroll-container.test.ts index c229327c..6879e991 100644 --- a/tests/ui/scroll-container.test.ts +++ b/tests/ui/scroll-container.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { ScrollContainer, Spacer, VStack } from '../../src/ui'; +import { ScrollContainer, Spacer, Theme, VStack } from '../../src/ui'; /** Row tops after a layout; an unlaid-out stack leaves every child at 0. */ const tops = (stack: VStack) => stack.children.map((c) => c.y); @@ -36,3 +36,28 @@ describe('a scroll container told its content changed', () => { expect(scroll.maxScroll).toBe(10); }); }); + +describe('a scroll container and its scrollbar', () => { + const filled = (viewportH: number, rowCount: number) => { + const scroll = new ScrollContainer(); + scroll.resize(200, viewportH); + const rows = new VStack(); + for (let i = 0; i < rowCount; i++) rows.add(new Spacer(20)); + scroll.setContent(rows); + return { scroll, rows }; + }; + + it('gives content that fits the full width', () => { + expect(filled(300, 3).rows.w).toBe(200); + }); + + it('keeps the gutter free for the bar while the content scrolls', () => { + expect(filled(50, 3).rows.w).toBe(200 - Theme.scrollbarWidth); + }); + + it('hands the gutter back once the content fits again', () => { + const { scroll, rows } = filled(50, 3); + scroll.resize(200, 300); + expect(rows.w).toBe(200); + }); +});