Seonghyeon

터미널 안에 어떻게 색을 입힐까?

개발

터미널에 표시 어떻게 초록색으로 나옴?

어제 vitest에 PR 하나를 올렸다. 아직 머지를 기다리고 있지만,, 코드 기여는 처음이라 넘 뿌듯했다 👍

메인테이너 분이 todo 테스트에 대한 새로운 아이콘을 추가로 적용해보는 게 어떻냐고 제안하셔서 아이콘을 추가했는데 그러던 중 터미널에 1 passed처럼 초록색을 입히는 방식이 궁금해졌다.

터미널

vitest는 tinyrainbow라는 라이브러리를 쓰고 있다.

import c from 'tinyrainbow' // color 약자

export const testPass: string = c.green(F_CHECK)
export const todo: string = c.dim(c.gray(F_TODO)) // 추가: 회색 + 투명도

라이브러리가 복잡할 줄 알았는데 150줄짜리 작은 코드였고, vitest 메인테이너 분들도 같이 관리하고 있는 것 같다.

#FFFFFF생각 많이 날거야

번들 파일의 2/3 라인을 차지하는 컬러맵이다. 헥사 코드일 줄 알았는데 튜플이 있어서 당황스럽다.

const colorsMap = {
  reset: [0, 0],
  bold: [1, 22, '\x1B[22m\x1B[1m'],
  dim: [2, 22, '\x1B[22m\x1B[2m'],
  italic: [3, 23],
  underline: [4, 24],
  inverse: [7, 27],
  hidden: [8, 28],
  strikethrough: [9, 29],
  black: [30, 39],
  red: [31, 39],
  green: [32, 39],
  yellow: [33, 39],
  blue: [34, 39],
  magenta: [35, 39],
  cyan: [36, 39],
  white: [37, 39],
  gray: [90, 39],
  bgBlack: [40, 49],
  bgRed: [41, 49],
  bgGreen: [42, 49],
  bgYellow: [43, 49],
  bgBlue: [44, 49],
  bgMagenta: [45, 49],
  bgCyan: [46, 49],
  bgWhite: [47, 49],

  blackBright: [90, 39],
  redBright: [91, 39],
  greenBright: [92, 39],
  yellowBright: [93, 39],
  blueBright: [94, 39],
  magentaBright: [95, 39],
  cyanBright: [96, 39],
  whiteBright: [97, 39],

  bgBlackBright: [100, 49],
  bgRedBright: [101, 49],
  bgGreenBright: [102, 49],
  bgYellowBright: [103, 49],
  bgBlueBright: [104, 49],
  bgMagentaBright: [105, 49],
  bgCyanBright: [106, 49],
  bgWhiteBright: [107, 49],
} as const

ANSI 이스케이프 시퀀스?! 🤔

컴퓨터 초창기 시절, 서로 다른 터미널 장치들이 제각각의 방식으로 화면을 제어하던 혼란을 막기 위해 ANSI(미국 표준 협회) 에서 정한 표준 규약이다. [시작 코드, 종료 코드]의 형태로, 터미널에 글자를 뿌릴 때 '여기서부터는 빨간색으로 그려!', '커서를 왼쪽으로 3칸 옮겨!' 같은 제어 명령을 전달한다.

