iBetter Books
수정

진행 표시와 색상 출력

지금까지 만든 도구는 기능은 하지만 출력이 단조롭습니다. 파일이 1000개라면 진행 상황을 알 수 없고, 에러와 성공 메시지를 구분하기도 어렵습니다. 이번 챕터에서는 터미널 출력을 개선합니다.

ANSI 이스케이프 코드는 터미널에서 텍스트 색상, 스타일, 커서 위치를 제어하는 표준입니다. 외부 패키지 없이도 구현할 수 있습니다.

ANSI 이스케이프 코드

ANSI 코드는 \x1B[ (ESC + [)로 시작합니다. 이를 CSI(Control Sequence Introducer)라고 합니다.

// 색상 코드 예시
print('\x1B[31mRed Text\x1B[0m');    // 빨간색 → 초기화
print('\x1B[32mGreen Text\x1B[0m');  // 초록색 → 초기화
print('\x1B[1mBold Text\x1B[0m');    // 굵게 → 초기화

// 커서 제어
print('\x1B[A');   // 커서 한 줄 위로
print('\x1B[2K');  // 현재 줄 지우기
print('\r');       // 커서를 줄 처음으로

\x1B[0m은 모든 스타일을 초기화하는 코드입니다. 색상을 적용한 후 반드시 초기화해야 이후 텍스트에 영향을 주지 않습니다.

Terminal 유틸리티 클래스

// 새 파일: lib/src/utils/terminal.dart
import 'dart:io';

/// 터미널 출력 유틸리티.
///
/// ANSI 이스케이프 코드를 사용합니다. TTY가 아닌 환경(파이프, 파일 리다이렉션)에서는
/// 색상 코드를 출력하지 않습니다.
class Terminal {
  Terminal._();

  /// 현재 환경이 색상 출력을 지원하는지 여부.
  static bool get supportsAnsi =>
      stdout.hasTerminal && Platform.environment['NO_COLOR'] == null;

  // ── 색상 출력 ──────────────────────────────────────────────────────────

  static String red(String text) => _colorize(text, 31);
  static String green(String text) => _colorize(text, 32);
  static String yellow(String text) => _colorize(text, 33);
  static String blue(String text) => _colorize(text, 34);
  static String magenta(String text) => _colorize(text, 35);
  static String cyan(String text) => _colorize(text, 36);
  static String white(String text) => _colorize(text, 37);
  static String gray(String text) => _colorize(text, 90);

  // ── 스타일 ────────────────────────────────────────────────────────────

  static String bold(String text) => _stylize(text, 1);
  static String dim(String text) => _stylize(text, 2);
  static String underline(String text) => _stylize(text, 4);

  // ── 메시지 유형 ───────────────────────────────────────────────────────

  static void success(String message) =>
      print('${green("✓")} $message');

  static void error(String message) =>
      stderr.writeln('${red("✗")} $message');

  static void warning(String message) =>
      print('${yellow("!")} $message');

  static void info(String message) =>
      print('${blue("i")} $message');

  // ── 내부 헬퍼 ────────────────────────────────────────────────────────

  static String _colorize(String text, int code) {
    if (!supportsAnsi) return text;
    return '\x1B[${code}m$text\x1B[0m';
  }

  static String _stylize(String text, int code) {
    if (!supportsAnsi) return text;
    return '\x1B[${code}m$text\x1B[22m';
  }
}

ProgressBar 구현

진행률 표시는 커서를 제자리에서 업데이트하는 방식으로 구현합니다.

// 새 파일: lib/src/utils/progress_bar.dart
import 'dart:io';

/// 터미널 진행률 바.
///
/// 사용 예:
/// ```dart
/// final bar = ProgressBar(total: 100, label: '처리 중');
/// for (var i = 0; i < 100; i++) {
///   bar.update(i + 1);
///   await Future.delayed(Duration(milliseconds: 10));
/// }
/// bar.complete();
/// ```
class ProgressBar {
  ProgressBar({
    required this.total,
    this.label = '',
    this.width = 40,
  }) : _startTime = DateTime.now() {
    if (!stdout.hasTerminal) return;
    // 커서 숨기기
    stdout.write('\x1B[?25l');
    _render(0);
  }

  final int total;
  final String label;
  final int width;
  final DateTime _startTime;

  int _current = 0;

  /// 현재 진행 값을 업데이트합니다.
  void update(int current) {
    _current = current.clamp(0, total);
    if (!stdout.hasTerminal) return;
    _render(_current);
  }

  /// 진행률을 완료 상태로 표시합니다.
  void complete({String? message}) {
    if (!stdout.hasTerminal) {
      if (message != null) print(message);
      return;
    }
    // 완료 상태로 렌더링
    _render(total);
    stdout.writeln();
    // 커서 복원
    stdout.write('\x1B[?25h');
    if (message != null) print(message);
  }

  void _render(int current) {
    final percent = total > 0 ? current / total : 0.0;
    final filled = (width * percent).round();
    final empty = width - filled;

    final bar = '${'█' * filled}${'░' * empty}';
    final percentStr = '${(percent * 100).toStringAsFixed(1).padLeft(5)}%';

    final elapsed = DateTime.now().difference(_startTime);
    final eta = _calculateEta(current, elapsed);

    final line = '\r${label.isNotEmpty ? "$label " : ""}'
        '[$bar] $percentStr  $current/$total  ETA: $eta';

    stdout.write(line);
  }

  String _calculateEta(int current, Duration elapsed) {
    if (current == 0) return '--:--';
    final totalSeconds = elapsed.inSeconds * total / current;
    final remainingSeconds = (totalSeconds - elapsed.inSeconds).round();
    if (remainingSeconds < 0) return '00:00';
    final m = remainingSeconds ~/ 60;
    final s = remainingSeconds % 60;
    return '${m.toString().padLeft(2, '0')}:${s.toString().padLeft(2, '0')}';
  }
}

대화형 입력 — stdin

사용자에게 확인을 요청하는 대화형 입력을 구현합니다.

// 새 파일: lib/src/utils/prompt.dart
import 'dart:io';

/// 사용자에게 yes/no 확인을 요청합니다.
///
/// 기본값을 지정하면 Enter 입력 시 기본값을 반환합니다.
Future<bool> confirm(String message, {bool defaultValue = true}) async {
  final hint = defaultValue ? '[Y/n]' : '[y/N]';
  stdout.write('$message $hint ');

  final input = stdin.readLineSync()?.trim().toLowerCase() ?? '';

  if (input.isEmpty) return defaultValue;
  return input == 'y' || input == 'yes';
}

/// 사용자에게 텍스트 입력을 요청합니다.
Future<String> prompt(
  String message, {
  String? defaultValue,
  String Function(String)? validator,
}) async {
  while (true) {
    if (defaultValue != null) {
      stdout.write('$message [$defaultValue]: ');
    } else {
      stdout.write('$message: ');
    }

    final input = stdin.readLineSync()?.trim() ?? '';
    final value = input.isEmpty && defaultValue != null ? defaultValue : input;

    if (validator != null) {
      final error = validator(value);
      if (error.isNotEmpty) {
        stderr.writeln('입력 오류: $error');
        continue;
      }
    }

    return value;
  }
}

/// 사용자에게 선택지를 제시합니다.
Future<String> select(String message, List<String> options) async {
  print('$message');
  for (var i = 0; i < options.length; i++) {
    print('  ${i + 1}. ${options[i]}');
  }

  while (true) {
    stdout.write('선택 (1-${options.length}): ');
    final input = stdin.readLineSync()?.trim() ?? '';
    final index = int.tryParse(input);

    if (index != null && index >= 1 && index <= options.length) {
      return options[index - 1];
    }

    stderr.writeln('1~${options.length} 사이의 숫자를 입력하세요.');
  }
}

출력 개선 적용

OrganizeCommand에 진행률 바와 색상 출력을 적용합니다.

// 수정: lib/src/commands/organize_command.dart
import 'dart:io';

import 'package:args/command_runner.dart';
import 'package:path/path.dart' as p;

import '../utils/file_utils.dart';
import '../utils/progress_bar.dart';
import '../utils/prompt.dart';
import '../utils/terminal.dart';

class OrganizeCommand extends Command<void> {
  OrganizeCommand() {
    argParser
      ..addOption(
        'source',
        abbr: 's',
        help: '정리할 디렉토리 경로.',
        defaultsTo: '.',
      )
      ..addOption(
        'dest',
        abbr: 'd',
        help: '파일을 이동할 대상 디렉토리.',
      )
      ..addFlag(
        'dry-run',
        help: '실제로 이동하지 않고 결과만 출력합니다.',
        negatable: false,
      )
      ..addFlag(
        'recursive',
        abbr: 'r',
        help: '하위 디렉토리까지 탐색합니다.',
        negatable: false,
      )
      ..addFlag(
        'yes',
        abbr: 'y',
        help: '확인 없이 바로 실행합니다.',
        negatable: false,
      );
  }

  @override
  String get name => 'organize';

  @override
  String get description => '파일을 확장자별로 분류합니다.';

  @override
  Future<void> run() async {
    final source = argResults!['source'] as String;
    final dest = argResults!['dest'] as String? ?? source;
    final dryRun = argResults!['dry-run'] as bool;
    final recursive = argResults!['recursive'] as bool;
    final skipConfirm = argResults!['yes'] as bool;

    final sourceDir = Directory(p.absolute(source));
    if (!sourceDir.existsSync()) {
      usageException('소스 디렉토리가 존재하지 않습니다: $source');
    }

    Terminal.info('파일 수집 중: ${sourceDir.path}');
    final files = await collectFiles(sourceDir, recursive: recursive);

    if (files.isEmpty) {
      Terminal.warning('처리할 파일이 없습니다.');
      return;
    }

    Terminal.info('파일 ${Terminal.bold(files.length.toString())}개 발견');

    if (dryRun) {
      Terminal.warning('[dry-run 모드] 실제 파일을 이동하지 않습니다.');
    } else if (!skipConfirm) {
      final ok = await confirm(
        '${files.length}개 파일을 정리하시겠습니까?',
        defaultValue: true,
      );
      if (!ok) {
        Terminal.warning('취소되었습니다.');
        return;
      }
    }

    final bar = ProgressBar(
      total: files.length,
      label: '정리 중',
    );

    var moved = 0;
    var failed = 0;

    for (var i = 0; i < files.length; i++) {
      final fileInfo = files[i];
      bar.update(i + 1);

      if (!dryRun) {
        try {
          final category = extensionToCategory(fileInfo.extension);
          final targetDir = p.join(p.absolute(dest), category);
          await moveFileSafely(File(fileInfo.path), targetDir);
          moved++;
        } catch (e) {
          failed++;
        }
      }
    }

    bar.complete();

    if (dryRun) {
      Terminal.success('시뮬레이션 완료 (${files.length}개 파일)');
    } else {
      Terminal.success('완료: ${moved}개 이동, ${failed}개 실패');
    }
  }
}

실행 모습

dart run bin/file_organizer.dart organize -s ~/Downloads
i 파일 수집 중: /Users/user/Downloads
i 파일 42개 발견
정리하시겠습니까? [Y/n] 
정리 중 [████████████████████░░░░░░░░░░░░░░░░░░░░]  47.6%  20/42  ETA: 00:03

완료 후에는 이렇게 나옵니다.

정리 중 [████████████████████████████████████████] 100.0%  42/42  ETA: 00:00
✓ 완료: 42개 이동, 0개 실패

NO_COLOR 지원

터미널이 색상을 지원하지 않거나 파이프로 연결될 때를 대비합니다. Terminal.supportsAnsifalse이면 ANSI 코드를 출력하지 않도록 이미 구현했습니다. NO_COLOR=1 환경 변수도 지원합니다.

# 색상 없이 출력NO_COLOR=1 dart run bin/file_organizer.dart stats# 파이프로 연결 시 자동으로 색상 비활성화dart run bin/file_organizer.dart stats | cat

정리

이번 챕터에서는 사용자 경험을 크게 개선했습니다.

  • ANSI 이스케이프 코드로 색상과 스타일을 적용했습니다.
  • ProgressBar로 실시간 진행률을 표시합니다.
  • stdin으로 대화형 입력을 처리합니다.
  • TTY 감지로 파이프/리다이렉션 환경을 자동 처리합니다.

다음 챕터에서는 완성된 도구를 단일 실행 파일로 컴파일하고 배포합니다.