WIP 좁은 창 열 숨기기 · 보기 화면 창 띄우기 (관리자 지시 10/20): ① Windows · TC 스타일에서 이름 열이 120 (× 글자 배율) 보다 좁으면 유형 (TC 확장자) → 날짜 순으로 숨김, 크기는 남김 - 화면이 모자랄 때만, 넓어지면 다시 보임, 열 설정은 그대로 (열 머리 · 줄이 같은 창 폭으로 정함) · 글자 배율을 곱한 것은 d3 판단 (1.95 배 글자의 120px 은 네 글자쯤) ② 보기 화면 PDF 창 (페이지 지우기 · 그림 넣기) 과 저장 안 한 변경 창도 지금 장면이 끝난 뒤 띄움 ('node.built' 와 같은 모양 - 미리 막음, 저장 안 한 변경 창은 d3 판단) · 시험 (검토 대기)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
xplore_view
RyuJongJin 2026-10-21 01:45:26 +09:00
parent cb50d28486
commit 77710c2a5f
4 changed files with 103 additions and 19 deletions

View File

@ -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')],
],
),
);

View File

@ -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<ExplorerPage> {
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<ExplorerPage> {
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<ExplorerPage> {
]),
),
if ((_selecting || (_alwaysMarks && pane.marked.isNotEmpty)) && !foldersOnly) _marksBar(pane),
]));
]);
});
}
/// 76: 목록 줄의 키 = 항목 경로. 키가 없으면 줄 목록이 바뀔 때 (빠른 필터 · 펼치기 · 새로 고침) 같은 자리의 줄 위젯을
@ -4992,7 +5003,12 @@ class _ExplorerPageState extends State<ExplorerPage> {
];
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<ExplorerPage> {
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<ExplorerPage> {
)),
),
if (_selecting) _marksBar(pane, listDir: dir),
]));
]);
});
}
/// 76: 빠른 필터 열기 ([text] 가 있으면 그 글자로 시작)
@ -5613,21 +5631,22 @@ class _ExplorerPageState extends State<ExplorerPage> {
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
? <Widget>[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) ?? '<DIR>') : 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);

View File

@ -609,7 +609,13 @@ class _ReaderPageState extends State<ReaderPage> {
PdfSource? get _pdf => src is PdfSource ? src as PdfSource : null;
/// 28 지적 (10/20): 위 막대 단추를 누른 그 이벤트 안에서 창을 띄우면 (장을 손으로 넘긴 뒤) 창을 닫을 때 디버그 판에서
/// 의미 트리 assertion ('node.built') 이 났다 - 창은 지금 장면이 끝난 뒤에 띄운다 (부른 쪽은 뒤에 mounted 확인)
Future<void> _afterFrame() => WidgetsBinding.instance.endOfFrame;
Future<void> _deletePage() async {
await _afterFrame();
if (!mounted) return;
final pdf = _pdf!;
if (pdf.length <= 1) {
_snack(tr('마지막 한 페이지는 지울 수 없습니다.'));
@ -634,6 +640,8 @@ class _ReaderPageState extends State<ReaderPage> {
}
Future<void> _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<ReaderPage> {
Future<bool> _confirmLeave() async {
final pdf = _pdf;
if (pdf == null || !pdf.dirty) return true;
await _afterFrame();
if (!mounted) return false;
final r = await showDialog<String>(
context: context,
builder: (ctx) => AlertDialog(
@ -882,10 +892,8 @@ class _ReaderPageState extends State<ReaderPage> {
/// X-plore 맞추기 (관리자 지시 10/20): 지금 그림의 사진 정보 (찍은 날 · 기기 · 크기 · 위치). 위치는 [위치 보기] 를 누를 때만
/// 보이고, 지도 · 인터넷으로 보내지 않는다 (링크 없음)
Future<void> _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;

View File

@ -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;