Ratatui로 Miller Columns 3-Pane 파일 탐색 UI 만들기: Finder 스타일 TUI 설계 가이드

ratatui 튜토리얼은 대부분 단일 리스트에서 멈춥니다. Finder 스타일 3-pane 네비게이션을 만들기 위한 레이아웃 설계, pane 팩토리, 포커스 관리, 캐스케이딩 리셋 패턴을 Rosé Pine 테마와 함께 정리합니다.

1. 문제 상황

Rust TUI 라이브러리 ratatui의 튜토리얼을 다섯 개쯤 훑어보면 같은 패턴이 눈에 들어옵니다.

단일 List 위젯에 아이템을 렌더링하고, ↑↓로 선택하고, Enter로 동작.

좋은 시작입니다. 하지만 실제 앱은 리스트 하나로 끝나지 않습니다. 프로젝트 탐색기, 파일 관리자, Git 브라우저, 데이터베이스 셸 — 모두 여러 패널이 서로 연결되어 있고, 한 패널의 선택이 다음 패널의 내용을 결정합니다.

TUI 프로젝트인 duru를 만들면서 정확히 이 벽과 마주쳤습니다. 요구사항은 단순했습니다.

  • 패널 1: ~/.claude/ 안의 모든 프로젝트
  • 패널 2: 선택된 프로젝트의 메모리 파일들 (CLAUDE.md, MEMORY.md 등)
  • 패널 3: 선택된 파일의 마크다운 프리뷰

macOS Finder의 컬럼 뷰와 동일한 UX입니다. 익숙한 패턴이지만, ratatui 문서에서는 이걸 정확히 보여주는 예제를 찾지 못했습니다. 그래서 직접 설계해야 했습니다.

이 글은 그 설계의 결과를 처음부터 따라갈 수 있도록 풀어놓은 가이드입니다. **파일 한 개(src/ui.rs 180줄)**로 Finder 스타일 3-pane TUI를 만드는 실전 코드를 다룹니다.

2. 원인 분석

2.1 Miller Columns란

"Miller Columns"는 Mark S. Miller가 1980년대 NeXTSTEP 파일 브라우저를 위해 제안한 캐스케이딩 컬럼 방식입니다. 이후 macOS Finder의 "Column View"로 대중화되면서 오늘날 가장 익숙한 파일 탐색 패턴 중 하나가 되었습니다.

구조는 매우 단순합니다.

┌──────────┬──────────┬──────────┐
│ 루트     │ 선택의   │ 그 아래  │
│ 리스트   │ 자식들   │ 자식들   │
│          │          │          │
│ ▸ dir-a  │   file1  │          │
│   dir-b  │ ▸ sub-a  │   leaf1  │
│   dir-c  │   sub-b  │ ▸ leaf2  │
│          │          │   leaf3  │
└──────────┴──────────┴──────────┘

각 컬럼이 "왼쪽 컬럼에서 선택된 아이템의 자식"을 보여줍니다. 오른쪽으로 가면 깊이로 내려가고, 왼쪽으로 가면 부모로 올라옵니다. 트리 구조의 직관을 가로 축으로 펼친 셈입니다.

왜 파일 탐색에 좋은가?

  • 한 번의 시선으로 세 개 수준의 컨텍스트가 보입니다. 지금 어디에 있는지(가운데), 어디서 왔는지(왼쪽), 다음에 뭐가 있는지(오른쪽).
  • 트리 뷰처럼 들여쓰기로 깊이를 표현하지 않아도 되므로 가로 공간을 잘 씁니다.
  • 키보드 내비게이션이 극도로 직관적입니다 — 부모, 자식, ↑↓ 현재 레벨.
  • 선택 상태가 여러 컬럼에 동시에 남아 있어서 현재 위치의 "경로"를 시각적으로 기억할 필요가 없습니다.

