Skip to content

Commit c98b3ea

Browse files
committed
Replace rainbow bar with moving hill for no-color mode
1 parent d1c51b0 commit c98b3ea

File tree

3 files changed

+107
-3
lines changed

3 files changed

+107
-3
lines changed

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('shrinks the no-color display 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: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,22 @@ 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+
// Chars that can be arranged to form a colorless horizontal figure that displays progress as it moves.
14+
// The string is 15 chars long so it can always be displayed even within a box inside a 20-char-wide terminal.
15+
const hillString = '▁▂▃▄▅▆▇█▇▆▅▄▃▂▁'
16+
// Like the hill string, but forming a gentler slope for motion that is less jarring
17+
const gradualHillString = hillString
18+
.split('')
19+
.map((char) => char.repeat(2).split(''))
20+
.flat()
21+
.join('')
1222

1323
export interface Task<TContext = unknown> {
1424
title: string
@@ -25,6 +35,7 @@ interface TasksProps<TContext> {
2535
silent?: boolean
2636
onComplete?: (ctx: TContext) => void
2737
abortSignal?: AbortSignal
38+
noColor?: boolean
2839
}
2940

3041
enum TasksState {
@@ -65,9 +76,20 @@ function Tasks<TContext>({
6576
silent = isUnitTest(),
6677
onComplete = noop,
6778
abortSignal,
79+
noColor,
6880
}: React.PropsWithChildren<TasksProps<TContext>>) {
6981
const {twoThirds} = useLayout()
70-
const loadingBar = new Array(twoThirds).fill(loadingBarChar).join('')
82+
let loadingBar = new Array(twoThirds).fill(loadingBarChar).join('')
83+
if (noColor ?? !shouldDisplayColors()) {
84+
// fill loading bar with the no color chars, repeating until the length is two thirds but only using a whole number repeat of the no color chars
85+
const repeatCount = Math.floor(twoThirds / gradualHillString.length)
86+
if (repeatCount === 0) {
87+
const shortRepeatCount = Math.floor(twoThirds / hillString.length)
88+
loadingBar = hillString.repeat(Math.max(1, shortRepeatCount))
89+
} else {
90+
loadingBar = gradualHillString.repeat(repeatCount)
91+
}
92+
}
7193
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
7294
const [currentTask, setCurrentTask] = useState<Task<TContext>>(tasks[0]!)
7395
const [state, setState] = useState<TasksState>(TasksState.Loading)

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

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,16 @@ function rainbow(text: string, frame: number) {
1414
return gradient(leftColor, rightColor)(text, {interpolation: 'hsv', hsvSpin: 'long'})
1515
}
1616

17+
function rotated(text: string, steps: number) {
18+
const textLength = text.length
19+
return text
20+
.split('')
21+
.map((_, index) => {
22+
return text[(index + steps) % textLength]
23+
})
24+
.join('')
25+
}
26+
1727
/**
1828
* `TextAnimation` applies a rainbow animation to text.
1929
*/
@@ -26,7 +36,7 @@ const TextAnimation = memo(({text}: TextAnimationProps): JSX.Element => {
2636
const newFrame = frame.current + 1
2737
frame.current = newFrame
2838

29-
setRenderedFrame(rainbow(text, frame.current))
39+
setRenderedFrame(rainbow(rotated(text, frame.current), frame.current))
3040

3141
timeout.current = setTimeout(() => {
3242
renderAnimation()

0 commit comments

Comments
 (0)