diff --git a/assets/l10n/en.json b/assets/l10n/en.json index 94b2f10..073141c 100644 --- a/assets/l10n/en.json +++ b/assets/l10n/en.json @@ -466,6 +466,7 @@ "{0}개 · {1}": "{0} · {1}", "{0}개 · 주소 {1}개": "{0} · {1} links", "{0}개 맞춤": "{0} synced", + "{0}개 보임": "{0} shown", "{0}개 선택": "{0} selected", "{0}개 중 {1}개를 지우지 못했습니다": "Couldn't delete {1} of {0}", "{0}개 지우고 맞추기": "Delete {0} and sync", @@ -964,6 +965,7 @@ "많이 (빠름): {0} - 다른 앱이 많이 켜져 있으면 Android 가 이 앱을 끌 수 있습니다": "High (faster): {0} - if many other apps are open, Android may close this app", "말레이어": "Malay", "망에 닿을 수 없음": "network unreachable", + "맞는 것이 없습니다": "No matches", "맞추기 (검은 여백)": "Fit (black bars)", "맞추기 (비율이 다를 때)": "Conform (when the aspect differs)", "맞추기 (흐린 배경)": "Fit (blurred background)", @@ -1191,6 +1193,7 @@ "빈 폴더 지움 · 원본 폴더는 남김": "Delete empty folders · keep the source folder", "빈 폴더 지움 · 원본 폴더도 지움": "Delete empty folders · delete the source folder too", "빠른 디코더 (TAESD)": "Fast decoder (TAESD)", + "빠른 필터": "Quick filter", "빼고 싶은 것 (선택)": "What to avoid (optional)", "사라지는 것": "What is lost", "사본 만들기": "Make a copy", @@ -1977,6 +1980,7 @@ "지금 짝을 즐겨찾기에": "Add the current pair to favorites", "지금 폴더 추가": "Add current folder", "지금 폴더를 즐겨찾기에": "Add the current folder to favorites", + "지금 폴더에서 이름에 든 글자로 좁히기 (* · ? 패턴도 됨)": "Narrow this folder by text in the name (* and ? patterns work)", "지금 확인": "Check now", "지금: {0}": "Now: {0}", "지금은 듀얼 앱 (복제한 앱) 입니다. Android 가 듀얼 앱에는 이 권한을 주지 않을 수 있습니다. 허용 화면에서 켤 수 없으면 배지 없는 원래 JJ_MKVMaker 아이콘으로 여세요.": "This is the Dual App (cloned app). Android may not grant this permission to dual apps. If you can't turn it on, open the original JJ_MKVMaker icon without the badge.", @@ -2223,6 +2227,7 @@ "필요한 것 모두 받기": "Download everything needed", "필요한 공간 {0}": "Space needed {0}", "필요한 공간 {0} · 남은 공간 {1}": "Space needed {0} · free {1}", + "필터 닫기 (모두 보기)": "Close filter (show all)", "하나씩 멈추려면 목록 줄의 ■ 를 누르세요.": "To stop one at a time, press ■ on its row in the list.", "하위 폴더가 없습니다": "No subfolders", "한 번 더": "Once more", diff --git a/assets/l10n/ja.json b/assets/l10n/ja.json index dd034dc..4b002cd 100644 --- a/assets/l10n/ja.json +++ b/assets/l10n/ja.json @@ -466,6 +466,7 @@ "{0}개 · {1}": "{0}個・{1}", "{0}개 · 주소 {1}개": "{0}個・URL {1}個", "{0}개 맞춤": "{0} 件を同期", + "{0}개 보임": "{0} 件表示", "{0}개 선택": "{0}個選択", "{0}개 중 {1}개를 지우지 못했습니다": "{0} 個中 {1} 個を削除できませんでした", "{0}개 지우고 맞추기": "{0} 個を削除して同期", @@ -964,6 +965,7 @@ "많이 (빠름): {0} - 다른 앱이 많이 켜져 있으면 Android 가 이 앱을 끌 수 있습니다": "多め (速い): {0} - 他のアプリが多く開いていると Android がこのアプリを終了することがあります", "말레이어": "マレー語", "망에 닿을 수 없음": "ネットワークに到達できません", + "맞는 것이 없습니다": "一致するものがありません", "맞추기 (검은 여백)": "フィット(黒い余白)", "맞추기 (비율이 다를 때)": "合わせ方(比率が違うとき)", "맞추기 (흐린 배경)": "フィット(ぼかし背景)", @@ -1191,6 +1193,7 @@ "빈 폴더 지움 · 원본 폴더는 남김": "空フォルダーを削除 · 元フォルダーは残す", "빈 폴더 지움 · 원본 폴더도 지움": "空フォルダーを削除 · 元フォルダーも削除", "빠른 디코더 (TAESD)": "高速デコーダー (TAESD)", + "빠른 필터": "クイックフィルター", "빼고 싶은 것 (선택)": "避けたいもの (任意)", "사라지는 것": "消えるもの", "사본 만들기": "コピーを作る", @@ -1977,6 +1980,7 @@ "지금 짝을 즐겨찾기에": "今のペアをお気に入りに", "지금 폴더 추가": "今のフォルダーを追加", "지금 폴더를 즐겨찾기에": "今のフォルダーをお気に入りに", + "지금 폴더에서 이름에 든 글자로 좁히기 (* · ? 패턴도 됨)": "このフォルダーを名前に含む文字で絞り込み (* · ? パターンも可)", "지금 확인": "今すぐ確認", "지금: {0}": "現在: {0}", "지금은 듀얼 앱 (복제한 앱) 입니다. Android 가 듀얼 앱에는 이 권한을 주지 않을 수 있습니다. 허용 화면에서 켤 수 없으면 배지 없는 원래 JJ_MKVMaker 아이콘으로 여세요.": "これはデュアルアプリ (複製したアプリ) です。Android はデュアルアプリにこの権限を与えない場合があります。許可画面でオンにできない場合は、バッジのない元の JJ_MKVMaker アイコンから開いてください。", @@ -2223,6 +2227,7 @@ "필요한 것 모두 받기": "必要なものをすべてダウンロード", "필요한 공간 {0}": "必要な容量 {0}", "필요한 공간 {0} · 남은 공간 {1}": "必要な容量 {0}・空き容量 {1}", + "필터 닫기 (모두 보기)": "フィルターを閉じる (すべて表示)", "하나씩 멈추려면 목록 줄의 ■ 를 누르세요.": "1 つずつ止めるには、一覧の行の ■ を押してください。", "하위 폴더가 없습니다": "サブフォルダーがありません", "한 번 더": "もう一度", diff --git a/assets/l10n/zh-Hans.json b/assets/l10n/zh-Hans.json index 7448ba0..6796574 100644 --- a/assets/l10n/zh-Hans.json +++ b/assets/l10n/zh-Hans.json @@ -466,6 +466,7 @@ "{0}개 · {1}": "{0} 个 · {1}", "{0}개 · 주소 {1}개": "{0} 个 · 地址 {1} 个", "{0}개 맞춤": "已同步 {0} 个", + "{0}개 보임": "显示 {0} 项", "{0}개 선택": "已选 {0} 个", "{0}개 중 {1}개를 지우지 못했습니다": "{0} 个中有 {1} 个无法删除", "{0}개 지우고 맞추기": "删除 {0} 个并同步", @@ -964,6 +965,7 @@ "많이 (빠름): {0} - 다른 앱이 많이 켜져 있으면 Android 가 이 앱을 끌 수 있습니다": "多(较快):{0} - 若打开了很多其他应用,Android 可能会关闭本应用", "말레이어": "马来语", "망에 닿을 수 없음": "网络不可达", + "맞는 것이 없습니다": "没有匹配项", "맞추기 (검은 여백)": "适应(黑边)", "맞추기 (비율이 다를 때)": "适配方式(比例不同时)", "맞추기 (흐린 배경)": "适应(模糊背景)", @@ -1191,6 +1193,7 @@ "빈 폴더 지움 · 원본 폴더는 남김": "删除空文件夹 · 保留源文件夹", "빈 폴더 지움 · 원본 폴더도 지움": "删除空文件夹 · 源文件夹也删除", "빠른 디코더 (TAESD)": "快速解码器 (TAESD)", + "빠른 필터": "快速筛选", "빼고 싶은 것 (선택)": "要排除的内容 (可选)", "사라지는 것": "丢失的内容", "사본 만들기": "创建副本", @@ -1977,6 +1980,7 @@ "지금 짝을 즐겨찾기에": "将当前一对加入收藏", "지금 폴더 추가": "添加当前文件夹", "지금 폴더를 즐겨찾기에": "将当前文件夹加入收藏", + "지금 폴더에서 이름에 든 글자로 좁히기 (* · ? 패턴도 됨)": "按名称中的文字筛选当前文件夹 (也可用 * · ? 模式)", "지금 확인": "立即检查", "지금: {0}": "当前: {0}", "지금은 듀얼 앱 (복제한 앱) 입니다. Android 가 듀얼 앱에는 이 권한을 주지 않을 수 있습니다. 허용 화면에서 켤 수 없으면 배지 없는 원래 JJ_MKVMaker 아이콘으로 여세요.": "当前是双开应用 (复制的应用)。Android 可能不会给双开应用此权限。如果无法开启,请从没有徽标的原 JJ_MKVMaker 图标打开。", @@ -2223,6 +2227,7 @@ "필요한 것 모두 받기": "下载全部所需文件", "필요한 공간 {0}": "所需空间 {0}", "필요한 공간 {0} · 남은 공간 {1}": "所需空间 {0} · 剩余空间 {1}", + "필터 닫기 (모두 보기)": "关闭筛选 (全部显示)", "하나씩 멈추려면 목록 줄의 ■ 를 누르세요.": "要逐个停止,请按列表行上的 ■。", "하위 폴더가 없습니다": "没有子文件夹", "한 번 더": "再输入一次", diff --git a/lib/ui/explorer_page.dart b/lib/ui/explorer_page.dart index ba30631..22ab9e0 100644 --- a/lib/ui/explorer_page.dart +++ b/lib/ui/explorer_page.dart @@ -217,6 +217,12 @@ class _Pane extends ChangeNotifier { /// 74: 범위 고르기의 시작 (마지막으로 표시한 것) String? anchor; + /// 76: 빠른 필터 - 열려 있으면 이름에 [filter] 가 든 것만 보인다 (창마다, 닫으면 지움) + bool filterOpen = false; + final filterCtl = TextEditingController(); + final filterFocus = FocusNode(); + String get filter => filterOpen ? filterCtl.text.trim() : ''; + /// 목록 스크롤 (폴더로 이동하면 그 폴더가 보이게) final scroll = ScrollController(); @@ -241,6 +247,8 @@ class _Pane extends ChangeNotifier { scroll.dispose(); listScroll.dispose(); tabScroll.dispose(); + filterCtl.dispose(); + filterFocus.dispose(); super.dispose(); } } @@ -303,6 +311,9 @@ class _ExplorerPageState extends State { int _active = 0; bool _ready = false; + /// 화면 단축키 (F5 · Delete …) 를 받는 곳. 76: 빠른 필터 칸을 닫으면 여기로 돌려준다 + final _keys = FocusNode(debugLabel: 'explorer'); + @override void initState() { super.initState(); @@ -346,6 +357,7 @@ class _ExplorerPageState extends State { @override void dispose() { + _keys.dispose(); if (widget.rsync) { ExplorerPage._openRsync--; } else { @@ -859,13 +871,23 @@ class _ExplorerPageState extends State { final out = <_Row>[]; final rootEntry = FileEntry(pane.root, isDir: true, modified: DateTime(0)); out.add(_Row(rootEntry, 0, isRoot: true)); - void walk(String dir, int depth) { - if (!pane.expanded.contains(dir)) return; + // 76: 빠른 필터 - 지금 폴더 안만 거른다 (트리의 다른 곳은 그대로). + // 이름이 맞지 않아도 안에 맞는 것이 보이는 폴더는 남긴다 (어디에 있는지 보이게) + bool walk(String dir, int depth) { + if (!pane.expanded.contains(dir)) return false; + var kept = false; for (final e in pane.cache[dir] ?? const []) { if (foldersOnly && !e.isDir) continue; + final at = out.length; out.add(_Row(e, depth)); - if (e.isDir) walk(e.path, depth + 1); + final inner = e.isDir && walk(e.path, depth + 1); + if (!inner && !_passes(pane, e)) { + out.length = at; + } else { + kept = true; + } } + return kept; } walk(pane.root, 1); @@ -950,9 +972,19 @@ class _ExplorerPageState extends State { return a != null && !samePath(a, e.path) && samePath(vDirname(a), vDirname(e.path)) && _isMarked(pane, a); } + /// 76: 빠른 필터를 지나는지 - 지금 폴더 안의 것만 거른다 + bool _passes(_Pane pane, FileEntry e) => + pane.filter.isEmpty || + !isSameOrInside(e.path, pane.current) || + samePath(e.path, pane.current) || + quickFilterMatch(vBasename(e.path), pane.filter); + + /// 76: [dir] 에서 지금 보이는 것 (빠른 필터에 맞는 것) + List _shown(_Pane pane, String dir) => [for (final x in pane.cache[dir] ?? const []) if (_passes(pane, x)) x]; + /// 74: 마지막으로 표시한 것부터 [to] 까지 (보이는 순서대로) 모두 표시 void _markRange(_Pane pane, FileEntry to) { - final list = pane.cache[vDirname(to.path)] ?? const []; + final list = _shown(pane, vDirname(to.path)); final a = list.indexWhere((x) => samePath(x.path, pane.anchor!)); final b = list.indexWhere((x) => samePath(x.path, to.path)); if (a < 0 || b < 0) return; @@ -968,14 +1000,14 @@ class _ExplorerPageState extends State { /// 74: 고르기 도구 - 이 폴더 ([dir]) 의 항목에서 void _invertMarks(_Pane pane, String dir) => setState(() { - for (final x in pane.cache[dir] ?? const []) { + for (final x in _shown(pane, dir)) { if (!pane.marked.remove(x.path)) pane.marked.add(x.path); } _normalizeMarks(pane); }); void _markWhere(_Pane pane, String dir, bool Function(FileEntry e) test) => setState(() { - pane.marked.addAll([for (final x in pane.cache[dir] ?? const []) if (test(x)) x.path]); + pane.marked.addAll([for (final x in _shown(pane, dir)) if (test(x)) x.path]); _normalizeMarks(pane); }); @@ -2789,8 +2821,14 @@ class _ExplorerPageState extends State { const SingleActivator(LogicalKeyboardKey.f2): () => _button(ExplorerButton.rename), // 180: 선택 모드 · 표시 풀기 (복사 · 이동이 끝나도 선택 모드는 그대로이므로 바로 나올 수 있게) const SingleActivator(LogicalKeyboardKey.escape): () { - if (_selecting || _panes.any((x) => x.marked.isNotEmpty)) _setSelecting(false); + // 76: 열린 빠른 필터부터 닫는다 + if (_panes[_active].filterOpen) { + _closeFilter(_panes[_active]); + } else if (_selecting || _panes.any((x) => x.marked.isNotEmpty)) { + _setSelecting(false); + } }, + const SingleActivator(LogicalKeyboardKey.keyF, control: true): () => _openFilter(_panes[_active]), const SingleActivator(LogicalKeyboardKey.backspace): () => _button(ExplorerButton.up), // 75: 지금 창의 뒤로 · 앞으로 const SingleActivator(LogicalKeyboardKey.arrowLeft, alt: true): () => _travel(_panes[_active], forward: false), @@ -2799,7 +2837,10 @@ class _ExplorerPageState extends State { setState(() => _active = _dual ? 1 - _active : 0), }, child: Focus( + focusNode: _keys, autofocus: true, + // 76: 목록에서 글자를 바로 치면 지금 창의 빠른 필터가 그 글자로 열린다 (One Commander · TC 처럼) + onKeyEvent: (_, ev) => _typeToFilter(ev), child: Listener( // 75: 마우스 4 · 5번 (뒤로 · 앞으로) 버튼 - 창 밖 (위 막대 등) 이면 지금 창 (창 위면 그 창이 먼저 받음, [_frame]) onPointerDown: (ev) => _mouseTravel(_active, ev), @@ -3066,7 +3107,7 @@ class _ExplorerPageState extends State { TextButton( onPressed: () => setState(() { final dir = listDir ?? pane.current; - pane.marked.addAll([for (final x in pane.cache[dir] ?? const []) x.path]); + pane.marked.addAll([for (final x in _shown(pane, dir)) x.path]); _normalizeMarks(pane); }), child: Text(tr('모두 선택')), @@ -3086,6 +3127,9 @@ class _ExplorerPageState extends State { return LayoutBuilder(builder: (context, box) => _frame(i, [ _paneHeader(pane), const Divider(height: 1), + // 보이는 수는 맞는 것만 (맞는 것이 든 위 폴더는 빼고) + if (pane.filterOpen) + _filterBar(pane, rows.where((r) => !r.isRoot && isSameOrInside(r.entry.path, pane.current) && !samePath(r.entry.path, pane.current) && quickFilterMatch(vBasename(r.entry.path), pane.filter)).length), // 38: 안내가 길어도 목록을 덮지 않게 (창 높이의 35% 까지, 넘치면 넘겨 봄) ?_noticeArea([_accessNotice(pane), _errorNotice(pane, pane.current), _partialNotice(pane, pane.current)], box.maxHeight), if (look.columns && !foldersOnly) ExplorerColumnsHeader(style: look, markColumn: _selecting), @@ -3110,9 +3154,10 @@ class _ExplorerPageState extends State { final dir = pane.current; final entries = pane.cache[dir] ?? const []; final up = !samePath(dir, pane.root); + final shown = _shown(pane, dir); final rows = [ if (up) _Row(FileEntry(vDirname(dir), isDir: true, modified: DateTime(0)), 0, isUp: true), - for (final e in entries) _Row(e, 0), + for (final e in shown) _Row(e, 0), ]; final look = _look; // 38: 창 높이를 알아야 안내 상자의 높이를 제한할 수 있다 @@ -3124,6 +3169,7 @@ class _ExplorerPageState extends State { child: Text(_displayPath(dir), maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(color: JjColors.textDim)), ), const Divider(height: 1), + if (pane.filterOpen) _filterBar(pane, shown.length), ?_noticeArea([_accessNotice(pane), _errorNotice(pane, dir), _partialNotice(pane, dir)], box.maxHeight), if (look.columns) ExplorerColumnsHeader(style: look, markColumn: _selecting), Expanded( @@ -3143,6 +3189,86 @@ class _ExplorerPageState extends State { ])); } + /// 76: 빠른 필터 열기 ([text] 가 있으면 그 글자로 시작) + void _openFilter(_Pane pane, [String? text]) { + setState(() { + _active = _panes.indexOf(pane); + pane.filterOpen = true; + if (text != null) { + pane.filterCtl.value = TextEditingValue(text: text, selection: TextSelection.collapsed(offset: text.length)); + } + }); + WidgetsBinding.instance.addPostFrameCallback((_) => pane.filterFocus.requestFocus()); + // 거른 결과 (지금 폴더 아래) 가 보이게 + _reveal(pane, pane.current); + } + + /// 76: 닫으면 글자도 지워 모두 보인다. 줄이 다시 늘어나 맨 위로 가지 않게 고른 것 (없으면 지금 폴더) 으로 + void _closeFilter(_Pane pane) { + setState(() { + pane + ..filterOpen = false + ..filterCtl.clear(); + }); + _reveal(pane, pane.focused ?? pane.current); + _keys.requestFocus(); + } + + KeyEventResult _typeToFilter(KeyEvent ev) { + if (ev is! KeyDownEvent) return KeyEventResult.ignored; + final ch = ev.character; + final kb = HardwareKeyboard.instance; + if (ch == null || ch.isEmpty || ch.runes.first < 0x20 || ch.runes.first == 0x7f) return KeyEventResult.ignored; + if (kb.isControlPressed || kb.isAltPressed || kb.isMetaPressed) return KeyEventResult.ignored; + // 글자 칸 (필터 · 경로 입력 등) 에 치는 중이면 그 칸이 받는다 + if (FocusManager.instance.primaryFocus?.context?.findAncestorStateOfType() != null) { + return KeyEventResult.ignored; + } + if (!_ready || _panes.isEmpty) return KeyEventResult.ignored; + final pane = _panes[_active]; + _openFilter(pane, pane.filterOpen ? pane.filterCtl.text + ch : ch); + return KeyEventResult.handled; + } + + /// 76: 창 위 줄 아래의 빠른 필터 칸 - 보이는 수 · 맞는 것이 없을 때 안내 · [✕] + Widget _filterBar(_Pane pane, int shown) { + final none = pane.filter.isNotEmpty && shown == 0; + return Container( + height: 40, + color: JjColors.panel, + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Row(children: [ + Icon(Icons.filter_alt, size: 16, color: JjColors.accent), + const SizedBox(width: 6), + Expanded( + child: CallbackShortcuts( + bindings: {const SingleActivator(LogicalKeyboardKey.escape): () => _closeFilter(pane)}, + child: TextField( + controller: pane.filterCtl, + focusNode: pane.filterFocus, + style: const TextStyle(fontSize: 13), + decoration: InputDecoration( + isDense: true, + border: InputBorder.none, + hintText: tr('지금 폴더에서 이름에 든 글자로 좁히기 (* · ? 패턴도 됨)'), + ), + onChanged: (_) => setState(() {}), + ), + ), + ), + if (pane.filter.isNotEmpty) + Text(none ? tr('맞는 것이 없습니다') : trf('{0}개 보임', [shown]), + style: TextStyle(fontSize: 12, color: none ? JjColors.danger : JjColors.textDim)), + IconButton( + tooltip: keyHint(tr('필터 닫기 (모두 보기)'), 'Esc'), + visualDensity: VisualDensity.compact, + icon: const Icon(Icons.close, size: 16), + onPressed: () => _closeFilter(pane), + ), + ]), + ); + } + /// 창 위쪽: 저장 장치 고르기 /// 창 위 줄: [뒤로 · 앞으로 · 즐겨찾기] · 저장 장치 탭 (넘치면 옆으로 밀기) · [WebDAV · 경로 입력]. /// 202: 드라이브가 많아 넘쳐도 끝의 버튼은 늘 보이게 (예전에는 줄 전체가 한 목록이라 화면 밖으로 밀려 누를 수 없었다) @@ -3206,6 +3332,15 @@ class _ExplorerPageState extends State { color: Colors.amber), onPressed: () => _bookmarks(pane), ), + // 76: 빠른 필터 (켜져 있으면 강조) + IconButton( + tooltip: keyHint(tr('빠른 필터'), 'Ctrl+F'), + visualDensity: VisualDensity.compact, + isSelected: pane.filterOpen, + icon: Icon(pane.filterOpen ? Icons.filter_alt : Icons.filter_alt_outlined, + size: 16, color: pane.filterOpen ? JjColors.accent : null), + onPressed: () => pane.filterOpen ? _closeFilter(pane) : _openFilter(pane), + ), ]; /// 202: 저장 장치 탭 - 넘치면 마우스 휠 · 끌기로도 옆으로. 비어 있거나 연결 안 된 드라이브는 숨기지 않고 뒤쪽으로 @@ -3962,6 +4097,13 @@ class _Thumb extends StatelessWidget { ); } +/// 76: 빠른 필터 - 비어 있으면 모두, * · ? 가 있으면 이름 전체 패턴, 아니면 이름에 든 글자 (대소문자 무시) +bool quickFilterMatch(String name, String filter) { + if (filter.isEmpty) return true; + if (filter.contains('*') || filter.contains('?')) return globToRegExp(filter).hasMatch(name); + return name.toLowerCase().contains(filter.toLowerCase()); +} + /// 146 · 37-2: 경로 입력 창 - 없는 폴더면 닫지 않고 칸 아래에 "폴더가 없거나 열 수 없습니다" /// 74: 패턴 (예: *.srt, 여러 개는 ; 로) → 이름 전체에 맞는 정규식 (대소문자 무시) RegExp globToRegExp(String pattern) { diff --git a/test/explorer_test.dart b/test/explorer_test.dart index a602af3..0059add 100644 --- a/test/explorer_test.dart +++ b/test/explorer_test.dart @@ -525,6 +525,70 @@ void main() { expect(fileDateText(DateTime(2026, 10, 11, 9, 5)), '2026-10-11 09:05'); }); + testWidgets('76: 빠른 필터 - Ctrl+F · 글자 바로 치기 · 패턴, 모두 선택은 보이는 것만, Esc 로 닫으면 모두', (tester) async { + for (final n in ['ep01.mkv', 'ep02.mkv', 'ep10.srt', 'other.txt']) { + File(p.join(left, n)).writeAsStringSync('12345678'); + } + await open(tester); + await settle(tester, () => find.text('other.txt').evaluate().isNotEmpty); + Iterable names() => [ + for (final n in ['doc.txt', 'sub', 'ep01.mkv', 'ep02.mkv', 'ep10.srt', 'other.txt']) + if (find.text(n).evaluate().isNotEmpty) n, + ]; + + // Ctrl+F → 칸 · "ep0" + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.keyF); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await calm(tester); + expect(find.textContaining('이름에 든 글자로 좁히기'), findsOneWidget); + await tester.enterText(find.byType(TextField).first, 'EP0'); + await tester.pump(); + expect(names(), ['ep01.mkv', 'ep02.mkv'], reason: '대소문자 무시'); + expect(find.text('2개 보임'), findsOneWidget); + + // 모두 선택 · 반전은 보이는 것만 + await act(tester, () => tester.tap(find.text('선택').first)); + await tester.tap(find.text('모두 선택').first); + await tester.pump(); + expect(find.textContaining('2개 표시함'), findsOneWidget); + await tester.tap(find.text('선택 끝').first); + await tester.pump(); + + // 패턴 · 맞는 것 없음 + await tester.enterText(find.byType(TextField).first, '*.srt'); + await tester.pump(); + expect(names(), ['ep10.srt']); + await tester.enterText(find.byType(TextField).first, 'zzz'); + await tester.pump(); + expect(find.text('맞는 것이 없습니다'), findsOneWidget); + + // Esc → 닫고 모두 + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await calm(tester); + expect(find.textContaining('이름에 든 글자로 좁히기'), findsNothing); + expect(names(), containsAll(['doc.txt', 'sub', 'ep01.mkv', 'other.txt'])); + + // 닫으면 키는 탐색기로 돌아온다 - 목록에서 글자를 바로 치면 그 글자로 열린다 + await tester.pump(); + await tester.sendKeyEvent(LogicalKeyboardKey.keyO, character: 'o'); + await calm(tester); + expect(tester.widget(find.byType(TextField).first).controller!.text, 'o'); + expect(names(), ['doc.txt', 'other.txt']); + // 칸에서 더 치면 칸이 받는다 (두 번 들어가지 않음) + await tester.enterText(find.byType(TextField).first, 'oth'); + await tester.pump(); + expect(names(), ['other.txt']); + }); + + test('76: 빠른 필터 맞추기 - 빈 칸은 모두 · 글자는 이름 안 어디든 · * ? 는 이름 전체 패턴', () { + expect(quickFilterMatch('Show.S01E02.mkv', ''), isTrue); + expect(quickFilterMatch('Show.S01E02.mkv', 's01e'), isTrue); + expect(quickFilterMatch('Show.S01E02.mkv', 'e03'), isFalse); + expect(quickFilterMatch('Show.S01E02.mkv', '*.mkv'), isTrue); + expect(quickFilterMatch('Show.S01E02.mkv', 'S01*'), isFalse, reason: '패턴은 이름 전체'); + }); + test('74: 패턴 (* · ? · 여러 개 · 대소문자 무시)', () { final re = globToRegExp('*.srt; ep??.mkv'); expect(['a.srt', 'A.SRT', 'ep01.mkv'].every(re.hasMatch), isTrue);