트레이드오프는 가로 공간을 많이 먹는다는 것과, 4-5단 이상 깊이가 잘 안 맞는다는 점입니다. 파일 탐색처럼 "보통 3-4단이면 충분한" 도메인에는 잘 맞습니다.

2.2 ratatui의 기본 빌딩 블록

ratatui 0.29 기준, Miller Columns를 만들 때 필요한 빌딩 블록은 이렇습니다.

빌딩 블록 역할
Layout::vertical / Layout::horizontal 영역을 퍼센트·고정 크기로 분할
Constraint::Percentage 비율 분할
Constraint::Length 고정 높이/너비
Block + Borders 테두리, 타이틀, 배경
List / ListItem 선택 가능한 리스트
Paragraph + Wrap 래핑 가능한 텍스트 블록
Style::fg / Style::bg / Modifier::BOLD 색상·굵기

핵심은 Layout으로 영역을 쪼개고, 각 영역에 Block + 위젯을 그린다는 점입니다. 우리가 만드는 게 하나의 "큰 위젯"이 아니라, 여러 작은 위젯들의 배치라는 걸 먼저 받아들여야 합니다.

3. 해결 방법

3.1 전체 레이아웃 설계

먼저 화면을 어떻게 쪼갤지부터 정합니다.

┌ Projects (30%) ┬ Files (30%) ┬ Preview (40%) ┐
│                │             │               │
│                │             │               │
│                │             │               │
│                │             │               │
└────────────────┴─────────────┴───────────────┘
 ↑↓ navigate  ←→ pane  q quit                     ← 1줄 help bar
  • 상단에 3개 패널 (가로 비율 30/30/40)
  • 하단에 1줄 help bar (고정 높이)

30/30/40인가? 프리뷰 패널에 마크다운이 렌더링되므로 가장 넓은 공간이 필요합니다. 프로젝트와 파일 리스트는 이름만 표시하면 되므로 같은 너비로 충분합니다. 이 비율은 기호에 따라 조절할 수 있습니다.

코드로는 두 단계 분할로 표현됩니다.

pub fn render(frame: &mut Frame, app: &App, theme: &Theme) {
    let area = frame.area();

    // Step 1: 세로 분할 — main area + help bar
    let outer = Layout::vertical([
        Constraint::Min(1),      // ← main 영역 (남은 공간 전부)
        Constraint::Length(1),   // ← help bar (정확히 1줄)
    ])
    .split(area);

    // Step 2: main 영역을 가로 3등분
    let chunks = Layout::horizontal([
        Constraint::Percentage(30),
        Constraint::Percentage(30),
        Constraint::Percentage(40),
    ])
    .split(outer[0]);

    render_projects_pane(frame, app, theme, chunks[0]);
    render_files_pane(frame, app, theme, chunks[1]);
    render_preview_pane(frame, app, theme, chunks[2]);
    render_help_bar(frame, theme, outer[1]);
}

Constraint::Min(1)의 의미: 최소 1셀을 보장하되, 남는 공간을 전부 흡수합니다. help bar가 Constraint::Length(1)로 정확히 1줄을 차지하므로, main 영역은 터미널 높이 - 1이 됩니다.

split()Rc<[Rect]>를 반환합니다. 인덱싱으로 각 영역을 꺼낼 수 있고, 내부적으로 참조 카운팅되므로 clone 비용이 거의 없습니다.

3.2 공통 pane_block 팩토리

세 패널이 동일한 스타일 규칙을 따릅니다: 둥근 보더, 포커스되면 iris 색, 아니면 overlay 색. 매번 반복하면 코드가 지저분해지므로 팩토리 함수로 뽑아냅니다.

fn pane_block<'a>(title: &'a str, focused: bool, theme: &'a Theme) -> Block<'a> {
    let border_color = if focused { theme.iris } else { theme.overlay };
    let title_style = if focused {
        Style::default().fg(theme.iris).add_modifier(Modifier::BOLD)
    } else {
        Style::default().fg(theme.muted)
    };

    Block::default()
        .title(Line::from(Span::styled(format!(" {title} "), title_style)))
        .borders(Borders::ALL)
        .border_type(BorderType::Rounded)    // ← 둥근 모서리
        .border_style(Style::default().fg(border_color))
        .style(Style::default().bg(theme.base))
}

