diff --git a/assets/l10n/en.json b/assets/l10n/en.json index 5290dd2..eb6ed4d 100644 --- a/assets/l10n/en.json +++ b/assets/l10n/en.json @@ -453,6 +453,7 @@ "[N] 원래 이름 · [C] 번호 예: Show S01E[C]": "[N] original name · [C] counter e.g. Show S01E[C]", "[N] 원래 이름 · [C] 번호 · 확장자는 그대로 예: Show S01E[C]": "[N] original name · [C] counter · extension kept e.g. Show S01E[C]", "[N] 원래 이름 · [N2-5] 그 2~5 번째 글자 · [C] 번호 · [D] 날짜 · [T] 시각 · ♪ 음악 태그 · 확장자는 그대로 예: Show S01E[C]": "[N] original name · [N2-5] its characters 2–5 · [C] counter · [D] date · [T] time · ♪ music tags · extension kept e.g. Show S01E[C]", + "[{0}] 남은 {1} / 전체 {2}": "[{0}] {1} free / {2} total", "[서버 설정 고치기] 에서 비밀번호를 넣으면 이 기기의 안전 저장소에 남아 다음부터는 다시 넣지 않아도 됩니다.": "Enter the password in [Edit server settings]. It is kept in this device's secure storage, so you won't need to enter it again.", "[서버 설정 고치기] 에서 아이디 · 비밀번호를 확인하세요.": "Check the user name and password in [Edit server settings].", "[시험 길] GitHub 대신 이 PC 의 zip 으로 업데이트합니다 (JJ_UPDATE_LOCAL_ZIP). 시험용 데이터 폴더로 켰을 때만 쓰입니다.": "[Test path] Updates from a zip on this PC instead of GitHub (JJ_UPDATE_LOCAL_ZIP). Used only when started with a test data folder.", diff --git a/assets/l10n/ja.json b/assets/l10n/ja.json index 5016e2f..5f45383 100644 --- a/assets/l10n/ja.json +++ b/assets/l10n/ja.json @@ -453,6 +453,7 @@ "[N] 원래 이름 · [C] 번호 예: Show S01E[C]": "[N] 元の名前 · [C] 番号 例: Show S01E[C]", "[N] 원래 이름 · [C] 번호 · 확장자는 그대로 예: Show S01E[C]": "[N] 元の名前 · [C] 番号 · 拡張子はそのまま 例: Show S01E[C]", "[N] 원래 이름 · [N2-5] 그 2~5 번째 글자 · [C] 번호 · [D] 날짜 · [T] 시각 · ♪ 음악 태그 · 확장자는 그대로 예: Show S01E[C]": "[N] 元の名前・[N2-5] その 2~5 文字目・[C] 番号・[D] 日付・[T] 時刻・♪ 音楽タグ・拡張子はそのまま 例: Show S01E[C]", + "[{0}] 남은 {1} / 전체 {2}": "[{0}] 空き {1} / 全体 {2}", "[서버 설정 고치기] 에서 비밀번호를 넣으면 이 기기의 안전 저장소에 남아 다음부터는 다시 넣지 않아도 됩니다.": "[サーバー設定を修正] でパスワードを入力すると、この端末の安全な保存領域に残り、次回からは入力不要です。", "[서버 설정 고치기] 에서 아이디 · 비밀번호를 확인하세요.": "[サーバー設定を修正] でユーザー名・パスワードを確認してください。", "[시험 길] GitHub 대신 이 PC 의 zip 으로 업데이트합니다 (JJ_UPDATE_LOCAL_ZIP). 시험용 데이터 폴더로 켰을 때만 쓰입니다.": "[テスト経路] GitHub ではなくこの PC の zip で更新します (JJ_UPDATE_LOCAL_ZIP)。テスト用データフォルダーで起動したときだけ使われます。", diff --git a/assets/l10n/zh-Hans.json b/assets/l10n/zh-Hans.json index 29662fe..31497c8 100644 --- a/assets/l10n/zh-Hans.json +++ b/assets/l10n/zh-Hans.json @@ -453,6 +453,7 @@ "[N] 원래 이름 · [C] 번호 예: Show S01E[C]": "[N] 原名称 · [C] 序号 例如: Show S01E[C]", "[N] 원래 이름 · [C] 번호 · 확장자는 그대로 예: Show S01E[C]": "[N] 原名称 · [C] 序号 · 保留扩展名 例如: Show S01E[C]", "[N] 원래 이름 · [N2-5] 그 2~5 번째 글자 · [C] 번호 · [D] 날짜 · [T] 시각 · ♪ 음악 태그 · 확장자는 그대로 예: Show S01E[C]": "[N] 原名 · [N2-5] 其第 2~5 个字 · [C] 编号 · [D] 日期 · [T] 时间 · ♪ 音乐标签 · 扩展名不变 例: Show S01E[C]", + "[{0}] 남은 {1} / 전체 {2}": "[{0}] 可用 {1} / 总共 {2}", "[서버 설정 고치기] 에서 비밀번호를 넣으면 이 기기의 안전 저장소에 남아 다음부터는 다시 넣지 않아도 됩니다.": "在 [修改服务器设置] 中输入密码后会保存在本设备的安全存储中,以后无需再次输入。", "[서버 설정 고치기] 에서 아이디 · 비밀번호를 확인하세요.": "请在 [修改服务器设置] 中检查用户名和密码。", "[시험 길] GitHub 대신 이 PC 의 zip 으로 업데이트합니다 (JJ_UPDATE_LOCAL_ZIP). 시험용 데이터 폴더로 켰을 때만 쓰입니다.": "[测试路径] 不从 GitHub,而是用本机的 zip 更新 (JJ_UPDATE_LOCAL_ZIP)。仅在以测试数据文件夹启动时使用。", diff --git a/integration_test/explorer_use_check_test.dart b/integration_test/explorer_use_check_test.dart index 98339e8..58e55e1 100644 --- a/integration_test/explorer_use_check_test.dart +++ b/integration_test/explorer_use_check_test.dart @@ -30,8 +30,10 @@ void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); final out = Platform.environment['JJ_SHOT_DIR'] ?? p.join(Directory.systemTemp.path, 'jj_shots_use'); + // E6: 두 모양 - X-plore (캡처 이름 use_너비_…, base7 과 견주는 회귀 확인) · Total Commander (Windows 처음 값, use_tc_너비_…) + for (final style in ['xplore', 'totalcmd']) { for (final (w, h) in [(1920, 1040), (960, 600), (412, 900)]) { - testWidgets('탐색기 사용 ${w}x$h', (t) async { + testWidgets('탐색기 사용 $style ${w}x$h', (t) async { t.view.physicalSize = Size(w.toDouble(), h.toDouble()); t.view.devicePixelRatio = 1; addTearDown(t.view.reset); @@ -59,8 +61,17 @@ void main() { for (final f in tmp.listSync(recursive: true).whereType()) { f.setLastModifiedSync(DateTime(2026, 10, 22, 9)); } + // E6: TC 모양은 폴더 날짜 칸이 있다 - 폴더 시각도 고정 (Dart 는 폴더 시각을 못 바꿔 PowerShell 로) + Process.runSync('powershell', [ + '-NoProfile', + '-Command', + "Get-ChildItem -LiteralPath '${tmp.path}' -Recurse -Directory | ForEach-Object { \$_.LastWriteTime = [datetime]'2026-10-22 09:00' }", + ]); device.volumes = () => [(a.path, 'C:'), (b.path, 'D:')]; + // E6: TC 모양 창 위의 남은 공간 - 이 PC 의 디스크 대신 정한 값 (실행마다 같게) + device.space = (_) => (107374182400, 536870912000); final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); + c.settings.explorer.style = style; final shotKey = GlobalKey(); await t.runAsync(() => t.pumpWidget(RepaintBoundary( key: shotKey, @@ -85,7 +96,7 @@ void main() { final bytes = await t.runAsync(() => img!.toByteData(format: ui.ImageByteFormat.png)); Directory(out).createSync(recursive: true); no++; - File(p.join(out, 'use_${w}_${no.toString().padLeft(2, '0')}_$name.png')).writeAsBytesSync(bytes!.buffer.asUint8List()); + File(p.join(out, '${style == 'totalcmd' ? 'use_tc' : 'use'}_${w}_${no.toString().padLeft(2, '0')}_$name.png')).writeAsBytesSync(bytes!.buffer.asUint8List()); } // 열린 창 (대화 상자 · 메뉴) 의 글을 기록 - 무엇이 떴는지 @@ -98,7 +109,7 @@ void main() { } } // ignore: avoid_print - print('[$w] $step: ${texts.isEmpty ? '(뜬 창 없음)' : texts.join(' | ')}'); + print('[${style == 'totalcmd' ? 'tc ' : ''}$w] $step: ${texts.isEmpty ? '(뜬 창 없음)' : texts.join(' | ')}'); } // 복사 · 이동이 끝나 진행 패널이 다 내려갈 때까지 (실제 시간, 10초까지) - 캡처가 PC 빠르기에 따라 달라지지 않게 @@ -125,7 +136,7 @@ void main() { dump(name); } catch (e) { // ignore: avoid_print - print('[$w] $name: 실패 - ${e.toString().split('\n').first}'); + print('[${style == 'totalcmd' ? 'tc ' : ''}$w] $name: 실패 - ${e.toString().split('\n').first}'); } await shot(name); } @@ -137,7 +148,13 @@ void main() { if (el.isEmpty) throw StateError('왼쪽 창에 없음'); return find.byWidget(el.first.widget).first; } - Finder txt(String name) => leftOf(find.text(name)); + // TC 는 폴더를 [이름], 파일은 확장자를 뺀 이름으로 보인다 (드라이브 탭 C: · D: 는 그대로) + String shown(String name) => style != 'totalcmd' || name.endsWith(':') + ? name + : name.contains('.') + ? name.substring(0, name.lastIndexOf('.')) + : '[$name]'; + Finder txt(String name) => leftOf(find.text(shown(name))); Finder row(String name) => find.ancestor(of: txt(name), matching: find.byType(InkWell)).first; Finder tip(String s) => find.byWidgetPredicate((x) => x is Tooltip && (x.message ?? '').startsWith(s)).first; // 줄 끝 표시 단추: 이름 글과 같은 높이의 것 @@ -157,7 +174,7 @@ void main() { if (e is File) p.relative(e.path, from: tmp.path) ]..sort(); // ignore: avoid_print - print('[$w] 파일 ($when): ${left.join(', ')}'); + print('[${style == 'totalcmd' ? 'tc ' : ''}$w] 파일 ($when): ${left.join(', ')}'); } Finder paneBtn(String s) => find.byWidgetPredicate((x) => x is IconButton && (x.tooltip ?? '').startsWith(s)).first; @@ -166,7 +183,12 @@ void main() { dump('처음'); await step('Show S01 누르기', () => t.tap(txt('Show S01'))); await step('Show S01 다시 누르기 (접히나)', () => t.tap(txt('Show S01'))); - if (find.text('ep01.mkv').evaluate().isEmpty) await t.tap(txt('Show S01')); + // 펼쳐지지 않았으면 한 번 더 (TC 목록은 두 번 누르기로 폴더 안에 들어가 [Show S01] 이 없을 수 있다 - 그러면 그대로) + if (find.text(shown('ep01.mkv')).evaluate().isEmpty) { + try { + await t.tap(txt('Show S01')); + } on StateError catch (_) {} + } await settle(); await step('ep01 한 번 누르기 (선택)', () => t.tap(txt('ep01.mkv'))); // 규칙 4 (E3): 표시는 선택 모드에서만 - [선택] 을 켜고 표시 @@ -236,6 +258,8 @@ void main() { await t.sendKeyEvent(LogicalKeyboardKey.space); }); await step('Insert', () => t.sendKeyEvent(LogicalKeyboardKey.insert)); + // TC: 앞 단계의 글만 있는 알림 (Flutter 기본 4초) 이 찍는 때에 따라 남거나 사라졌다 - 여기서 닫힌 것으로 (X-plore 는 base7 그대로) + if (style == 'totalcmd') await noticeTimesOut(); await step('report 누르고 Enter (열기 대신 무엇이)', () async { await t.tap(txt('report.pdf')); await settle(4); @@ -246,9 +270,10 @@ void main() { if (e is File) p.relative(e.path, from: tmp.path) ]..sort(); // ignore: avoid_print - print('[$w] 끝 파일: ${left.join(', ')}'); + print('[${style == 'totalcmd' ? 'tc ' : ''}$w] 끝 파일: ${left.join(', ')}'); await t.pumpWidget(const SizedBox()); await settle(4); }); } + } } diff --git a/lib/app/explorer_device.dart b/lib/app/explorer_device.dart index 091c3c5..b116262 100644 --- a/lib/app/explorer_device.dart +++ b/lib/app/explorer_device.dart @@ -1,13 +1,15 @@ import 'dart:io'; +import 'copy_center.dart' as copy show diskSpace; import 'volumes.dart'; /// 약속: 파일 탐색기가 기기에서 받는 것 - 저장 장치 목록 · 드라이브를 열 수 있는지 (화면을 멈추지 않는 비동기) · -/// Android 에서만 하는 것 (경로 입력의 SMB 안내 · SMB 서버 메뉴, 앱 휴지통, .nomedia 메뉴). +/// 저장 장치의 남은 공간 · 전체 (E6 Total Commander 모양, 못 재면 null) · Android 에서만 하는 것 (경로 입력의 SMB 안내 · SMB 서버 메뉴, 앱 휴지통, .nomedia 메뉴). /// 앱은 [RealExplorerDevice] (이 기기 그대로) 를 쓰고, 시험은 시험 폴더를 저장 장치로 주는 구현을 넘긴다 abstract interface class ExplorerDevice { Future> listVolumes(); Future probeDrive(String path); + Future<(int, int)?> diskSpace(String path); bool get androidPaths; bool get androidTrash; bool get nomedia; @@ -22,6 +24,8 @@ class RealExplorerDevice implements ExplorerDevice { @override Future probeDrive(String path) => Directory(path).exists(); @override + Future<(int, int)?> diskSpace(String path) => copy.diskSpace(path); + @override bool get androidPaths => Platform.isAndroid; @override bool get androidTrash => Platform.isAndroid; diff --git a/lib/ui/explorer/explorer_body.dart b/lib/ui/explorer/explorer_body.dart index e61b48e..4493584 100644 --- a/lib/ui/explorer/explorer_body.dart +++ b/lib/ui/explorer/explorer_body.dart @@ -2,6 +2,10 @@ import 'dart:math' as math; import 'package:flutter/material.dart'; +import '../../app/settings.dart' show AppSettings; +import '../../core/file_ops.dart' show samePath; +import '../explorer_look.dart'; + import 'explorer_bars.dart'; import 'explorer_buttons.dart'; import 'explorer_controller.dart'; @@ -9,6 +13,7 @@ import 'explorer_flows.dart'; import 'explorer_layout.dart'; import 'explorer_select.dart'; import 'pane_view.dart'; +import 'tc_key_bar.dart'; import 'toolbar.dart'; /// 약속 (E5d-6): 창들과 기능 버튼 줄의 배치 - 설정 (두 창 · 폴더 + 파일 목록 · 한 창, 좌우 · 위아래, 버튼 줄 사이 · 끝 · 숨김) 과 @@ -38,6 +43,32 @@ class ExplorerPanesBody extends StatelessWidget { layout.shape = shape; } final dual = layout.dual; + final panes = _panes(context, s, dual); + // E6: Total Commander 모양은 아래에 F3~F8 단추 줄 (키 F3~F8 과 같은 일) + if (ctl.look != ExplorerStyle.totalcmd || ctl.rsync) return panes; + return Column(children: [Expanded(child: panes), TcKeyBar(onPressed: (k) => _tcKey(context, k))]); + } + + void _tcKey(BuildContext context, TcKey k) { + final pane = ctl.pane; + switch (k) { + case TcKey.view: + flows.opener.quickLook(context, pane); + case TcKey.edit: + final f = pane.focused; + if (f != null && !samePath(f, pane.root)) flows.opener.open(context, f, pane: pane); + case TcKey.copy: + buttons.press(ExplorerButton.copy); + case TcKey.move: + buttons.press(ExplorerButton.move); + case TcKey.newFolder: + buttons.press(ExplorerButton.newFolder); + case TcKey.delete: + buttons.press(ExplorerButton.delete); + } + } + + Widget _panes(BuildContext context, AppSettings s, bool dual) { return LayoutBuilder(builder: (context, box) { // 두 창이 다 들어가는지: 트리 (화살표 · 아이콘 · 여백 78 + 이름 40) + 목록 (아이콘 · ✔ · 여백 104 + 이름 50), 이름은 글자 배율만큼. // 1배면 360 폭에도 들어가고, 기기 글자 1.3 × 150% (약 2배) 면 412 폭에도 모자란다 diff --git a/lib/ui/explorer/pane_view.dart b/lib/ui/explorer/pane_view.dart index 3c23d09..f8a42e4 100644 --- a/lib/ui/explorer/pane_view.dart +++ b/lib/ui/explorer/pane_view.dart @@ -18,6 +18,7 @@ import 'explorer_controller.dart'; import 'explorer_pane.dart'; import 'explorer_select.dart'; import 'row_view.dart'; +import 'tc_drive_space.dart'; /// 약속 (E5d-3): 창 하나를 그리는 데 화면이 주는 것. 창의 틀 (활성 · 놓기) · 창 머리 · 안내 · 표시 막대 · 줄 메뉴 · /// 끌어 놓기 · 필터 닫기는 아직 화면의 일이라 여기로 받는다 (나눌 때마다 이 목록이 줄어든다) @@ -37,6 +38,9 @@ abstract interface class ExplorerPaneParts { Widget frame(int i, List children); Widget header(ExplorerPane pane); + /// 저장 장치의 남은 공간 · 전체 (E6 Total Commander 모양의 창 위 줄) + Future<(int, int)?> diskSpace(String path); + /// [dir] 위의 안내들 (없는 것은 null) List notices(ExplorerPane pane, String dir); Widget marksBar(ExplorerPane pane, {String? listDir}); @@ -86,8 +90,28 @@ Widget? noticeArea(List notices, double paneHeight) { ); } +/// E6: Total Commander 모양 창 위의 남은 공간 줄 (지금 폴더의 저장 장치) +Widget _driveSpace(ExplorerController ctl, ExplorerPaneParts parts, ExplorerPane pane) { + final root = ctl.volumeOf(pane.current); + final label = ctl.local.where((v) => samePath(v.$1, root)).map((v) => v.$2).firstOrNull ?? root; + return TcDriveSpace(root: root, label: label, space: parts.diskSpace); +} + +/// [dir] 한 폴더의 목록 줄 (트리 아님): 맨 위 ".." (맨 위 폴더가 아니면) · 보이는 항목 · 숨긴 항목 [n개 보이기]. +/// "폴더 + 파일 목록" 의 오른쪽과 Total Commander 모양의 창이 같이 쓴다 (E6) +List paneListRows(ExplorerController ctl, ExplorerSelect sel, ExplorerPane pane, String dir) { + final entries = pane.cache[dir] ?? const []; + final hidden = entries.where((e) => ctl.appHidden(e.path)).length; + return [ + if (!samePath(dir, pane.root)) TreeRow(FileEntry(vDirname(dir), isDir: true, modified: DateTime(0)), 0, isUp: true), + for (final e in sel.shown(pane, dir)) TreeRow(e, 0), + // X-plore 숨기기: 맨 아래 [숨긴 항목 n개 보이기] + if (hidden > 0) TreeRow(FileEntry('\u0000hidden', isDir: false, modified: DateTime(0)), 0, kind: TreeRow.hiddenToggle, count: hidden), + ]; +} + Widget _rowOf(ExplorerController ctl, ExplorerSelect sel, ExplorerPaneParts parts, ExplorerPane pane, TreeRow row, - {bool list = false, bool compact = false}) => + {bool list = false, bool compact = false, bool narrow = false}) => KeyedSubtree( key: paneRowKey(row), child: row.kind != 0 @@ -99,6 +123,7 @@ Widget _rowOf(ExplorerController ctl, ExplorerSelect sel, ExplorerPaneParts part row: row, list: list, compact: compact, + narrow: narrow, rowHeight: parts.rowHeight, narrowSplit: parts.narrowSplit, alwaysMarks: parts.alwaysMarks, @@ -119,8 +144,11 @@ class ExplorerPaneView extends StatelessWidget { @override Widget build(BuildContext context) { final pane = ctl.panes[index]; - final rows = ctl.rows(pane, foldersOnly: foldersOnly); final look = ctl.look; + // E6: Total Commander 모양은 트리 대신 지금 폴더의 목록 (맨 위 "..") - 사용자가 고른 "폴더 + 파일 목록" (트리 + 목록, + // 좁아 한 창이 될 때도) · Rsync 는 그대로 + final flat = look == ExplorerStyle.totalcmd && !foldersOnly && !ctl.rsync && ctl.c.settings.explorer.layout != 'split'; + final rows = flat ? paneListRows(ctl, sel, pane, pane.current) : ctl.rows(pane, foldersOnly: foldersOnly); // 38: 창 높이를 알아야 안내 상자의 높이를 제한할 수 있다 return LayoutBuilder(builder: (context, box) { // 열을 그리는 창만 숨길 열을 정한다 ("폴더 + 파일 목록" 의 트리는 열이 없고 목록과 같은 창을 쓴다) @@ -129,12 +157,15 @@ class ExplorerPaneView extends StatelessWidget { } return parts.frame(index, [ parts.header(pane), + if (flat) _driveSpace(ctl, parts, 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, + shown: flat + ? rows.where((r) => r.kind == 0 && !r.isUp).length + : 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% 까지, 넘치면 넘겨 봄) @@ -143,7 +174,8 @@ class ExplorerPaneView extends StatelessWidget { ExplorerColumnsHeader( style: look, markColumn: sel.selecting, - hide: pane.colHide, + // E6: 좁은 창의 줄은 열 없이 이름만 - 열 머리도 이름만 + hide: box.maxWidth < narrowRowWidth ? 3 : pane.colHide, sortBy: ctl.sortOf(pane.current).$1, sortDesc: ctl.sortOf(pane.current).$2, onSort: (by) => ctl.sortByColumn(pane, by), @@ -151,18 +183,19 @@ class ExplorerPaneView extends StatelessWidget { Expanded( child: Stack(children: [ scaleListText(context, parts.listTextScale, ListView.builder( - controller: pane.scroll, + // TC 목록은 목록의 스크롤 (폴더를 옮기면 맨 위로) - 트리의 스크롤은 트리 줄 자리로 옮겨 다닌다 + controller: flat ? pane.listScroll : pane.scroll, itemCount: rows.length, itemExtent: parts.rowHeight, // 76: 줄 위젯은 항목을 따라간다 ([paneRowKey]) - itemBuilder: (_, k) => _rowOf(ctl, sel, parts, pane, rows[k], compact: foldersOnly), + itemBuilder: (_, k) => _rowOf(ctl, sel, parts, pane, rows[k], list: flat, compact: foldersOnly, narrow: box.maxWidth < narrowRowWidth), 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), + if ((sel.selecting || (parts.alwaysMarks && !pane.sel.isEmpty)) && !foldersOnly) parts.marksBar(pane, listDir: flat ? pane.current : null), ]); }); } @@ -183,13 +216,7 @@ class ExplorerListView extends StatelessWidget { final entries = pane.cache[dir] ?? const []; 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 rows = paneListRows(ctl, sel, pane, dir); final look = ctl.look; // 38: 창 높이를 알아야 안내 상자의 높이를 제한할 수 있다 return LayoutBuilder(builder: (context, box) { @@ -208,7 +235,8 @@ class ExplorerListView extends StatelessWidget { ExplorerColumnsHeader( style: look, markColumn: sel.selecting, - hide: pane.colHide, + // E6: 좁은 창의 줄은 열 없이 이름만 - 열 머리도 이름만 + hide: box.maxWidth < narrowRowWidth ? 3 : pane.colHide, sortBy: ctl.sortOf(dir).$1, sortDesc: ctl.sortOf(dir).$2, onSort: (by) => ctl.sortByColumn(pane, by), @@ -223,7 +251,7 @@ class ExplorerListView extends StatelessWidget { controller: pane.listScroll, itemCount: rows.length, itemExtent: parts.rowHeight, - itemBuilder: (_, k) => _rowOf(ctl, sel, parts, pane, rows[k], list: true), + itemBuilder: (_, k) => _rowOf(ctl, sel, parts, pane, rows[k], list: true, narrow: box.maxWidth < narrowRowWidth), findChildIndexCallback: _rowIndexOf(rows), )), ), diff --git a/lib/ui/explorer/row_view.dart b/lib/ui/explorer/row_view.dart index e00b46c..285ecf4 100644 --- a/lib/ui/explorer/row_view.dart +++ b/lib/ui/explorer/row_view.dart @@ -25,6 +25,10 @@ import 'file_thumbs.dart'; double treeChevronWidth(TargetPlatform platform) => platform == TargetPlatform.android || platform == TargetPlatform.iOS ? 44 : 20; +/// E6 (결정 10/22): 창 폭이 이보다 좁으면 (폰 세로의 좌우 두 창 등) 줄을 좁은 모양으로 - 아이콘 칸 줄임 · 끌기 손잡이 숨김 · +/// 열 대신 이름 먼저 (크기 · 날짜는 이름 아래 작은 글). 두 창 좌우 배치는 그대로 +const narrowRowWidth = 200.0; + /// 탐색기 줄 하나 (E5d-2). 상태 · 일은 [ExplorerController] · [ExplorerSelect], 메뉴 · 끌어 놓기는 부르는 쪽 콜백. /// [list]: "폴더 + 파일 목록" 의 오른쪽 목록 (트리 아님). [compact]: 폴더 트리 (열 · 표시 버튼 없이) class ExplorerRowView extends StatelessWidget { @@ -36,6 +40,7 @@ class ExplorerRowView extends StatelessWidget { required this.row, this.list = false, this.compact = false, + this.narrow = false, required this.rowHeight, required this.narrowSplit, required this.alwaysMarks, @@ -49,6 +54,9 @@ class ExplorerRowView extends StatelessWidget { final ExplorerPane pane; final TreeRow row; final bool list, compact; + + /// 좁은 창의 줄 ([narrowRowWidth]) + final bool narrow; final double rowHeight; final bool narrowSplit, alwaysMarks; @@ -67,6 +75,12 @@ class ExplorerRowView extends StatelessWidget { final look = ctl.look; // 좁은 "폴더 + 파일 목록" 의 트리 (관리자 지시 10/20): ✔ 칸 · 크기 줄 없이 이름만, 줄에 두 줄이 들어가면 이름을 두 줄까지 final slim = compact && narrowSplit && !list; + // E6: 좁은 창의 줄 (트리 · 목록 모두, 폴더 트리 (compact) 는 이미 이름만) + final tight = narrow && !compact; + // 크기 · 날짜 줄은 두 줄이 들어가는 줄 높이에서만 (Total Commander 26 에는 이름만 - 넘치지 않게) + final ts = MediaQuery.textScalerOf(context); + // 글 한 줄의 실제 높이는 글자 크기의 1.4배 안팎 (1.2 로 재면 큰 글자 · 넓은 줄 간격에서 1px 넘쳤다) + final metaFits = rowHeight >= ts.scale(look.fontSize) * 1.4 + ts.scale(look.fontSize - 2) * 1.4 + 4; // 이 줄은 목록 글자 배율을 이미 곱한 MediaQuery 안에 있다 (scaleListText) - 배율을 다시 곱하지 않는다 final nameLines = slim && rowHeight >= MediaQuery.textScalerOf(context).scale(look.fontSize) * 2.5 + 6 ? 2 : 1; final isCurrent = e.isDir && !list && !row.isUp && samePath(pane.current, e.path); @@ -129,8 +143,9 @@ class ExplorerRowView extends StatelessWidget { // 아주 좁은 창 (412 폭 "폴더 + 파일 목록" 의 트리, 77px 쯤): 아이콘 칸 · 틈을 남은 폭만큼만 (아이콘은 줄여 그림) - 넘치지 않게 final room = (box.maxWidth - indent - (list ? 0 : chevronWidth)).clamp(0.0, double.infinity); // 좁은 트리 (slim) 는 큰 썸네일 칸 (56) 대신 작은 아이콘 칸 (28 까지, 아이콘은 줄여 그림) - final iconW = math.min(slim ? math.min(look.iconSize, 24.0) + 4 : look.rich ? 56.0 : look.iconSize + 6, room); - final gapW = math.min(slim ? 4.0 : look.rich ? 8.0 : 6.0, room - iconW); + final small = slim || tight; + final iconW = math.min(small ? math.min(look.iconSize, 24.0) + 4 : look.rich ? 56.0 : look.iconSize + 6, room); + final gapW = math.min(small ? 4.0 : look.rich ? 8.0 : 6.0, room - iconW); return Row(children: [ SizedBox(width: indent), if (!list) chevron(), @@ -161,6 +176,9 @@ class ExplorerRowView extends StatelessWidget { Text(name, maxLines: nameLines, overflow: TextOverflow.ellipsis, style: nameStyle), if (slim) const SizedBox.shrink() + // E6: 좁은 창은 열이 없으니 파일의 크기 · 날짜를 이름 아래에 + else if (tight && metaFits && !e.isDir && !row.isUp && !row.isRoot) + Text('${formatSize(e.size)} · ${fileDateText(e.modified)}', style: dim, maxLines: 1, overflow: TextOverflow.ellipsis) else if (look.rich && e.isDir && pane.errors[e.path] != null) Text(tr('읽을 수 없음'), style: dim.copyWith(color: JjColors.alert)) // 80: 열 없는 모양 (X-plore · 좁은 화면) 은 폴더 이름 아래에 크기 @@ -181,7 +199,7 @@ class ExplorerRowView extends StatelessWidget { ); // 관리자 지시 10/20: 좁으면 유형 → 날짜 열을 숨김 (크기는 남김, [columnHideLevel]) final hide = pane.colHide; - final cells = !look.columns || compact || !real + final cells = !look.columns || compact || tight || !real ? [Expanded(child: nameCell())] : look == ExplorerStyle.windows ? [ @@ -229,7 +247,7 @@ class ExplorerRowView extends StatelessWidget { ), ), // 82: 터치로 끌기 - 선택 모드에서 표시한 줄의 ≡ 손잡이 (길게 누르기는 메뉴 그대로) - if (drag && sel.selecting && marked) + if (drag && sel.selecting && marked && !tight) Draggable( data: DragLoad(paneIndex, dragPaths(pane, e)), feedback: dragFeedback(dragPaths(pane, e)), diff --git a/lib/ui/explorer/tc_drive_space.dart b/lib/ui/explorer/tc_drive_space.dart new file mode 100644 index 0000000..829cb48 --- /dev/null +++ b/lib/ui/explorer/tc_drive_space.dart @@ -0,0 +1,52 @@ +/// 약속 (E6): Total Commander 모양의 창 위 한 줄 - 지금 저장 장치 ([root]) 의 남은 공간 / 전체. +/// 값은 [space] (기기 - ExplorerDevice.diskSpace) 에서 받고, 저장 장치가 바뀔 때만 다시 묻는다 (줄을 다시 그려도 다시 재지 않음). +/// 못 재면 저장 장치 이름만 보인다. +library; + +import 'package:flutter/material.dart'; + +import '../../core/files/file_listing.dart' show formatSize; +import '../../l10n/tr.dart'; +import '../theme.dart'; + +class TcDriveSpace extends StatefulWidget { + /// 저장 장치의 맨 위 (예: C:\) + final String root; + + /// 보이는 이름 (예: "C:") + final String label; + final Future<(int, int)?> Function(String path) space; + const TcDriveSpace({super.key, required this.root, required this.label, required this.space}); + + @override + State createState() => _TcDriveSpaceState(); +} + +class _TcDriveSpaceState extends State { + late Future<(int, int)?> _f = widget.space(widget.root); + + @override + void didUpdateWidget(TcDriveSpace old) { + super.didUpdateWidget(old); + if (old.root != widget.root) _f = widget.space(widget.root); + } + + @override + Widget build(BuildContext context) => FutureBuilder<(int, int)?>( + future: _f, + builder: (_, s) { + final d = s.data; + return Container( + height: 22, + padding: const EdgeInsets.symmetric(horizontal: 10), + alignment: Alignment.centerLeft, + child: Text( + d == null ? '[${widget.label}]' : trf('[{0}] 남은 {1} / 전체 {2}', [widget.label, formatSize(d.$1), formatSize(d.$2)]), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(fontSize: 11.5, color: JjColors.textDim), + ), + ); + }, + ); +} diff --git a/lib/ui/explorer/tc_key_bar.dart b/lib/ui/explorer/tc_key_bar.dart new file mode 100644 index 0000000..6db1fe6 --- /dev/null +++ b/lib/ui/explorer/tc_key_bar.dart @@ -0,0 +1,63 @@ +/// 약속 (E6): Total Commander 모양의 아래 단추 줄 - F3 보기 · F4 편집 · F5 복사 · F6 이동 · F7 새 폴더 · F8 삭제. +/// 누르면 [onPressed] 에 그 일 ([TcKey]) 을 넘기기만 한다 (일은 탐색기의 같은 동작 - 키 F3~F8 과 같다). +/// 좁으면 글을 줄이고 (예: "F5 복사" → "F5"), 단추는 늘 같은 폭으로 한 줄을 나눠 쓴다. +library; + +import 'package:flutter/material.dart'; + +import '../../l10n/tr.dart'; +import '../theme.dart'; + +enum TcKey { + view('F3', '보기'), + edit('F4', '편집'), + copy('F5', '복사'), + move('F6', '이동'), + newFolder('F7', '새 폴더'), + delete('F8', '삭제'); + + final String key; + final String label; + const TcKey(this.key, this.label); +} + +class TcKeyBar extends StatelessWidget { + final void Function(TcKey key) onPressed; + const TcKeyBar({super.key, required this.onPressed}); + + /// 이보다 좁으면 키 이름만 ("F5") + static const shortWidth = 480.0; + + @override + Widget build(BuildContext context) => LayoutBuilder(builder: (context, box) { + final short = box.maxWidth < shortWidth; + return Container( + height: 30, + decoration: BoxDecoration(color: JjColors.panel, border: Border(top: BorderSide(color: JjColors.border))), + child: Row(children: [ + for (final k in TcKey.values) + Expanded( + child: Tooltip( + message: '${k.key} ${tr(k.label)}', + child: InkWell( + onTap: () => onPressed(k), + child: Container( + alignment: Alignment.center, + decoration: BoxDecoration(border: Border(right: BorderSide(color: JjColors.border))), + child: Text.rich( + TextSpan(children: [ + TextSpan(text: k.key, style: TextStyle(fontWeight: FontWeight.w600, color: JjColors.accent)), + if (!short) TextSpan(text: ' ${tr(k.label)}'), + ]), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: 12), + ), + ), + ), + ), + ), + ]), + ); + }); +} diff --git a/lib/ui/explorer_look.dart b/lib/ui/explorer_look.dart index 02b9905..9981bde 100644 --- a/lib/ui/explorer_look.dart +++ b/lib/ui/explorer_look.dart @@ -201,7 +201,7 @@ class ExplorerColumnsHeader extends StatelessWidget { /// 선택 모드: 줄 끝의 선택 동그라미 칸만큼 비운다 final bool markColumn; - /// 관리자 지시 10/20: 좁아서 숨긴 열 ([columnHideLevel]: 1 = 유형, 2 = 유형 · 날짜) + /// 관리자 지시 10/20: 좁아서 숨긴 열 ([columnHideLevel]: 1 = 유형, 2 = 유형 · 날짜). 3 = 이름만 (E6 좁은 창의 줄 - 크기도 이름 아래로) final int hide; /// 84: 지금 정렬 ('name' · 'date' · 'size' · 'type') · 거꾸로 - 그 열에 ▲ ▼ @@ -253,12 +253,12 @@ class ExplorerColumnsHeader extends StatelessWidget { cell(tr('이름'), 6, by: 'name'), if (hide < 2) cell(tr('수정한 날짜'), 3, by: 'date'), if (hide < 1) cell(tr('유형'), 2, by: 'type'), - cell(tr('크기'), 2, align: TextAlign.end, by: 'size'), + if (hide < 3) cell(tr('크기'), 2, align: TextAlign.end, by: 'size'), ] : [ cell(tr('이름'), 6, by: 'name'), if (hide < 1) cell(tr('확장자'), 1, by: 'type'), - cell(tr('크기'), 2, align: TextAlign.end, by: 'size'), + if (hide < 3) cell(tr('크기'), 2, align: TextAlign.end, by: 'size'), if (hide < 2) ...[const SizedBox(width: 8), cell(tr('날짜'), 3, by: 'date')], ], ), diff --git a/lib/ui/explorer_page.dart b/lib/ui/explorer_page.dart index 2dca7f5..842684b 100644 --- a/lib/ui/explorer_page.dart +++ b/lib/ui/explorer_page.dart @@ -272,6 +272,9 @@ class _ExplorerPageState extends State implements ExplorerHost, Ex @override Widget frame(int i, List children) => ExplorerPaneFrame(ctl: _ctl, index: i, onPointer: _input.mouseTravel, onDrop: onDrop, children: children); + @override + Future<(int, int)?> diskSpace(String path) => widget.device.diskSpace(path); + @override Widget header(ExplorerPane pane) => ExplorerPaneHeader( ctl: _ctl, diff --git a/test/explorer_more_flows_contract_test.dart b/test/explorer_more_flows_contract_test.dart index 377bc14..9d5183e 100644 --- a/test/explorer_more_flows_contract_test.dart +++ b/test/explorer_more_flows_contract_test.dart @@ -192,6 +192,8 @@ class _HostState extends State<_Host> implements ExplorerHost, ExplorerCommands, @override Widget frame(int i, List children) => Column(children: children); @override + Future<(int, int)?> diskSpace(String path) async => null; + @override Widget header(ExplorerPane pane) => ExplorerPaneHeader( ctl: ctl, servers: ExplorerServers(ctl), diff --git a/test/explorer_pane_view_contract_test.dart b/test/explorer_pane_view_contract_test.dart index b8ac570..5143299 100644 --- a/test/explorer_pane_view_contract_test.dart +++ b/test/explorer_pane_view_contract_test.dart @@ -168,6 +168,8 @@ class _HostState extends State<_Host> implements ExplorerHost, ExplorerPaneParts @override Widget frame(int i, List children) => Column(children: children); @override + Future<(int, int)?> diskSpace(String path) async => null; + @override Widget header(ExplorerPane pane) => ExplorerPaneHeader( ctl: ctl!, servers: ExplorerServers(ctl!), diff --git a/test/explorer_tc_look_test.dart b/test/explorer_tc_look_test.dart new file mode 100644 index 0000000..a3ebd37 --- /dev/null +++ b/test/explorer_tc_look_test.dart @@ -0,0 +1,77 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:jj_mkvmaker/ui/explorer/tc_key_bar.dart'; +import 'package:jj_mkvmaker/ui/explorer_page.dart'; + +import 'support/explorer_clock_keys.dart'; + +import 'support/explorer_harness.dart'; + +/// E6 약속: Total Commander 모양 - 창마다 지금 폴더의 목록 ("..", 폴더는 [이름]) · 아래 F3~F8 단추 줄 (키와 같은 일) · +/// 창 위 남은 공간. X-plore 모양에는 단추 줄 · 남은 공간 줄이 없다 (모양만 다름) +void main() { + useExplorerFixture(); + + testWidgets('목록: 폴더를 두 번 누르면 들어가고 ".." 로 나온다 · 폴더는 [이름]', (t) async { + c.settings.explorer.style = 'totalcmd'; + await open(t); + // TC 는 이름 · 확장자를 나눠 보인다 (doc · txt) - 하네스의 open 은 'doc.txt' 를 기다리므로 여기서 다시 기다린다 + await settle(t, () => find.text('[sub]').evaluate().isNotEmpty); + expect(find.text('[sub]'), findsWidgets, reason: '폴더는 [이름] (TC)'); + expect([find.text('doc').evaluate().length, find.text('txt').evaluate().length], [1, 1], reason: '파일은 이름 · 확장자 칸'); + expect(find.text('inner'), findsNothing, reason: '트리처럼 펼치지 않는다'); + await doubleTap(t, find.text('[sub]').first); + await settle(t, () => find.text('inner').evaluate().isNotEmpty); + expect(find.text('..'), findsWidgets); + await doubleTap(t, find.text('..').first); + await settle(t, () => find.text('[sub]').evaluate().isNotEmpty); + expect(find.text('inner'), findsNothing); + }); + + testWidgets('아래 단추 줄 F3~F8 · F7 은 새 폴더 창 (가운데 [새 폴더] 와 같음) · 창 위 남은 공간', (t) async { + c.settings.explorer.style = 'totalcmd'; + device.space = (_) => (107374182400, 536870912000); // 100 GB / 500 GB + await open(t); + expect(find.byType(TcKeyBar), findsOneWidget); + for (final k in TcKey.values) { + expect(find.byTooltip('${k.key} ${k.label}'), findsOneWidget); + } + expect(find.textContaining('남은 100'), findsWidgets); + await t.tap(find.byTooltip('F7 새 폴더')); + await t.pumpAndSettle(); + expect(find.byType(Dialog), findsOneWidget, reason: '새 폴더 이름 창'); + }); + + testWidgets('X-plore 모양에는 아래 단추 줄 · 남은 공간 줄이 없다', (t) async { + device.space = (_) => (107374182400, 536870912000); + await open(t); // 하네스는 X-plore + expect(find.byType(TcKeyBar), findsNothing); + expect(find.textContaining('남은 100'), findsNothing); + }); + + testWidgets('좁은 창 (412 폭 두 창, 창마다 200 아래): 이름 먼저 · 크기 · 날짜는 이름 아래 · 끌기 손잡이 숨김 (X-plore · TC 같은 줄 부품)', (t) async { + for (final style in ['xplore', 'totalcmd']) { + c.settings.explorer.style = style; + t.view.physicalSize = const Size(412, 900); + t.view.devicePixelRatio = 1; + addTearDown(t.view.reset); + await t.runAsync(() => t.pumpWidget(MaterialApp(home: ExplorerPage(c: c, clock: () => testNow, device: device)))); + final name = style == 'totalcmd' ? 'doc' : 'doc.txt'; + await settle(t, () => find.text(name).evaluate().isNotEmpty); + // X-plore (줄 52) 는 크기 · 날짜가 이름 아래 한 줄, TC (줄 26) 는 두 줄이 들어가지 않아 이름만 + expect(find.textContaining('5B · '), style == 'xplore' ? findsWidgets : findsNothing, reason: '$style: 이름 아래 크기 · 날짜'); + expect(t.takeException(), isNull, reason: '$style: 넘침 없음'); + expect(find.text(''), findsNothing, reason: '$style: 열 (크기 칸) 대신 이름 먼저'); + // 선택 모드에서 표시해도 좁은 창에는 끌기 손잡이 (≡) 가 없다 + await act(t, () => t.tap(find.byWidgetPredicate((w) => w is Tooltip && (w.message ?? '').startsWith('선택')).first)); + await calm(t); + final mark = find.byTooltip('표시 (여러 개 고르기)'); + if (mark.evaluate().isNotEmpty) { + await act(t, () => t.tap(mark.first)); + await calm(t); + } + expect(find.byIcon(Icons.drag_indicator), findsNothing, reason: '$style: 좁은 창은 끌기 손잡이 숨김'); + await t.pumpWidget(const SizedBox()); + } + }); +} diff --git a/test/support/test_explorer_device.dart b/test/support/test_explorer_device.dart index 88226ff..5666e12 100644 --- a/test/support/test_explorer_device.dart +++ b/test/support/test_explorer_device.dart @@ -11,6 +11,9 @@ class TestExplorerDevice implements ExplorerDevice { /// 드라이브를 열 수 있는지 (null 이면 이 기기) Future Function(String path)? probe; + /// 남은 공간 · 전체 (null 이면 이 기기) - 캡처가 실행마다 같게 정한다 + (int, int)? Function(String path)? space; + /// 경로 입력 SMB 안내 · 앱 휴지통 · .nomedia 메뉴 (null 이면 이 기기) bool? android, trash, nomediaOn; @@ -19,6 +22,8 @@ class TestExplorerDevice implements ExplorerDevice { @override Future probeDrive(String path) => (probe ?? _real.probeDrive)(path); @override + Future<(int, int)?> diskSpace(String path) async => space != null ? space!(path) : await _real.diskSpace(path); + @override bool get androidPaths => android ?? _real.androidPaths; @override bool get androidTrash => trash ?? _real.androidTrash;