diff --git a/assets/l10n/en.json b/assets/l10n/en.json index 5249f82..3514bd9 100644 --- a/assets/l10n/en.json +++ b/assets/l10n/en.json @@ -1778,6 +1778,7 @@ "위 ⇄ 아래": "Top ⇄ bottom", "위 폴더": "Parent folder", "위 폴더로": "Go up", + "위 폴더로 가기": "Go to a parent folder", "위로": "Up", "위아래": "Top and bottom", "위아래 ⇅": "Stacked ⇅", diff --git a/assets/l10n/ja.json b/assets/l10n/ja.json index 4af21e1..0e18cf4 100644 --- a/assets/l10n/ja.json +++ b/assets/l10n/ja.json @@ -1778,6 +1778,7 @@ "위 ⇄ 아래": "上 ⇄ 下", "위 폴더": "上のフォルダー", "위 폴더로": "上のフォルダーへ", + "위 폴더로 가기": "上のフォルダーへ移動", "위로": "上へ", "위아래": "上下", "위아래 ⇅": "上下 ⇅", diff --git a/assets/l10n/zh-Hans.json b/assets/l10n/zh-Hans.json index 569e4ab..9fa6dc0 100644 --- a/assets/l10n/zh-Hans.json +++ b/assets/l10n/zh-Hans.json @@ -1778,6 +1778,7 @@ "위 ⇄ 아래": "上 ⇄ 下", "위 폴더": "上级文件夹", "위 폴더로": "返回上级文件夹", + "위 폴더로 가기": "转到上级文件夹", "위로": "向上", "위아래": "上下", "위아래 ⇅": "上下 ⇅", diff --git a/integration_test/windows_p3_shot_test.dart b/integration_test/windows_p3_shot_test.dart new file mode 100644 index 0000000..0fc0f72 --- /dev/null +++ b/integration_test/windows_p3_shot_test.dart @@ -0,0 +1,84 @@ +import 'dart:io'; +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:jj_mkvmaker/app/i18n_controller.dart'; +import 'package:jj_mkvmaker/ui/file_tools.dart'; +import 'package:jj_mkvmaker/ui/theme.dart'; +import 'package:path/path.dart' as p; + +/// 관리자 지시 (10/18): X-plore P3 화면을 PNG 로 (JJ_SHOT_DIR, 없으면 임시 폴더) - 해시 창 · 폴더 속성 · 16진 보기. +/// 크기 412 (폰 세로) · 960, 언어 ko · en · ja. 시험 파일은 임시 폴더에만 (끝나면 지움). 실행: flutter test integration_test\windows_p3_shot_test.dart -d windows +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + final out = Platform.environment['JJ_SHOT_DIR'] ?? p.join(Directory.systemTemp.path, 'jj_shots_p3'); + for (final lang in ['ko', 'en', 'ja']) { + for (final (w, h) in [(412, 900), (960, 600)]) { + testWidgets('P3 화면 $lang ${w}x$h', (t) async { + t.view.physicalSize = Size(w.toDouble(), h.toDouble()); + t.view.devicePixelRatio = 1; + addTearDown(t.view.reset); + await t.runAsync(() => i18n.apply(lang, save: false)); + addTearDown(() => i18n.apply('ko', save: false)); + final tmp = Directory.systemTemp.createTempSync('jj_p3shot_'); + addTearDown(() => tmp.deleteSync(recursive: true)); + final file = File(p.join(tmp.path, '영상 폴더', 'ep01 긴 이름의 동영상 파일.mkv')) + ..createSync(recursive: true) + ..writeAsBytesSync(List.generate(5000, (i) => i % 256)); + File(p.join(tmp.path, '영상 폴더', '속', 'a.txt')) + ..createSync(recursive: true) + ..writeAsStringSync('hello'); + final navKey = GlobalKey(); + final shotKey = GlobalKey(); + await t.pumpWidget(RepaintBoundary( + key: shotKey, + child: MaterialApp( + theme: buildTheme(), debugShowCheckedModeBanner: false, navigatorKey: navKey, home: const Scaffold(body: SizedBox.expand())), + )); + BuildContext ctx() => navKey.currentContext!; + Future settle([int n = 8]) async { + for (var i = 0; i < n; i++) { + await t.runAsync(() => Future.delayed(const Duration(milliseconds: 60))); + await t.pump(); + } + } + + Future shot(String name) async { + await settle(3); + final b = shotKey.currentContext!.findRenderObject()! as RenderRepaintBoundary; + final img = await t.runAsync(() => b.toImage()); + final bytes = await t.runAsync(() => img!.toByteData(format: ui.ImageByteFormat.png)); + Directory(out).createSync(recursive: true); + File(p.join(out, 'p3_${name}_${lang}_$w.png')).writeAsBytesSync(bytes!.buffer.asUint8List()); + } + + Future closeAll() async { + navKey.currentState!.popUntil((r) => r.isFirst); + await settle(4); + } + + // 해시 창 (다 계산된 뒤 · 붙여 넣은 값과 비교) + showFileHash(ctx(), file.path); + await settle(12); + await shot('hash'); + await t.enterText(find.byType(TextField).first, 'abc'); + await settle(); + await shot('hash_compare'); + await closeAll(); + // 폴더 속성 + showFolderProperties(ctx(), p.join(tmp.path, '영상 폴더')); + await settle(12); + await shot('props'); + await closeAll(); + // 16진 보기 + navKey.currentState!.push(MaterialPageRoute(builder: (_) => HexViewerPage(path: file.path))); + await settle(12); + await shot('hex'); + await closeAll(); + }); + } + } +} diff --git a/lib/ui/explorer_page.dart b/lib/ui/explorer_page.dart index f174275..c26f385 100644 --- a/lib/ui/explorer_page.dart +++ b/lib/ui/explorer_page.dart @@ -3698,15 +3698,45 @@ class _ExplorerPageState extends State { ); } + /// 지금 폴더 · 그 위 폴더들 (창의 맨 위 [pane.root] 까지, 가까운 것부터) + List _ancestors(_Pane pane) { + final out = []; + var d = pane.current; + for (var i = 0; i < 64; i++) { + out.add(d); + if (samePath(d, pane.root)) break; + final up = vDirname(d); + if (up == d || up.isEmpty) break; + d = up; + } + return out; + } + Widget _topBar() => AppTopBar( nav: AppNavButtons(onExplorerPage: !widget.rsync, onRsyncPage: widget.rsync), actions: AppActions(c: c), middle: Row(children: [ Text(widget.rsync ? 'Rsync' : tr('파일 탐색기'), style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)), const SizedBox(width: 12), + // X-plore 경로 막대 (4.4x): 경로를 누르면 위 폴더들 (창의 맨 위까지) 이 나와 골라 간다 Expanded( - child: Text(_ready ? _displayPath(_pane.current) : '', - maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(color: JjColors.textDim)), + child: !_ready + ? const SizedBox.shrink() + : PopupMenuButton( + tooltip: tr('위 폴더로 가기'), + position: PopupMenuPosition.under, + onSelected: (d) => _goTo(_pane, d), + itemBuilder: (_) => [ + for (final d in _ancestors(_pane)) + PopupMenuItem( + value: d, + enabled: !samePath(d, _pane.current), + child: Text(_displayPath(d), maxLines: 1, overflow: TextOverflow.ellipsis), + ), + ], + child: Text(_displayPath(_pane.current), + maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(color: JjColors.textDim)), + ), ), PopupMenuButton( tooltip: tr('창 배치 · 버튼 구성'), diff --git a/lib/ui/file_tools.dart b/lib/ui/file_tools.dart index ff546fa..a3a5a8e 100644 --- a/lib/ui/file_tools.dart +++ b/lib/ui/file_tools.dart @@ -112,7 +112,8 @@ Future showFileHash(BuildContext context, String path) async { ); }), ); - compare.dispose(); + // 창이 닫히는 동안 (애니메이션) 글 칸이 아직 쓰므로 바로 dispose 하면 "used after being disposed" (15 캡처) - 닫힌 뒤에 + Future.delayed(const Duration(milliseconds: 500), compare.dispose); } /// 16진 보기: 한 줄 16 바이트 (위치 · 16진 · 글자). 큰 파일은 [더 읽기] @@ -121,12 +122,12 @@ class HexViewerPage extends StatefulWidget { final int chunk; const HexViewerPage({super.key, required this.path, this.chunk = 64 * 1024}); - /// 한 줄 (16 바이트): "00000010 41 42 ... AB.." - static String line(int offset, List bytes) { + /// 한 줄 ([per] 바이트, 처음 16): "00000010 41 42 ... AB.." + static String line(int offset, List bytes, {int per = 16}) { final hex = StringBuffer(); - for (var i = 0; i < 16; i++) { + for (var i = 0; i < per; i++) { hex.write(i < bytes.length ? bytes[i].toRadixString(16).padLeft(2, '0') : ' '); - hex.write(i == 7 ? ' ' : ' '); + hex.write(i == 7 && per > 8 ? ' ' : ' '); } final text = String.fromCharCodes([for (final b in bytes) b >= 0x20 && b < 0x7f ? b : 0x2e]); return '${offset.toRadixString(16).padLeft(8, '0')} $hex $text'; @@ -163,7 +164,9 @@ class _HexViewerPageState extends State { @override Widget build(BuildContext context) { - final rows = (_data.length + 15) ~/ 16; + // 좁은 화면 (폰 세로) 은 한 줄 8 바이트 - 16 이면 끝의 글자 칸이 화면 밖으로 잘렸다 (15 캡처, 412 폭) + final per = MediaQuery.sizeOf(context).width < 560 ? 8 : 16; + final rows = (_data.length + per - 1) ~/ per; return Scaffold( appBar: AppBar(title: Text(trf('16진 보기 · {0}', [p.basename(widget.path)]), maxLines: 1, overflow: TextOverflow.ellipsis)), body: _error != null @@ -176,7 +179,7 @@ class _HexViewerPageState extends State { itemCount: rows, itemExtent: 18, itemBuilder: (_, i) => Text( - HexViewerPage.line(i * 16, _data.sublist(i * 16, (i * 16 + 16).clamp(0, _data.length))), + HexViewerPage.line(i * per, _data.sublist(i * per, (i * per + per).clamp(0, _data.length)), per: per), style: const TextStyle(fontFamily: 'monospace', fontSize: 12), maxLines: 1, softWrap: false, diff --git a/test/explorer_test.dart b/test/explorer_test.dart index 0c2f7ad..52b4167 100644 --- a/test/explorer_test.dart +++ b/test/explorer_test.dart @@ -193,6 +193,25 @@ void main() { await calm(tester); }); + testWidgets('X-plore 경로 막대: 위 막대의 경로를 누르면 지금 폴더 · 위 폴더들 (창의 맨 위까지) - 고르면 그리로', (tester) async { + await open(tester); + await act(tester, () => tester.tap(find.text('sub'))); + await settle(tester, () => find.text('inner.txt').evaluate().isNotEmpty); + await act(tester, () => tester.tap(find.byTooltip('위 폴더로 가기'))); + await calm(tester); + final items = find.byType(PopupMenuItem); + final n = items.evaluate().length; + expect(n, greaterThanOrEqualTo(2), reason: 'sub · 그 위 폴더들 (창의 맨 위까지)'); + expect(tester.widget>(items.first).enabled, isFalse, reason: '지금 폴더는 고를 수 없음'); + expect(tester.widget>(items.at(1)).value, left, reason: '바로 위 = left'); + await act(tester, () => tester.tap(items.at(1))); + await settle(tester, () => false, rounds: 15); + // 위로 갔으면 다시 열 때 하나 적다 (지금 폴더 = left) + await act(tester, () => tester.tap(find.byTooltip('위 폴더로 가기'))); + await calm(tester); + expect(find.byType(PopupMenuItem), findsNWidgets(n - 1)); + }); + testWidgets('X-plore b: 가운데 버튼 길게 누르기 = 짝 동작 - [복사] 길게 = 이동 창 · [삭제] 길게 = 영구 삭제 창', (tester) async { await open(tester); await act(tester, () => tester.tap(find.text('doc.txt')));