E5d-2: 줄 위젯 · 누르기/고르기 작은 컨트롤러 · 썸네일을 따로 (explorer_page 는 부르기만)

관리자 승인 E5d 6단계 중 2. 동작은 바꾸지 않음 (ExplorerController 에는 더하지 않음 - 관리자 지시)
- lib/ui/explorer/explorer_select.dart ExplorerSelect (242줄): 줄 누르기 · 두 번 · ✔ · Rsync 고르기 · Shift 범위 · Ctrl 고르기 ·
  펼치기/접기 · 선택 모드. 규칙은 ClickPolicy · Selection 이 가지고 여기는 화면 상태에 이어 주기만. 파일 여는 일은 부르는 쪽 콜백
- lib/ui/explorer/row_view.dart ExplorerRowView (323줄): 줄 하나 그리기 - 컨트롤러 둘 + 콜백 (메뉴 · 끌기 · 놓기) 만 받음
- lib/ui/explorer/file_thumbs.dart (174줄): 동영상 정보 · 썸네일 · 큰 썸네일 (빠른 보기도 씀)
- 줄 위젯으로 옮기며 생긴 회귀 하나를 고침: 좁은 폴더 트리의 이름 두 줄 판단에서 목록 글자 배율을 두 번 곱함 (줄 위젯은 이미
  배율을 곱한 MediaQuery 안) → 150% Total Commander 에서 2px 넘침 (explorer_text_scale_test 40). 배율을 다시 곱하지 않게 해
  원래 계산과 같게. E5d-1 판에서 그 시험이 2회 통과함을 확인한 뒤 고침
- explorer_page 5,497 → 4,857

확인: analyze 0 · 전체 단위 2회 통과 1,335 · 건너뜀 18 · 실패 0 · 점검 통합 시험 (1920 · 960 · 412) 2회 통과.
캡처 픽셀 비교 (고정 기준 E5a): 90장 중 88 · 89장 픽셀까지 같음, 다른 1 · 2장은 기준끼리도 다른 움직이는 장면 (이동 확인)
약속 시험 test/explorer_select_contract_test.dart 3 (실제 시험 폴더 · Host 는 실제 화면 · 시각 주입)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
split_pages
RyuJongJin 2026-10-22 11:14:31 +09:00
parent 4279747371
commit e6e4499f22
5 changed files with 911 additions and 695 deletions

View File