포커스 시그널을 두 곳에 넣는 이유: 색상 변화(border_color)만으로는 색약 사용자나 어두운 터미널에서 구별이 어렵습니다. 타이틀을 BOLD 처리하는 걸 추가하면 색이 아니라 무게로도 포커스를 표현할 수 있습니다. 접근성 디테일이지만 습관이 되면 자연스럽습니다.

타이틀을 " {title} "로 패딩하는 이유: 보더 라인과 글자가 붙어 있으면 답답해 보입니다. 앞뒤 공백 하나씩으로 숨통을 틔워줍니다.

라이프타임 'a의 역할: title 문자열과 theme 참조가 반환되는 Block과 같은 수명을 공유함을 명시합니다. ratatui의 Block<'a>는 참조를 보관하므로, 호출자가 해당 데이터를 살려놓아야 합니다.

3.3 포커스 상태를 enum으로

세 패널 중 "지금 어디"에 있는지를 App 구조체에 저장합니다. bool 세 개를 쓰면 "포커스 두 개에 있는 상태"라는 불가능한 경우가 타입 수준에서 허용되어 버리므로, enum이 정답입니다.

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Pane {
    Projects,
    Files,
    Preview,
}

pub struct App {
    pub projects: Vec<Project>,
    pub focus: Pane,              // ← 현재 포커스
    pub project_index: usize,     // ← Projects pane 선택
    pub file_index: usize,        // ← Files pane 선택
    pub scroll_offset: u16,       // ← Preview pane 스크롤
    pub content: String,          // ← 현재 파일 내용 (캐시)
    pub should_quit: bool,
}

여기 주목할 게 있습니다. 각 패널은 자기만의 "선택 상태"를 갖습니다. project_index, file_index, scroll_offset이 각각 독립적입니다. 패널을 전환해도 이전 패널의 선택이 유지되어야 하기 때문입니다.

Pane enum이 Copy이므로 move 걱정 없이 자유롭게 복사·비교할 수 있습니다. enum variant에 데이터가 없는 플랫 enum은 거의 항상 Copy로 derive하는 게 편합니다.

3.4 Projects 패널 구현

왼쪽 첫 패널입니다. 프로젝트 이름 목록 + 파일 개수 표시.

fn render_projects_pane(frame: &mut Frame, app: &App, theme: &Theme, area: Rect) {
    let focused = app.focus == Pane::Projects;
    let block = pane_block("duru", focused, theme);

    let items: Vec<ListItem> = app
        .projects
        .iter()
        .enumerate()
        .map(|(i, project)| {
            let is_selected = i == app.project_index;
            let style = if is_selected {
                Style::default().fg(theme.iris).bg(theme.overlay)
            } else {
                Style::default().fg(theme.text)
            };

            let prefix = if is_selected { "▸ " } else { "  " };
            let count = format!(" ({})", project.files.len());

            ListItem::new(Line::from(vec![
                Span::styled(prefix, style),
                Span::styled(project.name.clone(), style),
                Span::styled(count, Style::default().fg(theme.muted)),
            ]))
        })
        .collect();

    let list = List::new(items).block(block);
    frame.render_widget(list, area);
}

