Skip to content

Commit

Permalink
Merge pull request Shopify#2364 from Shopify/prompt-scrollbar
Browse files Browse the repository at this point in the history
Prompt scrollbar
  • Loading branch information
amcaplan authored Jul 9, 2023
2 parents e95958a + 594325b commit 45f52b1
Show file tree
Hide file tree
Showing 12 changed files with 436 additions and 277 deletions.
5 changes: 5 additions & 0 deletions .changeset/poor-hairs-nail.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@shopify/cli-kit': minor
---

Add scrollbar to prompts
27 changes: 1 addition & 26 deletions packages/cli-kit/bin/documentation/examples.ts
Original file line number Diff line number Diff line change
Expand Up @@ -439,32 +439,7 @@ export const examples: {[key in string]: Example} = {
{label: 'twenty-second', value: 'twenty-second'},
{label: 'twenty-third', value: 'twenty-third'},
{label: 'twenty-fourth', value: 'twenty-fourth'},
{label: 'twenty-fifth', value: 'twenty-fifth'},
{label: 'twenty-sixth', value: 'twenty-sixth'},
{label: 'twenty-seventh', value: 'twenty-seventh'},
{label: 'twenty-eighth', value: 'twenty-eighth'},
{label: 'twenty-ninth', value: 'twenty-ninth'},
{label: 'thirtieth', value: 'thirtieth'},
{label: 'thirty-first', value: 'thirty-first'},
{label: 'thirty-second', value: 'thirty-second'},
{label: 'thirty-third', value: 'thirty-third'},
{label: 'thirty-fourth', value: 'thirty-fourth'},
{label: 'thirty-fifth', value: 'thirty-fifth'},
{label: 'thirty-sixth', value: 'thirty-sixth'},
{label: 'thirty-seventh', value: 'thirty-seventh'},
{label: 'thirty-eighth', value: 'thirty-eighth'},
{label: 'thirty-ninth', value: 'thirty-ninth'},
{label: 'fortieth', value: 'fortieth'},
{label: 'forty-first', value: 'forty-first'},
{label: 'forty-second', value: 'forty-second'},
{label: 'forty-third', value: 'forty-third'},
{label: 'forty-fourth', value: 'forty-fourth'},
{label: 'forty-fifth', value: 'forty-fifth'},
{label: 'forty-sixth', value: 'forty-sixth'},
{label: 'forty-seventh', value: 'forty-seventh'},
{label: 'forty-eighth', value: 'forty-eighth'},
{label: 'forty-ninth', value: 'forty-ninth'},
{label: 'fiftieth', value: 'fiftieth'},
{label: 'twenty-fifth', value: 'twenty-fifth'}
]

