포스트

snacks.dashboard 헤더 다색으로 칠하기 — 그리고 평면 ASCII 아트의 레이어 한계

snacks.nvim 대시보드 헤더를 영역별로 다른 색으로 렌더링하는 방법(text 조각 배열 + 조각별 hl, 섹션 align 중앙정렬)과, 그 과정에서 부딪힌 '평면 ASCII 아트는 겹친 그림을 색으로 분리할 수 없다'는 근본 한계·우회법.

snacks.dashboard 헤더 다색으로 칠하기 — 그리고 평면 ASCII 아트의 레이어 한계

snacks.nvim(folke의 미니 플러그인 모음, “snacks” = 가벼운 간식거리 유틸이라는 뜻)의 대시보드 헤더에 ASCII 아트를 넣는 것까진 쉽다. 문제는 그다음이다 — 헤더 전체가 한 색으로만 나온다. 구름은 보라, 고양이는 청록, 펜스는 회색처럼 영역별로 색을 나누고 싶다면 헤더를 문자열로 넘기는 방식으로는 안 된다.

이 글은 (1) snacks 헤더를 다색으로 칠하는 방법과, (2) 그 과정에서 만난 “평면 ASCII 아트는 원리적으로 색 분리가 불가능한 지점이 있다”는 함정과 우회법을 정리한다.

대시보드 헤더의 이미지 vs ASCII 선택, 그리고 줄별 center 정렬로 아트가 깨지는 문제는 별도 주제다. 여기서는 에 집중한다.

1. preset.header(문자열)는 단색뿐

흔히 쓰는 방식은 헤더를 문자열 하나로 넘기는 것이다.

1
opts.dashboard.preset.header = table.concat(lines, "\n")

이렇게 하면 snacks가 헤더 전체에 SnacksDashboardHeader 하이라이트 그룹 하나를 입힌다. 그룹의 fg를 바꾸면 색은 바뀌지만 여전히 단색이다. 부분마다 다른 색을 주는 길이 없다.

2. 다색은 text 조각 배열로

해법은 preset.header를 버리고, 섹션에 text 조각(fragment) 배열을 직접 넘기는 것이다. 각 조각은 { 문자열, hl = "그룹명" } 꼴이고, snacks가 조각마다 extmark로 하이라이트를 입힌다(snacks 소스에서 text.hl → hl_group으로 연결).

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
opts.dashboard.sections = {
  {
    text = {
      { "   구름 줄  ", hl = "CatSky" },
      { "\n" },
      { "   고양이 줄 ", hl = "CatBody" },
      { "\n" },
      { "clang", hl = "CatSign" }, { ".engineer", hl = "CatSign" },
    },
    align = "center",
    padding = 1,
  },
  { section = "keys", gap = 1, padding = 1 },
  { section = "startup" },
}

여기서 두 가지가 핵심이다.

첫째, 줄바꿈은 별도 { "\n" } 조각으로 넣는다. snacks는 조각들을 이어 붙이며 \n을 만나면 줄을 나눈다. 한 시각적 줄은 여러 조각으로 쪼갤 수 있어서, 같은 줄 안에서도 조각마다 다른 색을 줄 수 있다(위의 서명 줄이 그 예).

둘째, 중앙 정렬은 조각이 아니라 “섹션의 align“이 담당한다. 이걸 몰라서 조각마다 align = "center"를 넣으면 아무 일도 안 일어난다. snacks의 포맷 단계(D:format)가 각 줄 전체를 대시보드 폭에 맞춰 정렬하는데, 이때 참조하는 건 섹션 아이템의 align이다:

1
2
-- snacks 내부 (요지)
self:align(center[l], self.opts.width - left - right, item.align)

preset.header가 알아서 가운데 정렬됐던 것도 내부 포맷이 header = { "%s", align = "center" }로 정의돼 있어서였다. 직접 text를 쓰면 그 편의가 사라지므로 섹션에 align = "center"를 명시해야 한다.

한 가지 더: 아트가 깨지지 않으려면 모든 줄을 최대 폭으로 오른쪽 패딩해 폭을 통일해야 한다(줄마다 center 오프셋이 달라지면 왼쪽 시작점이 어긋난다). 이건 다색과 무관하게 필요한 전처리다.

3. 색은 줄 번호가 아니라 “내용”으로 매핑하라

각 줄에 어떤 색을 줄지 정할 때, lines[16]은 펜스, lines[8]은 고양이 식으로 줄 번호를 하드코딩하면 아트를 하나만 쓸 때는 되지만 헤더 아트를 교체하는 순간 전부 어긋난다.

대신 줄의 내용으로 영역을 판별하면 여러 아트에 그대로 적용된다.

1
2
3
4
5
6
7
8
local function region_of(line)
  if line:find("|  |") or line:find("^_/\\") then
    return "CatFence"  -- 펜스: 기둥(| |) 또는 레일(_/\)
  elseif line:find("[M&]") then
    return "CatSky"    -- 구름 글리프
  end
  return "CatBody"     -- 나머지 = 고양이
end

여기 함정이 하나 있다: 판별 순서다. 서명이 들어간 줄에도 오른쪽으로 펜스 기둥(| |)이 이어지므로, 서명 줄을 먼저 따로 걸러내지 않으면 통째로 펜스색이 된다. 또 고양이 몸통의 | |(한 칸 공백)은 펜스 규칙 | |(두 칸 공백)에 안 걸리도록 규칙을 잡아야 한다 — 사소해 보여도 이런 글리프 간섭이 색을 틀리게 만든다.

