ink icon indicating copy to clipboard operation
ink copied to clipboard

Support scrolling primitives

Open jacob314 opened this issue 3 months ago • 6 comments

Support overflow="scroll"

Currently the foundational ink <Box> components support overflow="hidden" and overflow="visible" but not overflow="scroll". This makes it difficult for CLI Chat applications such as Gemini CLI that need to constrain large amounts of text to fit within the visible area of the terminal while needing to show easy to understand visual indicators of how much content scrolled off the screen. This is crucial to avoid flicker and to ensure the UI is by default scrolled to the most relevant information. Gemini CLI partially solves this with the workarounds of MaxSizedBox but this implementation is much more fragile and limited than a scrolling solution directly integrated into Ink. Fortunately the Yoga layout model that Ink uses has support for YGOverflowScroll suggesting there are idiomatic and efficient ways this can be implemented.

Proposed Ink Scrolling Feature Implementation Plan

This document outlines the plan and implementation details for adding enhanced scrolling capabilities to Ink's <Box> component.

1. Goal

The primary goal was to introduce a robust scrolling mechanism directly into the native Ink <Box> component, inspired by the overflow scroll model on the web.

  • Scrollable Overflow: A <Box> with overflow: 'scroll' should not expand beyond its flexbox-defined height. Instead, its content should be scrollable.
  • Virtual Scrollbar: A visual, non-interactive scrollbar should be rendered on the right-hand side of the box to indicate the scroll position and the amount of overflow.
  • Initial Scroll Position: The component should accept a prop to define whether the initial scroll position is at the top or the bottom of the content.
  • Programmatic Control: An imperative API should be available to programmatically get scroll information (like scrollHeight) and set the scroll position.
  • Overflow Indicators Optional lines at the top and bottom of the box to indicate that there is more content hidden above or below the visible area.
  • Horizontal Scrolling: Support for horizontal scrolling with a horizontal scrollbar.

Non goals for the initial implementation

  • Supporting lazy list rendering to increase efficiency for very large scrollable lists.
  • Implementing interactive scrollbars within Ink. These could be adopted later or layered on top of Ink with Ink potentially surfacing the apis to simulate mouse events but with Ink not having an opinion about how the mouse events should be detected.
  • Overflow Indicators Optional lines at the top and bottom of the box to indicate that there is more content hidden above or below the visible area.

2. Implementation Strategy

The implementation involved the following steps:

Step 1: Extending the Styling System

  • File: src/styles.ts
  • Changes:
    • Modified the Styles type definition to add 'scroll' as a valid value for the overflow, overflowX, and overflowY properties.
    • Added new style properties to the Styles type for configuring the scrollbar's appearance and behavior (scrollTop, scrollLeft, initialScrollPosition, scrollbarThumbCharacter, etc.).
    • Introduced a new function, applyOverflowStyles, to set the YGOverflowScroll property on the underlying Yoga layout node when overflow: 'scroll' is specified. This is the key to enabling Yoga's native overflow measurement capabilities.

Step 2: Updating the Box Component

  • File: src/components/Box.tsx
  • Changes:
    • Updated the component's Props type to include the new scroll-related properties (scrollLeft), making them available to developers.

Step 3: Modifying the Internal DOM Structure

  • File: src/dom.ts
  • Changes:
    • Extended the DOMElement type to include internal fields for storing calculated scroll state (internal_scrollTop, internal_scrollHeight, internal_clientHeight). This allows the renderer to access this information without needing to recalculate it.
    • Added getScrollWidth to calculate the total width of the content and other measurement metrics required for users to implement their own keyboard or mouse based scroll on top of the Ink library without Ink having to commit to an API for that.

Step 4: Implementing the Core Scrolling Logic

  • File: src/render-node-to-output.ts
  • Changes:
    • This file saw the most significant changes. The renderNodeToOutput function was heavily modified to:
      1. Detect Scrollable Boxes: Check if a <Box> has overflowX: 'scroll' or overflowY: 'scroll'.
      2. Calculate Scroll Dimensions: If a box is scrollable, calculate its clientHeight/clientWidth (the visible area) and scrollHeight/scrollWidth (the total size of its children).
      3. Manage Scroll Position: Determine the correct scrollTop and scrollLeft based on the props, ensuring it stays within valid bounds.
      4. Render the Scrollbar: Calculate the scrollbar thumb's size and position and render both the track and the thumb for both vertical and horizontal scrollbars.
      5. Apply Content Scrolling: Apply a negative vertical and/or horizontal offset to the children of the scrollable box to simulate scrolling.
      6. Clip Overflowing Content: Set up a clipping region to ensure that content outside the visible area (including the area reserved for the scrollbar) is not rendered.

