WIP 좁은 화면 "폴더 + 파일 목록" 트리 창 (관리자 지시 10/20): 폭 < 600 이면 트리에 최소 폭 (130px 또는 버튼 줄을 뺀 폭의 42%) · 트리에는 ✔ 칸 · 크기 줄 없이 이름만 (선택은 오른쪽 목록), 줄에 들어가면 이름을 두 줄까지 · 작은 아이콘 칸 (28) · 화살표 36 · 들여쓰기 반 (폭의 1/10 까지) - 412 폭에서 트리 77px 로 이름이 한 글자도 안 보이던 것 · 넓은 화면은 그대로 (2 : 3, ✔) · 끌어서 폭 바꾸기는 지금 없음 · 시험 (412 · 360 × 3 스타일, Android 화살표 폭) · 캡처는 Android 플랫폼으로 (Windows 로 그리면 화살표가 20 이라 폰보다 넉넉해 보였다) (검토 대기)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
xplore_view
RyuJongJin 2026-10-20 23:39:31 +09:00
parent ba968cbed9
commit 9ff8343835
3 changed files with 83 additions and 13 deletions

View File

@ -1,6 +1,7 @@
import 'dart:io'; import 'dart:io';
import 'dart:ui' as ui; import 'dart:ui' as ui;
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart'; import 'package:flutter/rendering.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
@ -28,7 +29,9 @@ void main() {
final c = AppController(PlatformServices.create()); final c = AppController(PlatformServices.create());
c.settings.explorerPaths = [tmp.path, tmp.path]; c.settings.explorerPaths = [tmp.path, tmp.path];
final key = GlobalKey(); final key = GlobalKey();
for (final layout in ['single', 'split']) { // 폰처럼 (Android 는 트리 펼치기 화살표가 44 로 넓다 - Windows 로 그리면 폰보다 넉넉해 보인다)
debugDefaultTargetPlatformOverride = TargetPlatform.android;
for (final (layout, width) in [('single', 412.0), ('split', 412.0), ('split', 360.0)]) {
for (final style in ['xplore', 'windows', 'totalcmd']) { for (final style in ['xplore', 'windows', 'totalcmd']) {
for (final (scale, spacing) in [(100, 'normal'), (150, 'wide'), (80, 'compact')]) { for (final (scale, spacing) in [(100, 'normal'), (150, 'wide'), (80, 'compact')]) {
c.settings c.settings
@ -36,7 +39,7 @@ void main() {
..explorerStyle = style ..explorerStyle = style
..explorerTextScale = scale ..explorerTextScale = scale
..explorerRowSpacing = spacing; ..explorerRowSpacing = spacing;
tester.view.physicalSize = const Size(412, 900); tester.view.physicalSize = Size(width, 900);
tester.view.devicePixelRatio = 1; tester.view.devicePixelRatio = 1;
await tester.runAsync(() => tester.pumpWidget(MaterialApp( await tester.runAsync(() => tester.pumpWidget(MaterialApp(
theme: buildTheme(), theme: buildTheme(),
@ -46,16 +49,17 @@ void main() {
await tester.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 250))); await tester.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 250)));
await tester.pump(); await tester.pump();
} }
expect(tester.takeException(), isNull, reason: '$layout $style $scale% $spacing 넘치지 않음'); expect(tester.takeException(), isNull, reason: '$layout $width $style $scale% $spacing 넘치지 않음');
final boundary = key.currentContext!.findRenderObject()! as RenderRepaintBoundary; final boundary = key.currentContext!.findRenderObject()! as RenderRepaintBoundary;
final image = await tester.runAsync(() => boundary.toImage()); final image = await tester.runAsync(() => boundary.toImage());
final bytes = await tester.runAsync(() => image!.toByteData(format: ui.ImageByteFormat.png)); final bytes = await tester.runAsync(() => image!.toByteData(format: ui.ImageByteFormat.png));
File('$out/explorer_text_${layout}_${style}_${scale}_$spacing.png').writeAsBytesSync(bytes!.buffer.asUint8List()); File('$out/explorer_text_$layout${width.round()}_${style}_${scale}_$spacing.png').writeAsBytesSync(bytes!.buffer.asUint8List());
await tester.pumpWidget(const SizedBox()); await tester.pumpWidget(const SizedBox());
await tester.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 300))); await tester.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 300)));
} }
} }
} }
debugDefaultTargetPlatformOverride = null;
tester.view.reset(); tester.view.reset();
}); });
} }

View File