핵심 기법 네 가지:

  • 선택된 행 강조는 배경색 변경: bg(theme.overlay)로 배경을 어둡게 바꾸면 "이 줄이 선택되었다"가 한눈에 보입니다. 텍스트 색(theme.iris)과 조합해 이중 신호로 강조합니다.
  • 접두어 ▸ 기호: 선택된 행에만 붙는 "▸" 기호는 색이 구별되지 않는 상황에서도 선택을 표현할 수 있는 저비용 방법입니다. 선택되지 않은 행은 " " (공백 2칸)으로 동일한 폭을 유지해서 텍스트가 안 밀리게 합니다.
  • Line::from(vec![Span, Span, ...]) 다중 스팬: 하나의 행 안에서 스타일이 다른 조각을 결합합니다. 여기서는 이름은 theme.text, 개수는 theme.muted로 차등을 줍니다. "중요한 것은 강하게, 보조 정보는 흐리게"라는 시각 위계 원칙입니다.
  • project.name.clone() 필요성: Span::styledCow<'a, str>를 받지만, 수명 문제로 여기서는 clone이 가장 간단합니다. 프로젝트 50개 수준이라면 성능 영향은 무시할 수준입니다.

3.5 Files 패널 구현

가운데 패널. 첫 번째 패널에서 선택된 프로젝트의 파일 목록을 보여줍니다. 여기서 Miller Columns의 핵심 성질이 드러납니다.

fn render_files_pane(frame: &mut Frame, app: &App, theme: &Theme, area: Rect) {
    let focused = app.focus == Pane::Files;
    let project_name = app
        .selected_project()         // ← 왼쪽 패널 선택에 의존
        .map(|p| p.name.as_str())
        .unwrap_or("");
    let block = pane_block(project_name, focused, theme);

    let items: Vec<ListItem> = app
        .selected_project()
        .map(|project| {
            project
                .files
                .iter()
                .enumerate()
                .map(|(i, file)| {
                    let is_selected = i == app.file_index;
                    let style = if is_selected {
                        Style::default().fg(theme.iris).bg(theme.overlay)
                    } else {
                        Style::default().fg(theme.text)
                    };

                    let prefix = if is_selected { "▸ " } else { "  " };
                    let size = format_size(file.size);

                    ListItem::new(Line::from(vec![
                        Span::styled(prefix, style),
                        Span::styled(file.name.clone(), style),
                        Span::styled(
                            format!("  {size}"),
                            Style::default().fg(theme.muted),
                        ),
                    ]))
                })
                .collect()
        })
        .unwrap_or_default();        // ← 프로젝트 없으면 빈 리스트

    let list = List::new(items).block(block);
    frame.render_widget(list, area);
}

selected_project().map(|p| ...).unwrap_or_default() 패턴이 핵심입니다.

  • App::selected_project()Option<&Project>를 반환 — 인덱스가 유효하지 않을 수 있음
  • 유효하면 파일 목록을 만들고, 아니면 빈 Vec
  • 빈 리스트로 렌더링해도 크래시 나지 않음 — 그냥 아무것도 안 보일 뿐

이 자세가 중요합니다. 렌더링은 항상 현재 상태를 그대로 반영해야 하고, 상태가 "불완전"해도 깨지면 안 됩니다. panic이 발생하면 TUI는 raw mode에서 빠져나오지 못하고 터미널이 엉망이 됩니다.

App::selected_project()의 구현도 소박합니다.

impl App {
    pub fn selected_project(&self) -> Option<&Project> {
        self.projects.get(self.project_index)   // ← 안전한 인덱스 조회
    }
}

.get()을 쓰는 이유는 [] 인덱싱이 panic을 내기 때문입니다. 렌더링 코드에서는 .get()Option 처리가 표준입니다.

3.6 Preview 패널 구현

가장 오른쪽 패널. 선택된 파일의 마크다운을 렌더링합니다.

fn render_preview_pane(frame: &mut Frame, app: &App, theme: &Theme, area: Rect) {
    let focused = app.focus == Pane::Preview;
    let file_name = app
        .selected_project()
        .and_then(|p| p.files.get(app.file_index))   // ← 두 번 chaining
        .map(|f| f.name.as_str())
        .unwrap_or("");
    let block = pane_block(file_name, focused, theme);

    // Pane width minus the Block's left/right border (1 cell each).
    let content_width = area.width.saturating_sub(2);
    let rendered = markdown::render_markdown(&app.content, theme, content_width);

    let paragraph = Paragraph::new(rendered)
        .block(block)
        .style(Style::default().fg(theme.text).bg(theme.base))
        .wrap(Wrap { trim: false })
        .scroll((app.scroll_offset, 0));

    frame.render_widget(paragraph, area);
}

