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
parent
4279747371
commit
e6e4499f22
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
]),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
@ -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
|
|
@ -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());
|
||||
}
|
||||
Loading…
Reference in New Issue