257 lines
9.5 KiB
Dart
257 lines
9.5 KiB
Dart
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.explorer.click != '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.explorer.retapCollapse);
|
||
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);
|
||
|
||
/// 기능의 대상: 표시한 항목, 없으면 마지막으로 누른 항목 (저장 장치 맨 위는 빼고)
|
||
List<String> targets(ExplorerPane pane) {
|
||
if (!pane.sel.isEmpty) return pane.sel.marked;
|
||
final f = pane.focused;
|
||
if (f == null || samePath(f, pane.root)) return const [];
|
||
return [f];
|
||
}
|
||
|
||
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();
|
||
}
|
||
|
||
/// [표시 지우기]: 이 창의 표시를 모두 풀기 (선택 모드는 그대로)
|
||
void clearMarks(ExplorerPane pane) {
|
||
pane.sel.clear();
|
||
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);
|
||
}
|
||
}
|