From 8eb167438291cf2c99126b92b8702e4e3650fd0d Mon Sep 17 00:00:00 2001 From: RyuJongJin <266630912+RyuJongJin@users.noreply.github.com> Date: Sun, 18 Oct 2026 01:43:17 +0900 Subject: [PATCH] =?UTF-8?q?WIP=20X-plore=20P3=20=EB=B3=B4=EC=B6=A9=20(?= =?UTF-8?q?=EC=BA=A1=EC=B2=98=EC=97=90=EC=84=9C=20=EC=B0=BE=EC=9D=8C)=20?= =?UTF-8?q?=C2=B7=20=EA=B2=BD=EB=A1=9C=20=EB=A7=89=EB=8C=80=20=EB=88=84?= =?UTF-8?q?=EB=A5=B4=EA=B8=B0=EB=A1=9C=20=EC=9C=84=20=ED=8F=B4=EB=8D=94=20?= =?UTF-8?q?=EA=B3=A0=EB=A5=B4=EA=B8=B0=20(=EA=B2=80=ED=86=A0=20=EB=8C=80?= =?UTF-8?q?=EA=B8=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 캡처 (integration_test/windows_p3_shot_test.dart - 해시 · 비교 · 폴더 속성 · 16진, 412/960 × ko/en/ja, release\test\shots_p3_fix) 에서 찾아 고침: - 16진 보기: 폭 412 에서 한 줄 16 바이트면 끝의 글자 칸이 화면 밖으로 잘렸다 → 폭 560 미만은 한 줄 8 바이트 (line(per:), 처음 값 16 그대로) - 해시 창: 닫자마자 글 칸 컨트롤러를 dispose 해 닫히는 동안 "A TextEditingController was used after being disposed" (닫을 때마다) → 닫힌 뒤 dispose 관리자 지시 3 (X-plore 비교표의 작은 것): 위 막대의 경로를 누르면 지금 폴더 · 위 폴더들 (창의 맨 위까지, 가까운 것부터) 이 나와 고르면 그리로 (X-plore 4.4x 경로 막대). 지금 폴더는 고를 수 없게 흐리게. 시험: explorer_test (새: 경로 막대 - 지금 폴더 못 고름 · 바로 위 = left · 고르면 하나 적어짐) · file_tools_test 통과, analyze 문제 없음, l10n 빠짐 0, 캡처 시험 6 통과. Co-Authored-By: Claude Opus 5.5 --- assets/l10n/en.json | 1 + assets/l10n/ja.json | 1 + assets/l10n/zh-Hans.json | 1 + integration_test/windows_p3_shot_test.dart | 84 ++++++++++++++++++++++ lib/ui/explorer_page.dart | 34 ++++++++- lib/ui/file_tools.dart | 17 +++-- test/explorer_test.dart | 19 +++++ 7 files changed, 148 insertions(+), 9 deletions(-) create mode 100644 integration_test/windows_p3_shot_test.dart 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')));