const infoMessage = {
Expand Down

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ enum PromptState {
}

const MIN_NUMBER_OF_ITEMS_FOR_SEARCH = 5
const SELECT_INPUT_FOOTER_HEIGHT = 4

// eslint-disable-next-line react/function-component-definition
function AutocompletePrompt<T>({
Expand All @@ -60,7 +61,7 @@ function AutocompletePrompt<T>({
const [hasMorePages, setHasMorePages] = useState(initialHasMorePages)
const [wrapperHeight, setWrapperHeight] = useState(0)
const [promptAreaHeight, setPromptAreaHeight] = useState(0)
const currentAvailableLines = stdout.rows - promptAreaHeight - 5
const currentAvailableLines = stdout.rows - promptAreaHeight - SELECT_INPUT_FOOTER_HEIGHT
const [availableLines, setAvailableLines] = useState(currentAvailableLines)

const paginatedSearch = useCallback(
Expand Down Expand Up @@ -92,7 +93,7 @@ function AutocompletePrompt<T>({

useLayoutEffect(() => {
function onResize() {
const newAvailableLines = stdout.rows - promptAreaHeight - 5
const newAvailableLines = stdout.rows - promptAreaHeight - SELECT_INPUT_FOOTER_HEIGHT
if (newAvailableLines !== availableLines) {
setAvailableLines(newAvailableLines)
}
Expand Down
106 changes: 106 additions & 0 deletions packages/cli-kit/src/private/node/ui/components/Scrollbar.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import {Scrollbar} from './Scrollbar.js'
import {render} from '../../testing/ui.js'
import {describe, expect, test} from 'vitest'
import React from 'react'

describe('Scrollbar', async () => {
test('renders correctly when at the top of the list', async () => {
const options = {
containerHeight: 10,
visibleListSectionLength: 10,
fullListLength: 50,
visibleFromIndex: 0,
}

const {lastFrame} = render(<Scrollbar {...options} />)

// First 2 are colored in
expect(lastFrame()).toMatchInlineSnapshot(`
"\u001b[46m \u001b[49m
\u001b[46m \u001b[49m
\u001b[100m \u001b[49m
\u001b[100m \u001b[49m
\u001b[100m \u001b[49m
\u001b[100m \u001b[49m
\u001b[100m \u001b[49m
\u001b[100m \u001b[49m
\u001b[100m \u001b[49m
\u001b[100m \u001b[49m"
`)
})

test('renders correctly when in the middle of the list', async () => {
const options = {
containerHeight: 10,
visibleListSectionLength: 10,
fullListLength: 50,
visibleFromIndex: 20,
}

const {lastFrame} = render(<Scrollbar {...options} />)

// Scrollbar is in the middle
expect(lastFrame()).toMatchInlineSnapshot(`
"\u001b[100m \u001b[49m
\u001b[100m \u001b[49m
\u001b[100m \u001b[49m
\u001b[100m \u001b[49m
\u001b[46m \u001b[49m
\u001b[46m \u001b[49m
\u001b[100m \u001b[49m
\u001b[100m \u001b[49m
\u001b[100m \u001b[49m
\u001b[100m \u001b[49m"
`)
})

test('renders correctly when at the bottom of the list', async () => {
const options = {
containerHeight: 10,
visibleListSectionLength: 10,
fullListLength: 50,
visibleFromIndex: 40,
}

const {lastFrame} = render(<Scrollbar {...options} />)

// Last 2 are colored in
expect(lastFrame()).toMatchInlineSnapshot(`
"\u001b[100m \u001b[49m
\u001b[100m \u001b[49m
\u001b[100m \u001b[49m
\u001b[100m \u001b[49m
\u001b[100m \u001b[49m
\u001b[100m \u001b[49m
\u001b[100m \u001b[49m
\u001b[100m \u001b[49m
\u001b[46m \u001b[49m
\u001b[46m \u001b[49m"
`)
})

test('renders correctly in the middle of the list in no-color mode', async () => {
const options = {
containerHeight: 10,
visibleListSectionLength: 10,
fullListLength: 50,
visibleFromIndex: 20,
noColor: true,
}

const {lastFrame} = render(<Scrollbar {...options} />)
// Scrollbar is in the middle
expect(lastFrame()).toMatchInlineSnapshot(`
"△
▽"
`)
})
})
79 changes: 79 additions & 0 deletions packages/cli-kit/src/private/node/ui/components/Scrollbar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import {shouldDisplayColors} from '../../../../public/node/output.js'
import {Box, Text} from 'ink'
import React, {FunctionComponent} from 'react'

export interface ScrollbarProps {
containerHeight: number
visibleListSectionLength: number
fullListLength: number
visibleFromIndex: number
noColor?: boolean
}

const BACKGROUND_CHAR = '│'
const SCROLLBOX_CHAR = '║'

const Scrollbar: FunctionComponent<ScrollbarProps> = ({
containerHeight,
visibleListSectionLength,
fullListLength,
visibleFromIndex,
noColor = !shouldDisplayColors(),
}) => {
const displayArrows = containerHeight >= 4 && noColor
const visibleToIndex = visibleFromIndex + visibleListSectionLength - 1

// Leave 2 rows for top/bottom arrows when there is vertical room for them.
const fullHeight = displayArrows ? containerHeight - 2 : containerHeight
const scrollboxHeight = Math.min(
fullHeight - 1,
Math.ceil(Math.min(1, visibleListSectionLength / fullListLength) * fullHeight),
)

let topBuffer: number
// Ensure it scrolls all the way to the bottom when we hit the bottom
if (visibleToIndex >= fullListLength - 1) {
topBuffer = fullHeight - scrollboxHeight
} else {
// This is the actual number of rows available for the scrollbar to go up and down
const scrollingLength = fullHeight - scrollboxHeight
// This is the number of times the screen itself can scroll down
const scrollableIncrements = fullListLength - visibleListSectionLength

topBuffer = Math.max(
// Never go negative, that causes errors!
0,
Math.min(
// Never have more buffer than filling in all spaces above the scrollbox
fullHeight - scrollboxHeight,
Math.round((visibleFromIndex / scrollableIncrements) * scrollingLength),
),
)
}
const bottomBuffer = fullHeight - scrollboxHeight - topBuffer

const backgroundChar = noColor ? BACKGROUND_CHAR : ' '
const scrollboxChar = noColor ? SCROLLBOX_CHAR : ' '
const bgColor = noColor ? undefined : 'gray'
const scrollboxColor = noColor ? undefined : 'cyan'

return (
<Box flexDirection="column">
{displayArrows ? <Text></Text> : null}

<Box width={1}>
<Text backgroundColor={bgColor}>{backgroundChar.repeat(topBuffer)}</Text>
</Box>
<Box width={1}>
<Text backgroundColor={scrollboxColor}>{scrollboxChar.repeat(scrollboxHeight)}</Text>
</Box>
<Box width={1}>
<Text backgroundColor={bgColor}>{backgroundChar.repeat(bottomBuffer)}</Text>
</Box>

{displayArrows ? <Text></Text> : null}
</Box>
)
}

export {Scrollbar}
Original file line number Diff line number Diff line change
Expand Up @@ -414,19 +414,18 @@ describe('SelectInput', async () => {
const renderInstance = render(<SelectInput items={items} onChange={() => {}} availableLines={10} />)

expect(renderInstance.lastFrame()).toMatchInlineSnapshot(`
" Automations
> (a) fifth
(2) sixth
" Automations  
> (a) fifth  
(2) sixth  
 
Merchant Admin  
(3) eighth  
(4) ninth  
 
Other  
(f) first  
Merchant Admin
(3) eighth
(4) ninth
Other
(f) first
Press ↑↓ arrows to select, enter to confirm.
10 options available, 5 visible."
Press ↑↓ arrows to select, enter to confirm."
`)

await waitForInputsToBeReady()
Expand All @@ -437,19 +436,18 @@ describe('SelectInput', async () => {
await sendInputAndWaitForChange(renderInstance, ARROW_DOWN)

expect(renderInstance.lastFrame()).toMatchInlineSnapshot(`
" [1mAutomations[22m
(2) sixth
[1mMerchant Admin[22m
(3) eighth
(4) ninth
[1mOther[22m
(f) first
[36m>[39m [36m(s) second[39m
" [1mAutomations[22m [100m [49m
(2) sixth [46m [49m
[46m [49m
[1mMerchant Admin[22m [46m [49m
(3) eighth [46m [49m
(4) ninth [46m [49m
[100m [49m
[1mOther[22m [100m [49m
(f) first [100m [49m
[36m>[39m [36m(s) second[39m [100m [49m
Press ↑↓ arrows to select, enter to confirm.
10 options available, 5 visible."
Press ↑↓ arrows to select, enter to confirm."
`)
})

Expand Down
53 changes: 31 additions & 22 deletions packages/cli-kit/src/private/node/ui/components/SelectInput.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import {Scrollbar} from './Scrollbar.js'
import {debounce} from '../../../../public/common/function.js'
import {useSelectState} from '../hooks/use-select-state.js'
import {handleCtrlC} from '../../ui.js'
Expand Down Expand Up @@ -263,38 +264,47 @@ function SelectInputInner<T>(
)
} else {
const optionsHeight = initialItems.length + maximumLinesLostToGroups(initialItems)
const minHeight = hasAnyGroup ? 5 : 2
const sectionHeight = Math.max(minHeight, Math.min(availableLinesToUse, optionsHeight))
const maxKeyLength = itemsWithKeys
.map((item) => item.key?.length ?? 0)
.reduce((lenA, lenB) => Math.max(lenA, lenB), 0)
const minHeight = hasAnyGroup ? 5 : 2

return (
<Box flexDirection="column" ref={ref}>
<Box
flexDirection="column"
height={Math.max(minHeight, Math.min(availableLinesToUse, optionsHeight))}
overflowY="hidden"
>
{state.visibleOptions.map((item: ItemWithKey<T>, index: number) => (
<Item
key={item.key}
item={item}
previousItem={state.visibleOptions[index - 1]}
highlightedTerm={highlightedTerm}
isSelected={item.value === state.value}
items={state.visibleOptions}
enableShortcuts={enableShortcuts}
hasAnyGroup={hasAnyGroup}
maxKeyLength={maxKeyLength}
<Box flexDirection="column" ref={ref} gap={1}>
<Box flexDirection="row" height={sectionHeight} width="100%">
<Box flexDirection="column" overflowY="hidden" flexGrow={1}>
{state.visibleOptions.map((item: ItemWithKey<T>, index: number) => (
<Item
key={item.key}
item={item}
previousItem={state.visibleOptions[index - 1]}
highlightedTerm={highlightedTerm}
isSelected={item.value === state.value}
items={state.visibleOptions}
enableShortcuts={enableShortcuts}
hasAnyGroup={hasAnyGroup}
maxKeyLength={maxKeyLength}
/>
))}
</Box>

{hasLimit ? (
<Scrollbar
containerHeight={sectionHeight}
visibleListSectionLength={limit}
fullListLength={items.length}
visibleFromIndex={state.visibleFromIndex}
/>
))}
) : null}
</Box>

{noItems ? (
<Box marginTop={1} marginLeft={3} height={2}>
<Box marginLeft={3}>
<Text dimColor>Try again with a different keyword.</Text>
</Box>
) : (
<Box marginTop={1} marginLeft={3} flexDirection="column">
<Box marginLeft={3} flexDirection="column">
<Text dimColor>
{infoMessage
? infoMessage
Expand All @@ -306,7 +316,6 @@ function SelectInputInner<T>(
{morePagesMessage ? ` ${morePagesMessage}` : null}
</Text>
) : null}
{hasLimit ? <Text dimColor>{`${items.length} options available, ${limit} visible.`}</Text> : null}
</Box>
)}
</Box>
Expand Down
Loading

0 comments on commit 45f52b1

Please sign in to comment.