WIP 76 파일 탐색기 빠른 필터: 창 위 필터 버튼 · Ctrl+F · 글자 바로 치기, 지금 폴더 안을 이름으로 좁힘, 고르기는 보이는 것만, Esc 로 닫기 (검토 대기)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>backup_묶음6_d46e089
parent
354798c0e7
commit
a0019274cd
|
|
@ -465,6 +465,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",
|
||||
|
|
@ -963,6 +964,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)",
|
||||
|
|
@ -1190,6 +1192,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",
|
||||
|
|
@ -1975,6 +1978,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.",
|
||||
|
|
@ -2221,6 +2225,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",
|
||||
|
|
|
|||
|
|
@ -465,6 +465,7 @@
|
|||
"{0}개 · {1}": "{0}個・{1}",
|
||||
"{0}개 · 주소 {1}개": "{0}個・URL {1}個",
|
||||
"{0}개 맞춤": "{0} 件を同期",
|
||||
"{0}개 보임": "{0} 件表示",
|
||||
"{0}개 선택": "{0}個選択",
|
||||
"{0}개 중 {1}개를 지우지 못했습니다": "{0} 個中 {1} 個を削除できませんでした",
|
||||
"{0}개 지우고 맞추기": "{0} 個を削除して同期",
|
||||
|
|
@ -963,6 +964,7 @@
|
|||
"많이 (빠름): {0} - 다른 앱이 많이 켜져 있으면 Android 가 이 앱을 끌 수 있습니다": "多め (速い): {0} - 他のアプリが多く開いていると Android がこのアプリを終了することがあります",
|
||||
"말레이어": "マレー語",
|
||||
"망에 닿을 수 없음": "ネットワークに到達できません",
|
||||
"맞는 것이 없습니다": "一致するものがありません",
|
||||
"맞추기 (검은 여백)": "フィット(黒い余白)",
|
||||
"맞추기 (비율이 다를 때)": "合わせ方(比率が違うとき)",
|
||||
"맞추기 (흐린 배경)": "フィット(ぼかし背景)",
|
||||
|
|
@ -1190,6 +1192,7 @@
|
|||
"빈 폴더 지움 · 원본 폴더는 남김": "空フォルダーを削除 · 元フォルダーは残す",
|
||||
"빈 폴더 지움 · 원본 폴더도 지움": "空フォルダーを削除 · 元フォルダーも削除",
|
||||
"빠른 디코더 (TAESD)": "高速デコーダー (TAESD)",
|
||||
"빠른 필터": "クイックフィルター",
|
||||
"빼고 싶은 것 (선택)": "避けたいもの (任意)",
|
||||
"사라지는 것": "消えるもの",
|
||||
"사본 만들기": "コピーを作る",
|
||||
|
|
@ -1975,6 +1978,7 @@
|
|||
"지금 짝을 즐겨찾기에": "今のペアをお気に入りに",
|
||||
"지금 폴더 추가": "今のフォルダーを追加",
|
||||
"지금 폴더를 즐겨찾기에": "今のフォルダーをお気に入りに",
|
||||
"지금 폴더에서 이름에 든 글자로 좁히기 (* · ? 패턴도 됨)": "このフォルダーを名前に含む文字で絞り込み (* · ? パターンも可)",
|
||||
"지금 확인": "今すぐ確認",
|
||||
"지금: {0}": "現在: {0}",
|
||||
"지금은 듀얼 앱 (복제한 앱) 입니다. Android 가 듀얼 앱에는 이 권한을 주지 않을 수 있습니다. 허용 화면에서 켤 수 없으면 배지 없는 원래 JJ_MKVMaker 아이콘으로 여세요.": "これはデュアルアプリ (複製したアプリ) です。Android はデュアルアプリにこの権限を与えない場合があります。許可画面でオンにできない場合は、バッジのない元の JJ_MKVMaker アイコンから開いてください。",
|
||||
|
|
@ -2221,6 +2225,7 @@
|
|||
"필요한 것 모두 받기": "必要なものをすべてダウンロード",
|
||||
"필요한 공간 {0}": "必要な容量 {0}",
|
||||
"필요한 공간 {0} · 남은 공간 {1}": "必要な容量 {0}・空き容量 {1}",
|
||||
"필터 닫기 (모두 보기)": "フィルターを閉じる (すべて表示)",
|
||||
"하나씩 멈추려면 목록 줄의 ■ 를 누르세요.": "1 つずつ止めるには、一覧の行の ■ を押してください。",
|
||||
"하위 폴더가 없습니다": "サブフォルダーがありません",
|
||||
"한 번 더": "もう一度",
|
||||
|
|
|
|||
|
|
@ -465,6 +465,7 @@
|
|||
"{0}개 · {1}": "{0} 个 · {1}",
|
||||
"{0}개 · 주소 {1}개": "{0} 个 · 地址 {1} 个",
|
||||
"{0}개 맞춤": "已同步 {0} 个",
|
||||
"{0}개 보임": "显示 {0} 项",
|
||||
"{0}개 선택": "已选 {0} 个",
|
||||
"{0}개 중 {1}개를 지우지 못했습니다": "{0} 个中有 {1} 个无法删除",
|
||||
"{0}개 지우고 맞추기": "删除 {0} 个并同步",
|
||||
|
|
@ -963,6 +964,7 @@
|
|||
"많이 (빠름): {0} - 다른 앱이 많이 켜져 있으면 Android 가 이 앱을 끌 수 있습니다": "多(较快):{0} - 若打开了很多其他应用,Android 可能会关闭本应用",
|
||||
"말레이어": "马来语",
|
||||
"망에 닿을 수 없음": "网络不可达",
|
||||
"맞는 것이 없습니다": "没有匹配项",
|
||||
"맞추기 (검은 여백)": "适应(黑边)",
|
||||
"맞추기 (비율이 다를 때)": "适配方式(比例不同时)",
|
||||
"맞추기 (흐린 배경)": "适应(模糊背景)",
|
||||
|
|
@ -1190,6 +1192,7 @@
|
|||
"빈 폴더 지움 · 원본 폴더는 남김": "删除空文件夹 · 保留源文件夹",
|
||||
"빈 폴더 지움 · 원본 폴더도 지움": "删除空文件夹 · 源文件夹也删除",
|
||||
"빠른 디코더 (TAESD)": "快速解码器 (TAESD)",
|
||||
"빠른 필터": "快速筛选",
|
||||
"빼고 싶은 것 (선택)": "要排除的内容 (可选)",
|
||||
"사라지는 것": "丢失的内容",
|
||||
"사본 만들기": "创建副本",
|
||||
|
|
@ -1975,6 +1978,7 @@
|
|||
"지금 짝을 즐겨찾기에": "将当前一对加入收藏",
|
||||
"지금 폴더 추가": "添加当前文件夹",
|
||||
"지금 폴더를 즐겨찾기에": "将当前文件夹加入收藏",
|
||||
"지금 폴더에서 이름에 든 글자로 좁히기 (* · ? 패턴도 됨)": "按名称中的文字筛选当前文件夹 (也可用 * · ? 模式)",
|
||||
"지금 확인": "立即检查",
|
||||
"지금: {0}": "当前: {0}",
|
||||
"지금은 듀얼 앱 (복제한 앱) 입니다. Android 가 듀얼 앱에는 이 권한을 주지 않을 수 있습니다. 허용 화면에서 켤 수 없으면 배지 없는 원래 JJ_MKVMaker 아이콘으로 여세요.": "当前是双开应用 (复制的应用)。Android 可能不会给双开应用此权限。如果无法开启,请从没有徽标的原 JJ_MKVMaker 图标打开。",
|
||||
|
|
@ -2221,6 +2225,7 @@
|
|||
"필요한 것 모두 받기": "下载全部所需文件",
|
||||
"필요한 공간 {0}": "所需空间 {0}",
|
||||
"필요한 공간 {0} · 남은 공간 {1}": "所需空间 {0} · 剩余空间 {1}",
|
||||
"필터 닫기 (모두 보기)": "关闭筛选 (全部显示)",
|
||||
"하나씩 멈추려면 목록 줄의 ■ 를 누르세요.": "要逐个停止,请按列表行上的 ■。",
|
||||
"하위 폴더가 없습니다": "没有子文件夹",
|
||||
"한 번 더": "再输入一次",
|
||||
|
|
|
|||
|
|
@ -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,13 +871,23 @@ 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);
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in New Issue