Step 5: Verification

  • Files:
    • examples/scroll/scroll.tsx
    • examples/scroll/index.ts
  • Changes:
    • Added a scroll example to visually test and verify both vertical and horizontal scrolling functionality.
    • The example includes a <Box> with a flex model driven height and width, overflowing content, and interactive controls to switch between scrolling and not scrolling on each axis.

jacob314 avatar Sep 11 '25 07:09 jacob314

It makes sense to enable this, but I don't all of this should be in Ink. There are a lot of opinionated things in this proposal, like scroll bar and overflow indicators. I would focus on what needs to be added to support creating a ScrollView component in userland.

Here's what I think should be done:

  • Add contentOffsetX?: number and contentOffsetY?: number props to Box.
  • Extend measureElement to also include client and scroll size.
  • Add useBoxMetrics hook for convenience.

sindresorhus avatar Sep 25 '25 09:09 sindresorhus

ChatGPT example of what it could look like:

export type MeasuredBox = {
	width: number;
	height: number;
	clientWidth: number;
	clientHeight: number;
	scrollWidth: number;
	scrollHeight: number;
};

export function measureElement(node: DOMElement): MeasuredBox {
	const y = node.yogaNode!;
	const width = y.getComputedWidth();
	const height = y.getComputedHeight();
	const clientWidth = width - y.getComputedBorder(Yoga.EDGE_LEFT) - y.getComputedBorder(Yoga.EDGE_RIGHT);
	const clientHeight = height - y.getComputedBorder(Yoga.EDGE_TOP) - y.getComputedBorder(Yoga.EDGE_BOTTOM);

	let scrollWidth = 0;
	let scrollHeight = 0;
	for (let i = 0; i < y.getChildCount(); i++) {
		const c = y.getChild(i);
		scrollWidth = Math.max(scrollWidth, c.getComputedLeft() + c.getComputedWidth() + c.getComputedMargin(Yoga.EDGE_RIGHT));
		scrollHeight = Math.max(scrollHeight, c.getComputedTop() + c.getComputedHeight() + c.getComputedMargin(Yoga.EDGE_BOTTOM));
	}

	return {width, height, clientWidth, clientHeight, scrollWidth, scrollHeight};
}

export function useBoxMetrics(ref: React.RefObject<DOMElement>) {
	const [m, setM] = React.useState(() => ({
		clientWidth: 0, clientHeight: 0, scrollWidth: 0, scrollHeight: 0
	}));
	React.useLayoutEffect(() => {
		if (!ref.current) { return; }
		const {clientWidth, clientHeight, scrollWidth, scrollHeight} = measureElement(ref.current);
		setM({clientWidth, clientHeight, scrollWidth, scrollHeight});
	});
	return m;
}

Example ScrollView:

function ScrollView({height, children}: {height: number; children: React.ReactNode}) {
	const viewportRef = React.useRef<DOMElement>(null);
	const {clientHeight, scrollHeight} = useBoxMetrics(viewportRef);
	const [scrollTop, setScrollTop] = React.useState(0);

	useInput((_, key) => {
		if (key.upArrow) { setScrollTop(s => Math.max(0, s - 1)); }
		if (key.downArrow) { setScrollTop(s => Math.min(s + 1, Math.max(0, scrollHeight - clientHeight))); }
	});

	return (
		<Box ref={viewportRef} height={height} overflow="hidden" contentOffsetY={scrollTop}>
			{children}
		</Box>
	);
}

sindresorhus avatar Sep 25 '25 09:09 sindresorhus

Thanks for the feedback. I think this is a reasonable long term path forward. Sorry I missed this notification as I'm also drowning in notifications from my main project.

useBoxMetrics won't quite work as written because the size of the component could change in a case where useLayoutEffect won't run. For example think a child component that updates due to keyboard events. To support this purely client side we will need to add an api similar to ResizeObserver from the DOM so user code can be notified when the scrollable component's size changes. That is doable and in fact on my branch I've implemented that already as part of supporting lazy lists. Would you be open to accepting that api? Resize observer api: https://github.com/jacob314/ink/commit/0bb9b4bfd4b2b10e7ea21cff88bb4c9c2d0f8ee2

jacob314 avatar Nov 10 '25 17:11 jacob314

Yes, ResizeObserver would be welcome. Ideally, as spec compliant as possible. Would probably be a good to battletest it a bit in Gemini CLI first though.

sindresorhus avatar Nov 10 '25 18:11 sindresorhus

Sounds good. Generally for all these changes I agree the best plan is to battle test them all in Gemini CLI and only upstream once they have had time to bake. Last thing I want to do is add a bunch of long term support burden in Ink supporting APIs that could have been implemented more simply.

jacob314 avatar Nov 10 '25 18:11 jacob314

Thanks for your effort, scroll is so useful for me, i want to check does this feature published yet? @jacob314

nyqykk avatar Nov 18 '25 03:11 nyqykk