JJ_MKVMaker/lib/ui/explorer/pane_view.dart

433 lines
19 KiB
Dart

import 'dart:async';
import 'dart:io';
import 'package:flutter/foundation.dart' show defaultTargetPlatform;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart' show LogicalKeyboardKey;
import '../../app/settings.dart' show FolderBookmark;
import '../../core/explorer/tree_rows.dart';
import '../../core/file_ops.dart' show FileEntry, isSameOrInside, samePath;
import '../../core/vfs.dart' show isDav, vBasename, vDirname;
import '../../l10n/tr.dart';
import '../explorer_drag.dart';
import '../explorer_look.dart';
import '../key_hint.dart';
import '../theme.dart';
import 'explorer_controller.dart';
import 'explorer_pane.dart';
import 'explorer_select.dart';
import 'row_view.dart';
/// 약속 (E5d-3): 창 하나를 그리는 데 화면이 주는 것. 창의 틀 (활성 · 놓기) · 창 머리 · 안내 · 표시 막대 · 줄 메뉴 ·
/// 끌어 놓기 · 필터 닫기는 아직 화면의 일이라 여기로 받는다 (나눌 때마다 이 목록이 줄어든다)
abstract interface class ExplorerPaneParts {
/// 줄 높이 (목록 글자 크기 · 줄 간격을 넣은 것)
double get rowHeight;
/// 목록 글자 배율 (설정 %)
double get listTextScale;
/// 좁은 "폴더 + 파일 목록" 의 트리 (이름만)
bool get narrowSplit;
/// X-plore 스타일 표시 동그라미 늘 보이기
bool get alwaysMarks;
Widget frame(int i, List<Widget> children);
Widget header(ExplorerPane pane);
/// [dir] 위의 안내들 (없는 것은 null)
List<Widget?> notices(ExplorerPane pane, String dir);
Widget marksBar(ExplorerPane pane, {String? listDir});
void onMenu(ExplorerPane pane, FileEntry e, Offset at);
List<String> dragPaths(ExplorerPane pane, FileEntry e);
Future<void> onDrop(DragLoad load, int to, String dest);
void closeFilter(ExplorerPane pane);
/// 필터 글자가 바뀜 (화면을 다시 그림)
void filterChanged();
}
/// 관리자 지시 10/20 · 10/21: 좁은 창의 열 - 숨길 열 · 날짜를 짧게 쓸지. 줄의 흐린 글과 같은 모양으로 날짜 글 폭을 잰다
/// (날짜가 "2026-10-2…" 로 잘리지 않게). 열 머리 · 줄이 같이 쓴다
void layoutPaneColumns(BuildContext context, ExplorerPane pane, ExplorerStyle look, double width,
{required double listTextScale, required bool marks}) {
final scale = MediaQuery.textScalerOf(context).scale(14) / 14 * listTextScale;
final style = DefaultTextStyle.of(context).style.merge(TextStyle(fontSize: look.fontSize - 2));
double measure(String t) =>
(TextPainter(text: TextSpan(text: t, style: style), textDirection: TextDirection.ltr, textScaler: TextScaler.linear(scale), maxLines: 1)
..layout())
.width;
final lay = columnLayout(look, width, marks: marks, textScale: scale, measure: measure);
pane.colHide = lay.$1;
pane.colDateShort = lay.$2;
}
/// 76: 목록 줄의 키 = 항목 경로. 키가 없으면 줄 목록이 바뀔 때 (빠른 필터 · 펼치기 · 새로 고침) 같은 자리의 줄 위젯을
/// 다른 항목에 다시 써서, 방금 누른 파일 줄이 폴더 줄로 바뀌면 두 번 누르기 기다림이 그리는 중에 버려지고
/// 그 누르기가 다른 항목에 들어갔다 (디버그에서는 "setState() called during build")
Key paneRowKey(TreeRow r) =>
ValueKey<String>('${r.kind != 0 ? 'k${r.kind}:' : r.isRoot ? 'root:' : r.isUp ? 'up:' : ''}${r.entry.path}');
/// 76: 줄이 옮겨 가도 같은 줄 위젯을 찾게 (키 → 지금 자리)
int? Function(Key) _rowIndexOf(List<TreeRow> rows) {
Map<Key, int>? index;
return (key) => (index ??= {for (var i = 0; i < rows.length; i++) paneRowKey(rows[i]): i})[key];
}
/// 목록 위 안내들 (없으면 null). 좁은 화면 (접은 폴드 · 두 창) 에서 목록이 밀려 사라지지 않게 높이를 제한한다
Widget? noticeArea(List<Widget?> notices, double paneHeight) {
final list = notices.whereType<Widget>().toList();
if (list.isEmpty) return null;
return ConstrainedBox(
constraints: BoxConstraints(maxHeight: paneHeight.isFinite ? paneHeight * 0.35 : 240),
child: SingleChildScrollView(child: Column(mainAxisSize: MainAxisSize.min, children: list)),
);
}
Widget _rowOf(ExplorerController ctl, ExplorerSelect sel, ExplorerPaneParts parts, ExplorerPane pane, TreeRow row,
{bool list = false, bool compact = false}) =>
KeyedSubtree(
key: paneRowKey(row),
child: row.kind != 0
? ExplorerExtraRow(ctl: ctl, pane: pane, row: row)
: ExplorerRowView(
ctl: ctl,
sel: sel,
pane: pane,
row: row,
list: list,
compact: compact,
rowHeight: parts.rowHeight,
narrowSplit: parts.narrowSplit,
alwaysMarks: parts.alwaysMarks,
onMenu: parts.onMenu,
dragPaths: parts.dragPaths,
onDrop: parts.onDrop));
/// 트리 창 [index] (저장 장치 고르기 + 트리). [foldersOnly]: 폴더만 ("폴더 + 파일 목록" 배치의 왼쪽 · Rsync)
class ExplorerPaneView extends StatelessWidget {
const ExplorerPaneView({super.key, required this.ctl, required this.sel, required this.parts, required this.index, this.foldersOnly = false});
final ExplorerController ctl;
final ExplorerSelect sel;
final ExplorerPaneParts parts;
final int index;
final bool foldersOnly;
@override
Widget build(BuildContext context) {
final pane = ctl.panes[index];
final rows = ctl.rows(pane, foldersOnly: foldersOnly);
final look = ctl.look;
// 38: 창 높이를 알아야 안내 상자의 높이를 제한할 수 있다
return LayoutBuilder(builder: (context, box) {
// 열을 그리는 창만 숨길 열을 정한다 ("폴더 + 파일 목록" 의 트리는 열이 없고 목록과 같은 창을 쓴다)
if (look.columns && !foldersOnly) {
layoutPaneColumns(context, pane, look, box.maxWidth, listTextScale: parts.listTextScale, marks: sel.selecting);
}
return parts.frame(index, [
parts.header(pane),
const Divider(height: 1),
// 보이는 수는 맞는 것만 (맞는 것이 든 위 폴더는 빼고)
if (pane.filterOpen)
ExplorerFilterBar(
pane: pane,
shown: 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,
onClose: () => parts.closeFilter(pane),
onChanged: parts.filterChanged),
// 38: 안내가 길어도 목록을 덮지 않게 (창 높이의 35% 까지, 넘치면 넘겨 봄)
?noticeArea(parts.notices(pane, pane.current), box.maxHeight),
if (look.columns && !foldersOnly)
ExplorerColumnsHeader(
style: look,
markColumn: sel.selecting,
hide: pane.colHide,
sortBy: ctl.sortOf(pane.current).$1,
sortDesc: ctl.sortOf(pane.current).$2,
onSort: (by) => ctl.sortByColumn(pane, by),
),
Expanded(
child: Stack(children: [
scaleListText(context, parts.listTextScale, ListView.builder(
controller: pane.scroll,
itemCount: rows.length,
itemExtent: parts.rowHeight,
// 76: 줄 위젯은 항목을 따라간다 ([paneRowKey])
itemBuilder: (_, k) => _rowOf(ctl, sel, parts, pane, rows[k], compact: foldersOnly),
findChildIndexCallback: _rowIndexOf(rows),
)),
// 읽는 중 (WebDAV 서버를 기다릴 때 등). 180: 목록 위에 겹쳐 그린다 (끼어들면 목록이 밀려 다음 것을 누르려다 엉뚱한 것을 누른다)
if (pane.loading.isNotEmpty) const Positioned(left: 0, right: 0, top: 0, child: LinearProgressIndicator(minHeight: 2)),
]),
),
if ((sel.selecting || (parts.alwaysMarks && !pane.sel.isEmpty)) && !foldersOnly) parts.marksBar(pane),
]);
});
}
}
/// "폴더 + 파일 목록" 배치의 오른쪽: 왼쪽 트리에서 고른 폴더의 내용 (맨 위 ".." = 상위 폴더)
class ExplorerListView extends StatelessWidget {
const ExplorerListView({super.key, required this.ctl, required this.sel, required this.parts, required this.pane});
final ExplorerController ctl;
final ExplorerSelect sel;
final ExplorerPaneParts parts;
final ExplorerPane pane;
@override
Widget build(BuildContext context) {
final dir = pane.current;
final entries = pane.cache[dir] ?? const <FileEntry>[];
final up = !samePath(dir, pane.root);
final shown = sel.shown(pane, dir);
final hidden = entries.where((e) => ctl.appHidden(e.path)).length;
final rows = [
if (up) TreeRow(FileEntry(vDirname(dir), isDir: true, modified: DateTime(0)), 0, isUp: true),
for (final e in shown) TreeRow(e, 0),
// X-plore 숨기기: 맨 아래 [숨긴 항목 n개 보이기]
if (hidden > 0) TreeRow(FileEntry('\u0000hidden', isDir: false, modified: DateTime(0)), 0, kind: TreeRow.hiddenToggle, count: hidden),
];
final look = ctl.look;
// 38: 창 높이를 알아야 안내 상자의 높이를 제한할 수 있다
return LayoutBuilder(builder: (context, box) {
if (look.columns) layoutPaneColumns(context, pane, look, box.maxWidth, listTextScale: parts.listTextScale, marks: sel.selecting);
return parts.frame(0, [
Container(
height: 40,
alignment: Alignment.centerLeft,
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Text(ctl.displayPath(dir), maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(color: JjColors.textDim)),
),
const Divider(height: 1),
if (pane.filterOpen) ExplorerFilterBar(pane: pane, shown: shown.length, onClose: () => parts.closeFilter(pane), onChanged: parts.filterChanged),
?noticeArea(parts.notices(pane, dir), box.maxHeight),
if (look.columns)
ExplorerColumnsHeader(
style: look,
markColumn: sel.selecting,
hide: pane.colHide,
sortBy: ctl.sortOf(dir).$1,
sortDesc: ctl.sortOf(dir).$2,
onSort: (by) => ctl.sortByColumn(pane, by),
),
Expanded(
// 180: 다시 읽는 동안은 앞의 목록을 그대로 (빙글이로 바꾸면 목록이 새로 만들어져 스크롤이 맨 위로 간다)
child: pane.loading.contains(dir) && !pane.cache.containsKey(dir)
? const Center(child: CircularProgressIndicator())
: entries.isEmpty && !up
? Center(child: Text(tr('비어 있음'), style: TextStyle(color: JjColors.textDim)))
: scaleListText(context, parts.listTextScale, ListView.builder(
controller: pane.listScroll,
itemCount: rows.length,
itemExtent: parts.rowHeight,
itemBuilder: (_, k) => _rowOf(ctl, sel, parts, pane, rows[k], list: true),
findChildIndexCallback: _rowIndexOf(rows),
)),
),
if (sel.selecting) parts.marksBar(pane, listDir: dir),
]);
});
}
}
/// 76: 창 위 줄 아래의 빠른 필터 칸 - 보이는 수 · 맞는 것이 없을 때 안내 · [✕]
class ExplorerFilterBar extends StatelessWidget {
const ExplorerFilterBar({super.key, required this.pane, required this.shown, required this.onClose, required this.onChanged});
final ExplorerPane pane;
final int shown;
final VoidCallback onClose, onChanged;
@override
Widget build(BuildContext context) {
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): onClose},
child: TextField(
controller: pane.filterCtl,
focusNode: pane.filterFocus,
style: const TextStyle(fontSize: 13),
decoration: InputDecoration(
isDense: true,
border: InputBorder.none,
hintText: tr('지금 폴더에서 이름에 든 글자로 좁히기 (* · ? 패턴도 됨)'),
),
onChanged: (_) => onChanged(),
),
),
),
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: onClose,
),
]),
);
}
}
/// X-plore 비교 c: 트리 맨 위 항목 줄 (다른 저장 장치 · 서버 · 즐겨찾기) - 누르면 그곳으로, 즐겨찾기 묶음은 펼치기 · 접기.
/// X-plore 숨기기: [숨긴 항목 n개 보이기] 줄도 (누르면 보이기 · 다시 숨기기)
class ExplorerExtraRow extends StatelessWidget {
const ExplorerExtraRow({super.key, required this.ctl, required this.pane, required this.row});
final ExplorerController ctl;
final ExplorerPane pane;
final TreeRow row;
Widget _hiddenToggle() {
final look = ctl.look;
final label = ctl.showAppHidden ? trf('숨긴 항목 {0}개 다시 숨기기', [row.count]) : trf('숨긴 항목 {0}개 보이기', [row.count]);
return Semantics(
button: true,
label: label,
excludeSemantics: true,
onTap: ctl.toggleAppHidden,
child: InkWell(
onTap: ctl.toggleAppHidden,
child: Row(children: [
SizedBox(width: treeChevronWidth(defaultTargetPlatform) + 4),
Icon(ctl.showAppHidden ? Icons.visibility_off_outlined : Icons.visibility_outlined, size: look.iconSize * 0.8, color: JjColors.textDim),
const SizedBox(width: 8),
Expanded(
child: Text(label,
maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(fontSize: look.fontSize - 1, color: JjColors.textDim)),
),
]),
),
);
}
@override
Widget build(BuildContext context) {
if (row.kind == TreeRow.hiddenToggle) return _hiddenToggle();
final look = ctl.look;
final path = row.entry.path;
final volume = ctl.volumes.firstWhere((v) => samePath(v.$1, path), orElse: () => (path, vBasename(path))).$2;
final fav = row.kind == TreeRow.favorite
? ctl.c.settings.general.folderBookmarks.firstWhere((b) => b.path == path, orElse: () => FolderBookmark(name: vBasename(path), path: path))
: null;
final (IconData icon, Color color, String label, bool? open) = switch (row.kind) {
TreeRow.otherRoot => (
isDav(path)
? Icons.cloud_outlined
: Platform.isWindows
? Icons.storage
: Icons.sd_storage_outlined,
JjColors.accent,
volume,
false,
),
TreeRow.favorites => (Icons.star, JjColors.amber, tr('즐겨찾기'), pane.favoritesOpen),
_ => (Icons.star_outline, JjColors.amber, fav!.name, null),
};
void tap() {
ctl
..active = ctl.panes.indexOf(pane)
..update();
if (row.kind == TreeRow.favorites) {
pane.favoritesOpen = !pane.favoritesOpen;
pane.changed();
return;
}
unawaited(ctl.goTo(pane, path, fresh: true).then((_) => ctl.host.reveal(pane, path)));
}
return Semantics(
button: true,
label: label,
excludeSemantics: true,
onTap: tap,
child: InkWell(
onTap: tap,
child: Row(children: [
SizedBox(width: look.indent * row.depth + 4),
SizedBox(
width: treeChevronWidth(defaultTargetPlatform),
child: open == null ? null : Icon(open ? Icons.expand_more : Icons.chevron_right, size: 18, color: JjColors.textDim),
),
Icon(icon, size: look.iconSize * 0.8, color: color),
const SizedBox(width: 8),
Expanded(
child: Column(mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(label, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(fontSize: look.fontSize)),
if (fav != null)
Text(ctl.displayPath(path),
maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(fontSize: look.fontSize - 3, color: JjColors.textDim)),
]),
),
]),
),
);
}
}
/// 창 하나의 틀 - 지금 창 테두리 · 누르면 지금 창 · 마우스 4 · 5번 ([onPointer]) · 82: 빈 곳에 놓으면 그 창의 지금 폴더로
/// (놓을 수 있으면 강조, 못 놓으면 빨간 테두리)
class ExplorerPaneFrame extends StatelessWidget {
const ExplorerPaneFrame(
{super.key, required this.ctl, required this.index, required this.onPointer, required this.onDrop, required this.children});
final ExplorerController ctl;
final int index;
final void Function(int index, PointerDownEvent ev) onPointer;
final Future<void> Function(DragLoad load, int to, String dest) onDrop;
final List<Widget> children;
@override
Widget build(BuildContext context) {
final i = index;
final active = i == ctl.active;
return Listener(
// 75: 마우스가 올라간 창에 (지금 활성 창이 아니어도)
onPointerDown: (ev) => onPointer(i, ev),
child: GestureDetector(
onTapDown: (_) {
if (ctl.active != i) {
ctl
..active = i
..update();
}
},
// 82: 창의 빈 곳에 놓으면 그 창의 지금 폴더로. 놓을 수 있으면 강조, 못 놓으면 (같은 창 등) 빨간 테두리
child: DragTarget<DragLoad>(
onWillAcceptWithDetails: (d) => canDropInto(d.data, i, ctl.panes[i].current),
onAcceptWithDetails: (d) => unawaited(onDrop(d.data, i, ctl.panes[i].current)),
builder: (context, ok, no) => Container(
margin: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: ok.isNotEmpty ? JjColors.accent.withValues(alpha: 0.06) : JjColors.bg,
border: Border.all(
color: ok.isNotEmpty
? JjColors.accent
: no.isNotEmpty
? JjColors.danger
: active
? JjColors.accent
: JjColors.border,
width: ok.isNotEmpty || no.isNotEmpty ? 2 : (active ? 1.5 : 1)),
borderRadius: BorderRadius.circular(6),
),
child: Column(children: children),
),
),
),
);
}
}