놓치기 쉬운 네 가지 디테일:

  • and_then 체이닝: 프로젝트가 있고 → 파일이 있고 → 이름이 있을 때만 타이틀을 만듭니다. 하나라도 실패하면 빈 문자열. Optionand_then은 이런 "nested Option"을 flat하게 다루는 표준 패턴입니다.
  • content_width = area.width - 2: Block의 좌/우 보더가 각 1셀씩 차지합니다. 마크다운을 렌더링할 때 실제 쓸 수 있는 폭은 area.width - 2이고, 이 값으로 wrap 위치를 계산해야 글자가 테두리 바깥으로 새지 않습니다. saturating_sub를 쓰는 이유는 초소형 창(width < 2)에서 언더플로우를 막기 위함입니다.
  • Wrap { trim: false }: trim: true는 각 줄의 앞쪽 공백을 지웁니다. 마크다운 리스트의 들여쓰기가 같이 사라지므로, 코드 블록이나 중첩 리스트가 있다면 반드시 false로 둬야 합니다.
  • .scroll((vertical, horizontal)): app.scroll_offset을 세로 오프셋으로 전달하면 Paragraph가 해당 줄부터 렌더링합니다. Paragraph 자체가 스크롤 상태를 들고 있지 않으므로, 오프셋은 외부 상태(App)에 보관해야 합니다.

3.7 Help bar

하단 한 줄. 키바인딩을 시각적으로 안내합니다.

fn render_help_bar(frame: &mut Frame, theme: &Theme, area: Rect) {
    let help = Line::from(vec![
        Span::styled(" ↑↓", Style::default().fg(theme.text)),
        Span::styled(" navigate  ", Style::default().fg(theme.muted)),
        Span::styled("←→", Style::default().fg(theme.text)),
        Span::styled(" pane  ", Style::default().fg(theme.muted)),
        Span::styled("q", Style::default().fg(theme.text)),
        Span::styled(" quit", Style::default().fg(theme.muted)),
    ]);
    frame.render_widget(
        Paragraph::new(help).style(Style::default().bg(theme.surface)),
        area,
    );
}

키 기호만 밝게, 설명은 흐리게가 핵심 트릭입니다. 스캔하는 눈이 먼저 키를 찾을 수 있고, 필요하면 그 다음에 설명을 읽습니다. 정보 위계를 한 줄 안에 녹여 넣는 저비용 방식입니다.

배경색을 theme.surface: 본문과는 살짝 다른 밝기의 배경을 쓰면 "여기가 상태 영역이다"라는 시각 경계가 생깁니다. 명시적 보더 없이도 영역을 분리할 수 있습니다.

3.8 포커스 이동 로직

키를 눌렀을 때 어떤 패널에서 어떤 행동을 할지는 App::handle_key에서 분기합니다. 포커스에 따라 같은 키가 다른 동작을 합니다.

impl App {
    pub fn handle_key(&mut self, key: KeyEvent) {
        if key.modifiers.contains(KeyModifiers::CONTROL)
            && key.code == KeyCode::Char('c')
        {
            self.should_quit = true;
            return;
        }

        match key.code {
            KeyCode::Char('q') => self.should_quit = true,
            KeyCode::Up | KeyCode::Char('k') => self.move_up(),
            KeyCode::Down | KeyCode::Char('j') => self.move_down(),
            KeyCode::Left | KeyCode::Char('h') => self.move_left(),
            KeyCode::Right | KeyCode::Char('l') | KeyCode::Enter => {
                self.move_right()
            }
            _ => {}
        }
    }
}

vim 키바인딩을 함께 지원: h/j/k/l←/↓/↑/→와 같은 의미를 갖습니다. 터미널에서 손 안 움직이고 싶은 사용자들을 위한 호의입니다. 한 줄이면 되므로 넣지 않을 이유가 없습니다.