@ -0,0 +1,242 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart' show kDoubleTapTimeout;
import 'package:flutter/services.dart' show HardwareKeyboard;
import '../../app/archive_browse.dart';
import '../../core/explorer/tree_rows.dart';
import '../../core/file_ops.dart' show FileEntry, isSameOrInside, samePath;
import '../../core/selection.dart';
import '../../core/vfs.dart' show isDav, vDirname;
import '../file_error.dart' show explainFileError;
import 'explorer_controller.dart';
import 'explorer_pane.dart';
/// 약속 (E5d-2): 탐색기 줄 누르기 · 고르기 (작은 컨트롤러). 규칙은 [ClickPolicy] (누르기 → 할 일) · [Selection] (표시) 이
/// 가지고, 여기는 화면 상태 (선택 모드 · 지금 창 · 펼치기 · 읽기) 에 이어 줄 뿐이다. 창 · 읽기는 [ExplorerController].
/// - 입력: 줄 누르기 [onTap] · 두 번 [onOpen] · ✔ 단추 [toggleMark] · Rsync [pick] · Shift 범위 · Ctrl 고르기.
/// - 파일을 여는 일은 부르는 쪽 [openFile] (file_opener 흐름).
/// - 지금 시각은 [clock] (폴더 두 번 누르기의 두 번째를 가리려고 - 시험은 정한 시각).
/// - 바뀌면 알림. 목록을 읽지 못하면 표시를 바꾸지 않고 까닭을 알림 (Host).
class ExplorerSelect extends ChangeNotifier {
ExplorerSelect(this.ctl, {required this.clock, required this.openFile});
final ExplorerController ctl;
final DateTime Function() clock;
final Future<void> Function(String path, ExplorerPane pane) openFile;
/// 선택 모드 (가운데 [선택]): 각 줄의 ✔ 가 보이고, 누르면 고르기 · 풀기
bool selecting = false;
/// 한 번 누르면 선택 · 두 번 누르면 열기 (아니면 한 번에 바로 열기)
bool get selectMode => ctl.c.settings.explorerClick != 'open';
/// 마지막으로 누른 폴더 · 시각 (두 번 누르기의 두 번째를 가리려고)
(String, DateTime)? _lastFolderTap;
void setSelecting(bool on) {
selecting = on;
if (!on) {
for (final x in ctl.panes) {
x.sel.clear();
}
}
notifyListeners();
}
void _activate(ExplorerPane pane) {
ctl.active = ctl.panes.indexOf(pane);
ctl.update();
}
/// 줄의 종류 ([ClickPolicy] 에 넘김)
static ItemKind kindOf(TreeRow row) => row.isUp
? ItemKind.up
: row.isRoot
? ItemKind.root
: row.entry.isDir
? ItemKind.folder
: ItemKind.file;
/// 한 번 누르기 - Rsync 는 폴더 하나 고르기, Shift = 범위 · Ctrl = 고르기 (PC), 그 밖은 [ClickPolicy]
Future<void> onTap(ExplorerPane pane, TreeRow row, {bool list = false}) async {
final e = row.entry;
_activate(pane);
if (ctl.rsync && !row.isUp) return pick(pane, e);
final real = !row.isRoot && !row.isUp;
if (real && HardwareKeyboard.instance.isShiftPressed && canRange(pane, e)) {
if (!selecting) setSelecting(true);
return markRange(pane, e);
}
if (real && HardwareKeyboard.instance.isControlPressed) {
if (!selecting) setSelecting(true);
return toggleMark(pane, e);
}
final now = clock();
final last = _lastFolderTap;
_lastFolderTap = (e.path, now);
final repeat = last != null && samePath(last.$1, e.path) && now.difference(last.$2) < kDoubleTapTimeout * 2;
final act = ClickPolicy.decide(Press.tap, kindOf(row),
selectMode: selecting,
expanded: pane.expanded.contains(e.path),
list: list,
repeat: repeat,
tapOpens: !selectMode,
retapCollapses: ctl.c.settings.explorerRetapCollapse);
await _act(pane, row, act, list: list);
}
Future<void> _act(ExplorerPane pane, TreeRow row, ClickAction act, {bool list = false}) async {
final e = row.entry;
switch (act) {
case ClickAction.none:
case ClickAction.menu:
return;
case ClickAction.focus:
pane.focused = e.path;
if (!list) pane.current = vDirname(e.path);
pane.changed();
case ClickAction.open:
await onOpen(pane, row, list: list);
case ClickAction.collapse:
await toggle(pane, e);
case ClickAction.expand:
if (!pane.expanded.contains(e.path)) {
await toggle(pane, e);
} else if (!samePath(pane.current, e.path)) {
// 펼쳐 둔 폴더 (다시 눌러 접기를 끈 설정): 그 폴더를 지금 폴더로
pane
..current = e.path
..focused = e.path
..changed();
ctl.remember(pane, e.path);
}
case ClickAction.toggleMark:
if (!selecting) setSelecting(true);
await toggleMark(pane, e);
}
}
bool isMarked(ExplorerPane pane, String path) => pane.sel.isMarked(path);
Future<List<String>> _childPaths(ExplorerPane pane, String dir) async {
await ctl.load(pane, dir);
return [for (final x in pane.cache[dir] ?? const <FileEntry>[]) x.path];
}
/// Rsync 화면: 창마다 폴더 하나만 (규칙 8). 고르면 펼쳐서 안의 폴더가 보이게
Future<void> pick(ExplorerPane pane, FileEntry e) async {
if (!e.isDir) return;
await pane.sel.toggle(e.path, isDir: true, list: (d) => _childPaths(pane, d));
if (!ctl.host.alive) return;
notifyListeners();
if (!pane.sel.has(e.path)) return;
if (!pane.expanded.contains(e.path)) {
await toggle(pane, e);
} else {
pane
..current = e.path
..focused = e.path
..changed();
}
}
/// 74: 범위 고르기를 할 수 있는지 - 마지막으로 표시한 것이 같은 폴더에 있고 다른 항목
bool canRange(ExplorerPane pane, FileEntry e) {
final a = pane.sel.anchor;
return a != null && !samePath(a, e.path) && samePath(vDirname(a), vDirname(e.path)) && isMarked(pane, a);
}
/// 76: [dir] 에서 지금 보이는 것 (빠른 필터 · 목록에서 숨기기)
List<FileEntry> shown(ExplorerPane pane, String dir) => [
for (final x in pane.cache[dir] ?? const <FileEntry>[])
if (passesFilter(pane, x) && (ctl.showAppHidden || !ctl.appHidden(x.path))) x
];
void markRange(ExplorerPane pane, FileEntry to) {
pane.sel.range([for (final x in shown(pane, vDirname(to.path))) x.path], to.path);
pane.focused = to.path;
notifyListeners();
}
void invertMarks(ExplorerPane pane, String dir) {
pane.sel.invert([for (final x in shown(pane, dir)) x.path]);
notifyListeners();
}
void markWhere(ExplorerPane pane, String dir, bool Function(FileEntry e) test) {
pane.sel.addAll([for (final x in shown(pane, dir)) if (test(x)) x.path]);
notifyListeners();
}
/// 선택 모드에서 누르기 (규칙 5 · 6 은 [Selection.toggle]). 고른 폴더는 펼쳐서 안의 것도 고른 모양으로
Future<void> toggleMark(ExplorerPane pane, FileEntry e) async {
pane.focused = e.path;
try {
await pane.sel.toggle(e.path, isDir: e.isDir, list: (d) => _childPaths(pane, d));
} on FileSystemException catch (err) {
ctl.host.snack(explainFileError('$err', dav: isDav(e.path)).$1);
}
if (!ctl.host.alive) return;
notifyListeners();
if (e.isDir && pane.sel.has(e.path) && !pane.expanded.contains(e.path)) {
await ctl.load(pane, e.path);
if (ctl.host.alive) {
pane.expanded.add(e.path);
notifyListeners();
}
}
}
/// 열기: 트리의 폴더는 펼치기 (이름으로는 접지 않음 - 189), 목록의 폴더 · ".." 는 들어가기, 파일은 [openFile]
Future<void> onOpen(ExplorerPane pane, TreeRow row, {bool list = false}) async {
final e = row.entry;
_activate(pane);
pane.focused = e.path;
if (row.isUp || (e.isDir && list)) return ctl.goTo(pane, e.path);
if (e.isDir) {
if (!pane.expanded.contains(e.path)) {
await toggle(pane, e);
} else if (!samePath(pane.current, e.path)) {
pane
..current = e.path
..changed();
ctl.remember(pane, e.path);
}
return;
}
if (!list) pane.current = vDirname(e.path);
pane.changed();
await openFile(e.path, pane);
}
/// 트리에서 펼칠 수 있는 압축 (이 기기의 파일 · 탐색기 화면)
bool canBrowseArchive(FileEntry e) => !ctl.rsync && ArchiveBrowser.canBrowse(e);
/// 트리 폴더 펼치기 · 접기 (› 화살표 · 열기). 압축은 폴더처럼 (읽기 전용)
Future<void> toggle(ExplorerPane pane, FileEntry e) async {
_activate(pane);
if (canBrowseArchive(e) && !pane.expanded.contains(e.path)) {
if (!await ctl.readArchive(e.path)) return;
pane.cache.remove(e.path);
pane.expanded.add(e.path);
await ctl.load(pane, e.path);
return;
}
if (pane.expanded.contains(e.path)) {
pane.expanded.remove(e.path);
// 접은 폴더 안이 지금 폴더였으면 접은 폴더로
if (isSameOrInside(pane.current, e.path)) pane.current = e.path;
pane.changed();
return;
}
pane
..expanded.add(e.path)
..current = e.path
..focused = e.path;
await ctl.load(pane, e.path);
if (pane.listScroll.hasClients) pane.listScroll.jumpTo(0);
ctl.remember(pane, e.path);
}
}

