From c65f4851f7d8605a9512abc9f21967bf56584aa6 Mon Sep 17 00:00:00 2001 From: RyuJongJin <266630912+RyuJongJin@users.noreply.github.com> Date: Wed, 21 Oct 2026 19:39:14 +0900 Subject: [PATCH] =?UTF-8?q?rc2:=20=ED=83=90=EC=83=89=EA=B8=B0=20Windows=20?= =?UTF-8?q?=C2=B7=20TC=20=EB=82=A0=EC=A7=9C=20=EC=97=B4=EC=9D=B4=20"2026-1?= =?UTF-8?q?0-2=E2=80=A6"=20=EB=A1=9C=20=EC=9E=98=EB=A6=AC=EB=8D=98=20?= =?UTF-8?q?=EA=B2=83=20-=20=EB=AA=A8=EC=9E=90=EB=9D=BC=EB=A9=B4=20?= =?UTF-8?q?=EC=A7=A7=EC=9D=80=20=EA=BC=B4,=20=EA=B7=B8=EB=9E=98=EB=8F=84?= =?UTF-8?q?=20=EB=AA=A8=EC=9E=90=EB=9D=BC=EB=A9=B4=20=EC=88=A8=EA=B9=80=20?= =?UTF-8?q?(28=20=EC=A7=80=EC=A0=81,=20=EA=B4=80=EB=A6=AC=EC=9E=90=20?= =?UTF-8?q?=EC=A7=80=EC=8B=9C=2010/21)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 날짜 열 폭 (열 비율 · 창 폭) 과 날짜 글 폭 (줄의 흐린 글 모양 · 지금 글꼴 · 기기 × 목록 글자 배율, TextPainter) 을 견줘: 다 들어가면 "2026-10-21 14:05" (예전 그대로) → 아니면 짧은 꼴 (올해 "10-21 14:05" · 아니면 "2025-10-21") → 아니면 유형을 먼저 숨기고 (아직이면) 다시 봄 → 그래도 모자라면 날짜 숨김. 열 머리 · 줄이 같은 결정을 씀 (두 창 · 폴더 + 파일 목록 모두) - 일본어는 / ("2026/10/21 14:05" · "10/21 14:05"), 한국어 · 중국어 · 영어는 예전 꼴 그대로 - 함께 찾은 것: 300 폭 · 글자 1.3 × 150% 에서 위 막대 제목 ("파일 탐색기") 이 넘쳐 그리기 오류 → 경로 막대에 80 남짓 남을 때까지는 제목 폭 그대로, 그보다 좁으면 제목을 "…" 로 (보통 화면은 바뀌지 않음) - 시험 integration_test/windows_datecol_shot_test.dart: 실제 글꼴로 312 조합 (폭 13 × 스타일 2 × 언어 ko · ja · zh × 글자 배율 4) - 날짜 칸 잘림 0 (RenderParagraph.didExceedMaxLines), 넓은 창은 다 쓴 꼴, 짧은 꼴 칸 162 · 날짜 숨김 84 조합, 360 · 412 는 PNG. 목록이 빈 채로 남는 일이 가끔 있어 (빙글이 없이, 3번 중 2번 실행에서 1~2 조합) 그림을 남기고 [새로 고침] 뒤 확인 - 따로 알림 Co-Authored-By: Claude Opus 5.5 --- .../windows_datecol_shot_test.dart | 122 ++++++++++++++++++ lib/ui/explorer_look.dart | 37 ++++++ lib/ui/explorer_page.dart | 43 ++++-- 3 files changed, 189 insertions(+), 13 deletions(-) create mode 100644 integration_test/windows_datecol_shot_test.dart diff --git a/integration_test/windows_datecol_shot_test.dart b/integration_test/windows_datecol_shot_test.dart new file mode 100644 index 0000000..4f74489 --- /dev/null +++ b/integration_test/windows_datecol_shot_test.dart @@ -0,0 +1,122 @@ +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/app_controller.dart'; +import 'package:jj_mkvmaker/app/i18n_controller.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_page.dart'; +import 'package:jj_mkvmaker/ui/theme.dart'; +import 'package:path/path.dart' as p; + +import 'support/temp_dir.dart'; + +/// 관리자 지시 (10/21, 28 지적): Windows · TC 스타일의 날짜 열이 시간까지 들어가지 않으면 "2026-10-2…" 로 잘리던 것 - +/// 모자라면 짧은 꼴 (올해 "10-21 14:05" · 아니면 "2025-10-21"), 그래도 모자라면 숨김. 실제 글꼴로 312 조합 +/// (폭 13 × 스타일 2 × 언어 3 × 글자 배율 4) 에서 날짜 칸이 잘리지 않는지 (RenderParagraph.didExceedMaxLines) 와 +/// 날짜를 보일 수 있는 넓은 창에서는 다 쓴 꼴이 나오는지 본다. 좁은 몇 개는 PNG (JJ_SHOT_DIR). +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + if (Platform.environment['JJ_SHOT_THEME'] == 'light') JjColors.use(Brightness.light); + final out = Platform.environment['JJ_SHOT_DIR'] ?? p.join(Directory.systemTemp.path, 'jj_shots_datecol'); + final dateLike = RegExp(r'^(\d{4}[-/])?\d{2}[-/]\d{2}( \d{2}:\d{2})?$'); + + testWidgets('날짜 열: 312 조합에서 잘림 0', (t) async { + final tmp = testTempDir('jj_datecol_'); + final dir = Directory(p.join(tmp.path, 'files'))..createSync(); + final now = DateTime.now(); + for (final (n, at) in [ + ('올해 사진.jpg', DateTime(now.year, 12, 28, 23, 58).isAfter(now) ? now : DateTime(now.year, 12, 28, 23, 58)), + ('작년 문서.pdf', DateTime(now.year - 1, 12, 28, 23, 58)), + ]) { + File(p.join(dir.path, n)) + ..writeAsStringSync('x') + ..setLastModifiedSync(at); + } + ExplorerPage.debugVolumes = () => [(dir.path, 'T:')]; + addTearDown(() => ExplorerPage.debugVolumes = null); + final key = GlobalKey(); + var checked = 0, short = 0, hidden = 0; + final emptyOnce = []; + final widths = [300, 340, 360, 390, 412, 460, 520, 600, 700, 800, 960, 1280, 1600]; + final scales = [(1.0, 80), (1.0, 100), (1.0, 150), (1.3, 150)]; + for (final lang in ['ko', 'ja', 'zh-Hans']) { + await t.runAsync(() => i18n.apply(lang, save: false)); + for (final style in ['windows', 'totalcmd']) { + for (final (dev, list) in scales) { + for (final w in widths) { + final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); + c.settings + ..explorerStyle = style + ..explorerLayout = 'single' + ..explorerTextScale = list + ..explorerPaths = [dir.path, dir.path]; + t.view.physicalSize = Size(w, 800); + t.view.devicePixelRatio = 1; + await t.runAsync(() => t.pumpWidget(RepaintBoundary( + key: key, + child: MaterialApp( + theme: buildTheme(), + debugShowCheckedModeBanner: false, + builder: (ctx, child) => MediaQuery(data: MediaQuery.of(ctx).copyWith(textScaler: TextScaler.linear(dev)), child: child!), + home: ExplorerPage(key: UniqueKey(), c: c), + ), + ))); + for (var i = 0; i < 200 && find.textContaining('작년 문서').evaluate().isEmpty; i++) { + await t.runAsync(() => Future.delayed(const Duration(milliseconds: 50))); + await t.pump(); + } + await t.pump(const Duration(milliseconds: 100)); + final name = '${lang}_${style}_${w.round()}_d${dev}_l$list'; + if (find.textContaining('작년 문서').evaluate().isEmpty) { + // 목록이 빈 채로 남는 일이 가끔 있다 (빙글이 없이 - 3a 에 알림). 그림을 남기고 [새로 고침] 으로 한 번 더 + emptyOnce.add(name); + final r = key.currentContext!.findRenderObject()! as RenderRepaintBoundary; + final img = await t.runAsync(() => r.toImage()); + final bytes = await t.runAsync(() => img!.toByteData(format: ui.ImageByteFormat.png)); + Directory(out).createSync(recursive: true); + File(p.join(out, 'datecol_EMPTY_$name.png')).writeAsBytesSync(bytes!.buffer.asUint8List()); + await t.tap(find.byIcon(Icons.refresh).first); + for (var i = 0; i < 200 && find.textContaining('작년 문서').evaluate().isEmpty; i++) { + await t.runAsync(() => Future.delayed(const Duration(milliseconds: 50))); + await t.pump(); + } + await t.pump(const Duration(milliseconds: 100)); + } + expect(find.textContaining('작년 문서'), findsWidgets, reason: '$name 목록'); + final dates = find.byWidgetPredicate((x) => x is Text && x.data != null && dateLike.hasMatch(x.data!)); + for (final e in dates.evaluate()) { + final para = e.findRenderObject()! as RenderParagraph; + expect(para.didExceedMaxLines, isFalse, reason: '$name: "${(e.widget as Text).data}" 잘림'); + if (!(e.widget as Text).data!.contains(':') || !(e.widget as Text).data!.startsWith('20')) short++; + } + if (dates.evaluate().isEmpty) hidden++; + // 넓은 창 (1280 이상) 에서는 다 쓴 꼴 (연도 + 시각) + if (w >= 1280 && dev == 1.0 && list <= 100) { + expect(dates.evaluate().map((e) => (e.widget as Text).data!).every((x) => x.length == 16), isTrue, reason: '$name 다 쓴 꼴'); + } + if (w == 360 || w == 412) { + final r = key.currentContext!.findRenderObject()! as RenderRepaintBoundary; + final img = await t.runAsync(() => r.toImage()); + final bytes = await t.runAsync(() => img!.toByteData(format: ui.ImageByteFormat.png)); + Directory(out).createSync(recursive: true); + File(p.join(out, 'datecol_$name.png')).writeAsBytesSync(bytes!.buffer.asUint8List()); + } + checked++; + await t.pumpWidget(const SizedBox()); + } + } + } + } + await t.runAsync(() => i18n.apply('ko', save: false)); + t.view.reset(); + expect(checked, 312); + // ignore: avoid_print + print('날짜 열 조합 $checked · 짧은 꼴 칸 $short · 날짜 숨긴 조합 $hidden · 목록이 빈 채였던 조합 $emptyOnce'); + }, timeout: const Timeout(Duration(minutes: 30))); +} diff --git a/lib/ui/explorer_look.dart b/lib/ui/explorer_look.dart index 2a685c0..02b9905 100644 --- a/lib/ui/explorer_look.dart +++ b/lib/ui/explorer_look.dart @@ -1,3 +1,5 @@ +import 'dart:math'; + import 'package:flutter/material.dart'; import '../core/file_ops.dart'; @@ -95,6 +97,41 @@ int columnHideLevel(ExplorerStyle style, double width, {bool marks = false, doub return 2; } +/// 날짜 열의 글 (관리자 지시 10/21): 다 쓰면 "2026-10-21 14:05", [short] 면 올해는 "10-21 14:05" · 아니면 "2025-10-21". +/// 일본어는 / ("2026/10/21") - 한국어 · 중국어 · 영어는 - (예전 그대로) +String columnDateText(DateTime d, {bool short = false, DateTime? now, String? lang}) { + if (d.year <= 1970) return '—'; + final s = (lang ?? uiLanguage) == 'ja' ? '/' : '-'; + String two(int x) => x.toString().padLeft(2, '0'); + final md = '${two(d.month)}$s${two(d.day)}'; + final hm = '${two(d.hour)}:${two(d.minute)}'; + if (!short) return '${d.year}$s$md $hm'; + return d.year == (now ?? DateTime.now()).year ? '$md $hm' : '${d.year}$s$md'; +} + +/// 좁은 창의 열 (관리자 지시 10/20 · 10/21): (숨김 단계 [columnHideLevel], 날짜를 짧게 쓸지). +/// 날짜 열에 다 들어가지 않으면 먼저 짧은 꼴로, 그래도 모자라면 유형을 숨겨 (아직이면) 다시 보고, 그래도 모자라면 날짜를 숨긴다. +/// [measure] 지금 글꼴 · 글자 배율로 그 글의 폭 (목록의 흐린 글 모양) +(int, bool) columnLayout(ExplorerStyle style, double width, + {bool marks = false, double textScale = 1, required double Function(String text) measure, String? lang}) { + if (!style.columns) return (0, false); + var level = columnHideLevel(style, width, marks: marks, textScale: textScale); + final inner = width - 16 - (marks ? 36 : 0); + final extra = style == ExplorerStyle.totalcmd ? 8.0 : 0.0; + // 날짜 열 몫: Windows 이름 6 · 날짜 3 · 유형 2 · 크기 2 (유형 숨기면 11), TC 이름 6 · 확장자 1 · 크기 2 · 날짜 3 (확장자 숨기면 11) + final sums = style == ExplorerStyle.windows ? [13, 11] : [12, 11]; + final sample = DateTime(2026, 12, 28, 23, 58); + final full = measure(columnDateText(sample, lang: lang)); + final short = max(measure(columnDateText(sample, short: true, now: DateTime(2026), lang: lang)), + measure(columnDateText(DateTime(2025, 12, 28), short: true, now: DateTime(2026), lang: lang))); + for (; level < 2; level++) { + final w = (inner - extra) * 3 / sums[level] - 2; + if (full <= w) return (level, false); + if (short <= w) return (level, true); + } + return (2, false); +} + /// 파일 종류별 아이콘 · 색 (스타일마다 다르게) (IconData, Color) fileIcon(ExplorerStyle style, FileEntry e, {bool open = false}) { final x = e.ext; diff --git a/lib/ui/explorer_page.dart b/lib/ui/explorer_page.dart index a1952bc..cd7159e 100644 --- a/lib/ui/explorer_page.dart +++ b/lib/ui/explorer_page.dart @@ -258,6 +258,9 @@ class _Pane extends ChangeNotifier { /// 관리자 지시 10/20: 좁아서 숨긴 열 ([columnHideLevel]) - 열을 그리는 창 (목록) 의 폭으로 정하고 열 머리 · 줄이 같이 쓴다 int colHide = 0; + /// 관리자 지시 10/21: 날짜 열이 좁아 짧은 꼴 ([columnDateText] short) 로 쓰는지 + bool colDateShort = false; + /// 지금 폴더. 75: 바뀔 때마다 뒤로 가기 내역에 (뒤로 · 앞으로로 움직일 때는 빼고) String get current => _current; set current(String v) { @@ -4710,8 +4713,14 @@ class _ExplorerPageState extends State { 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)), + // 아주 좁고 글자가 크면 (300 폭 · 1.3 × 150%) 제목도 줄임 - 넘쳐 그리기 오류가 나던 것 (날짜 열 312 조합 시험에서 찾음). + // 보통은 제목 폭 그대로 (경로 막대에 80 남짓 남을 때까지) + middle: LayoutBuilder(builder: (context, box) => Row(children: [ + ConstrainedBox( + constraints: BoxConstraints(maxWidth: math.max(40, box.maxWidth - 92)), + child: Text(widget.rsync ? 'Rsync' : tr('파일 탐색기'), + maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)), + ), const SizedBox(width: 12), // X-plore 경로 막대 (4.4x): 경로를 누르면 위 폴더들 (창의 맨 위까지) 이 나와 골라 간다 Expanded( @@ -4765,7 +4774,7 @@ class _ExplorerPageState extends State { ), ], ), - ]), + ])), ); /// 모양 · 배치가 바뀌면 (줄 높이 · 창 너비가 달라져) 보던 폴더가 밀려나므로 다시 그 폴더로 @@ -5122,6 +5131,20 @@ class _ExplorerPageState extends State { } /// 트리 창 (저장 장치 고르기 + 트리) + /// 관리자 지시 10/20 · 10/21: 좁은 창의 열 - 숨길 열 · 날짜를 짧게 쓸지. 줄의 흐린 글과 같은 모양으로 날짜 글 폭을 잰다 + /// (날짜가 "2026-10-2…" 로 잘리지 않게). 열 머리 · 줄이 같이 쓴다 + void _layoutColumns(BuildContext context, _Pane pane, ExplorerStyle look, double width) { + final scale = MediaQuery.textScalerOf(context).scale(14) / 14 * _listTextScale; + final style = DefaultTextStyle.of(context).style.merge(TextStyle(fontSize: look.fontSize - 2)); + double measure(String t) => + (TextPainter(text: TextSpan(text: t, style: style), textDirection: TextDirection.ltr, textScaler: TextScaler.linear(scale), maxLines: 1) + ..layout()) + .width; + final lay = columnLayout(look, width, marks: _selecting, textScale: scale, measure: measure); + pane.colHide = lay.$1; + pane.colDateShort = lay.$2; + } + Widget _paneView(int i, {bool foldersOnly = false}) { final pane = _panes[i]; final rows = _rows(pane, foldersOnly: foldersOnly); @@ -5129,10 +5152,7 @@ class _ExplorerPageState extends State { // 38: 창 높이를 알아야 안내 상자의 높이를 제한할 수 있다 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); - } + if (look.columns && !foldersOnly) _layoutColumns(context, pane, look, box.maxWidth); return _frame(i, [ _paneHeader(pane), const Divider(height: 1), @@ -5199,10 +5219,7 @@ class _ExplorerPageState extends State { final look = _look; // 38: 창 높이를 알아야 안내 상자의 높이를 제한할 수 있다 return LayoutBuilder(builder: (context, box) { - if (look.columns) { - pane.colHide = columnHideLevel(look, box.maxWidth, - marks: _selecting, textScale: MediaQuery.textScalerOf(context).scale(14) / 14 * _listTextScale); - } + if (look.columns) _layoutColumns(context, pane, look, box.maxWidth); return _frame(0, [ Container( height: 40, @@ -5872,7 +5889,7 @@ class _ExplorerPageState extends State { : look == ExplorerStyle.windows ? [ Expanded(flex: 6, child: nameCell()), - if (hide < 2) cell(_date(e.modified), 3), + if (hide < 2) cell(columnDateText(e.modified, short: pane.colDateShort), 3), if (hide < 1) cell(typeLabel(e), 2), cell(e.isDir ? (_dirSize(pane, e) ?? '') : formatSize(e.size), 2, align: TextAlign.end), ] @@ -5880,7 +5897,7 @@ class _ExplorerPageState extends State { Expanded(flex: 6, child: nameCell()), if (hide < 1) cell(e.isDir ? '' : e.ext, 1), cell(e.isDir ? (_dirSize(pane, e) ?? '') : formatSize(e.size), 2, align: TextAlign.end), - if (hide < 2) ...[const SizedBox(width: 8), cell(_date(e.modified), 3)], + if (hide < 2) ...[const SizedBox(width: 8), cell(columnDateText(e.modified, short: pane.colDateShort), 3)], ]; final paneIndex = _panes.indexOf(pane);