Support scrolling primitives
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>withoverflow: '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
Stylestype definition to add'scroll'as a valid value for theoverflow,overflowX, andoverflowYproperties. - Added new style properties to the
Stylestype for configuring the scrollbar's appearance and behavior (scrollTop,scrollLeft,initialScrollPosition,scrollbarThumbCharacter, etc.). - Introduced a new function,
applyOverflowStyles, to set theYGOverflowScrollproperty on the underlying Yoga layout node whenoverflow: 'scroll'is specified. This is the key to enabling Yoga's native overflow measurement capabilities.
- Modified the
Step 2: Updating the Box Component
- File:
src/components/Box.tsx - Changes:
- Updated the component's
Propstype to include the new scroll-related properties (scrollLeft), making them available to developers.
- Updated the component's
Step 3: Modifying the Internal DOM Structure
- File:
src/dom.ts - Changes:
- Extended the
DOMElementtype 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
getScrollWidthto 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.
- Extended the
Step 4: Implementing the Core Scrolling Logic
- File:
src/render-node-to-output.ts - Changes:
- This file saw the most significant changes. The
renderNodeToOutputfunction was heavily modified to:- Detect Scrollable Boxes: Check if a
<Box>hasoverflowX: 'scroll'oroverflowY: 'scroll'. - Calculate Scroll Dimensions: If a box is scrollable, calculate its
clientHeight/clientWidth(the visible area) andscrollHeight/scrollWidth(the total size of its children). - Manage Scroll Position: Determine the correct
scrollTopandscrollLeftbased on the props, ensuring it stays within valid bounds. - 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.
- Apply Content Scrolling: Apply a negative vertical and/or horizontal offset to the children of the scrollable box to simulate scrolling.
- 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.
- Detect Scrollable Boxes: Check if a
- This file saw the most significant changes. The
Step 5: Verification
- Files:
examples/scroll/scroll.tsxexamples/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.
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?: numberandcontentOffsetY?: numberprops toBox. - Extend
measureElementto also include client and scroll size. - Add
useBoxMetricshook for convenience.
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>
);
}
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
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.
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.
Thanks for your effort, scroll is so useful for me, i want to check does this feature published yet? @jacob314