EnterRight의 동의어: 리스트에서 항목을 "열어본다"는 자연스러운 동작은 다음 패널로 넘어가는 것과 같습니다. Enter를 같은 동작에 매핑하면 사용자가 아무 키로나 진입할 수 있습니다.

Ctrl-C는 항상 탈출: 터미널 앱의 암묵적 계약입니다. 어떤 모드에서도 Ctrl-C는 즉시 종료 신호로 동작해야 합니다. 일반 문자 키는 KeyCode::Char('c')로 들어오지만, modifier와 함께 오면 "탈출 의도"로 해석합니다.

다음은 "위로/아래로" 로직. 포커스별로 다르게 행동합니다.

impl App {
    fn move_up(&mut self) {
        match self.focus {
            Pane::Projects => {
                if self.project_index > 0 {
                    self.project_index -= 1;
                    self.file_index = 0;    // ← 프로젝트 바뀌면 파일 선택 리셋
                    self.load_content();
                }
            }
            Pane::Files => {
                if self.file_index > 0 {
                    self.file_index -= 1;
                    self.load_content();
                }
            }
            Pane::Preview => {
                self.scroll_offset = self.scroll_offset.saturating_sub(1);
            }
        }
    }
    // move_down은 대칭
}

핵심 규칙: Projects 패널에서 선택이 바뀌면 file_index = 0으로 리셋하고, 내용을 다시 로드합니다. 왼쪽 패널의 선택이 바뀌면 오른쪽 패널은 "처음으로" 되돌려야 합니다. 이게 Miller Columns의 본질입니다 — 각 컬럼이 왼쪽의 "자식"을 보여주므로, 부모가 바뀌면 자식 목록도 갈아엎어집니다.

saturating_sub0 - 1 = usize 오버플로우 panic을 막는 관용구입니다. 산술 연산이 바닥을 칠 가능성이 있는 곳에 거의 자동으로 씁니다.

포커스 이동(좌우 패널 전환)도 같은 패턴이지만 간단합니다.

impl App {
    fn move_left(&mut self) {
        self.focus = match self.focus {
            Pane::Projects => Pane::Projects,    // ← 더 왼쪽 없음
            Pane::Files => Pane::Projects,
            Pane::Preview => Pane::Files,
        };
    }

    fn move_right(&mut self) {
        self.focus = match self.focus {
            Pane::Projects => Pane::Files,
            Pane::Files => Pane::Preview,
            Pane::Preview => Pane::Preview,      // ← 더 오른쪽 없음
        };
    }
}

끝에서는 "움직이지 않음"이 올바른 동작입니다. 더 왼쪽이 없다고 래핑(wrap around)해서 맨 오른쪽으로 보내면 사용자는 자기가 어디 있는지 감을 잃습니다. Finder, VS Code, vim의 window 이동 모두 이 방식입니다.

enum match는 컴파일러가 모든 variant를 검사하므로, 새 Pane을 추가할 때 자동으로 컴파일 에러가 납니다. 4번째 패널을 추가하는 순간 move_left/move_right에서 빨간 밑줄이 뜨고, 나 자신에게 "아, 여기도 업데이트해야지"라고 알려줍니다. enum의 보이지 않는 위력입니다.

3.9 테마 연결: Rosé Pine 색상

Rosé Pine은 차분한 무채색 + 살짝 차가운 포인트 색으로 구성된 테마입니다. duru에서는 각 역할마다 의미를 고정해놓고 씁니다.

역할 색 이름 Dark mode 쓰임새
배경 base #191724 전체 배경, 패널 내부
보조 배경 surface #1f1d2e help bar
선택 배경 overlay #26233a 선택된 행, unfocused 보더
보조 텍스트 muted #6e6a86 설명, 개수, 크기
본문 텍스트 text #e0def4 일반 아이템 이름
포커스 강조 iris #c4a7e7 focused 보더·타이틀, 선택 텍스트

