WIP 76 파일 탐색기 빠른 필터: 창 위 필터 버튼 · Ctrl+F · 글자 바로 치기, 지금 폴더 안을 이름으로 좁힘, 고르기는 보이는 것만, Esc 로 닫기 (검토 대기)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
묶음6
RyuJongJin 2026-10-12 08:48:11 +09:00
parent a3e898c66d
commit 1eef4ff65f
5 changed files with 230 additions and 9 deletions

View File

@ -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",

View File

@ -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 つずつ止めるには、一覧の行の ■ を押してください。",
"하위 폴더가 없습니다": "サブフォルダーがありません",
"한 번 더": "もう一度",

View File

@ -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}",
"필터 닫기 (모두 보기)": "关闭筛选 (全部显示)",
"하나씩 멈추려면 목록 줄의 ■ 를 누르세요.": "要逐个停止,请按列表行上的 ■。",
"하위 폴더가 없습니다": "没有子文件夹",
"한 번 더": "再输入一次",

View File

@ -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<ExplorerPage> {
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<ExplorerPage> {
@override
void dispose() {
_keys.dispose();
if (widget.rsync) {
ExplorerPage._openRsync--;
} else {
@ -859,14 +871,24 @@ class _ExplorerPageState extends State<ExplorerPage> {
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 <FileEntry>[]) {
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);
return out;
@ -950,9 +972,19 @@ class _ExplorerPageState extends State<ExplorerPage> {
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<FileEntry> _shown(_Pane pane, String dir) => [for (final x in pane.cache[dir] ?? const <FileEntry>[]) if (_passes(pane, x)) x];
/// 74: 마지막으로 표시한 것부터 [to] 까지 (보이는 순서대로) 모두 표시
void _markRange(_Pane pane, FileEntry to) {
final list = pane.cache[vDirname(to.path)] ?? const <FileEntry>[];
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<ExplorerPage> {
/// 74: 고르기 도구 - 이 폴더 ([dir]) 의 항목에서
void _invertMarks(_Pane pane, String dir) => setState(() {
for (final x in pane.cache[dir] ?? const <FileEntry>[]) {
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 <FileEntry>[]) 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<ExplorerPage> {
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<ExplorerPage> {
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<ExplorerPage> {
TextButton(
onPressed: () => setState(() {
final dir = listDir ?? pane.current;
pane.marked.addAll([for (final x in pane.cache[dir] ?? const <FileEntry>[]) 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<ExplorerPage> {
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<ExplorerPage> {
final dir = pane.current;
final entries = pane.cache[dir] ?? const <FileEntry>[];
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<ExplorerPage> {
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<ExplorerPage> {
]));
}
/// 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<EditableTextState>() != 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<ExplorerPage> {
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) {

View File

@ -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<String> 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<TextField>(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);