Skip to content

Commit 33f6fb8

Browse files
authored
Merge pull request #5262 from Shopify/no-color-loader-bar
Replace rainbow bar with moving hill for no-color mode
2 parents cad370d + 549c884 commit 33f6fb8

File tree

4 files changed

+102
-6
lines changed

4 files changed

+102
-6
lines changed

.changeset/shiny-lions-marry.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@shopify/cli-kit': minor
3+
---
4+
5+
Improve display of loading bar in no-color mode

packages/cli-kit/src/private/node/ui/components/Tasks.test.tsx

Lines changed: 73 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,28 @@ import {Task, Tasks} from './Tasks.js'
22
import {getLastFrameAfterUnmount, render} from '../../testing/ui.js'
33
import {unstyled} from '../../../../public/node/output.js'
44
import {AbortController} from '../../../../public/node/abort.js'
5+
import {Stdout} from '../../ui.js'
56
import React from 'react'
6-
import {describe, expect, test, vi} from 'vitest'
7+
import {beforeEach, describe, expect, test, vi} from 'vitest'
8+
import {useStdout} from 'ink'
9+
10+
vi.mock('ink', async () => {
11+
const original: any = await vi.importActual('ink')
12+
return {
13+
...original,
14+
useStdout: vi.fn(),
15+
}
16+
})
17+
18+
beforeEach(() => {
19+
vi.mocked(useStdout).mockReturnValue({
20+
stdout: new Stdout({
21+
columns: 80,
22+
rows: 80,
23+
}) as any,
24+
write: () => {},
25+
})
26+
})
727

