rc2: 탐색기 Windows · TC 날짜 열이 "2026-10-2…" 로 잘리던 것 - 모자라면 짧은 꼴, 그래도 모자라면 숨김 (28 지적, 관리자 지시 10/21)

- 날짜 열 폭 (열 비율 · 창 폭) 과 날짜 글 폭 (줄의 흐린 글 모양 · 지금 글꼴 · 기기 × 목록 글자 배율, 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 <noreply@anthropic.com>
rc2
RyuJongJin 2026-10-21 19:39:14 +09:00
parent d4266a7236
commit c65f4851f7
3 changed files with 189 additions and 13 deletions

View File

@ -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 = <String>[];
final widths = <double>[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<void>.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<void>.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)));
}

View File

@ -1,3 +1,5 @@
import 'dart:math';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../core/file_ops.dart'; import '../core/file_ops.dart';
@ -95,6 +97,41 @@ int columnHideLevel(ExplorerStyle style, double width, {bool marks = false, doub
return 2; 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}) { (IconData, Color) fileIcon(ExplorerStyle style, FileEntry e, {bool open = false}) {
final x = e.ext; final x = e.ext;

View File

@ -258,6 +258,9 @@ class _Pane extends ChangeNotifier {
/// 관리자 지시 10/20: 좁아서 숨긴 열 ([columnHideLevel]) - 열을 그리는 창 (목록) 의 폭으로 정하고 열 머리 · 줄이 같이 쓴다 /// 관리자 지시 10/20: 좁아서 숨긴 열 ([columnHideLevel]) - 열을 그리는 창 (목록) 의 폭으로 정하고 열 머리 · 줄이 같이 쓴다
int colHide = 0; int colHide = 0;
/// 관리자 지시 10/21: 날짜 열이 좁아 짧은 꼴 ([columnDateText] short) 로 쓰는지
bool colDateShort = false;
/// 지금 폴더. 75: 바뀔 때마다 뒤로 가기 내역에 (뒤로 · 앞으로로 움직일 때는 빼고) /// 지금 폴더. 75: 바뀔 때마다 뒤로 가기 내역에 (뒤로 · 앞으로로 움직일 때는 빼고)
String get current => _current; String get current => _current;
set current(String v) { set current(String v) {
@ -4710,8 +4713,14 @@ class _ExplorerPageState extends State<ExplorerPage> {
Widget _topBar() => AppTopBar( Widget _topBar() => AppTopBar(
nav: AppNavButtons(onExplorerPage: !widget.rsync, onRsyncPage: widget.rsync), nav: AppNavButtons(onExplorerPage: !widget.rsync, onRsyncPage: widget.rsync),
actions: AppActions(c: c), actions: AppActions(c: c),
middle: Row(children: [ // 아주 좁고 글자가 크면 (300 폭 · 1.3 × 150%) 제목도 줄임 - 넘쳐 그리기 오류가 나던 것 (날짜 열 312 조합 시험에서 찾음).
Text(widget.rsync ? 'Rsync' : tr('파일 탐색기'), style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)), // 보통은 제목 폭 그대로 (경로 막대에 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), const SizedBox(width: 12),
// X-plore 경로 막대 (4.4x): 경로를 누르면 위 폴더들 (창의 맨 위까지) 이 나와 골라 간다 // X-plore 경로 막대 (4.4x): 경로를 누르면 위 폴더들 (창의 맨 위까지) 이 나와 골라 간다
Expanded( Expanded(
@ -4765,7 +4774,7 @@ class _ExplorerPageState extends State<ExplorerPage> {
), ),
], ],
), ),
]), ])),
); );
/// 모양 · 배치가 바뀌면 (줄 높이 · 창 너비가 달라져) 보던 폴더가 밀려나므로 다시 그 폴더로 /// 모양 · 배치가 바뀌면 (줄 높이 · 창 너비가 달라져) 보던 폴더가 밀려나므로 다시 그 폴더로
@ -5122,6 +5131,20 @@ class _ExplorerPageState extends State<ExplorerPage> {
} }
/// 트리 창 (저장 장치 고르기 + 트리) /// 트리 창 (저장 장치 고르기 + 트리)
/// 관리자 지시 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}) { Widget _paneView(int i, {bool foldersOnly = false}) {
final pane = _panes[i]; final pane = _panes[i];
final rows = _rows(pane, foldersOnly: foldersOnly); final rows = _rows(pane, foldersOnly: foldersOnly);
@ -5129,10 +5152,7 @@ class _ExplorerPageState extends State<ExplorerPage> {
// 38: 창 높이를 알아야 안내 상자의 높이를 제한할 수 있다 // 38: 창 높이를 알아야 안내 상자의 높이를 제한할 수 있다
return LayoutBuilder(builder: (context, box) { return LayoutBuilder(builder: (context, box) {
// 열을 그리는 창만 숨길 열을 정한다 ("폴더 + 파일 목록" 의 트리는 열이 없고 목록과 같은 창을 쓴다) // 열을 그리는 창만 숨길 열을 정한다 ("폴더 + 파일 목록" 의 트리는 열이 없고 목록과 같은 창을 쓴다)
if (look.columns && !foldersOnly) { if (look.columns && !foldersOnly) _layoutColumns(context, pane, look, box.maxWidth);
pane.colHide = columnHideLevel(look, box.maxWidth,
marks: _selecting, textScale: MediaQuery.textScalerOf(context).scale(14) / 14 * _listTextScale);
}
return _frame(i, [ return _frame(i, [
_paneHeader(pane), _paneHeader(pane),
const Divider(height: 1), const Divider(height: 1),
@ -5199,10 +5219,7 @@ class _ExplorerPageState extends State<ExplorerPage> {
final look = _look; final look = _look;
// 38: 창 높이를 알아야 안내 상자의 높이를 제한할 수 있다 // 38: 창 높이를 알아야 안내 상자의 높이를 제한할 수 있다
return LayoutBuilder(builder: (context, box) { return LayoutBuilder(builder: (context, box) {
if (look.columns) { if (look.columns) _layoutColumns(context, pane, look, box.maxWidth);
pane.colHide = columnHideLevel(look, box.maxWidth,
marks: _selecting, textScale: MediaQuery.textScalerOf(context).scale(14) / 14 * _listTextScale);
}
return _frame(0, [ return _frame(0, [
Container( Container(
height: 40, height: 40,
@ -5872,7 +5889,7 @@ class _ExplorerPageState extends State<ExplorerPage> {
: look == ExplorerStyle.windows : look == ExplorerStyle.windows
? [ ? [
Expanded(flex: 6, child: nameCell()), 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), if (hide < 1) cell(typeLabel(e), 2),
cell(e.isDir ? (_dirSize(pane, e) ?? '') : formatSize(e.size), 2, align: TextAlign.end), cell(e.isDir ? (_dirSize(pane, e) ?? '') : formatSize(e.size), 2, align: TextAlign.end),
] ]
@ -5880,7 +5897,7 @@ class _ExplorerPageState extends State<ExplorerPage> {
Expanded(flex: 6, child: nameCell()), Expanded(flex: 6, child: nameCell()),
if (hide < 1) 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), cell(e.isDir ? (_dirSize(pane, e) ?? '<DIR>') : 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); final paneIndex = _panes.indexOf(pane);