"의미 기반 팔레트"의 이점: 코드에서 theme.iris라고 쓰면 "지금 포커스 강조색"이라는 의도가 드러납니다. Color::Rgb(196, 167, 231)로 직접 쓰면 "무슨 색이지?"로 바뀌어 다크/라이트 모드 대응도 어렵습니다. 팔레트에 이름을 붙이는 한 줄이 6개월 뒤 유지보수의 8할을 결정합니다.

라이트 모드는 같은 구조에 색만 바꿔서 Theme::light()으로 제공합니다. theme.iris를 일관되게 쓰면 두 모드 사이의 전환은 Theme 생성자 하나만 바꿔서 끝납니다.

4. 핵심 개념 정리

개념 설명
Miller Columns 왼쪽 컬럼 선택의 자식을 오른쪽 컬럼에 보여주는 캐스케이딩 레이아웃. Finder, NeXTSTEP
Layout::vertical / horizontal ratatui의 영역 분할 DSL. Percentage/Length/Min 제약 조합
Block 보더 + 타이틀 + 배경을 담당하는 껍데기 위젯. 다른 위젯을 감쌈
pane_block 팩토리 공통 보더·스타일을 한 곳에서 관리하는 헬퍼. 중복 제거 + 일관성
Pane enum 포커스 상태를 타입으로 강제. 불가능한 상태가 컴파일되지 않음
선택 상태 분리 각 패널이 독립된 index 보유. 패널 전환 시 상태 유지
캐스케이딩 리셋 왼쪽 패널 선택 변경 시 오른쪽 패널 index 리셋
vim 키바인딩 h/j/k/l←/↓/↑/→와 병행 지원
Rosé Pine 역할 팔레트 색을 "의미"로 명명하여 다크/라이트 모드 전환을 단순화

5. 베스트 프랙티스

ratatui 3-pane 레이아웃 체크리스트

  • [ ] 영역 분할은 이중 Layout으로 — 세로로 main + bar 나누고, main을 가로로 또 나누는 패턴이 가장 간결합니다.
  • [ ] 공통 보더 스타일은 팩토리로 뽑기pane_block 하나가 세 패널의 일관성을 보장합니다.
  • [ ] 포커스는 enum으로bool 여러 개는 불가능한 상태를 만듭니다.
  • [ ] 선택 상태는 패널별로 독립 관리 — 한 번 스크롤한 위치를 기억해야 사용자가 헤매지 않습니다.
  • [ ] 왼쪽 패널 선택 변경 시 오른쪽 리셋 — Miller Columns의 본질. 이걸 빼면 파일 인덱스가 엉뚱한 프로젝트에 남습니다.
  • [ ] .get(i) 안전 인덱싱 — 렌더링 루프에서 [i] 인덱싱은 panic 위험. 항상 Option을 반환받아 처리하세요.

Rust TUI 상태 관리 체크리스트

  • [ ] saturating_sub / saturating_add — 스크롤 오프셋, 인덱스 등 음수로 떨어지면 안 되는 값에 항상 사용하세요.
  • [ ] Wrap { trim: false } — 마크다운/코드 렌더링에서 들여쓰기를 지우지 마세요.
  • [ ] Ctrl-C 탈출 경로 보장 — 어떤 모드에서도 즉시 종료 가능해야 합니다.
  • [ ] .load_content()scroll_offset = 0 — 새 파일을 열 때 이전 스크롤 위치가 남아 있으면 혼란스럽습니다.
  • [ ] enum match를 exhaustive하게_ => {}는 새 variant 추가 시 경고를 놓칩니다. 모든 variant를 명시하면 컴파일러가 리팩토링을 도와줍니다.

