diff --git a/lib/ui/explorer_look.dart b/lib/ui/explorer_look.dart index 525c0dc..581d620 100644 --- a/lib/ui/explorer_look.dart +++ b/lib/ui/explorer_look.dart @@ -78,6 +78,23 @@ class _TimesScaler extends TextScaler { int get hashCode => Object.hash(base, factor); } +/// 관리자 지시 10/20: 좁은 창의 열 (Windows 탐색기 · Total Commander) - 이름 열이 120 (× 글자 배율) 보다 좁으면 유형 (TC 는 확장자) → +/// 날짜 순으로 숨긴다 (크기는 남김). 0 = 다 보임 · 1 = 유형 숨김 · 2 = 유형 · 날짜 숨김. 화면이 모자랄 때만 - 열 설정은 그대로. +/// [width] 창 폭, [marks] 줄 끝 ✔ 칸 (36) 이 있는지, [textScale] 목록 글자 배율 (기기 × 목록 글자 크기) +int columnHideLevel(ExplorerStyle style, double width, {bool marks = false, double textScale = 1}) { + if (!style.columns) return 0; + // 이름 열 120 (글자가 크면 그만큼 - 1.95 배 글자의 120 은 네 글자쯤이었다) + final minName = 120 * (textScale < 1 ? 1 : textScale); + final inner = width - 16 - (marks ? 36 : 0); + // 열 비율: Windows 이름 6 · 날짜 3 · 유형 2 · 크기 2, TC 이름 6 · 확장자 1 · 크기 2 · (8px) · 날짜 3 + final flexes = style == ExplorerStyle.windows ? [13, 11, 8] : [12, 11, 8]; + final extra = style == ExplorerStyle.totalcmd ? 8 : 0; + for (var level = 0; level < 2; level++) { + if ((inner - (level < 2 ? extra : 0)) * 6 / flexes[level] >= minName) return level; + } + return 2; +} + /// 파일 종류별 아이콘 · 색 (스타일마다 다르게) (IconData, Color) fileIcon(ExplorerStyle style, FileEntry e, {bool open = false}) { final x = e.ext; @@ -146,6 +163,9 @@ class ExplorerColumnsHeader extends StatelessWidget { /// 선택 모드: 줄 끝의 선택 동그라미 칸만큼 비운다 final bool markColumn; + /// 관리자 지시 10/20: 좁아서 숨긴 열 ([columnHideLevel]: 1 = 유형, 2 = 유형 · 날짜) + final int hide; + /// 84: 지금 정렬 ('name' · 'date' · 'size' · 'type') · 거꾸로 - 그 열에 ▲ ▼ final String? sortBy; final bool sortDesc; @@ -153,7 +173,7 @@ class ExplorerColumnsHeader extends StatelessWidget { /// 84: 열 머리를 누르면 (같은 열을 다시 누르면 거꾸로 - 부르는 쪽이 정한다) final void Function(String by)? onSort; const ExplorerColumnsHeader( - {super.key, required this.style, this.markColumn = false, this.sortBy, this.sortDesc = false, this.onSort}); + {super.key, required this.style, this.markColumn = false, this.hide = 0, this.sortBy, this.sortDesc = false, this.onSort}); @override Widget build(BuildContext context) { @@ -193,16 +213,15 @@ class ExplorerColumnsHeader extends StatelessWidget { children: style == ExplorerStyle.windows ? [ cell(tr('이름'), 6, by: 'name'), - cell(tr('수정한 날짜'), 3, by: 'date'), - cell(tr('유형'), 2, by: 'type'), + if (hide < 2) cell(tr('수정한 날짜'), 3, by: 'date'), + if (hide < 1) cell(tr('유형'), 2, by: 'type'), cell(tr('크기'), 2, align: TextAlign.end, by: 'size'), ] : [ cell(tr('이름'), 6, by: 'name'), - cell(tr('확장자'), 1, by: 'type'), + if (hide < 1) cell(tr('확장자'), 1, by: 'type'), cell(tr('크기'), 2, align: TextAlign.end, by: 'size'), - const SizedBox(width: 8), - cell(tr('날짜'), 3, by: 'date'), + 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 c6a39c4..95ddffc 100644 --- a/lib/ui/explorer_page.dart +++ b/lib/ui/explorer_page.dart @@ -234,6 +234,9 @@ enum ExplorerButton { class _Pane extends ChangeNotifier { String root; + /// 관리자 지시 10/20: 좁아서 숨긴 열 ([columnHideLevel]) - 열을 그리는 창 (목록) 의 폭으로 정하고 열 머리 · 줄이 같이 쓴다 + int colHide = 0; + /// 지금 폴더. 75: 바뀔 때마다 뒤로 가기 내역에 (뒤로 · 앞으로로 움직일 때는 빼고) String get current => _current; set current(String v) { @@ -4929,7 +4932,13 @@ class _ExplorerPageState extends State { final rows = _rows(pane, foldersOnly: foldersOnly); final look = _look; // 38: 창 높이를 알아야 안내 상자의 높이를 제한할 수 있다 - return LayoutBuilder(builder: (context, box) => _frame(i, [ + return LayoutBuilder(builder: (context, box) { + // 열을 그리는 창만 숨길 열을 정한다 ("폴더 + 파일 목록" 의 트리는 열이 없고 목록과 같은 창을 쓴다) + if (look.columns && !foldersOnly) { + pane.colHide = columnHideLevel(look, box.maxWidth, + marks: _selecting, textScale: MediaQuery.textScalerOf(context).scale(14) / 14 * _listTextScale); + } + return _frame(i, [ _paneHeader(pane), const Divider(height: 1), // 보이는 수는 맞는 것만 (맞는 것이 든 위 폴더는 빼고) @@ -4941,6 +4950,7 @@ class _ExplorerPageState extends State { ExplorerColumnsHeader( style: look, markColumn: _selecting, + hide: pane.colHide, sortBy: _sortOf(pane.current).$1, sortDesc: _sortOf(pane.current).$2, onSort: (by) => _sortByColumn(pane, by), @@ -4962,7 +4972,8 @@ class _ExplorerPageState extends State { ]), ), if ((_selecting || (_alwaysMarks && pane.marked.isNotEmpty)) && !foldersOnly) _marksBar(pane), - ])); + ]); + }); } /// 76: 목록 줄의 키 = 항목 경로. 키가 없으면 줄 목록이 바뀔 때 (빠른 필터 · 펼치기 · 새로 고침) 같은 자리의 줄 위젯을 @@ -4992,7 +5003,12 @@ class _ExplorerPageState extends State { ]; final look = _look; // 38: 창 높이를 알아야 안내 상자의 높이를 제한할 수 있다 - return LayoutBuilder(builder: (context, box) => _frame(0, [ + return LayoutBuilder(builder: (context, box) { + if (look.columns) { + pane.colHide = columnHideLevel(look, box.maxWidth, + marks: _selecting, textScale: MediaQuery.textScalerOf(context).scale(14) / 14 * _listTextScale); + } + return _frame(0, [ Container( height: 40, alignment: Alignment.centerLeft, @@ -5006,6 +5022,7 @@ class _ExplorerPageState extends State { ExplorerColumnsHeader( style: look, markColumn: _selecting, + hide: pane.colHide, sortBy: _sortOf(dir).$1, sortDesc: _sortOf(dir).$2, onSort: (by) => _sortByColumn(pane, by), @@ -5026,7 +5043,8 @@ class _ExplorerPageState extends State { )), ), if (_selecting) _marksBar(pane, listDir: dir), - ])); + ]); + }); } /// 76: 빠른 필터 열기 ([text] 가 있으면 그 글자로 시작) @@ -5613,21 +5631,22 @@ class _ExplorerPageState extends State { flex: flex, child: Text(t, style: dim, textAlign: align, maxLines: 1, overflow: TextOverflow.ellipsis), ); + // 관리자 지시 10/20: 좁으면 유형 → 날짜 열을 숨김 (크기는 남김, [columnHideLevel]) + final hide = pane.colHide; final cells = !look.columns || compact || !real ? [Expanded(child: nameCell())] : look == ExplorerStyle.windows ? [ Expanded(flex: 6, child: nameCell()), - cell(_date(e.modified), 3), - cell(typeLabel(e), 2), + if (hide < 2) cell(_date(e.modified), 3), + if (hide < 1) cell(typeLabel(e), 2), cell(e.isDir ? (_dirSize(pane, e) ?? '') : formatSize(e.size), 2, align: TextAlign.end), ] : [ Expanded(flex: 6, child: nameCell()), - cell(e.isDir ? '' : e.ext, 1), + if (hide < 1) cell(e.isDir ? '' : e.ext, 1), cell(e.isDir ? (_dirSize(pane, e) ?? '') : formatSize(e.size), 2, align: TextAlign.end), - const SizedBox(width: 8), - cell(_date(e.modified), 3), + if (hide < 2) ...[const SizedBox(width: 8), cell(_date(e.modified), 3)], ]; final paneIndex = _panes.indexOf(pane); diff --git a/lib/ui/reader_page.dart b/lib/ui/reader_page.dart index 55c9b1f..e61e346 100644 --- a/lib/ui/reader_page.dart +++ b/lib/ui/reader_page.dart @@ -609,7 +609,13 @@ class _ReaderPageState extends State { PdfSource? get _pdf => src is PdfSource ? src as PdfSource : null; + /// 28 지적 (10/20): 위 막대 단추를 누른 그 이벤트 안에서 창을 띄우면 (장을 손으로 넘긴 뒤) 창을 닫을 때 디버그 판에서 + /// 의미 트리 assertion ('node.built') 이 났다 - 창은 지금 장면이 끝난 뒤에 띄운다 (부른 쪽은 뒤에 mounted 확인) + Future _afterFrame() => WidgetsBinding.instance.endOfFrame; + Future _deletePage() async { + await _afterFrame(); + if (!mounted) return; final pdf = _pdf!; if (pdf.length <= 1) { _snack(tr('마지막 한 페이지는 지울 수 없습니다.')); @@ -634,6 +640,8 @@ class _ReaderPageState extends State { } Future _insertImages() async { + await _afterFrame(); + if (!mounted) return; final pdf = _pdf!; final page = _cur.page; final files = await c.services.storage.pickFiles(title: tr('PDF 에 넣을 그림 선택'), extensions: c.settings.imageExts); @@ -662,6 +670,8 @@ class _ReaderPageState extends State { Future _confirmLeave() async { final pdf = _pdf; if (pdf == null || !pdf.dirty) return true; + await _afterFrame(); + if (!mounted) return false; final r = await showDialog( context: context, builder: (ctx) => AlertDialog( @@ -882,10 +892,8 @@ class _ReaderPageState extends State { /// X-plore 맞추기 (관리자 지시 10/20): 지금 그림의 사진 정보 (찍은 날 · 기기 · 크기 · 위치). 위치는 [위치 보기] 를 누를 때만 /// 보이고, 지도 · 인터넷으로 보내지 않는다 (링크 없음) Future _showPhotoInfo() async { - // 28 지적 (10/20): 장을 손으로 몇 번 넘긴 뒤 위 막대 단추를 누른 그 이벤트 안에서 창을 띄우면, 창을 닫을 때 디버그 판에서 - // 의미 트리 assertion ('node.built', rendering/object.dart) 이 났다 (Windows 통합 시험에서 재현 - 넘기지 않거나, 시험에서 따로 띄운 - // 창은 괜찮음). 지금 장면이 끝난 뒤에 띄우면 나지 않는다 (재현 시험 4번 → 0번) - await WidgetsBinding.instance.endOfFrame; + // 지금 장면이 끝난 뒤에 띄운다 ([_afterFrame], Windows 통합 시험에서 재현 4번 → 0번) + await _afterFrame(); if (!mounted) return; final s = src; if (s is! ImageFilesSource || _v.isEmpty) return; diff --git a/test/explorer_text_scale_test.dart b/test/explorer_text_scale_test.dart index c9cceac..6e7b36d 100644 --- a/test/explorer_text_scale_test.dart +++ b/test/explorer_text_scale_test.dart @@ -9,6 +9,7 @@ import 'package:jj_mkvmaker/app/settings.dart'; import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.dart'; import 'package:jj_mkvmaker/platform/windows/process_media_tool.dart'; import 'package:jj_mkvmaker/services/platform_services.dart'; +import 'package:jj_mkvmaker/ui/explorer_look.dart'; import 'package:jj_mkvmaker/ui/explorer_page.dart'; import 'package:path/path.dart' as p; @@ -131,6 +132,43 @@ void main() { }); } + // 관리자 지시 10/20: 좁은 창의 열 - 이름 열이 120 보다 좁으면 유형 (TC 확장자) → 날짜 순으로 숨김, 크기는 남김 + test('숨길 열 정하기: 넓으면 0 · 좁아질수록 유형 → 날짜 · 열 없는 X-plore 는 0', () { + expect(columnHideLevel(ExplorerStyle.windows, 1280), 0); + expect(columnHideLevel(ExplorerStyle.windows, 300), 0, reason: '이름 열 (300-16)*6/13 = 131'); + expect(columnHideLevel(ExplorerStyle.windows, 300, marks: true), 1, reason: '✔ 칸 (36) 이 있으면 114'); + expect(columnHideLevel(ExplorerStyle.windows, 260), 1); + expect(columnHideLevel(ExplorerStyle.windows, 180), 2); + expect(columnHideLevel(ExplorerStyle.totalcmd, 1280), 0); + expect(columnHideLevel(ExplorerStyle.totalcmd, 180), 2); + expect(columnHideLevel(ExplorerStyle.xplore, 100), 0); + }); + + for (final style in ['windows', 'totalcmd']) { + testWidgets('좁은 한 창 ($style, 412 · 기기 글자 1.3): 이름 열이 좁으면 유형 → 날짜 열을 숨기고 크기는 남김 · 넓으면 다 보임', (tester) async { + tester.view.physicalSize = const Size(412, 900); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final type = style == 'windows' ? '유형' : '확장자'; + final date = style == 'windows' ? '수정한 날짜' : '날짜'; + final sub = style == 'totalcmd' ? '[sub]' : 'sub'; + final c = await open(tester, style, sub, device: 1.3, scale: 150); + await tester.pump(); + expect(c.settings.explorerLayout, 'split'); + expect(tester.takeException(), isNull); + expect(find.text(type), findsNothing, reason: '좁아서 유형 열 숨김'); + expect(find.text('크기'), findsOneWidget, reason: '크기는 남김'); + await close(tester); + tester.view.physicalSize = const Size(1600, 900); + await open(tester, style, sub); + await tester.pump(); + expect(find.text(type), findsOneWidget); + expect(find.text(date), findsOneWidget); + expect(find.text('크기'), findsOneWidget); + await close(tester); + }); + } + testWidgets('넓은 화면 (1280) 의 폴더 + 파일 목록은 그대로 (2 : 3, 트리에도 ✔)', (tester) async { tester.view.physicalSize = const Size(1280, 900); tester.view.devicePixelRatio = 1;