진행 표시와 색상 출력
지금까지 만든 도구는 기능은 하지만 출력이 단조롭습니다. 파일이 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.supportsAnsi가 false이면 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 감지로 파이프/리다이렉션 환경을 자동 처리합니다.
다음 챕터에서는 완성된 도구를 단일 실행 파일로 컴파일하고 배포합니다.