4. 핵심 — 평면 ASCII 아트는 겹친 그림을 색으로 분리할 수 없다

여기까지는 “줄 단위”로 색을 나눴다. 그런데 고양이가 펜스에 걸터앉은 그림에서는 고양이의 꼬리·엉덩이가 펜스 줄 안에 섞여 있다.

1
2
3
4
_/\_/\_/\__  _/_/\_/\_/\_/\_/\_/\_/\_    ← 레일 (여기 가운데가 고양이 밑면)
|  |  |  |( (  |  |  |  |  |  |  |  |    ← 기둥 사이 ( ( = 꼬리
|  |  |  | ) ) |  |  |  |  |  |  |  |
|  |  |  |(_(  |  |  |  |  |  |  |  |

줄 전체를 펜스색으로 칠하면 이 꼬리·엉덩이가 회색이 된다. 문자 단위로 더 쪼개야 하는데, 여기서 되는 줄과 안 되는 줄이 갈린다.

포스트 줄(| | |)은 깔끔하게 분리된다. 포스트 줄에 등장하는 문자는 펜스(|, 공백)와 고양이((, ), _, \)뿐이라, “|·공백이면 펜스, 나머지는 고양이”로 완벽히 갈린다.

1
2
3
4
-- 포스트 줄: | 와 공백만 펜스, 나머지는 고양이
local function post_char(ch)
  return (ch == "|" or ch == " ") and "CatFence" or "CatBody"
end

하지만 레일 줄(_/\_/\)은 원리적으로 불가능하다. 레일도 _ / \로 그려지고, 고양이의 밑면(엉덩이가 펜스에 눌린 부분)도 _ / \로 그려진다. 둘이 완전히 같은 문자를 공유한다. 문자 종류만 봐서는 “이 \가 레일인지 고양이 다리인지” 구분할 방법이 없다.

이유는 근본적이다. 원래 이 그림은 고양이 레이어펜스 레이어가 따로 있었을 텐데, ASCII로 평면화되는 순간 두 레이어가 한 장으로 합쳐지고 “어느 글자가 어느 레이어였는지” 정보가 영구히 사라진다. 색 복원은 사실상 소실된 레이어를 추론하는 일이다.

우회: 완전 범용을 포기하고 “아트별 시그니처”를 쓴다

원리적으로 범용 분리가 불가능하다면, 특정 아트에만 있는 시각적 시그니처를 잡는다. 이 고양이의 경우, 레일에서 고양이가 걸터앉은 가랑이 부분이 \__ _/처럼 연속 2칸 공백을 품는다 — 정상 레일에는 없는 패턴이다.

1
2
3
4
5
-- 레일 줄에서 고양이 밑면 찾기: \…/ 사이에 '연속 2칸 공백'을 낀 구간
local s, e = line:find("\\[_ ]+/")
if s and line:sub(s, e):find("  ") then
  for i = s, e do cat_columns[i] = true end  -- 그 열들을 고양이색으로
end

이건 magic 고양이 아트 전용 휴리스틱이고, 다른 아트에는 안 맞을 수 있다. 즉 최종 설계는 “범용 규칙(포스트 줄) + 아트별 시그니처(레일 줄)”의 절충이다. 완전 자동을 고집하기보다, 원리적 한계를 인정하고 그 지점만 국소적으로 특수 처리하는 편이 정직하고 유지보수도 낫다. (실제로 이 방식으로도 완벽하진 않다 — 두 번째 고양이 아트의 레일에서는 엉덩이 글리프 몇 개가 회색으로 남는데, 이건 위 한계의 “수용된 잔여물”로 두었다.)

5. 색은 스크린샷으로만 최종 검증된다

색 로직은 헤드리스로 어느 정도 검증할 수 있다. 실제로 nvim --headless -l script.lua로 각 줄을 돌려 “몇 번째 열이 어느 색 그룹에 배정되는지”까지는 시뮬레이션된다.

1
2
3
4
5
-- 열별 색 배정을 텍스트로 시각화 (^ = 고양이색)
local mark = ""
for i = 1, #line do mark = mark .. (cat_columns[i] and "^" or " ") end
print(line)
print(mark)

하지만 여기까지다. 실제 색 대비나 “고양이 다리와 꼬리가 자연스럽게 이어지는가” 같은 건 extmark로 그려지는 결과라 렌더된 대시보드 스크린샷을 직접 봐야 확정된다. 실제로 연결부에서 경계 2칸(\ /)을 포함하느냐 마느냐가 “다리가 이어져 보이느냐”를 갈랐는데, 이건 열 시뮬레이션만으로는 안 보이고 화면을 봐야 판단이 됐다.

정리

  • snacks 대시보드 헤더 다색preset.header(단색) 대신 섹션에 text 조각 배열을 넘기고 조각마다 hl을 지정한다. 중앙 정렬은 섹션의 align이 담당.
  • 색은 줄 번호가 아니라 줄 내용으로 판별하면 아트를 바꿔도 재사용된다. 단 판별 순서에 주의.
  • 평면 ASCII 아트는 겹친 그림을 색으로 완전 분리할 수 없다 — 두 그림이 같은 글자를 공유하는 순간 레이어 정보가 소실되기 때문. 문자 종류로 갈리는 곳(포스트 줄)은 범용 규칙으로, 안 갈리는 곳(레일 줄)은 아트별 시그니처로 국소 처리하는 절충이 현실적이다.
  • 색·정렬 같은 시각 결과는 헤드리스 시뮬레이션으로 열 배정까지만 확인되고, 최종 판단은 스크린샷으로 한다.
이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.