View File

@ -0,0 +1,174 @@
import 'dart:async';
import 'dart:collection';
import 'dart:convert';
import 'dart:io';
import 'package:crypto/crypto.dart';
import 'package:flutter/material.dart';
import 'package:path/path.dart' as p;
import '../../app/app_controller.dart';
import '../../core/file_ops.dart' show FileEntry;
import '../theme.dart';
/// 탐색기 줄 · 빠른 보기의 동영상 정보 ("1920×1080 · 1:23:45") 와 썸네일 (E5d-2).
/// FFmpeg 는 차례로 (한 번에 하나 · 둘) 부르고, 만든 썸네일은 임시 폴더의 jj_thumbs 에 두어 다시 쓴다.
/// 다시 쓰는 곳: 탐색기 줄 · 빠른 보기 · 찾기 결과 · 보관함 (잊기는 thumbMemoryForgetters).
// ───────── 동영상 정보 · 썸네일 (보이는 줄만, 차례로) ─────────
/// 한 번에 하나씩 (FFmpeg 를 동시에 많이 띄우지 않게)
class ThumbQueue {
final int max;
int _running = 0;
final _waiting = Queue<Completer<void>>();
ThumbQueue(this.max);
Future<T> run<T>(Future<T> Function() job) async {
if (_running >= max) {
final w = Completer<void>();
_waiting.add(w);
await w.future;
}
_running++;
try {
return await job();
} finally {
_running--;
if (_waiting.isNotEmpty) _waiting.removeFirst().complete();
}
}
}
/// "1920×1080 · 1:23:45"
class VideoMeta {
static final _cache = <String, Future<String>>{};
static final _q = ThumbQueue(2);
static Future<String> of(AppController c, String path) => _cache[path] ??= _q.run(() async {
try {
final info = await c.services.mediaTool.probe(path);
final v = info.ofType('video').where((s) => (s.width ?? 0) > 0).firstOrNull;
final d = info.duration;
return [
if (v != null) '${v.width}×${v.height}',
if (d != null) _dur(d),
].join(' · ');
} catch (_) {
return '';
}
});
static String _dur(Duration d) {
final h = d.inHours, m = d.inMinutes % 60, s = d.inSeconds % 60;
String two(int x) => x.toString().padLeft(2, '0');
return h > 0 ? '$h:${two(m)}:${two(s)}' : '$m:${two(s)}';
}
}
class VideoMetaText extends StatelessWidget {
final AppController c;
final String path;
/// 앞에 붙일 글 (크기 · 날짜) - 동영상 정보와 한 줄로 이어 그린다
final String lead;
final TextStyle? leadStyle;
const VideoMetaText({super.key, required this.c, required this.path, this.lead = '', this.leadStyle});
@override
Widget build(BuildContext context) => FutureBuilder<String>(
future: VideoMeta.of(c, path),
builder: (_, s) => Text.rich(
TextSpan(children: [
if (lead.isNotEmpty) TextSpan(text: lead, style: leadStyle),
if ((s.data ?? '').isNotEmpty) TextSpan(text: '${lead.isEmpty ? '' : ' '}${s.data}', style: TextStyle(fontSize: 11, color: JjColors.accent)),
]),
maxLines: 1,
overflow: TextOverflow.ellipsis),
);
}
/// 동영상 썸네일 (임시 폴더의 jj_thumbs 에 저장해 다시 쓰기). MKV 만들기 등 작업 중에는 만들지 않는다 (FFmpeg 를 같이 씀).
class VideoThumbs {
static final _cache = <String, Future<File?>>{};
static final _q = ThumbQueue(1);
/// 보관함에 넣은 파일의 기억을 잊는다 (관리자 지시 10/21)
static void forget(String path) => _cache.removeWhere((k, _) => k.startsWith('$path|'));
static Future<File?> of(AppController c, FileEntry e) {
final key = '${e.path}|${e.size}|${e.modified.millisecondsSinceEpoch}';
return _cache[key] ??= _q.run(() async {
try {
final dir = Directory(p.join(await c.services.storage.tempDirectory(), 'jj_thumbs'));
await dir.create(recursive: true);
final out = File(p.join(dir.path, '${md5.convert(utf8.encode(key))}.jpg'));
if (await out.exists() && await out.length() > 0) return out;
if (c.busy) {
_cache.remove(key); // 작업이 끝나면 다시 시도
return null;
}
for (final at in ['5', '0']) {
await c.services.mediaTool.runFfmpeg([
'-hide_banner', '-loglevel', 'error', '-ss', at, '-i', e.path, //
'-frames:v', '1', '-vf', 'scale=160:-2', '-y', out.path,
]);
if (await out.exists() && await out.length() > 0) return out;
}
} catch (_) {}
return null;
});
}
}
/// 81: 빠른 보기의 큰 썸네일 (가로 640, 같은 jj_thumbs 에 따로 저장). 사용자가 연 것이라 작업 중에도 만든다
class BigVideoThumbs {
static final _cache = <String, Future<File?>>{};
static final _q = ThumbQueue(1);
static void forget(String path) => _cache.removeWhere((k, _) => k.startsWith('$path|'));
static Future<File?> of(AppController c, FileEntry e) {
final key = '${e.path}|${e.size}|${e.modified.millisecondsSinceEpoch}|640';
return _cache[key] ??= _q.run(() async {
try {
final dir = Directory(p.join(await c.services.storage.tempDirectory(), 'jj_thumbs'));
await dir.create(recursive: true);
final out = File(p.join(dir.path, '${md5.convert(utf8.encode(key))}.jpg'));
if (await out.exists() && await out.length() > 0) return out;
for (final at in ['5', '0']) {
await c.services.mediaTool.runFfmpeg([
'-hide_banner', '-loglevel', 'error', '-ss', at, '-i', e.path, //
'-frames:v', '1', '-vf', 'scale=640:-2', '-y', out.path,
]);
if (await out.exists() && await out.length() > 0) return out;
}
} catch (_) {}
_cache.remove(key);
return null;
});
}
}
class VideoThumb extends StatelessWidget {
final AppController c;
final FileEntry entry;
const VideoThumb({super.key, required this.c, required this.entry});
@override
Widget build(BuildContext context) => FutureBuilder<File?>(
future: VideoThumbs.of(c, entry),
builder: (_, s) {
final f = s.data;
if (f == null) return Icon(Icons.movie_outlined, size: 28, color: JjColors.textDim);
return ClipRRect(
borderRadius: BorderRadius.circular(3),
child: Stack(alignment: Alignment.center, children: [
Image.file(f, width: 56, height: 44, fit: BoxFit.cover,
errorBuilder: (_, _, _) => const Icon(Icons.movie_outlined, size: 28)),
const Icon(Icons.play_arrow, size: 18, color: Colors.white70),
]),
);
},
);
}

