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 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 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 _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> _childPaths(ExplorerPane pane, String dir) async { await ctl.load(pane, dir); return [for (final x in pane.cache[dir] ?? const []) x.path]; } /// Rsync 화면: 창마다 폴더 하나만 (규칙 8). 고르면 펼쳐서 안의 폴더가 보이게 Future 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 shown(ExplorerPane pane, String dir) => [ for (final x in pane.cache[dir] ?? const []) 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(); } /// [표시 지우기]: 이 창의 표시를 모두 풀기 (선택 모드는 그대로) void clearMarks(ExplorerPane pane) { pane.sel.clear(); notifyListeners(); } /// 선택 모드에서 누르기 (규칙 5 · 6 은 [Selection.toggle]). 고른 폴더는 펼쳐서 안의 것도 고른 모양으로 Future 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 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 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); } }