diff --git a/assets/l10n/en.json b/assets/l10n/en.json index 595d89f..8673168 100644 --- a/assets/l10n/en.json +++ b/assets/l10n/en.json @@ -388,6 +388,7 @@ "모델 파일이 잘렸습니다 (위치 {0})": "The model file is truncated (position {0})", "모델을 내려받을 수 없습니다 ({0}): {1}": "Cannot download the model ({0}): {1}", "모두 내장 플레이어": "Built-in player for all", + "모두 선택": "Select all", "모두 종료": "Quit all", "모두 지우기": "Clear all", "모두 초기화": "Reset all", @@ -482,7 +483,9 @@ "새로고침 (F5)": "Reload (F5)", "색 보정": "Color correction", "색온도": "Temperature", + "선택": "Select", "선택 {0} / {1}개": "Selected {0} / {1}", + "선택 끝": "Done", "선택 줄 재생": "Play selected line", "선택 줄부터 여기로 맞추기": "Align from the selected line to here", "선택한 {0}개 받기": "Download {0} selected", diff --git a/assets/l10n/ja.json b/assets/l10n/ja.json index fe0b3f4..e07ac39 100644 --- a/assets/l10n/ja.json +++ b/assets/l10n/ja.json @@ -388,6 +388,7 @@ "모델 파일이 잘렸습니다 (위치 {0})": "モデルファイルが途中で切れています(位置 {0})", "모델을 내려받을 수 없습니다 ({0}): {1}": "モデルをダウンロードできません({0}): {1}", "모두 내장 플레이어": "すべて内蔵プレーヤー", + "모두 선택": "すべて選択", "모두 종료": "すべて終了", "모두 지우기": "すべて削除", "모두 초기화": "すべてリセット", @@ -482,7 +483,9 @@ "새로고침 (F5)": "再読み込み(F5)", "색 보정": "カラー補正", "색온도": "色温度", + "선택": "選択", "선택 {0} / {1}개": "選択 {0} / {1}個", + "선택 끝": "選択終了", "선택 줄 재생": "選択行を再生", "선택 줄부터 여기로 맞추기": "選択行からここに合わせる", "선택한 {0}개 받기": "選択した {0}個をダウンロード", diff --git a/assets/l10n/zh-Hans.json b/assets/l10n/zh-Hans.json index 7b0850a..df6a2f9 100644 --- a/assets/l10n/zh-Hans.json +++ b/assets/l10n/zh-Hans.json @@ -388,6 +388,7 @@ "모델 파일이 잘렸습니다 (위치 {0})": "模型文件被截断(位置 {0})", "모델을 내려받을 수 없습니다 ({0}): {1}": "无法下载模型({0}): {1}", "모두 내장 플레이어": "全部使用内置播放器", + "모두 선택": "全选", "모두 종료": "全部退出", "모두 지우기": "全部清除", "모두 초기화": "全部重置", @@ -482,7 +483,9 @@ "새로고침 (F5)": "刷新(F5)", "색 보정": "色彩校正", "색온도": "色温", + "선택": "选择", "선택 {0} / {1}개": "已选 {0} / {1} 个", + "선택 끝": "完成选择", "선택 줄 재생": "播放所选行", "선택 줄부터 여기로 맞추기": "从所选行起对齐到此处", "선택한 {0}개 받기": "下载所选的 {0} 个", diff --git a/lib/ui/explorer_look.dart b/lib/ui/explorer_look.dart index 95392df..e1919ae 100644 --- a/lib/ui/explorer_look.dart +++ b/lib/ui/explorer_look.dart @@ -106,7 +106,10 @@ String totalCmdName(FileEntry e) { /// 열 머리 (Windows 탐색기 · Total Commander) class ExplorerColumnsHeader extends StatelessWidget { final ExplorerStyle style; - const ExplorerColumnsHeader({super.key, required this.style}); + + /// 선택 모드: 줄 끝의 선택 동그라미 칸만큼 비운다 + final bool markColumn; + const ExplorerColumnsHeader({super.key, required this.style, this.markColumn = false}); @override Widget build(BuildContext context) { @@ -115,7 +118,7 @@ class ExplorerColumnsHeader extends StatelessWidget { Expanded(flex: flex, child: Text(t, style: st, textAlign: align, maxLines: 1, overflow: TextOverflow.clip)); return Container( height: 24, - padding: const EdgeInsets.only(left: 10, right: 40), + padding: EdgeInsets.only(left: 10, right: markColumn ? 40 : 4), decoration: const BoxDecoration(border: Border(bottom: BorderSide(color: JjColors.border))), child: Row( children: style == ExplorerStyle.windows diff --git a/lib/ui/explorer_page.dart b/lib/ui/explorer_page.dart index 9bd56ab..879acdf 100644 --- a/lib/ui/explorer_page.dart +++ b/lib/ui/explorer_page.dart @@ -56,6 +56,7 @@ class ExplorerPage extends StatefulWidget { enum ExplorerButton { up(Icons.arrow_upward, '상위 폴더'), refresh(Icons.refresh, '새로 고침'), + select(Icons.checklist, '선택'), play(Icons.play_circle_outline, '재생'), addMkv(Icons.playlist_add, 'MKV 목록에 추가'), newFolder(Icons.create_new_folder_outlined, '새 폴더'), @@ -104,10 +105,16 @@ class _Pane extends ChangeNotifier { _Pane(this.root) : current = root; - void changed() => notifyListeners(); + /// 화면을 닫은 뒤 끝난 파일 작업 · 읽기가 알리지 않게 + bool _disposed = false; + + void changed() { + if (!_disposed) notifyListeners(); + } @override void dispose() { + _disposed = true; scroll.dispose(); listScroll.dispose(); super.dispose(); @@ -294,10 +301,23 @@ class _ExplorerPageState extends State { // ───────── 누르기 ───────── + /// 선택 모드 (X-plore 처럼): 각 줄의 ✔ 동그라미가 보이고, 누르면 고르기 · 풀기 + bool _selecting = false; + + void _setSelecting(bool on) => setState(() { + _selecting = on; + if (!on) { + for (final x in _panes) { + x.marked.clear(); + } + } + }); + /// 한 번 누르기. - /// - 선택 (기본): 고르기만. 폴더는 그 폴더가 "지금 폴더" (오른쪽 파일 목록 · 새 폴더 등의 기준). - /// - 바로 열기: 열기 ([_onOpen]). - /// Ctrl 을 누른 채면 표시 (여러 개 고르기). 목록의 ".." 은 늘 상위 폴더로. + /// - 선택 모드: 고르기 · 풀기 ([_toggleMark]) + /// - 폴더: 바로 펼치기 · 접기 (트리), 들어가기 (오른쪽 파일 목록) + /// - 파일: 누르기 설정이 "선택" 이면 고르기만 (두 번 누르면 실행), "바로 실행" 이면 실행 + /// Ctrl 을 누른 채면 선택 모드로 고르기. 목록의 ".." 은 늘 상위 폴더로. Future _onTap(_Pane pane, _Row row, {bool list = false}) async { final e = row.entry; setState(() => _active = _panes.indexOf(pane)); @@ -305,25 +325,35 @@ class _ExplorerPageState extends State { await _goTo(pane, e.path); return; } - if (HardwareKeyboard.instance.isControlPressed && !row.isRoot) { - setState(() => pane.marked.contains(e.path) ? pane.marked.remove(e.path) : pane.marked.add(e.path)); + if (!row.isRoot && (_selecting || HardwareKeyboard.instance.isControlPressed)) { + if (!_selecting) setState(() => _selecting = true); + await _toggleMark(pane, e); return; } - if (!_selectMode) return _onOpen(pane, row, list: list); + if (e.isDir || !_selectMode) return _onOpen(pane, row, list: list); pane.focused = e.path; - if (e.isDir && !list) { - pane.current = e.path; - pane.changed(); - await _load(pane, e.path); - if (pane.listScroll.hasClients) pane.listScroll.jumpTo(0); - _remember(pane, e.path); - } else { - if (!list) pane.current = p.dirname(e.path); - pane.changed(); - } + if (!list) pane.current = p.dirname(e.path); + pane.changed(); } - /// 열기 (선택 모드는 두 번 누르기, 바로 열기 모드는 한 번 누르기): + /// 선택 모드에서 누르기: 파일은 고르기 · 풀기. + /// 폴더는 처음엔 고르기, 고른 폴더를 다시 누르면 폴더는 풀고 펼쳐서 그 안의 파일 · 폴더를 모두 고른다. + Future _toggleMark(_Pane pane, FileEntry e) async { + pane.focused = e.path; + if (!pane.marked.contains(e.path)) { + setState(() => pane.marked.add(e.path)); + return; + } + setState(() => pane.marked.remove(e.path)); + if (!e.isDir) return; + await _load(pane, e.path); + setState(() { + pane.expanded.add(e.path); + pane.marked.addAll([for (final x in pane.cache[e.path] ?? const []) x.path]); + }); + } + + /// 열기 (파일: 선택 설정이면 두 번 누르기, 바로 실행이면 한 번 누르기 / 폴더: 한 번 누르기): /// 트리의 폴더는 펼치기 · 접기, 목록의 폴더는 들어가기 (왼쪽 트리도 따라감), 파일은 실행. Future _onOpen(_Pane pane, _Row row, {bool list = false}) async { final e = row.entry; @@ -385,6 +415,7 @@ class _ExplorerPageState extends State { if (!e.isDir) ('openWith', Icons.open_in_new, tr('다른 앱으로 열기')), if (!e.isDir && !video) ('openDefault', Icons.launch, tr('기본 앱으로 열기')), if (video) ('addMkv', Icons.playlist_add, tr('MKV 목록에 추가')), + ('select', Icons.check_circle_outline, tr('선택')), ('rename', Icons.drive_file_rename_outline, tr('이름 변경')), if (dual) ('copy', Icons.copy_outlined, tr('다른 창으로 복사')), if (dual) ('move', Icons.drive_file_move_outline, tr('다른 창으로 이동')), @@ -427,6 +458,9 @@ class _ExplorerPageState extends State { if (!await c.services.shell.openWith(e.path)) _snack(tr('이 파일을 열 수 있는 앱이 없습니다.')); case 'addMkv': await _addMkv([e.path]); + case 'select': + setState(() => _selecting = true); + if (!pane.marked.contains(e.path)) await _toggleMark(pane, e); case 'rename': await _rename(pane, e.path); case 'copy': @@ -487,6 +521,8 @@ class _ExplorerPageState extends State { final up = p.dirname(pane.current); pane.expanded.remove(pane.current); await _goTo(pane, up); + case ExplorerButton.select: + _setSelecting(!_selecting); case ExplorerButton.refresh: pane.cache.clear(); for (final d in pane.expanded.toList()) { @@ -630,6 +666,7 @@ class _ExplorerPageState extends State { } for (final x in _panes) { x.marked.removeAll(paths); + if (_selecting && x.marked.isEmpty) _selecting = false; x.expanded.removeWhere((d) => paths.any((s) => isSameOrInside(d, s))); if (paths.any((s) => isSameOrInside(x.current, s))) x.current = p.dirname(paths.first); } @@ -725,6 +762,7 @@ class _ExplorerPageState extends State { } if (mounted) Navigator.of(context).pop(); pane.marked.clear(); + if (_selecting && mounted) _setSelecting(false); await _refreshAll([dest, ...sources.map(p.dirname)]); // 넣은 폴더가 창의 지금 폴더면 펼쳐서 새 항목이 보이게 for (final x in _panes) { @@ -1077,7 +1115,8 @@ class _ExplorerPageState extends State { ExplorerButton.paste => _clip.isNotEmpty, _ => true, }, - selected: (b) => b == ExplorerButton.hidden && s.explorerShowHidden, + selected: (b) => + (b == ExplorerButton.hidden && s.explorerShowHidden) || (b == ExplorerButton.select && _selecting), ); final middle = bar != null && s.explorerToolbar == 'middle'; final edge = bar != null && s.explorerToolbar == 'edge'; @@ -1121,13 +1160,24 @@ class _ExplorerPageState extends State { ); } - Widget _marksBar(_Pane pane) => Container( + /// 선택 모드 막대: 고른 수 · 모두 고르기 · 선택 끝 + Widget _marksBar(_Pane pane, {String? listDir}) => Container( color: JjColors.panel, padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), child: Row(children: [ + Icon(Icons.checklist, size: 18, color: JjColors.accent), + const SizedBox(width: 6), Text(trf('{0}개 표시함', [pane.marked.length]), style: const TextStyle(fontSize: 12)), const Spacer(), + TextButton( + onPressed: () => setState(() { + final dir = listDir ?? pane.current; + pane.marked.addAll([for (final x in pane.cache[dir] ?? const []) x.path]); + }), + child: Text(tr('모두 선택')), + ), TextButton(onPressed: () => setState(pane.marked.clear), child: Text(tr('표시 지우기'))), + TextButton(onPressed: () => _setSelecting(false), child: Text(tr('선택 끝'))), ]), ); @@ -1139,7 +1189,7 @@ class _ExplorerPageState extends State { return _frame(i, [ _paneHeader(pane), const Divider(height: 1), - if (look.columns && !foldersOnly) ExplorerColumnsHeader(style: look), + if (look.columns && !foldersOnly) ExplorerColumnsHeader(style: look, markColumn: _selecting), Expanded( child: ListView.builder( controller: pane.scroll, @@ -1148,7 +1198,7 @@ class _ExplorerPageState extends State { itemBuilder: (_, k) => _rowView(pane, rows[k], compact: foldersOnly), ), ), - if (pane.marked.isNotEmpty && !foldersOnly) _marksBar(pane), + if (_selecting && !foldersOnly) _marksBar(pane), ]); } @@ -1170,7 +1220,7 @@ class _ExplorerPageState extends State { child: Text(dir, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(color: JjColors.textDim)), ), const Divider(height: 1), - if (look.columns) ExplorerColumnsHeader(style: look), + if (look.columns) ExplorerColumnsHeader(style: look, markColumn: _selecting), Expanded( child: pane.loading.contains(dir) ? const Center(child: CircularProgressIndicator()) @@ -1183,7 +1233,7 @@ class _ExplorerPageState extends State { itemBuilder: (_, k) => _rowView(pane, rows[k], list: true), ), ), - if (pane.marked.isNotEmpty) _marksBar(pane), + if (_selecting) _marksBar(pane, listDir: dir), ]); } @@ -1327,7 +1377,10 @@ class _ExplorerPageState extends State { child: InkWell( onTap: () => _onTap(pane, row, list: list), // 선택 모드: 두 번 누르면 열기 (바로 열기 모드는 두 번 누르기를 받지 않음 - 한 번 누르기가 늦어지지 않게) - onDoubleTap: _selectMode ? () => _onOpen(pane, row, list: list) : null, + // 폴더는 한 번 누르기로 바로 펼치므로 두 번 누르기를 받지 않는다 (기다리지 않게) + onDoubleTap: _selectMode && !_selecting && !e.isDir && !row.isUp + ? () => _onOpen(pane, row, list: list) + : null, child: Padding( padding: const EdgeInsets.only(left: 6, right: 4), child: Row(children: cells), @@ -1335,7 +1388,7 @@ class _ExplorerPageState extends State { ), ), // 표시 동그라미는 두 번 누르기 영역 밖 (기다리지 않고 바로) - if (real && !compact) + if (real && _selecting) SizedBox( width: 36, child: IconButton( @@ -1344,13 +1397,13 @@ class _ExplorerPageState extends State { padding: EdgeInsets.zero, icon: Icon(marked ? Icons.check_circle : Icons.radio_button_unchecked, size: look.rich ? 20 : 16, color: marked ? JjColors.accent : JjColors.textDim), - onPressed: () => setState(() { - _active = _panes.indexOf(pane); - marked ? pane.marked.remove(e.path) : pane.marked.add(e.path); - }), + onPressed: () { + setState(() => _active = _panes.indexOf(pane)); + _toggleMark(pane, e); + }, ), ) - else if (!compact) + else if (_selecting) const SizedBox(width: 36), ]), ), diff --git a/test/explorer_test.dart b/test/explorer_test.dart index 7fb53ef..a845b0c 100644 --- a/test/explorer_test.dart +++ b/test/explorer_test.dart @@ -53,17 +53,21 @@ void main() { await settle(tester, () => find.text('doc.txt').evaluate().isNotEmpty); } - testWidgets('두 창 (선택 모드): 한 번 = 선택 · 화살표 = 펼치기 · 두 번 = 실행 · 길게 = 메뉴 · 다른 창으로 복사', (tester) async { + testWidgets('두 창: 폴더 한 번 = 펼치기 · 파일 한 번 = 선택 · 두 번 = 실행 · 길게 = 메뉴 · 다른 창으로 복사', (tester) async { await open(tester); - // 두 창 + 가운데 버튼 줄 + // 두 창 + 가운데 버튼 줄, 선택 동그라미는 평소에 안 보임 expect(find.text('상위 폴더'), findsOneWidget); expect(find.text('doc.txt'), findsOneWidget); + expect(find.byTooltip('표시 (여러 개 고르기)'), findsNothing); - // 폴더를 한 번 누르면 고르기만 (펼치지 않음) + // 폴더를 한 번 누르면 바로 펼치고, 다시 누르면 접는다 await act(tester, () => tester.tap(find.text('sub'))); - await settle(tester, () => false, rounds: 15); + await settle(tester, () => find.text('inner.txt').evaluate().isNotEmpty); + expect(find.text('inner.txt'), findsOneWidget); + await act(tester, () => tester.tap(find.text('sub'))); + await settle(tester, () => find.text('inner.txt').evaluate().isEmpty); expect(find.text('inner.txt'), findsNothing); - // › 화살표로 펼친다 + // › 화살표로도 펼친다 await act(tester, () => tester.tap(chevronOf('sub'))); await settle(tester, () => find.text('inner.txt').evaluate().isNotEmpty); expect(find.text('inner.txt'), findsOneWidget); @@ -161,12 +165,42 @@ void main() { expect(find.text('확장자'), findsOneWidget); }); + testWidgets('선택 모드: 파일 누르기 = 선택 · 취소, 고른 폴더 다시 누르기 = 펼쳐서 안의 것 선택', (tester) async { + await open(tester); + await act(tester, () => tester.tap(find.text('선택').first)); // 가운데 [선택] 버튼 + expect(find.byTooltip('표시 (여러 개 고르기)'), findsWidgets); // 동그라미가 보인다 + // 파일: 누르면 선택, 다시 누르면 취소 (실행하지 않음) + await act(tester, () => tester.tap(find.text('doc.txt'))); + expect(find.text('1개 표시함'), findsOneWidget); + await act(tester, () => tester.tap(find.text('doc.txt'))); + expect(find.text('0개 표시함'), findsNWidgets(2)); // 두 창 모두 (각 창의 선택 수) + expect(shell.opened, isEmpty); + // 폴더: 처음엔 선택 (펼치지 않음) + await act(tester, () => tester.tap(find.text('sub'))); + await settle(tester, () => false, rounds: 10); + expect(find.text('1개 표시함'), findsOneWidget); + expect(find.text('inner.txt'), findsNothing); + // 고른 폴더를 다시 누르면: 폴더는 풀고, 펼쳐서 안의 파일을 선택 + await act(tester, () => tester.tap(find.text('sub'))); + await settle(tester, () => find.text('inner.txt').evaluate().isNotEmpty); + expect(find.text('inner.txt'), findsOneWidget); + expect(find.text('1개 표시함'), findsOneWidget); + final inner = tester.widget(find.descendant( + of: find.ancestor(of: find.text('inner.txt'), matching: find.byType(Material)).first, + matching: find.byType(IconButton))); + expect((inner.icon as Icon).icon, Icons.check_circle); // ✔ + // [선택 끝] → 동그라미가 사라지고 표시도 지운다 + await act(tester, () => tester.tap(find.text('선택 끝').first)); // 두 창 모두에 있음 + expect(find.byTooltip('표시 (여러 개 고르기)'), findsNothing); + expect(find.text('1개 표시함'), findsNothing); + }); + testWidgets('여러 개 표시 → 삭제 · 새 폴더 · 이름 변경', (tester) async { await open(tester); + await act(tester, () => tester.tap(find.text('선택').first)); // 동그라미로 doc.txt · sub 표시 (반드시 이름으로 찾은 줄의 버튼만: 목록에는 시험 폴더 위쪽의 실제 폴더도 보인다) await act(tester, () => tester.tap(markOf('sub'))); await act(tester, () => tester.tap(markOf('doc.txt'))); - // 표시 버튼은 두 번 누르기를 기다리지 않고 바로 expect(find.text('2개 표시함'), findsOneWidget); await act(tester, () => tester.tap(find.text('삭제').first)); await tester.pumpAndSettle();