@ -1153,6 +1153,11 @@ class _ExplorerPageState extends State<ExplorerPage> {
/// 왼쪽 폴더 트리 + 오른쪽 파일 목록 배치 /// 왼쪽 폴더 트리 + 오른쪽 파일 목록 배치
bool get _split => !widget.rsync && c.settings.explorerLayout == 'split'; bool get _split => !widget.rsync && c.settings.explorerLayout == 'split';
/// 관리자 지시 10/20: 좁은 화면 (폭 < [narrowSplitWidth]) 의 "폴더 + 파일 목록" 좌우 배치 - 트리 창에 최소 폭을 두고
/// ✔ 칸 · 크기 줄을 숨긴다 (412 폭에서 트리가 77px 이라 폴더 이름이 한 글자도 안 보였다). [_body] 가 정한다
bool _narrowSplit = false;
static const narrowSplitWidth = 600.0;
/// 좌우 두 창 (Rsync 화면은 늘) /// 좌우 두 창 (Rsync 화면은 늘)
bool get _dual => bool get _dual =>
widget.rsync || widget.rsync ||
@ -4589,6 +4594,7 @@ class _ExplorerPageState extends State<ExplorerPage> {
// 191: Rsync 화면이 좁으면 (폴드 접은 화면 · 폰 세로의 좌우 두 창) 창 하나씩 - [왼쪽 | 오른쪽] 으로 바꿔 본다 // 191: Rsync 화면이 좁으면 (폴드 접은 화면 · 폰 세로의 좌우 두 창) 창 하나씩 - [왼쪽 | 오른쪽] 으로 바꿔 본다
// (두 창 + 가운데 버튼 줄이면 창이 140px 남짓이라 폴더 이름이 잘렸다) // (두 창 + 가운데 버튼 줄이면 창이 140px 남짓이라 폴더 이름이 잘렸다)
final rsyncOne = widget.rsync && side && box.maxWidth < rsyncTwoPaneMinWidth; final rsyncOne = widget.rsync && side && box.maxWidth < rsyncTwoPaneMinWidth;
_narrowSplit = split && side && box.maxWidth < narrowSplitWidth;
final bar = s.explorerToolbar == 'hidden' && !widget.rsync final bar = s.explorerToolbar == 'hidden' && !widget.rsync
? null ? null
: _Toolbar( : _Toolbar(
@ -4627,7 +4633,13 @@ class _ExplorerPageState extends State<ExplorerPage> {
Expanded(child: keep(1, _paneView(1, foldersOnly: widget.rsync))), Expanded(child: keep(1, _paneView(1, foldersOnly: widget.rsync))),
if (edge) bar, if (edge) bar,
] else if (split) ...[ ] else if (split) ...[
// 왼쪽 폴더 트리 (좁게) · 오른쪽 그 폴더의 파일 목록 // 왼쪽 폴더 트리 (좁게) · 오른쪽 그 폴더의 파일 목록. 좁은 화면은 트리에 최소 폭 (130px 또는 버튼 줄 (72) 을 뺀 폭의 42%) -
// 버튼 줄까지 넣어 38% 로 하면 360 폭에서 오른쪽 목록의 파일 이름이 두세 글자만 남았다
if (_narrowSplit)
SizedBox(
width: math.max(130.0, (box.maxWidth - (middle || edge ? 72 : 0)) * 0.42),
child: keep(0, _paneView(0, foldersOnly: true)))
else
Expanded(flex: 2, child: keep(0, _paneView(0, foldersOnly: true))), Expanded(flex: 2, child: keep(0, _paneView(0, foldersOnly: true))),
if (middle) bar, if (middle) bar,
Expanded(flex: 3, child: keep(2, _listView(_panes[0]))), Expanded(flex: 3, child: keep(2, _listView(_panes[0]))),
@ -5476,6 +5488,9 @@ class _ExplorerPageState extends State<ExplorerPage> {
Widget _rowView(_Pane pane, _Row row, {bool list = false, bool compact = false}) { Widget _rowView(_Pane pane, _Row row, {bool list = false, bool compact = false}) {
final e = row.entry; final e = row.entry;
final look = _look; final look = _look;
// 좁은 "폴더 + 파일 목록" 의 트리 (관리자 지시 10/20): ✔ 칸 · 크기 줄 없이 이름만, 줄에 두 줄이 들어가면 이름을 두 줄까지
final slim = compact && _narrowSplit && !list;
final nameLines = slim && _rowH() >= MediaQuery.textScalerOf(context).scale(look.fontSize) * _listTextScale * 2.5 + 6 ? 2 : 1;
final isCurrent = e.isDir && !list && !row.isUp && samePath(pane.current, e.path); final isCurrent = e.isDir && !list && !row.isUp && samePath(pane.current, e.path);
final isFocused = !row.isUp && pane.focused != null && samePath(pane.focused!, e.path); final isFocused = !row.isUp && pane.focused != null && samePath(pane.focused!, e.path);
final marked = _isMarked(pane, e.path); final marked = _isMarked(pane, e.path);
@ -5495,7 +5510,8 @@ class _ExplorerPageState extends State<ExplorerPage> {
color: isCurrent ? JjColors.accent : null, color: isCurrent ? JjColors.accent : null,
); );
final chevronWidth = treeChevronWidth(defaultTargetPlatform); // 좁은 트리 (slim) 는 조금 좁게 (36 - 줄 높이 전체를 누르므로 손가락으로도 눌림)
final chevronWidth = slim ? math.min(36.0, treeChevronWidth(defaultTargetPlatform)) : treeChevronWidth(defaultTargetPlatform);
// 트리의 펼치기 화살표 (따로 누름). 189: 접기 · 펼치기는 이것으로만 하므로 줄 높이 전체를 누를 수 있게, // 트리의 펼치기 화살표 (따로 누름). 189: 접기 · 펼치기는 이것으로만 하므로 줄 높이 전체를 누를 수 있게,
// 손가락으로 누르는 기기 (Android) 는 폭도 넓게 // 손가락으로 누르는 기기 (Android) 는 폭도 넓게
Widget chevron() => SizedBox( Widget chevron() => SizedBox(
@ -5525,11 +5541,17 @@ class _ExplorerPageState extends State<ExplorerPage> {
// 좁은 창 (폰 세로의 좌우 두 창) 에서 깊은 폴더: 들여쓰기는 이름 자리를 남기는 만큼만 (넘치지 않게) // 좁은 창 (폰 세로의 좌우 두 창) 에서 깊은 폴더: 들여쓰기는 이름 자리를 남기는 만큼만 (넘치지 않게)
Widget nameCell() => LayoutBuilder(builder: (context, box) { Widget nameCell() => LayoutBuilder(builder: (context, box) {
final fixed = (list ? 0 : chevronWidth) + (look.rich ? 64 : look.iconSize + 12) + 48.0; final fixed = (list ? 0 : chevronWidth) + (look.rich ? 64 : look.iconSize + 12) + 48.0;
final indent = list ? 0.0 : (row.depth * look.indent).clamp(0.0, (box.maxWidth - fixed).clamp(0.0, double.infinity)); // 좁은 트리 (slim): 들여쓰기는 반만 (폭의 1/10 까지) - 깊은 폴더에서 이름이 한 글자도 안 보이지 않게
final indent = list
? 0.0
: slim
? (row.depth * look.indent / 2).clamp(0.0, box.maxWidth * 0.1).toDouble()
: (row.depth * look.indent).clamp(0.0, (box.maxWidth - fixed).clamp(0.0, double.infinity)).toDouble();
// 아주 좁은 창 (412 폭 "폴더 + 파일 목록" 의 트리, 77px 쯤): 아이콘 칸 · 틈을 남은 폭만큼만 (아이콘은 줄여 그림) - 넘치지 않게 // 아주 좁은 창 (412 폭 "폴더 + 파일 목록" 의 트리, 77px 쯤): 아이콘 칸 · 틈을 남은 폭만큼만 (아이콘은 줄여 그림) - 넘치지 않게
final room = (box.maxWidth - indent - (list ? 0 : chevronWidth)).clamp(0.0, double.infinity); final room = (box.maxWidth - indent - (list ? 0 : chevronWidth)).clamp(0.0, double.infinity);
final iconW = math.min(look.rich ? 56.0 : look.iconSize + 6, room); // 좁은 트리 (slim) 는 큰 썸네일 칸 (56) 대신 작은 아이콘 칸 (28 까지, 아이콘은 줄여 그림)
final gapW = math.min(look.rich ? 8.0 : 6.0, room - iconW); final iconW = math.min(slim ? math.min(look.iconSize, 24.0) + 4 : look.rich ? 56.0 : look.iconSize + 6, room);
final gapW = math.min(slim ? 4.0 : look.rich ? 8.0 : 6.0, room - iconW);
return Row(children: [ return Row(children: [
SizedBox(width: indent), SizedBox(width: indent),
if (!list) chevron(), if (!list) chevron(),
@ -5557,8 +5579,10 @@ class _ExplorerPageState extends State<ExplorerPage> {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Text(name, maxLines: 1, overflow: TextOverflow.ellipsis, style: nameStyle), Text(name, maxLines: nameLines, overflow: TextOverflow.ellipsis, style: nameStyle),
if (look.rich && e.isDir && pane.errors[e.path] != null) if (slim)
const SizedBox.shrink()
else if (look.rich && e.isDir && pane.errors[e.path] != null)
Text(tr('읽을 수 없음'), style: dim.copyWith(color: Colors.redAccent)) Text(tr('읽을 수 없음'), style: dim.copyWith(color: Colors.redAccent))
// 80: 열 없는 모양 (X-plore · 좁은 화면) 은 폴더 이름 아래에 크기 // 80: 열 없는 모양 (X-plore · 좁은 화면) 은 폴더 이름 아래에 크기
else if (e.isDir && !row.isRoot && !row.isUp && (!look.columns || compact) && _dirSize(pane, e) != null) else if (e.isDir && !row.isRoot && !row.isUp && (!look.columns || compact) && _dirSize(pane, e) != null)
@ -5630,7 +5654,9 @@ class _ExplorerPageState extends State<ExplorerPage> {
), ),
), ),
// 표시 동그라미는 두 번 누르기 영역 밖 (기다리지 않고 바로) // 표시 동그라미는 두 번 누르기 영역 밖 (기다리지 않고 바로)
if ((real || (widget.rsync && row.isRoot)) && (_selecting || widget.rsync || _alwaysMarks)) if (slim)
const SizedBox.shrink()
else if ((real || (widget.rsync && row.isRoot)) && (_selecting || widget.rsync || _alwaysMarks))
SizedBox( SizedBox(
width: 36, width: 36,
child: IconButton( child: IconButton(

View File

@ -75,6 +75,46 @@ void main() {
await tester.pump(const Duration(seconds: 1)); await tester.pump(const Duration(seconds: 1));
} }
// 관리자 지시 10/20: 좁은 화면의 "폴더 + 파일 목록" - 트리 창 최소 폭 (120px 또는 38%), 트리에는 ✔ 칸 · 크기 줄 없이 이름이 보임
for (final width in [412.0, 360.0]) {
for (final style in ['xplore', 'windows', 'totalcmd']) {
testWidgets('좁은 폴더 + 파일 목록 ($width 폭, $style): 트리 창이 넓어 폴더 이름이 보이고 ✔ 칸은 오른쪽 목록에만', (tester) async {
tester.view.physicalSize = Size(width, 900);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await open(tester, style, 'sub');
expect(tester.takeException(), isNull);
// 트리 (먼저 그려짐) 의 'sub' 이름이 다 보인다
final tree = find.text(style == 'totalcmd' ? '[sub]' : 'sub').first;
final para = tester.renderObject<RenderParagraph>(tree);
// 시험 글꼴 (Ahem) 은 글자마다 글자 크기만큼 넓어 실제보다 훨씬 길다 - 이름 칸 폭으로 본다 (실제 글꼴은 캡처 시험)
expect(para.constraints.maxWidth, greaterThanOrEqualTo(20), reason: '트리의 폴더 이름 칸이 남는다 (예전 412 폭은 0, 실제 글꼴로 360 폭에서 한 줄 5글자쯤)');
final treeRight = tester.getTopRight(tree).dx;
final listLeft = tester.getTopLeft(find.text(style == 'totalcmd' ? 'a' : 'a.txt')).dx;
expect(listLeft, greaterThan(treeRight), reason: '트리는 왼쪽, 목록은 오른쪽');
// 트리 줄에는 ✔ 단추가 없다 (오른쪽 목록 줄에만) - X-plore 는 늘 ✔
if (style == 'xplore') {
final marks = find.byTooltip('표시 (여러 개 고르기)');
expect(marks, findsWidgets);
for (final m in marks.evaluate()) {
expect(tester.getTopLeft(find.byWidget(m.widget)).dx, greaterThan(treeRight), reason: '✔ 는 오른쪽 목록에만');
}
}
await close(tester);
});
}
}
testWidgets('넓은 화면 (1280) 의 폴더 + 파일 목록은 그대로 (2 : 3, 트리에도 ✔)', (tester) async {
tester.view.physicalSize = const Size(1280, 900);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await open(tester, 'xplore', 'sub');
final treeMarks = find.byTooltip('표시 (여러 개 고르기)').evaluate().where((m) => tester.getTopLeft(find.byWidget(m.widget)).dx < 500);
expect(treeMarks, isNotEmpty, reason: '넓은 화면은 지금 그대로');
await close(tester);
});
for (final style in ['xplore', 'windows', 'totalcmd']) { for (final style in ['xplore', 'windows', 'totalcmd']) {
final a = style == 'totalcmd' ? 'a' : 'a.txt'; final a = style == 'totalcmd' ? 'a' : 'a.txt';
testWidgets('40: $style - 처음 값 (100% · 보통) 은 지금 그대로 · 150% · 넓게는 글자 1.5배 · 줄 높이 늘어남 · 412 폭에서 넘치지 않음', (tester) async { testWidgets('40: $style - 처음 값 (100% · 보통) 은 지금 그대로 · 150% · 넓게는 글자 1.5배 · 줄 높이 늘어남 · 412 폭에서 넘치지 않음', (tester) async {