접근성과 시각 위계

  • [ ] 색 + 무게 + 기호 조합 — 포커스를 색(iris)뿐 아니라 BOLD와 기호로도 표현하세요.
  • [ ] 보조 정보는 muted — 파일 크기, 개수 같은 메타데이터는 흐리게 처리해서 중요한 이름이 두드러지게 하세요.
  • [ ] 선택 배경색 차별화 — 텍스트 색만 바꾸는 것보다 배경까지 바꾸면 한눈에 찾기 쉽습니다.
  • [ ] 테마는 "의미" 단위로 명명primary, selected, muted 같은 역할 기반 이름을 쓰고 구체적인 hex는 한 곳에만 두세요.

6. FAQ

Q: Layout::split()의 반환 타입이 Rc<[Rect]>인데 성능 문제는 없나요?
A: Rc는 단일 스레드 참조 카운팅이라 clone 비용이 카운터 증감뿐입니다. 렌더링당 clone이 몇 번 일어나도 무시할 수준이고, 벤치마크를 보면 layout 계산 자체가 지배적입니다. 성능 튜닝이 필요하다면 split 결과를 상위 스코프에서 한 번 저장하고 각 렌더 함수에 &[Rect]로 전달하는 방법이 있지만, 대부분의 앱에서는 과한 최적화입니다.

Q: 3-pane이 아닌 2-pane 또는 4-pane으로 확장하려면 어떻게 하나요?
A: Constraint 배열과 Pane enum만 바꾸면 됩니다. 2-pane은 [50, 50] 퍼센트, 4-pane은 [25, 25, 25, 25] 같은 식으로. 주의할 점은 move_left/move_right의 match arms를 모두 업데이트하는 것과, 캐스케이딩 리셋 로직이 각 단계마다 필요하다는 점입니다. 4-pane이면 1→2 리셋뿐 아니라 2→3, 3→4 리셋도 해야 Miller Columns 의미가 유지됩니다.

Q: 프로젝트가 많아 스크롤이 필요한데 List에 선택 행이 항상 보이게 하려면?
A: ratatui의 ListListState를 통해 내부 스크롤 상태를 관리합니다. list.highlight_style(...)frame.render_stateful_widget(list, area, &mut list_state)를 쓰면 선택이 화면 밖으로 나갈 때 자동으로 스크롤됩니다. duru는 항목 수가 적어서 stateful list까지 안 갔지만, 100개 이상이면 필수입니다.

Q: Block에 타이틀을 여러 개 달 수 있나요?
A: 네. Block::default().title(left_title).title(Title::from(...).alignment(Alignment::Right))로 좌우 타이틀을 분리할 수 있습니다. 파일명을 왼쪽에, 크기나 수정 시간을 오른쪽에 표시하는 레이아웃에 유용합니다. 접근법은 ratatui v0.27 기준이고 이후 버전은 API가 조금 바뀝니다 — 공식 문서 확인 권장.

Q: vim 키바인딩과 일반 키를 같이 지원하면 충돌은 없나요?
A: h/j/k/l을 네비게이션에 쓰면 리스트 항목 안에서 문자 입력을 받을 수 없습니다. 검색 기능을 넣을 때는 "검색 모드"와 "일반 모드"를 분리하는 vim 스타일 모드 전환이 필요합니다. duru는 검색이 없어서 이 문제를 미뤘지만, 확장할 계획이 있다면 Appmode: Mode enum을 추가해서 키 핸들러가 모드별로 분기하게 만들면 됩니다.

Q: 테마 모드가 많을 때 Theme 생성자를 늘리는 대신 파일로 빼면 어떤가요?
A: 좋은 아이디어입니다. duru는 지금 dark/light 두 개뿐이라 하드코딩이 가장 간단하지만, 사용자 커스텀 테마를 지원하려면 TOML/JSON 파일 로딩으로 가는 게 맞습니다. 필드 타입을 Color에서 ColorSpec(파싱 가능한 hex 문자열)으로 바꾸고, serde derive를 달면 됩니다. 시작은 하드코딩, 요청이 생기면 파일화 — 이 순서가 거의 항상 맞습니다.

7. 참고 자료