828
describe('Tasks', () => {
929
test('shows a loading state at the start', async () => {
@@ -29,6 +49,58 @@ describe('Tasks', () => {
2949
expect(firstTaskFunction).toHaveBeenCalled()
3050
})
3151

52+
test('shows a loading state that is useful in no-color mode', async () => {
53+
// Given
54+
const firstTaskFunction = vi.fn(async () => {
55+
await new Promise((resolve) => setTimeout(resolve, 2000))
56+
})
57+
58+
const firstTask = {
59+
title: 'task 1',
60+
task: firstTaskFunction,
61+
}
62+
63+
// When
64+
const renderInstance = render(<Tasks tasks={[firstTask]} silent={false} noColor />)
65+
66+
// Then
67+
expect(unstyled(renderInstance.lastFrame()!)).toMatchInlineSnapshot(`
68+
"▁▁▁▂▂▃▃▄▄▅▅▆▆▇▇██▇▇▆▆▅▅▄▄▃▃▂▂▁▁▁▁▂▂▃▃▄▄▅▅▆▆▇▇██▇▇▆▆▅▅▄▄▃▃▂▂▁▁▁▁▂▂▃▃▄▄▅▅▆▆▇▇██▇▇▆
69+
task 1 ..."
70+
`)
71+
expect(firstTaskFunction).toHaveBeenCalled()
72+
})
73+
74+
test('truncates the no-color display correctly for narrow screens', async () => {
75+
// Given
76+
vi.mocked(useStdout).mockReturnValue({
77+
stdout: new Stdout({
78+
columns: 10,
79+
rows: 80,
80+
}) as any,
81+
write: () => {},
82+
})
83+
84+
const firstTaskFunction = vi.fn(async () => {
85+
await new Promise((resolve) => setTimeout(resolve, 2000))
86+
})
87+
88+
const firstTask = {
89+
title: 'task 1',
90+
task: firstTaskFunction,
91+
}
92+
93+
// When
94+
const renderInstance = render(<Tasks tasks={[firstTask]} silent={false} noColor />)
95+
96+
// Then
97+
expect(unstyled(renderInstance.lastFrame()!)).toMatchInlineSnapshot(`
98+
"▁▁▁▂▂▃▃▄▄▅▅▆▆▇▇██▇▇▆
99+
task 1 ..."
100+
`)
101+
expect(firstTaskFunction).toHaveBeenCalled()
102+
})
103+
32104
test('shows nothing at the end in case of success', async () => {
33105
// Given
34106
const firstTaskFunction = vi.fn(async () => {})

packages/cli-kit/src/private/node/ui/components/Tasks.tsx

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,14 @@ import useLayout from '../hooks/use-layout.js'
33
import useAsyncAndUnmount from '../hooks/use-async-and-unmount.js'
44
import {isUnitTest} from '../../../../public/node/context/local.js'
55
import {AbortSignal} from '../../../../public/node/abort.js'
6+
import {shouldDisplayColors} from '../../../../public/node/output.js'
67
import useAbortSignal from '../hooks/use-abort-signal.js'
78
import {handleCtrlC} from '../../ui.js'
89
import {Box, Text, useStdin, useInput} from 'ink'
910
import React, {useRef, useState} from 'react'
1011

1112
const loadingBarChar = '▀'
13+
const hillString = '▁▁▂▂▃▃▄▄▅▅▆▆▇▇██▇▇▆▆▅▅▄▄▃▃▂▂▁▁'
1214

1315
export interface Task<TContext = unknown> {
1416
title: string
@@ -25,6 +27,7 @@ interface TasksProps<TContext> {
2527
silent?: boolean
2628
onComplete?: (ctx: TContext) => void
2729
abortSignal?: AbortSignal
30+
noColor?: boolean
2831
}
2932

3033
enum TasksState {
@@ -65,9 +68,13 @@ function Tasks<TContext>({
6568
silent = isUnitTest(),
6669
onComplete = noop,
6770
abortSignal,
71+
noColor,
6872
}: React.PropsWithChildren<TasksProps<TContext>>) {
6973
const {twoThirds} = useLayout()
70-
const loadingBar = new Array(twoThirds).fill(loadingBarChar).join('')
74+
let loadingBar = new Array(twoThirds).fill(loadingBarChar).join('')
75+
if (noColor ?? !shouldDisplayColors()) {
76+
loadingBar = hillString.repeat(Math.ceil(twoThirds / hillString.length))
77+
}
7178
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
7279
const [currentTask, setCurrentTask] = useState<Task<TContext>>(tasks[0]!)
7380
const [state, setState] = useState<TasksState>(TasksState.Loading)
@@ -121,7 +128,7 @@ function Tasks<TContext>({
121128

122129
return state === TasksState.Loading && !isAborted ? (
123130
<Box flexDirection="column">
124-
<TextAnimation text={loadingBar} />
131+
<TextAnimation text={loadingBar} maxWidth={twoThirds} />
125132
<Text>{currentTask.title} ...</Text>
126133
</Box>
127134
) : null

packages/cli-kit/src/private/node/ui/components/TextAnimation.tsx

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import gradient from 'gradient-string'
55

66
interface TextAnimationProps {
77
text: string
8+
maxWidth?: number
89
}
910

1011
function rainbow(text: string, frame: number) {
@@ -14,10 +15,21 @@ function rainbow(text: string, frame: number) {
1415
return gradient(leftColor, rightColor)(text, {interpolation: 'hsv', hsvSpin: 'long'})
1516
}
1617

18+
function rotated(text: string, steps: number) {
19+
const normalizedSteps = steps % text.length
20+
const start = text.slice(-normalizedSteps)
21+
const end = text.slice(0, -normalizedSteps)
22+
return start + end
23+
}
24+
25+
function truncated(text: string, maxWidth: number | undefined): string {
26+
return maxWidth ? text.slice(0, maxWidth) : text
27+
}
28+
1729
/**
1830
* `TextAnimation` applies a rainbow animation to text.
1931
*/
20-
const TextAnimation = memo(({text}: TextAnimationProps): JSX.Element => {
32+
const TextAnimation = memo(({text, maxWidth}: TextAnimationProps): JSX.Element => {
2133
const frame = useRef(0)
2234
const [renderedFrame, setRenderedFrame] = useState(text)
2335
const timeout = useRef<NodeJS.Timeout>()
@@ -26,12 +38,12 @@ const TextAnimation = memo(({text}: TextAnimationProps): JSX.Element => {
2638
const newFrame = frame.current + 1
2739
frame.current = newFrame
2840

29-
setRenderedFrame(rainbow(text, frame.current))
41+
setRenderedFrame(rainbow(truncated(rotated(text, frame.current), maxWidth), frame.current))
3042

3143
timeout.current = setTimeout(() => {
3244
renderAnimation()
3345
}, 35)
34-
}, [text])
46+
}, [text, maxWidth])
3547

3648
useLayoutEffect(() => {
3749
renderAnimation()

0 commit comments

Comments
 (0)