View File

@ -0,0 +1,323 @@
import 'dart:async';
import 'dart:io';
import 'dart:math' as math;
import 'package:flutter/foundation.dart' show defaultTargetPlatform;
import 'package:flutter/material.dart';
import '../../core/explorer/tree_rows.dart';
import '../../core/file_ops.dart' show FileEntry, formatSize, samePath;
import '../../core/name_clash.dart' show fileDateText;
import '../../core/playlist.dart' show isVideoFile;
import '../../core/selection.dart';
import '../../core/vfs.dart' show isDav;
import '../../l10n/tr.dart';
import '../explorer_drag.dart';
import '../explorer_look.dart';
import '../remote_thumb_view.dart';
import '../theme.dart';
import 'explorer_controller.dart';
import 'explorer_pane.dart';
import 'explorer_select.dart';
import 'file_thumbs.dart';
/// 189: 트리 화살표 칸의 폭 - 손가락으로 누르는 기기 (Android · iOS) 는 넓게 (줄 높이와 함께 누르는 영역)
double treeChevronWidth(TargetPlatform platform) =>
platform == TargetPlatform.android || platform == TargetPlatform.iOS ? 44 : 20;
/// 탐색기 줄 하나 (E5d-2). 상태 · 일은 [ExplorerController] · [ExplorerSelect], 메뉴 · 끌어 놓기는 부르는 쪽 콜백.
/// [list]: "폴더 + 파일 목록" 의 오른쪽 목록 (트리 아님). [compact]: 폴더 트리 (열 · 표시 버튼 없이)
class ExplorerRowView extends StatelessWidget {
const ExplorerRowView({
super.key,
required this.ctl,
required this.sel,
required this.pane,
required this.row,
this.list = false,
this.compact = false,
required this.rowHeight,
required this.narrowSplit,
required this.alwaysMarks,
required this.onMenu,
required this.dragPaths,
required this.onDrop,
});
final ExplorerController ctl;
final ExplorerSelect sel;
final ExplorerPane pane;
final TreeRow row;
final bool list, compact;
final double rowHeight;
final bool narrowSplit, alwaysMarks;
/// 길게 누르기 · 오른쪽 클릭 메뉴
final void Function(ExplorerPane pane, FileEntry e, Offset at) onMenu;
/// 끌 항목 (표시한 것 중 하나면 표시한 것 모두)
final List<String> Function(ExplorerPane pane, FileEntry e) dragPaths;
/// 폴더 줄 위에 놓음
final Future<void> Function(DragLoad load, int to, String dest) onDrop;
@override
Widget build(BuildContext context) {
final e = row.entry;
final look = ctl.look;
// 좁은 "폴더 + 파일 목록" 의 트리 (관리자 지시 10/20): ✔ 칸 · 크기 줄 없이 이름만, 줄에 두 줄이 들어가면 이름을 두 줄까지
final slim = compact && narrowSplit && !list;
// 이 줄은 목록 글자 배율을 이미 곱한 MediaQuery 안에 있다 (scaleListText) - 배율을 다시 곱하지 않는다
final nameLines = slim && rowHeight >= MediaQuery.textScalerOf(context).scale(look.fontSize) * 2.5 + 6 ? 2 : 1;
final isCurrent = e.isDir && !list && !row.isUp && samePath(pane.current, e.path);
final isFocused = !row.isUp && pane.focused != null && samePath(pane.focused!, e.path);
final marked = sel.isMarked(pane, e.path);
final open = (e.isDir || ctl.arc.contains(e.path)) && !list && pane.expanded.contains(e.path);
final loading = pane.loading.contains(e.path);
final name = row.isUp
? '..'
: row.isRoot
? (ctl.volumes.firstWhere((v) => samePath(v.$1, e.path), orElse: () => (e.path, e.path)).$2)
: look == ExplorerStyle.totalcmd
? totalCmdName(e)
: e.name;
final dim = TextStyle(fontSize: look.fontSize - 2, color: JjColors.textDim);
final nameStyle = TextStyle(
fontSize: look.fontSize,
fontWeight: isCurrent || (look == ExplorerStyle.totalcmd && e.isDir) ? FontWeight.w600 : FontWeight.normal,
color: isCurrent ? JjColors.accent : null,
);
// 좁은 트리 (slim) 는 조금 좁게 (36 - 줄 높이 전체를 누르므로 손가락으로도 눌림)
final chevronWidth = slim ? math.min(36.0, treeChevronWidth(defaultTargetPlatform)) : treeChevronWidth(defaultTargetPlatform);
// 트리의 펼치기 화살표 (따로 누름). 189: 접기 · 펼치기는 이것으로만 하므로 줄 높이 전체를 누를 수 있게,
// 손가락으로 누르는 기기 (Android) 는 폭도 넓게
Widget chevron() => SizedBox(
width: chevronWidth,
height: rowHeight,
child: (e.isDir || sel.canBrowseArchive(e)) && !list && !row.isUp
? (loading
? const Center(
child: SizedBox(width: 12, height: 12, child: CircularProgressIndicator(strokeWidth: 1.5)))
// 241: TalkBack 이 읽는 이름 ("… 펼치기" · "… 접기")
: Semantics(
button: true,
label: open ? trf('{0} 접기', [name]) : trf('{0} 펼치기', [name]),
excludeSemantics: true,
onTap: () => sel.toggle(pane, e),
child: InkResponse(
radius: chevronWidth / 2 + 4,
containedInkWell: false,
onTap: () => sel.toggle(pane, e),
child: Center(
child: Icon(open ? Icons.expand_more : Icons.chevron_right, size: 18, color: JjColors.textDim)),
),
))
: null,
);
// 좁은 창 (폰 세로의 좌우 두 창) 에서 깊은 폴더: 들여쓰기는 이름 자리를 남기는 만큼만 (넘치지 않게)
Widget nameCell() => LayoutBuilder(builder: (context, box) {
// 이름 자리는 48 또는 줄 폭의 40% (관리자 지시 10/20: 글자가 크고 폴더가 깊으면 들여쓰기에 밀려 이름이 몇 글자만 남았다)
final fixed = (list ? 0 : chevronWidth) + (look.rich ? 64 : look.iconSize + 12) + math.max(48.0, box.maxWidth * 0.4);
// 좁은 트리 (slim): 들여쓰기는 반만 (폭의 1/10 까지) - 깊은 폴더에서 이름이 한 글자도 안 보이지 않게
final indent = list
? 0.0
: slim
? (row.depth * look.indent / 2).clamp(0.0, box.maxWidth * 0.1).toDouble()
: (row.depth * look.indent).clamp(0.0, (box.maxWidth - fixed).clamp(0.0, double.infinity)).toDouble();
// 아주 좁은 창 (412 폭 "폴더 + 파일 목록" 의 트리, 77px 쯤): 아이콘 칸 · 틈을 남은 폭만큼만 (아이콘은 줄여 그림) - 넘치지 않게
final room = (box.maxWidth - indent - (list ? 0 : chevronWidth)).clamp(0.0, double.infinity);
// 좁은 트리 (slim) 는 큰 썸네일 칸 (56) 대신 작은 아이콘 칸 (28 까지, 아이콘은 줄여 그림)
final iconW = math.min(slim ? math.min(look.iconSize, 24.0) + 4 : look.rich ? 56.0 : look.iconSize + 6, room);
final gapW = math.min(slim ? 4.0 : look.rich ? 8.0 : 6.0, room - iconW);
return Row(children: [
SizedBox(width: indent),
if (!list) chevron(),
SizedBox(
width: iconW,
height: rowHeight - 8,
child: Center(child: FittedBox(fit: BoxFit.scaleDown, child: _icon(e, row.isRoot, open, up: row.isUp))),
),
SizedBox(width: gapW),
Expanded(
child: look.rich && !e.isDir && !row.isUp
? Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(name, maxLines: 1, overflow: TextOverflow.ellipsis, style: nameStyle),
// 좁은 화면 · 깊은 폴더에서 넘치지 않게. 960 캡처 (10/18): 동영상 정보와 한 줄 글로 (둘로 나누면 정보가 비어도
// 폭의 반을 차지해 넓은 창에서도 날짜가 잘렸다)
isVideoFile(e.path)
? VideoMetaText(c: ctl.c, path: e.path, lead: '${formatSize(e.size)} · ${fileDateText(e.modified)}', leadStyle: dim)
: Text('${formatSize(e.size)} · ${fileDateText(e.modified)}', style: dim, maxLines: 1, overflow: TextOverflow.ellipsis),
],
)
: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(name, maxLines: nameLines, overflow: TextOverflow.ellipsis, style: nameStyle),
if (slim)
const SizedBox.shrink()
else if (look.rich && e.isDir && pane.errors[e.path] != null)
Text(tr('읽을 수 없음'), style: dim.copyWith(color: JjColors.alert))
// 80: 열 없는 모양 (X-plore · 좁은 화면) 은 폴더 이름 아래에 크기
else if (e.isDir && !row.isRoot && !row.isUp && (!look.columns || compact) && ctl.dirSize(pane, e) != null)
Text(ctl.dirSize(pane, e)!, style: dim, maxLines: 1),
],
),
),
]);
});
final real = !row.isRoot && !row.isUp;
// 길게 · 오른쪽 = 메뉴 (규칙 2, [ClickPolicy])
final menu = ClickPolicy.decide(Press.longPress, ExplorerSelect.kindOf(row), selectMode: sel.selecting) == ClickAction.menu;
Widget cell(String t, int flex, {TextAlign align = TextAlign.start}) => Expanded(
flex: flex,
child: Text(t, style: dim, textAlign: align, maxLines: 1, overflow: TextOverflow.ellipsis),
);
// 관리자 지시 10/20: 좁으면 유형 → 날짜 열을 숨김 (크기는 남김, [columnHideLevel])
final hide = pane.colHide;
final cells = !look.columns || compact || !real
? <Widget>[Expanded(child: nameCell())]
: look == ExplorerStyle.windows
? [
Expanded(flex: 6, child: nameCell()),
if (hide < 2) cell(columnDateText(e.modified, short: pane.colDateShort), 3),
if (hide < 1) cell(typeLabel(e), 2),
cell(e.isDir ? (ctl.dirSize(pane, e) ?? '') : formatSize(e.size), 2, align: TextAlign.end),
]
: [
Expanded(flex: 6, child: nameCell()),
if (hide < 1) cell(e.isDir ? '' : e.ext, 1),
cell(e.isDir ? (ctl.dirSize(pane, e) ?? '<DIR>') : formatSize(e.size), 2, align: TextAlign.end),
if (hide < 2) ...[const SizedBox(width: 8), cell(columnDateText(e.modified, short: pane.colDateShort), 3)],
];
final paneIndex = ctl.panes.indexOf(pane);
final drag = real && !ctl.rsync;
Widget line = GestureDetector(
// Rsync 화면은 고르기만 (파일 기능 메뉴 없음)
// 25: Rsync 화면에서도 (폴더 메뉴)
onSecondaryTapUp: menu ? (d) => onMenu(pane, e, d.globalPosition) : null,
onLongPressStart: menu ? (d) => onMenu(pane, e, d.globalPosition) : null,
child: Material(
// U5 (점검 10/22): 지금 줄 (한 번 누른 것 - 표시가 없을 때 [복사] 등의 대상) 은 진한 바탕 + 왼쪽 강조 막대
color: marked
? JjColors.accent.withValues(alpha: 0.18)
: isFocused
? JjColors.accent.withValues(alpha: 0.09)
: Colors.transparent,
shape: isFocused ? Border(left: BorderSide(color: JjColors.accent, width: 3)) : null,
child: Row(children: [
Expanded(
child: InkWell(
onTap: () => sel.onTap(pane, row, list: list),
// 선택 모드: 두 번 누르면 열기 (바로 열기 모드는 두 번 누르기를 받지 않음 - 한 번 누르기가 늦어지지 않게)
// 폴더는 한 번 누르기로 바로 펼치므로 두 번 누르기를 받지 않는다 (기다리지 않게)
onDoubleTap: ClickPolicy.decide(Press.doubleTap, ExplorerSelect.kindOf(row), selectMode: sel.selecting, tapOpens: !sel.selectMode) ==
ClickAction.open
? () => sel.onOpen(pane, row, list: list)
: null,
child: Padding(
padding: const EdgeInsets.only(left: 6, right: 4),
child: Row(children: cells),
),
),
),
// 82: 터치로 끌기 - 선택 모드에서 표시한 줄의 ≡ 손잡이 (길게 누르기는 메뉴 그대로)
if (drag && sel.selecting && marked)
Draggable<DragLoad>(
data: DragLoad(paneIndex, dragPaths(pane, e)),
feedback: dragFeedback(dragPaths(pane, e)),
child: Tooltip(
message: tr('끌어서 다른 창으로'),
child: SizedBox(width: 28, height: 36, child: Icon(Icons.drag_indicator, size: 18, color: JjColors.textDim)),
),
),
// 표시 동그라미는 두 번 누르기 영역 밖 (기다리지 않고 바로)
if (slim)
const SizedBox.shrink()
else if ((real || (ctl.rsync && row.isRoot)) && (sel.selecting || ctl.rsync || alwaysMarks))
SizedBox(
width: 36,
child: IconButton(
tooltip: ctl.rsync
? (marked ? tr('고르기 취소') : tr('이 폴더를 rsync 원본 · 대상으로 고르기'))
: marked
? tr('표시 지우기')
: tr('표시 (여러 개 고르기)'),
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
// 규칙 4: V 모양 - 고르지 않은 것은 흐리게, 고른 것은 진하게
icon: Icon(Icons.check,
size: look.rich ? 24 : 20,
color: marked ? JjColors.accent : JjColors.textDim.withValues(alpha: 0.35)),
onPressed: () {
ctl
..active = ctl.panes.indexOf(pane)
..update();
ctl.rsync ? sel.pick(pane, e) : sel.toggleMark(pane, e);
},
),
)
else if (sel.selecting || ctl.rsync || alwaysMarks)
const SizedBox(width: 36),
]),
),
);
// X-plore 숨기기: 숨긴 것을 보이고 있을 때 숨긴 줄은 흐리게
if (real && ctl.showAppHidden && ctl.appHidden(e.path)) line = Opacity(opacity: 0.5, child: line);
if (!drag) return line;
// 82: 폴더 줄 위에 놓으면 그 폴더 안으로 (놓을 수 있으면 강조)
if (e.isDir) {
final inner = line;
line = DragTarget<DragLoad>(
onWillAcceptWithDetails: (d) => canDropInto(d.data, paneIndex, e.path),
onAcceptWithDetails: (d) => unawaited(onDrop(d.data, paneIndex, e.path)),
builder: (context, ok, _) => ok.isEmpty
? inner
: DecoratedBox(
decoration: BoxDecoration(
color: JjColors.accent.withValues(alpha: 0.15),
border: Border.all(color: JjColors.accent, width: 1.5),
),
child: inner,
),
);
}
// 82: 마우스로 8px 넘게 끌면 시작 (고르기 · 두 번 누르기는 그대로)
final paths = dragPaths(pane, e);
return MouseDraggable<DragLoad>(data: DragLoad(paneIndex, paths), feedback: dragFeedback(paths), child: line);
}
Widget _icon(FileEntry e, bool isRoot, bool open, {bool up = false}) {
final look = ctl.look;
if (up) return Icon(Icons.arrow_upward, size: look.iconSize, color: JjColors.textDim);
if (isRoot) return Icon(isDav(e.path) ? Icons.cloud : Icons.sd_storage, color: JjColors.accent, size: look.iconSize - 2);
// X-plore 비교 5-1 ② (관리자 지시 10/21): 원격은 설정 (처음 값 Wi-Fi 에서만) 에 따라 받아서 - 받기 전 · 못 받으면 아이콘
if (look.rich && isDav(e.path) && ctl.c.settings.explorerRemoteThumbs != 'off' && remoteThumbKind(e)) {
final (icon, color) = fileIcon(look, e, open: open);
return RemoteThumb(
key: ValueKey('rthumb:${e.path}'), c: ctl.c, entry: e, fallback: Icon(icon, size: 28, color: color));
}
// X-plore: 동영상 썸네일 · 그림 미리 보기
if (look.rich && !e.isDir && !isDav(e.path)) {
if (isVideoFile(e.path)) return VideoThumb(c: ctl.c, entry: e);
const images = {'jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp'};
if (images.contains(e.ext)) {
return ClipRRect(
borderRadius: BorderRadius.circular(3),
child: Image.file(File(e.path),
width: 56, height: 44, fit: BoxFit.cover, cacheWidth: 112,
errorBuilder: (_, _, _) => const Icon(Icons.image_outlined, size: 28)),
);
}
}
final (icon, color) = fileIcon(look, e, open: open);
return Icon(icon, size: look.rich && !e.isDir ? 28 : look.iconSize, color: color);
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,117 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:jj_mkvmaker/app/app_controller.dart';
import 'package:jj_mkvmaker/core/explorer/tree_rows.dart';
import 'package:jj_mkvmaker/core/file_ops.dart' show FileEntry;
import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.dart';
import 'package:jj_mkvmaker/platform/windows/process_media_tool.dart';
import 'package:jj_mkvmaker/services/platform_services.dart';
import 'package:jj_mkvmaker/ui/explorer/explorer_controller.dart';
import 'package:jj_mkvmaker/ui/explorer/explorer_host.dart';
import 'package:jj_mkvmaker/ui/explorer/explorer_pane.dart';
import 'package:jj_mkvmaker/ui/explorer/explorer_select.dart';
import 'package:path/path.dart' as p;
/// E5d-2 약속 시험: lib/ui/explorer/explorer_select.dart 맨 위 약속 (실제 시험 폴더 · Host 는 실제 화면).
void main() {
late Directory tmp;
late String root, show, ep1, ep2;
setUp(() {
tmp = Directory.systemTemp.createTempSync('jj_sel_ui_');
root = tmp.path;
show = p.join(root, 'Show');
Directory(show).createSync();
ep1 = p.join(show, 'ep1.mkv');
ep2 = p.join(show, 'ep2.mkv');
File(ep1).writeAsStringSync('1');
File(ep2).writeAsStringSync('2');
});
tearDown(() => tmp.deleteSync(recursive: true));
var now = DateTime(2026, 10, 22, 9);
Future<(ExplorerSelect, ExplorerPane, List<String>)> open(WidgetTester t, {bool rsync = false}) async {
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
final key = GlobalKey<_HostState>();
await t.pumpWidget(MaterialApp(home: _Host(key: key)));
final ctl = ExplorerController(c, rsync: rsync, host: key.currentState!)..local = [(root, 'T:')];
final opened = <String>[];
final sel = ExplorerSelect(ctl, clock: () => now, openFile: (path, _) async => opened.add(path));
addTearDown(() {
sel.dispose();
ctl.dispose();
});
final q = ctl.panes[0];
await t.runAsync(() => ctl.goTo(q, root));
return (sel, q, opened);
}
TreeRow rowOf(String path, {bool dir = false}) => TreeRow(FileEntry(path, isDir: dir, modified: DateTime(0)), 1);
testWidgets('폴더 한 번 = 펼침 · 다시 = 접힘 (두 번 누르기의 두 번째는 아님) · 파일 한 번 = 지금 줄 · 두 번 = 열기', (t) async {
final (sel, q, opened) = await open(t);
await t.runAsync(() => sel.onTap(q, rowOf(show, dir: true)));
expect(q.expanded.contains(show), isTrue);
now = now.add(const Duration(milliseconds: 100));
await t.runAsync(() => sel.onTap(q, rowOf(show, dir: true)));
expect(q.expanded.contains(show), isTrue, reason: '두 번 누르기의 두 번째');
now = now.add(const Duration(seconds: 1));
await t.runAsync(() => sel.onTap(q, rowOf(show, dir: true)));
expect(q.expanded.contains(show), isFalse, reason: '규칙 3');
await t.runAsync(() => sel.onTap(q, rowOf(show, dir: true)));
await t.runAsync(() => sel.onTap(q, rowOf(ep1)));
expect([q.focused, opened], [ep1, <String>[]]);
await t.runAsync(() => sel.onOpen(q, rowOf(ep1)));
expect(opened, [ep1]);
});
testWidgets('선택 모드: 파일 고름 · 폴더 = 자기 + 안 · 범위 · 뒤집기 · 끄면 모두 풀림', (t) async {
final (sel, q, _) = await open(t);
sel.setSelecting(true);
await t.runAsync(() => sel.onTap(q, rowOf(show, dir: true)));
expect([sel.isMarked(q, ep1), q.expanded.contains(show)], [true, true]);
await t.runAsync(() => sel.onTap(q, rowOf(show, dir: true)));
expect(q.sel.isEmpty, isTrue);
await t.runAsync(() => sel.toggleMark(q, FileEntry(ep1, isDir: false, modified: DateTime(0))));
expect(sel.canRange(q, FileEntry(ep2, isDir: false, modified: DateTime(0))), isTrue);
sel.markRange(q, FileEntry(ep2, isDir: false, modified: DateTime(0)));
expect(q.sel.marked.length, 2);
sel.invertMarks(q, show);
expect(q.sel.isEmpty, isTrue);
sel.markWhere(q, show, (e) => e.path.endsWith('2.mkv'));
expect(q.sel.marked, [ep2]);
sel.setSelecting(false);
expect([sel.selecting, q.sel.isEmpty], [false, true]);
});
testWidgets('Rsync: 폴더만 하나 · 고르면 펼침', (t) async {
final (sel, q, _) = await open(t, rsync: true);
await t.runAsync(() => sel.onTap(q, rowOf(ep1)));
expect(q.sel.isEmpty, isTrue);
await t.runAsync(() => sel.onTap(q, rowOf(show, dir: true)));
expect([q.sel.marked, q.expanded.contains(show)], [
[show],
true
]);
});
}
class _Host extends StatefulWidget {
const _Host({super.key});
@override
State<_Host> createState() => _HostState();
}
class _HostState extends State<_Host> implements ExplorerHost {
@override
bool get alive => mounted;
@override
void snack(String text) => ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(text)));
@override
void reveal(ExplorerPane pane, String path) {}
@override
void afterFrame(VoidCallback f) => WidgetsBinding.instance.addPostFrameCallback((_) => f());
@override
Widget build(BuildContext context) => const Scaffold(body: SizedBox());
}