import 'file_ops.dart' show isSameOrInside, samePath; import 'vfs.dart' show vDirname; /// 파일 탐색기의 누르기 · 고르기 (E1, 관리자 c5 승인 10/22). /// /// 기준: wiki "10 wiki/규칙/파일 탐색기 선택 규칙 (사용자 지시 모음)" 의 규칙 2~6 · 8. 화면은 이 모듈을 부르기만 한다. /// /// 약속 /// - [ClickPolicy.decide]: 누르기 (한 번 · 두 번 · 길게 · 오른쪽) · 항목 종류 · 선택 모드 · 펼침 → 할 일 하나 ([ClickAction]). /// 모르는 조합은 [ClickAction.none] (아무것도 하지 않음). 화면 · 기기 · 시각을 모른다 (같은 입력 = 같은 결과). /// - [Selection]: 창 하나의 표시 (고른 경로 집합). 이름 바꾸기 · 지우기를 따라간다 ([rename] · [forget] · [removeWhere]). /// · 파일: 누르면 고름, 다시 누르면 취소 (규칙 5) /// · 폴더: 누르면 자기 + 안의 것 전체, 다시 누르면 자기와 안의 것 전체 취소 (규칙 6) /// · 고른 폴더 안의 항목 하나를 누르면 그것만 빠짐 (나머지는 고른 채) /// · 그렇게 하나를 뺀 폴더를 다시 누르면 그 폴더와 안의 것 전체 취소 (관리자 결정 10/22) /// · Rsync ([Selection.rsync]): 폴더만, 하나만 (다시 누르면 취소) (규칙 8) /// 오류: 안의 목록을 읽지 못하면 ([SelectionLister] 가 던짐) 표시를 바꾸지 않고 그대로 던진다 (반쯤 바뀐 상태를 남기지 않음). /// - 경로 비교는 [samePath] · [isSameOrInside] (Windows 대소문자 무시 · WebDAV 주소) 를 그대로 쓴다. /// 줄의 종류 enum ItemKind { file, folder, /// 저장 장치 · 서버 · 즐겨찾기 같은 트리 맨 위 (고를 수 없음) root, /// 목록의 ".." (위 폴더로) up, } /// 누르기 enum Press { tap, doubleTap, longPress, secondary } /// 누르기의 결과 (화면이 할 일) enum ClickAction { none, /// 지금 줄로 (파일 한 번 누르기 = 선택) focus, /// 실행 (파일) · 들어가기 (목록의 폴더 · ..) open, /// 트리 폴더 펼치기 expand, /// 트리 폴더 접기 collapse, /// 기능 메뉴 (복사 · 이동 · 삭제 …) menu, /// 표시 바꾸기 ([Selection.toggle]) toggleMark, } /// 규칙 2 · 3 · 5 · 6: 누르기 → 할 일 class ClickPolicy { const ClickPolicy._(); /// [selectMode] 선택 모드 (가운데 [선택]) · [expanded] 그 폴더가 펼쳐져 있는지 · /// [list] 트리가 아니라 목록 (폴더 + 목록 배치의 오른쪽) 의 줄인지 · /// [repeat] 같은 줄을 두 번 누르기 시간 안에 다시 누른 것인지 (폴더는 두 번 누르기를 기다리지 않으므로, /// 두 번째 누르기로 방금 펼친 것을 접지 않게). /// 사용자 설정 (처음 값은 규칙대로): /// [tapOpens] 파일 한 번 누르기 = 바로 실행 (설정 "바로 열기", 이때 두 번 누르기는 받지 않음) · /// [retapCollapses] 펼친 폴더를 다시 누르면 접기 (끄면 [ClickAction.expand] - 화면은 펼친 폴더를 지금 폴더로만) static ClickAction decide(Press press, ItemKind kind, {required bool selectMode, bool expanded = false, bool list = false, bool repeat = false, bool tapOpens = false, bool retapCollapses = true}) { final real = kind == ItemKind.file || kind == ItemKind.folder; switch (press) { case Press.longPress: case Press.secondary: return real ? ClickAction.menu : ClickAction.none; case Press.doubleTap: // 두 번 = 실행 (파일만, 선택 모드 밖). 폴더 · 선택 모드 · 바로 열기는 한 번 누르기로 이미 처리됨 return kind == ItemKind.file && !selectMode && !tapOpens ? ClickAction.open : ClickAction.none; case Press.tap: if (kind == ItemKind.up) return ClickAction.open; if (selectMode && real) return ClickAction.toggleMark; if (kind == ItemKind.file) return tapOpens ? ClickAction.open : ClickAction.focus; if (list && kind == ItemKind.folder) return ClickAction.open; if (!expanded || !retapCollapses) return ClickAction.expand; return repeat ? ClickAction.none : ClickAction.collapse; } } } /// 폴더 안의 경로들 (바로 아래만). 읽지 못하면 던진다. typedef SelectionLister = Future> Function(String dir); /// 창 하나의 표시 (규칙 5 · 6 · 8) class Selection { Selection({this.rsync = false}); /// Rsync 화면: 폴더만 · 하나만 (규칙 8) final bool rsync; final List _marked = []; /// 고른 폴더에서 하나를 빼서 나눈 폴더 (다시 누르면 전체 취소 - 관리자 결정 10/22) final List _split = []; /// 범위 고르기의 시작 (마지막으로 고른 것) String? anchor; /// 고른 경로 (바깥 것만 - 폴더를 고르면 안의 것은 따로 들지 않음) List get marked => List.unmodifiable(_marked); bool get isEmpty => _marked.isEmpty; /// [path] 가 표시되었는지 (자기 자신, 또는 위 폴더가 표시됨). Rsync 는 그 폴더만. bool isMarked(String path) => _marked.any((m) => rsync ? samePath(path, m) : isSameOrInside(path, m)); /// [path] 를 [Selection] 의 규칙대로 바꾼다. 폴더를 나눌 때만 [list] 로 안을 읽는다. Future toggle(String path, {required bool isDir, required SelectionLister list}) async { if (rsync) { if (!isDir) return; final again = _marked.length == 1 && samePath(_marked.first, path); _marked.clear(); if (!again) _marked.add(path); return; } if (isMarked(path)) { final owner = _marked.firstWhere((m) => isSameOrInside(path, m)); if (samePath(owner, path)) { _clearInside(path); return; } // 위 폴더 [owner] 로 함께 골라진 것: 그것만 빼고 owner 에서 path 까지 가는 길의 다른 항목은 고른 채로 final keep = []; final splits = []; var dir = owner; while (!samePath(dir, path)) { final kids = await list(dir); splits.add(dir); String? next; for (final k in kids) { if (isSameOrInside(path, k)) { next = k; } else { keep.add(k); } } if (next == null) break; dir = next; } _marked.removeWhere((m) => samePath(m, owner)); _marked.addAll(keep); _split.addAll(splits); return; } if (isDir && _split.any((s) => samePath(s, path))) { // 하나를 뺀 폴더를 다시 누름: 그 폴더와 안의 것 전체 취소 _clearInside(path); return; } _marked.removeWhere((m) => isSameOrInside(m, path)); // 안에 따로 고른 것은 이 폴더 하나로 _split.removeWhere((s) => isSameOrInside(s, path)); _marked.add(path); anchor = path; } /// [from] (보통 [anchor]) 부터 [to] 까지 [visible] (보이는 차례) 의 것을 모두 고른다. 둘 중 하나가 없으면 그대로. void range(List visible, String to, {String? from}) { final a = visible.indexWhere((x) => samePath(x, from ?? anchor ?? '')); final b = visible.indexWhere((x) => samePath(x, to)); if (a < 0 || b < 0 || rsync) return; for (var i = a < b ? a : b; i <= (a < b ? b : a); i++) { if (!isMarked(visible[i])) _marked.add(visible[i]); } _normalize(); anchor = to; } /// [paths] 를 모두 고른다 (모두 선택 · 틀에 맞는 것) void addAll(Iterable paths) { if (rsync) return; for (final x in paths) { if (!isMarked(x)) _marked.add(x); } _normalize(); } /// [paths] 중 고른 것은 풀고 안 고른 것은 고른다 (뒤집기) void invert(Iterable paths) { if (rsync) return; for (final x in paths) { if (_marked.any((m) => samePath(m, x))) { _marked.removeWhere((m) => samePath(m, x)); } else if (!isMarked(x)) { _marked.add(x); } } _normalize(); } /// 지워지거나 옮겨진 경로를 표시에서 뺀다 (안의 것 포함) void forget(String path) { _marked.removeWhere((m) => isSameOrInside(m, path)); _split.removeWhere((s) => isSameOrInside(s, path)); } /// [test] 에 맞는 표시를 뺀다 (예: 다른 곳에서 지워져 없는 것) void removeWhere(bool Function(String path) test) { _marked.removeWhere(test); _split.removeWhere(test); } /// 이름이 바뀐 것: 표시돼 있었으면 새 이름으로 (안의 표시도 새 경로로) void rename(String from, String to) { String moved(String x) => samePath(x, from) ? to : to + x.substring(from.length); for (final list in [_marked, _split]) { for (var i = 0; i < list.length; i++) { if (isSameOrInside(list[i], from)) list[i] = moved(list[i]); } } if (anchor != null && isSameOrInside(anchor!, from)) anchor = moved(anchor!); } /// [path] 자신이 표시돼 있는지 (위 폴더로 함께 골라진 것은 아님) bool has(String path) => _marked.any((m) => samePath(m, path)); void clear() { _marked.clear(); _split.clear(); anchor = null; } /// 일할 대상: 고른 것, 없으면 지금 줄 [focused] 하나 (없으면 빈 목록) List targets(String? focused) => _marked.isNotEmpty ? marked : [?focused]; /// [dir] 바로 안에서 고른 개수 (폴더 이름 옆 표시 · 아래 줄 숫자) int countIn(String dir) => _marked.where((m) => samePath(vDirname(m), dir)).length; void _clearInside(String path) { _marked.removeWhere((m) => isSameOrInside(m, path)); _split.removeWhere((s) => isSameOrInside(s, path)); if (anchor != null && isSameOrInside(anchor!, path)) anchor = null; } /// 다른 표시 안에 든 표시는 뺀다 (폴더 하나로 안의 것까지 고른 것이므로) void _normalize() { final all = List.of(_marked); _marked.removeWhere((m) => all.any((o) => !identical(o, m) && !samePath(o, m) && isSameOrInside(m, o))); } }