컬러 맵에는 이렇게 숫자 값만 적어놓고, 나중에 \x1B[${숫자_값}m 처럼 wrapping해서 사용한다.

예시

파란 바탕에 글씨가 나오는 bgBlue를 예시로 들어보자.

bgBlue: [44, 49], // [시작, 종료]

44와 49 사이의 안녕 만 파란 바탕에 나오게 되고, 이후에는 초기화된다.

const text = "안녕"
const bgBlueText = `\x1B[44m${text}\x1B[49m`

console.log(bgBlueText);

우리가 터미널에서 /clear 하면 화면이 비워지는 것도 운영체제가 실행하는 clear라는 실행 파일이 \x1B[2J (화면 전체 지우기), \x1B[H (커서 처음으로 이동) 같은 ANSI 이스케이프 시퀀스를 출력하기 때문일 것이다.

근데 이건 왜 값이 3개인데?

그런데 bold와 dim에만 뒤에 하나가 더 붙어있다.

readonly bold: readonly [1, 22, "\u001B[22m\u001B[1m"],
readonly dim: readonly [2, 22, "\u001B[22m\u001B[2m"],

그 이유는 bold, dim의 종료 코드가 똑같이 22m이라서, 둘을 중첩해서 사용할 때 bold만 초기화하고 싶어도 bold와 dim이 함께 리셋버리는 문제가 생기기 때문이다.

c.dim("hello " + c.bold("world") + " bye") // 이런 경우!

즉, 세 번째 값은 평소에는 쓰이지 않고 bold와 dim이 중첩되는 경우에 쓰인다. 내부에 있는 bold가 닫힐 때 두 가지를 모두 초기화하고 다시 dim만 적용하는 방식이다.

근데 색상도 종료 코드는 똑같은데?

그렇게 따지면 색상도 종료 코드가 똑같은데 왜 세 번째 값이 없을까?

cyan: [36, 39],
white: [37, 39],

색상도 종료할 때 같이 초기화되는 건 마찬가지다. 다만 색상을 중첩하기보단, 보통 색상 + 스타일 조합으로 사용하는 경우가 많아서 의도적으로 세 번째 replace 값을 추가하지 않았을 거라 생각했다. 그리고 cyan(white("안녕"))은 중첩해도 어차피 cyan이고, 종료해도 똑같이 기본 색으로 돌아간다.

코드 살펴보기

전체 코드는 여기서 볼 수 있다.

일단 이 라이브러리는 default export로 메인 함수 createColors()가 반환하는 colorsObject를 내보낸다. vitest에서 봤던 c.dim()에서 c가 바로 colorsObject이다.

타입 선언은 이렇게 돼있다.

// 호출 시그니처 타입으로, `open`, `close` 프로퍼티도 갖고 있는 함수 객체
export interface Formatter {
  (input?: unknown): string
  open: string
  close: string
}

// colorsMap 키 집합
type ColorName = keyof typeof colorsMap 

// 각 키에 대해 Formatter를 지정
type ColorsMethods = {
  [Key in ColorName]: Formatter 
}

// colorsObject : Colors
export type Colors = ColorsMethods & { 
  isColorSupported: boolean
  reset: (input: unknown) => string
}

이외에도 getDefaultColors(), isSupported() 같은 함수도 내보낸다. 만약 터미널이 ANSI 색상을 지원하지 않거나 사용자 설정이 있다면 원래 색상의 문자를 그대로 렌더링하는 string 함수가 불리도록 한다.

// 가짜 포메터(?)
function string(str: unknown) {
  return String(str)
}
string.open = ''
string.close = ''

// 색상 안입힐 때
export function getDefaultColors(): Colors {
  const defaultColors = {
    isColorSupported: false,
    reset: string,
  } as Colors
  for (const name in colorsMap) {
    defaultColors[name as 'reset'] = string // 포메터와 같은 타입의 string 함수 세팅
  }
  return defaultColors
}

formatter 함수: 클로저 활용하기

formatter는 컬러 맵에서 보았던 2가지 또는 3가지 값을 인자로 받는다.

const formatter = (open: string, close: string, replace = open) => {
  const fn = (input: unknown) => {
    const string = String(input)
    // 텍스트 내부에 이미 종료 코드가 들어있는지 확인
    const index = string.indexOf(close, open.length)

    return ~index
      ? open + replaceClose(string, close, replace, index) + close
      : open + string + close
  }

  fn.open = open
  fn.close = close
  return fn // 클로저 함수를 반환
}

formatter는 이렇게 사용하는데

export function createColors(): Colors {
  const enabled = isSupported()
  
  // ...
  
  const colorsObject = {
    isColorSupported: enabled,
  } as Colors

  // 튜플 값을 ANSI 이스케이프 시퀀스로 래핑
  const wrap = (num: number) => `\x1B[${num}m`

  for (const name in colorsMap) {
    const formatterArgs = colorsMap[name as 'bold'] // 컬러 맵의 튜플 값
    colorsObject[name as ColorName] = enabled // 색칠 가능하면
      ? formatter(
          wrap(formatterArgs[0]), // 시작 코드
          wrap(formatterArgs[1]), // 종료 코드
          formatterArgs[2] // replace (bold, dim)
        )
      : string // 색상 없는 문자열
  }

  return colorsObject
}

로직을 보면 우리가 vitest에서 c('시작코드', '종료코드') 이런식으로 안부르고 c.yellow('hello')로 편하게 사용할 수 있게 미리 colorsObject의 키들을 돌면서 formatter 함수를 호출해놓는다.

결국 우리는 c.yellow()를 사용할 때 formatter 함수가 반환한 내부 클로저 함수 fn을 호출해 쓰는 것이다.

클로저는 외부 환경을 기억하고 더 오래 살아남는 내부 함수 정도로만 알고 있었는데, 이렇게 활용할 수 있구나 싶었다. fn은 formatter의 매개변수로 전달받은 open, close, replace를 클로저로 캡처하고 있기 때문에 나중에도 그 값들을 기억한다.

마치며

vitest 보다가 어쩌다가 여기까지 왔는지 모르겠지만,, 나름 재밌었다! 코드가 쉽지는 않았지만 어느 정도 이해할 수 있었다. 난 오픈소스에 별로 관심이 없었고 뭔가 기여한다는 게 무섭게 느껴졌는데 이번에 PR을 올리면서 이런 생각이 들었다.

앞으로는 ODD로 간다 ; (오픈 소스 주도 개발)

구조 설계부터 명확한 변수명, 좋은 패턴들, CI, 메인테이너의 리뷰까지 정말 배울 게 많은 것 같다.

함수랑 산악회 VDD 최고 👍❤️