E6 2 · 3: Total Commander 모양 (창마다 목록 · '..' · 아래 F3~F8 · 남은 공간) · 좁은 창의 줄 모양 (공용 줄 부품)

- TC 모양: 트리 대신 지금 폴더의 목록 (paneListRows - '폴더 + 파일 목록' 의 목록과 같은 함수, 목록의 스크롤).
  사용자가 고른 '폴더 + 파일 목록' 배치 · Rsync 화면은 그대로 트리
- tc_key_bar: F3 보기 · F4 편집 · F5 복사 · F6 이동 · F7 새 폴더 · F8 삭제 - 이미 있는 동작을 부름
- tc_drive_space: 창 위 남은 공간 / 전체 - ExplorerDevice.diskSpace 로 받음 (시험은 TestExplorerDevice.space)
- 좁은 창 (창 폭 200 아래, narrowRowWidth): 아이콘 칸 줄임 · 끌기 손잡이 숨김 · 열 대신 이름 먼저 (크기 · 날짜는
  두 줄이 들어가는 줄 높이에서 이름 아래 - TC 26 은 이름만) · 열 머리도 이름만. X-plore · TC 가 같은 ExplorerRowView
- 약속 시험 explorer_tc_look_test 4개. 점검 시험은 X-plore · TC 두 모양을 찍는다 (폴더 시각 · 남은 공간 고정)
- 확인: 단위 1456 ×2 · 통합 6/6 ×2 · 캡처 180장 2회 정확히 같음 (base8) · X-plore 1920 · 960 은 base7 과 같음

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
modular
RyuJongJin 2026-10-23 07:48:42 +09:00
parent 20f9d083b2
commit ae3365bff1
16 changed files with 345 additions and 32 deletions

View File

@ -453,6 +453,7 @@
"[N] 원래 이름 · [C] 번호 예: Show S01E[C]": "[N] original name · [C] counter e.g. Show S01E[C]",
"[N] 원래 이름 · [C] 번호 · 확장자는 그대로 예: Show S01E[C]": "[N] original name · [C] counter · extension kept e.g. Show S01E[C]",
"[N] 원래 이름 · [N2-5] 그 2~5 번째 글자 · [C] 번호 · [D] 날짜 · [T] 시각 · ♪ 음악 태그 · 확장자는 그대로 예: Show S01E[C]": "[N] original name · [N2-5] its characters 2–5 · [C] counter · [D] date · [T] time · ♪ music tags · extension kept e.g. Show S01E[C]",
"[{0}] 남은 {1} / 전체 {2}": "[{0}] {1} free / {2} total",
"[서버 설정 고치기] 에서 비밀번호를 넣으면 이 기기의 안전 저장소에 남아 다음부터는 다시 넣지 않아도 됩니다.": "Enter the password in [Edit server settings]. It is kept in this device's secure storage, so you won't need to enter it again.",
"[서버 설정 고치기] 에서 아이디 · 비밀번호를 확인하세요.": "Check the user name and password in [Edit server settings].",
"[시험 길] GitHub 대신 이 PC 의 zip 으로 업데이트합니다 (JJ_UPDATE_LOCAL_ZIP). 시험용 데이터 폴더로 켰을 때만 쓰입니다.": "[Test path] Updates from a zip on this PC instead of GitHub (JJ_UPDATE_LOCAL_ZIP). Used only when started with a test data folder.",

View File

@ -453,6 +453,7 @@
"[N] 원래 이름 · [C] 번호 예: Show S01E[C]": "[N] 元の名前 · [C] 番号 例: Show S01E[C]",
"[N] 원래 이름 · [C] 번호 · 확장자는 그대로 예: Show S01E[C]": "[N] 元の名前 · [C] 番号 · 拡張子はそのまま 例: Show S01E[C]",
"[N] 원래 이름 · [N2-5] 그 2~5 번째 글자 · [C] 번호 · [D] 날짜 · [T] 시각 · ♪ 음악 태그 · 확장자는 그대로 예: Show S01E[C]": "[N] 元の名前・[N2-5] その 2~5 文字目・[C] 番号・[D] 日付・[T] 時刻・♪ 音楽タグ・拡張子はそのまま 例: Show S01E[C]",
"[{0}] 남은 {1} / 전체 {2}": "[{0}] 空き {1} / 全体 {2}",
"[서버 설정 고치기] 에서 비밀번호를 넣으면 이 기기의 안전 저장소에 남아 다음부터는 다시 넣지 않아도 됩니다.": "[サーバー設定を修正] でパスワードを入力すると、この端末の安全な保存領域に残り、次回からは入力不要です。",
"[서버 설정 고치기] 에서 아이디 · 비밀번호를 확인하세요.": "[サーバー設定を修正] でユーザー名・パスワードを確認してください。",
"[시험 길] GitHub 대신 이 PC 의 zip 으로 업데이트합니다 (JJ_UPDATE_LOCAL_ZIP). 시험용 데이터 폴더로 켰을 때만 쓰입니다.": "[テスト経路] GitHub ではなくこの PC の zip で更新します (JJ_UPDATE_LOCAL_ZIP)。テスト用データフォルダーで起動したときだけ使われます。",

View File

@ -453,6 +453,7 @@
"[N] 원래 이름 · [C] 번호 예: Show S01E[C]": "[N] 原名称 · [C] 序号 例如: Show S01E[C]",
"[N] 원래 이름 · [C] 번호 · 확장자는 그대로 예: Show S01E[C]": "[N] 原名称 · [C] 序号 · 保留扩展名 例如: Show S01E[C]",
"[N] 원래 이름 · [N2-5] 그 2~5 번째 글자 · [C] 번호 · [D] 날짜 · [T] 시각 · ♪ 음악 태그 · 확장자는 그대로 예: Show S01E[C]": "[N] 原名 · [N2-5] 其第 2~5 个字 · [C] 编号 · [D] 日期 · [T] 时间 · ♪ 音乐标签 · 扩展名不变 例: Show S01E[C]",
"[{0}] 남은 {1} / 전체 {2}": "[{0}] 可用 {1} / 总共 {2}",
"[서버 설정 고치기] 에서 비밀번호를 넣으면 이 기기의 안전 저장소에 남아 다음부터는 다시 넣지 않아도 됩니다.": "在 [修改服务器设置] 中输入密码后会保存在本设备的安全存储中,以后无需再次输入。",
"[서버 설정 고치기] 에서 아이디 · 비밀번호를 확인하세요.": "请在 [修改服务器设置] 中检查用户名和密码。",
"[시험 길] GitHub 대신 이 PC 의 zip 으로 업데이트합니다 (JJ_UPDATE_LOCAL_ZIP). 시험용 데이터 폴더로 켰을 때만 쓰입니다.": "[测试路径] 不从 GitHub,而是用本机的 zip 更新 (JJ_UPDATE_LOCAL_ZIP)。仅在以测试数据文件夹启动时使用。",

View File

@ -30,8 +30,10 @@ void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
final out = Platform.environment['JJ_SHOT_DIR'] ?? p.join(Directory.systemTemp.path, 'jj_shots_use');
// E6: 두 모양 - X-plore (캡처 이름 use_너비_…, base7 과 견주는 회귀 확인) · Total Commander (Windows 처음 값, use_tc_너비_…)
for (final style in ['xplore', 'totalcmd']) {
for (final (w, h) in [(1920, 1040), (960, 600), (412, 900)]) {
testWidgets('탐색기 사용 ${w}x$h', (t) async {
testWidgets('탐색기 사용 $style ${w}x$h', (t) async {
t.view.physicalSize = Size(w.toDouble(), h.toDouble());
t.view.devicePixelRatio = 1;
addTearDown(t.view.reset);
@ -59,8 +61,17 @@ void main() {
for (final f in tmp.listSync(recursive: true).whereType<File>()) {
f.setLastModifiedSync(DateTime(2026, 10, 22, 9));
}
// E6: TC 모양은 폴더 날짜 칸이 있다 - 폴더 시각도 고정 (Dart 는 폴더 시각을 못 바꿔 PowerShell 로)
Process.runSync('powershell', [
'-NoProfile',
'-Command',
"Get-ChildItem -LiteralPath '${tmp.path}' -Recurse -Directory | ForEach-Object { \$_.LastWriteTime = [datetime]'2026-10-22 09:00' }",
]);
device.volumes = () => [(a.path, 'C:'), (b.path, 'D:')];
// E6: TC 모양 창 위의 남은 공간 - 이 PC 의 디스크 대신 정한 값 (실행마다 같게)
device.space = (_) => (107374182400, 536870912000);
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
c.settings.explorer.style = style;
final shotKey = GlobalKey();
await t.runAsync(() => t.pumpWidget(RepaintBoundary(
key: shotKey,
@ -85,7 +96,7 @@ void main() {
final bytes = await t.runAsync(() => img!.toByteData(format: ui.ImageByteFormat.png));
Directory(out).createSync(recursive: true);
no++;
File(p.join(out, 'use_${w}_${no.toString().padLeft(2, '0')}_$name.png')).writeAsBytesSync(bytes!.buffer.asUint8List());
File(p.join(out, '${style == 'totalcmd' ? 'use_tc' : 'use'}_${w}_${no.toString().padLeft(2, '0')}_$name.png')).writeAsBytesSync(bytes!.buffer.asUint8List());
}
// 열린 창 (대화 상자 · 메뉴) 의 글을 기록 - 무엇이 떴는지
@ -98,7 +109,7 @@ void main() {
}
}
// ignore: avoid_print
print('[$w] $step: ${texts.isEmpty ? '(뜬 창 없음)' : texts.join(' | ')}');
print('[${style == 'totalcmd' ? 'tc ' : ''}$w] $step: ${texts.isEmpty ? '(뜬 창 없음)' : texts.join(' | ')}');
}
// 복사 · 이동이 끝나 진행 패널이 다 내려갈 때까지 (실제 시간, 10초까지) - 캡처가 PC 빠르기에 따라 달라지지 않게
@ -125,7 +136,7 @@ void main() {
dump(name);
} catch (e) {
// ignore: avoid_print
print('[$w] $name: 실패 - ${e.toString().split('\n').first}');
print('[${style == 'totalcmd' ? 'tc ' : ''}$w] $name: 실패 - ${e.toString().split('\n').first}');
}
await shot(name);
}
@ -137,7 +148,13 @@ void main() {
if (el.isEmpty) throw StateError('왼쪽 창에 없음');
return find.byWidget(el.first.widget).first;
}
Finder txt(String name) => leftOf(find.text(name));
// TC 는 폴더를 [이름], 파일은 확장자를 뺀 이름으로 보인다 (드라이브 탭 C: · D: 는 그대로)
String shown(String name) => style != 'totalcmd' || name.endsWith(':')
? name
: name.contains('.')
? name.substring(0, name.lastIndexOf('.'))
: '[$name]';
Finder txt(String name) => leftOf(find.text(shown(name)));
Finder row(String name) => find.ancestor(of: txt(name), matching: find.byType(InkWell)).first;
Finder tip(String s) => find.byWidgetPredicate((x) => x is Tooltip && (x.message ?? '').startsWith(s)).first;
// 줄 끝 표시 단추: 이름 글과 같은 높이의 것
@ -157,7 +174,7 @@ void main() {
if (e is File) p.relative(e.path, from: tmp.path)
]..sort();
// ignore: avoid_print
print('[$w] 파일 ($when): ${left.join(', ')}');
print('[${style == 'totalcmd' ? 'tc ' : ''}$w] 파일 ($when): ${left.join(', ')}');
}
Finder paneBtn(String s) => find.byWidgetPredicate((x) => x is IconButton && (x.tooltip ?? '').startsWith(s)).first;
@ -166,7 +183,12 @@ void main() {
dump('처음');
await step('Show S01 누르기', () => t.tap(txt('Show S01')));
await step('Show S01 다시 누르기 (접히나)', () => t.tap(txt('Show S01')));
if (find.text('ep01.mkv').evaluate().isEmpty) await t.tap(txt('Show S01'));
// 펼쳐지지 않았으면 한 번 더 (TC 목록은 두 번 누르기로 폴더 안에 들어가 [Show S01] 이 없을 수 있다 - 그러면 그대로)
if (find.text(shown('ep01.mkv')).evaluate().isEmpty) {
try {
await t.tap(txt('Show S01'));
} on StateError catch (_) {}
}
await settle();
await step('ep01 한 번 누르기 (선택)', () => t.tap(txt('ep01.mkv')));
// 규칙 4 (E3): 표시는 선택 모드에서만 - [선택] 을 켜고 표시
@ -236,6 +258,8 @@ void main() {
await t.sendKeyEvent(LogicalKeyboardKey.space);
});
await step('Insert', () => t.sendKeyEvent(LogicalKeyboardKey.insert));
// TC: 앞 단계의 글만 있는 알림 (Flutter 기본 4초) 이 찍는 때에 따라 남거나 사라졌다 - 여기서 닫힌 것으로 (X-plore 는 base7 그대로)
if (style == 'totalcmd') await noticeTimesOut();
await step('report 누르고 Enter (열기 대신 무엇이)', () async {
await t.tap(txt('report.pdf'));
await settle(4);
@ -246,9 +270,10 @@ void main() {
if (e is File) p.relative(e.path, from: tmp.path)
]..sort();
// ignore: avoid_print
print('[$w] 끝 파일: ${left.join(', ')}');
print('[${style == 'totalcmd' ? 'tc ' : ''}$w] 끝 파일: ${left.join(', ')}');
await t.pumpWidget(const SizedBox());
await settle(4);
});
}
}
}

View File

@ -1,13 +1,15 @@
import 'dart:io';
import 'copy_center.dart' as copy show diskSpace;
import 'volumes.dart';
/// 약속: 파일 탐색기가 기기에서 받는 것 - 저장 장치 목록 · 드라이브를 열 수 있는지 (화면을 멈추지 않는 비동기) ·
/// Android 에서만 하는 것 (경로 입력의 SMB 안내 · SMB 서버 메뉴, 앱 휴지통, .nomedia 메뉴).
/// 저장 장치의 남은 공간 · 전체 (E6 Total Commander 모양, 못 재면 null) · Android 에서만 하는 것 (경로 입력의 SMB 안내 · SMB 서버 메뉴, 앱 휴지통, .nomedia 메뉴).
/// 앱은 [RealExplorerDevice] (이 기기 그대로) 를 쓰고, 시험은 시험 폴더를 저장 장치로 주는 구현을 넘긴다
abstract interface class ExplorerDevice {
Future<List<(String, String)>> listVolumes();
Future<bool> probeDrive(String path);
Future<(int, int)?> diskSpace(String path);
bool get androidPaths;
bool get androidTrash;
bool get nomedia;
@ -22,6 +24,8 @@ class RealExplorerDevice implements ExplorerDevice {
@override
Future<bool> probeDrive(String path) => Directory(path).exists();
@override
Future<(int, int)?> diskSpace(String path) => copy.diskSpace(path);
@override
bool get androidPaths => Platform.isAndroid;
@override
bool get androidTrash => Platform.isAndroid;

View File

@ -2,6 +2,10 @@ import 'dart:math' as math;
import 'package:flutter/material.dart';
import '../../app/settings.dart' show AppSettings;
import '../../core/file_ops.dart' show samePath;
import '../explorer_look.dart';
import 'explorer_bars.dart';
import 'explorer_buttons.dart';
import 'explorer_controller.dart';
@ -9,6 +13,7 @@ import 'explorer_flows.dart';
import 'explorer_layout.dart';
import 'explorer_select.dart';
import 'pane_view.dart';
import 'tc_key_bar.dart';
import 'toolbar.dart';
/// 약속 (E5d-6): 창들과 기능 버튼 줄의 배치 - 설정 (두 창 · 폴더 + 파일 목록 · 한 창, 좌우 · 위아래, 버튼 줄 사이 · 끝 · 숨김) 과
@ -38,6 +43,32 @@ class ExplorerPanesBody extends StatelessWidget {
layout.shape = shape;
}
final dual = layout.dual;
final panes = _panes(context, s, dual);
// E6: Total Commander 모양은 아래에 F3~F8 단추 줄 (키 F3~F8 과 같은 일)
if (ctl.look != ExplorerStyle.totalcmd || ctl.rsync) return panes;
return Column(children: [Expanded(child: panes), TcKeyBar(onPressed: (k) => _tcKey(context, k))]);
}
void _tcKey(BuildContext context, TcKey k) {
final pane = ctl.pane;
switch (k) {
case TcKey.view:
flows.opener.quickLook(context, pane);
case TcKey.edit:
final f = pane.focused;
if (f != null && !samePath(f, pane.root)) flows.opener.open(context, f, pane: pane);
case TcKey.copy:
buttons.press(ExplorerButton.copy);
case TcKey.move:
buttons.press(ExplorerButton.move);
case TcKey.newFolder:
buttons.press(ExplorerButton.newFolder);
case TcKey.delete:
buttons.press(ExplorerButton.delete);
}
}
Widget _panes(BuildContext context, AppSettings s, bool dual) {
return LayoutBuilder(builder: (context, box) {
// 두 창이 다 들어가는지: 트리 (화살표 · 아이콘 · 여백 78 + 이름 40) + 목록 (아이콘 · ✔ · 여백 104 + 이름 50), 이름은 글자 배율만큼.
// 1배면 360 폭에도 들어가고, 기기 글자 1.3 × 150% (약 2배) 면 412 폭에도 모자란다

View File

@ -18,6 +18,7 @@ import 'explorer_controller.dart';
import 'explorer_pane.dart';
import 'explorer_select.dart';
import 'row_view.dart';
import 'tc_drive_space.dart';
/// 약속 (E5d-3): 창 하나를 그리는 데 화면이 주는 것. 창의 틀 (활성 · 놓기) · 창 머리 · 안내 · 표시 막대 · 줄 메뉴 ·
/// 끌어 놓기 · 필터 닫기는 아직 화면의 일이라 여기로 받는다 (나눌 때마다 이 목록이 줄어든다)
@ -37,6 +38,9 @@ abstract interface class ExplorerPaneParts {
Widget frame(int i, List<Widget> children);
Widget header(ExplorerPane pane);
/// 저장 장치의 남은 공간 · 전체 (E6 Total Commander 모양의 창 위 줄)
Future<(int, int)?> diskSpace(String path);
/// [dir] 위의 안내들 (없는 것은 null)
List<Widget?> notices(ExplorerPane pane, String dir);
Widget marksBar(ExplorerPane pane, {String? listDir});
@ -86,8 +90,28 @@ Widget? noticeArea(List<Widget?> notices, double paneHeight) {
);
}
/// E6: Total Commander 모양 창 위의 남은 공간 줄 (지금 폴더의 저장 장치)
Widget _driveSpace(ExplorerController ctl, ExplorerPaneParts parts, ExplorerPane pane) {
final root = ctl.volumeOf(pane.current);
final label = ctl.local.where((v) => samePath(v.$1, root)).map((v) => v.$2).firstOrNull ?? root;
return TcDriveSpace(root: root, label: label, space: parts.diskSpace);
}
/// [dir] 한 폴더의 목록 줄 (트리 아님): 맨 위 ".." (맨 위 폴더가 아니면) · 보이는 항목 · 숨긴 항목 [n개 보이기].
/// "폴더 + 파일 목록" 의 오른쪽과 Total Commander 모양의 창이 같이 쓴다 (E6)
List<TreeRow> paneListRows(ExplorerController ctl, ExplorerSelect sel, ExplorerPane pane, String dir) {
final entries = pane.cache[dir] ?? const <FileEntry>[];
final hidden = entries.where((e) => ctl.appHidden(e.path)).length;
return [
if (!samePath(dir, pane.root)) TreeRow(FileEntry(vDirname(dir), isDir: true, modified: DateTime(0)), 0, isUp: true),
for (final e in sel.shown(pane, dir)) TreeRow(e, 0),
// X-plore 숨기기: 맨 아래 [숨긴 항목 n개 보이기]
if (hidden > 0) TreeRow(FileEntry('\u0000hidden', isDir: false, modified: DateTime(0)), 0, kind: TreeRow.hiddenToggle, count: hidden),
];
}
Widget _rowOf(ExplorerController ctl, ExplorerSelect sel, ExplorerPaneParts parts, ExplorerPane pane, TreeRow row,
{bool list = false, bool compact = false}) =>
{bool list = false, bool compact = false, bool narrow = false}) =>
KeyedSubtree(
key: paneRowKey(row),
child: row.kind != 0
@ -99,6 +123,7 @@ Widget _rowOf(ExplorerController ctl, ExplorerSelect sel, ExplorerPaneParts part
row: row,
list: list,
compact: compact,
narrow: narrow,
rowHeight: parts.rowHeight,
narrowSplit: parts.narrowSplit,
alwaysMarks: parts.alwaysMarks,
@ -119,8 +144,11 @@ class ExplorerPaneView extends StatelessWidget {
@override
Widget build(BuildContext context) {
final pane = ctl.panes[index];
final rows = ctl.rows(pane, foldersOnly: foldersOnly);
final look = ctl.look;
// E6: Total Commander 모양은 트리 대신 지금 폴더의 목록 (맨 위 "..") - 사용자가 고른 "폴더 + 파일 목록" (트리 + 목록,
// 좁아 한 창이 될 때도) · Rsync 는 그대로
final flat = look == ExplorerStyle.totalcmd && !foldersOnly && !ctl.rsync && ctl.c.settings.explorer.layout != 'split';
final rows = flat ? paneListRows(ctl, sel, pane, pane.current) : ctl.rows(pane, foldersOnly: foldersOnly);
// 38: 창 높이를 알아야 안내 상자의 높이를 제한할 수 있다
return LayoutBuilder(builder: (context, box) {
// 열을 그리는 창만 숨길 열을 정한다 ("폴더 + 파일 목록" 의 트리는 열이 없고 목록과 같은 창을 쓴다)
@ -129,12 +157,15 @@ class ExplorerPaneView extends StatelessWidget {
}
return parts.frame(index, [
parts.header(pane),
if (flat) _driveSpace(ctl, parts, pane),
const Divider(height: 1),
// 보이는 수는 맞는 것만 (맞는 것이 든 위 폴더는 빼고)
if (pane.filterOpen)
ExplorerFilterBar(
pane: pane,
shown: rows.where((r) => !r.isRoot && isSameOrInside(r.entry.path, pane.current) && !samePath(r.entry.path, pane.current) && quickFilterMatch(vBasename(r.entry.path), pane.filter)).length,
shown: flat
? rows.where((r) => r.kind == 0 && !r.isUp).length
: rows.where((r) => !r.isRoot && isSameOrInside(r.entry.path, pane.current) && !samePath(r.entry.path, pane.current) && quickFilterMatch(vBasename(r.entry.path), pane.filter)).length,
onClose: () => parts.closeFilter(pane),
onChanged: parts.filterChanged),
// 38: 안내가 길어도 목록을 덮지 않게 (창 높이의 35% 까지, 넘치면 넘겨 봄)
@ -143,7 +174,8 @@ class ExplorerPaneView extends StatelessWidget {
ExplorerColumnsHeader(
style: look,
markColumn: sel.selecting,
hide: pane.colHide,
// E6: 좁은 창의 줄은 열 없이 이름만 - 열 머리도 이름만
hide: box.maxWidth < narrowRowWidth ? 3 : pane.colHide,
sortBy: ctl.sortOf(pane.current).$1,
sortDesc: ctl.sortOf(pane.current).$2,
onSort: (by) => ctl.sortByColumn(pane, by),
@ -151,18 +183,19 @@ class ExplorerPaneView extends StatelessWidget {
Expanded(
child: Stack(children: [
scaleListText(context, parts.listTextScale, ListView.builder(
controller: pane.scroll,
// TC 목록은 목록의 스크롤 (폴더를 옮기면 맨 위로) - 트리의 스크롤은 트리 줄 자리로 옮겨 다닌다
controller: flat ? pane.listScroll : pane.scroll,
itemCount: rows.length,
itemExtent: parts.rowHeight,
// 76: 줄 위젯은 항목을 따라간다 ([paneRowKey])
itemBuilder: (_, k) => _rowOf(ctl, sel, parts, pane, rows[k], compact: foldersOnly),
itemBuilder: (_, k) => _rowOf(ctl, sel, parts, pane, rows[k], list: flat, compact: foldersOnly, narrow: box.maxWidth < narrowRowWidth),
findChildIndexCallback: _rowIndexOf(rows),
)),
// 읽는 중 (WebDAV 서버를 기다릴 때 등). 180: 목록 위에 겹쳐 그린다 (끼어들면 목록이 밀려 다음 것을 누르려다 엉뚱한 것을 누른다)
if (pane.loading.isNotEmpty) const Positioned(left: 0, right: 0, top: 0, child: LinearProgressIndicator(minHeight: 2)),
]),
),
if ((sel.selecting || (parts.alwaysMarks && !pane.sel.isEmpty)) && !foldersOnly) parts.marksBar(pane),
if ((sel.selecting || (parts.alwaysMarks && !pane.sel.isEmpty)) && !foldersOnly) parts.marksBar(pane, listDir: flat ? pane.current : null),
]);
});
}
@ -183,13 +216,7 @@ class ExplorerListView extends StatelessWidget {
final entries = pane.cache[dir] ?? const <FileEntry>[];
final up = !samePath(dir, pane.root);
final shown = sel.shown(pane, dir);
final hidden = entries.where((e) => ctl.appHidden(e.path)).length;
final rows = [
if (up) TreeRow(FileEntry(vDirname(dir), isDir: true, modified: DateTime(0)), 0, isUp: true),
for (final e in shown) TreeRow(e, 0),
// X-plore 숨기기: 맨 아래 [숨긴 항목 n개 보이기]
if (hidden > 0) TreeRow(FileEntry('\u0000hidden', isDir: false, modified: DateTime(0)), 0, kind: TreeRow.hiddenToggle, count: hidden),
];
final rows = paneListRows(ctl, sel, pane, dir);
final look = ctl.look;
// 38: 창 높이를 알아야 안내 상자의 높이를 제한할 수 있다
return LayoutBuilder(builder: (context, box) {
@ -208,7 +235,8 @@ class ExplorerListView extends StatelessWidget {
ExplorerColumnsHeader(
style: look,
markColumn: sel.selecting,
hide: pane.colHide,
// E6: 좁은 창의 줄은 열 없이 이름만 - 열 머리도 이름만
hide: box.maxWidth < narrowRowWidth ? 3 : pane.colHide,
sortBy: ctl.sortOf(dir).$1,
sortDesc: ctl.sortOf(dir).$2,
onSort: (by) => ctl.sortByColumn(pane, by),
@ -223,7 +251,7 @@ class ExplorerListView extends StatelessWidget {
controller: pane.listScroll,
itemCount: rows.length,
itemExtent: parts.rowHeight,
itemBuilder: (_, k) => _rowOf(ctl, sel, parts, pane, rows[k], list: true),
itemBuilder: (_, k) => _rowOf(ctl, sel, parts, pane, rows[k], list: true, narrow: box.maxWidth < narrowRowWidth),
findChildIndexCallback: _rowIndexOf(rows),
)),
),

View File

@ -25,6 +25,10 @@ import 'file_thumbs.dart';
double treeChevronWidth(TargetPlatform platform) =>
platform == TargetPlatform.android || platform == TargetPlatform.iOS ? 44 : 20;
/// E6 (결정 10/22): 창 폭이 이보다 좁으면 (폰 세로의 좌우 두 창 등) 줄을 좁은 모양으로 - 아이콘 칸 줄임 · 끌기 손잡이 숨김 ·
/// 열 대신 이름 먼저 (크기 · 날짜는 이름 아래 작은 글). 두 창 좌우 배치는 그대로
const narrowRowWidth = 200.0;
/// 탐색기 줄 하나 (E5d-2). 상태 · 일은 [ExplorerController] · [ExplorerSelect], 메뉴 · 끌어 놓기는 부르는 쪽 콜백.
/// [list]: "폴더 + 파일 목록" 의 오른쪽 목록 (트리 아님). [compact]: 폴더 트리 (열 · 표시 버튼 없이)
class ExplorerRowView extends StatelessWidget {
@ -36,6 +40,7 @@ class ExplorerRowView extends StatelessWidget {
required this.row,
this.list = false,
this.compact = false,
this.narrow = false,
required this.rowHeight,
required this.narrowSplit,
required this.alwaysMarks,
@ -49,6 +54,9 @@ class ExplorerRowView extends StatelessWidget {
final ExplorerPane pane;
final TreeRow row;
final bool list, compact;
/// 좁은 창의 줄 ([narrowRowWidth])
final bool narrow;
final double rowHeight;
final bool narrowSplit, alwaysMarks;
@ -67,6 +75,12 @@ class ExplorerRowView extends StatelessWidget {
final look = ctl.look;
// 좁은 "폴더 + 파일 목록" 의 트리 (관리자 지시 10/20): ✔ 칸 · 크기 줄 없이 이름만, 줄에 두 줄이 들어가면 이름을 두 줄까지
final slim = compact && narrowSplit && !list;
// E6: 좁은 창의 줄 (트리 · 목록 모두, 폴더 트리 (compact) 는 이미 이름만)
final tight = narrow && !compact;
// 크기 · 날짜 줄은 두 줄이 들어가는 줄 높이에서만 (Total Commander 26 에는 이름만 - 넘치지 않게)
final ts = MediaQuery.textScalerOf(context);
// 글 한 줄의 실제 높이는 글자 크기의 1.4배 안팎 (1.2 로 재면 큰 글자 · 넓은 줄 간격에서 1px 넘쳤다)
final metaFits = rowHeight >= ts.scale(look.fontSize) * 1.4 + ts.scale(look.fontSize - 2) * 1.4 + 4;
// 이 줄은 목록 글자 배율을 이미 곱한 MediaQuery 안에 있다 (scaleListText) - 배율을 다시 곱하지 않는다
final nameLines = slim && rowHeight >= MediaQuery.textScalerOf(context).scale(look.fontSize) * 2.5 + 6 ? 2 : 1;
final isCurrent = e.isDir && !list && !row.isUp && samePath(pane.current, e.path);
@ -129,8 +143,9 @@ class ExplorerRowView extends StatelessWidget {
// 아주 좁은 창 (412 폭 "폴더 + 파일 목록" 의 트리, 77px 쯤): 아이콘 칸 · 틈을 남은 폭만큼만 (아이콘은 줄여 그림) - 넘치지 않게
final room = (box.maxWidth - indent - (list ? 0 : chevronWidth)).clamp(0.0, double.infinity);
// 좁은 트리 (slim) 는 큰 썸네일 칸 (56) 대신 작은 아이콘 칸 (28 까지, 아이콘은 줄여 그림)
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);
final small = slim || tight;
final iconW = math.min(small ? math.min(look.iconSize, 24.0) + 4 : look.rich ? 56.0 : look.iconSize + 6, room);
final gapW = math.min(small ? 4.0 : look.rich ? 8.0 : 6.0, room - iconW);
return Row(children: [
SizedBox(width: indent),
if (!list) chevron(),
@ -161,6 +176,9 @@ class ExplorerRowView extends StatelessWidget {
Text(name, maxLines: nameLines, overflow: TextOverflow.ellipsis, style: nameStyle),
if (slim)
const SizedBox.shrink()
// E6: 좁은 창은 열이 없으니 파일의 크기 · 날짜를 이름 아래에
else if (tight && metaFits && !e.isDir && !row.isUp && !row.isRoot)
Text('${formatSize(e.size)} · ${fileDateText(e.modified)}', style: dim, maxLines: 1, overflow: TextOverflow.ellipsis)
else if (look.rich && e.isDir && pane.errors[e.path] != null)
Text(tr('읽을 수 없음'), style: dim.copyWith(color: JjColors.alert))
// 80: 열 없는 모양 (X-plore · 좁은 화면) 은 폴더 이름 아래에 크기
@ -181,7 +199,7 @@ class ExplorerRowView extends StatelessWidget {
);
// 관리자 지시 10/20: 좁으면 유형 → 날짜 열을 숨김 (크기는 남김, [columnHideLevel])
final hide = pane.colHide;
final cells = !look.columns || compact || !real
final cells = !look.columns || compact || tight || !real
? <Widget>[Expanded(child: nameCell())]
: look == ExplorerStyle.windows
? [
@ -229,7 +247,7 @@ class ExplorerRowView extends StatelessWidget {
),
),
// 82: 터치로 끌기 - 선택 모드에서 표시한 줄의 ≡ 손잡이 (길게 누르기는 메뉴 그대로)
if (drag && sel.selecting && marked)
if (drag && sel.selecting && marked && !tight)
Draggable<DragLoad>(
data: DragLoad(paneIndex, dragPaths(pane, e)),
feedback: dragFeedback(dragPaths(pane, e)),

View File

@ -0,0 +1,52 @@
/// 약속 (E6): Total Commander 모양의 창 위 한 줄 - 지금 저장 장치 ([root]) 의 남은 공간 / 전체.
/// 값은 [space] (기기 - ExplorerDevice.diskSpace) 에서 받고, 저장 장치가 바뀔 때만 다시 묻는다 (줄을 다시 그려도 다시 재지 않음).
/// 못 재면 저장 장치 이름만 보인다.
library;
import 'package:flutter/material.dart';
import '../../core/files/file_listing.dart' show formatSize;
import '../../l10n/tr.dart';
import '../theme.dart';
class TcDriveSpace extends StatefulWidget {
/// 저장 장치의 맨 위 (예: C:\)
final String root;
/// 보이는 이름 (예: "C:")
final String label;
final Future<(int, int)?> Function(String path) space;
const TcDriveSpace({super.key, required this.root, required this.label, required this.space});
@override
State<TcDriveSpace> createState() => _TcDriveSpaceState();
}
class _TcDriveSpaceState extends State<TcDriveSpace> {
late Future<(int, int)?> _f = widget.space(widget.root);
@override
void didUpdateWidget(TcDriveSpace old) {
super.didUpdateWidget(old);
if (old.root != widget.root) _f = widget.space(widget.root);
}
@override
Widget build(BuildContext context) => FutureBuilder<(int, int)?>(
future: _f,
builder: (_, s) {
final d = s.data;
return Container(
height: 22,
padding: const EdgeInsets.symmetric(horizontal: 10),
alignment: Alignment.centerLeft,
child: Text(
d == null ? '[${widget.label}]' : trf('[{0}] 남은 {1} / 전체 {2}', [widget.label, formatSize(d.$1), formatSize(d.$2)]),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: 11.5, color: JjColors.textDim),
),
);
},
);
}

View File

@ -0,0 +1,63 @@
/// 약속 (E6): Total Commander 모양의 아래 단추 줄 - F3 보기 · F4 편집 · F5 복사 · F6 이동 · F7 새 폴더 · F8 삭제.
/// 누르면 [onPressed] 에 그 일 ([TcKey]) 을 넘기기만 한다 (일은 탐색기의 같은 동작 - 키 F3~F8 과 같다).
/// 좁으면 글을 줄이고 (예: "F5 복사" → "F5"), 단추는 늘 같은 폭으로 한 줄을 나눠 쓴다.
library;
import 'package:flutter/material.dart';
import '../../l10n/tr.dart';
import '../theme.dart';
enum TcKey {
view('F3', '보기'),
edit('F4', '편집'),
copy('F5', '복사'),
move('F6', '이동'),
newFolder('F7', '새 폴더'),
delete('F8', '삭제');
final String key;
final String label;
const TcKey(this.key, this.label);
}
class TcKeyBar extends StatelessWidget {
final void Function(TcKey key) onPressed;
const TcKeyBar({super.key, required this.onPressed});
/// 이보다 좁으면 키 이름만 ("F5")
static const shortWidth = 480.0;
@override
Widget build(BuildContext context) => LayoutBuilder(builder: (context, box) {
final short = box.maxWidth < shortWidth;
return Container(
height: 30,
decoration: BoxDecoration(color: JjColors.panel, border: Border(top: BorderSide(color: JjColors.border))),
child: Row(children: [
for (final k in TcKey.values)
Expanded(
child: Tooltip(
message: '${k.key} ${tr(k.label)}',
child: InkWell(
onTap: () => onPressed(k),
child: Container(
alignment: Alignment.center,
decoration: BoxDecoration(border: Border(right: BorderSide(color: JjColors.border))),
child: Text.rich(
TextSpan(children: [
TextSpan(text: k.key, style: TextStyle(fontWeight: FontWeight.w600, color: JjColors.accent)),
if (!short) TextSpan(text: ' ${tr(k.label)}'),
]),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 12),
),
),
),
),
),
]),
);
});
}

View File

@ -201,7 +201,7 @@ class ExplorerColumnsHeader extends StatelessWidget {
/// 선택 모드: 줄 끝의 선택 동그라미 칸만큼 비운다
final bool markColumn;
/// 관리자 지시 10/20: 좁아서 숨긴 열 ([columnHideLevel]: 1 = 유형, 2 = 유형 · 날짜)
/// 관리자 지시 10/20: 좁아서 숨긴 열 ([columnHideLevel]: 1 = 유형, 2 = 유형 · 날짜). 3 = 이름만 (E6 좁은 창의 줄 - 크기도 이름 아래로)
final int hide;
/// 84: 지금 정렬 ('name' · 'date' · 'size' · 'type') · 거꾸로 - 그 열에 ▲ ▼
@ -253,12 +253,12 @@ class ExplorerColumnsHeader extends StatelessWidget {
cell(tr('이름'), 6, by: 'name'),
if (hide < 2) cell(tr('수정한 날짜'), 3, by: 'date'),
if (hide < 1) cell(tr('유형'), 2, by: 'type'),
cell(tr('크기'), 2, align: TextAlign.end, by: 'size'),
if (hide < 3) cell(tr('크기'), 2, align: TextAlign.end, by: 'size'),
]
: [
cell(tr('이름'), 6, by: 'name'),
if (hide < 1) cell(tr('확장자'), 1, by: 'type'),
cell(tr('크기'), 2, align: TextAlign.end, by: 'size'),
if (hide < 3) cell(tr('크기'), 2, align: TextAlign.end, by: 'size'),
if (hide < 2) ...[const SizedBox(width: 8), cell(tr('날짜'), 3, by: 'date')],
],
),

View File

@ -272,6 +272,9 @@ class _ExplorerPageState extends State<ExplorerPage> implements ExplorerHost, Ex
@override
Widget frame(int i, List<Widget> children) =>
ExplorerPaneFrame(ctl: _ctl, index: i, onPointer: _input.mouseTravel, onDrop: onDrop, children: children);
@override
Future<(int, int)?> diskSpace(String path) => widget.device.diskSpace(path);
@override
Widget header(ExplorerPane pane) => ExplorerPaneHeader(
ctl: _ctl,

View File

@ -192,6 +192,8 @@ class _HostState extends State<_Host> implements ExplorerHost, ExplorerCommands,
@override
Widget frame(int i, List<Widget> children) => Column(children: children);
@override
Future<(int, int)?> diskSpace(String path) async => null;
@override
Widget header(ExplorerPane pane) => ExplorerPaneHeader(
ctl: ctl,
servers: ExplorerServers(ctl),

View File

@ -168,6 +168,8 @@ class _HostState extends State<_Host> implements ExplorerHost, ExplorerPaneParts
@override
Widget frame(int i, List<Widget> children) => Column(children: children);
@override
Future<(int, int)?> diskSpace(String path) async => null;
@override
Widget header(ExplorerPane pane) => ExplorerPaneHeader(
ctl: ctl!,
servers: ExplorerServers(ctl!),

View File

@ -0,0 +1,77 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:jj_mkvmaker/ui/explorer/tc_key_bar.dart';
import 'package:jj_mkvmaker/ui/explorer_page.dart';
import 'support/explorer_clock_keys.dart';
import 'support/explorer_harness.dart';
/// E6 약속: Total Commander 모양 - 창마다 지금 폴더의 목록 ("..", 폴더는 [이름]) · 아래 F3~F8 단추 줄 (키와 같은 일) ·
/// 창 위 남은 공간. X-plore 모양에는 단추 줄 · 남은 공간 줄이 없다 (모양만 다름)
void main() {
useExplorerFixture();
testWidgets('목록: 폴더를 두 번 누르면 들어가고 ".." 로 나온다 · 폴더는 [이름]', (t) async {
c.settings.explorer.style = 'totalcmd';
await open(t);
// TC 는 이름 · 확장자를 나눠 보인다 (doc · txt) - 하네스의 open 은 'doc.txt' 를 기다리므로 여기서 다시 기다린다
await settle(t, () => find.text('[sub]').evaluate().isNotEmpty);
expect(find.text('[sub]'), findsWidgets, reason: '폴더는 [이름] (TC)');
expect([find.text('doc').evaluate().length, find.text('txt').evaluate().length], [1, 1], reason: '파일은 이름 · 확장자 칸');
expect(find.text('inner'), findsNothing, reason: '트리처럼 펼치지 않는다');
await doubleTap(t, find.text('[sub]').first);
await settle(t, () => find.text('inner').evaluate().isNotEmpty);
expect(find.text('..'), findsWidgets);
await doubleTap(t, find.text('..').first);
await settle(t, () => find.text('[sub]').evaluate().isNotEmpty);
expect(find.text('inner'), findsNothing);
});
testWidgets('아래 단추 줄 F3~F8 · F7 은 새 폴더 창 (가운데 [새 폴더] 와 같음) · 창 위 남은 공간', (t) async {
c.settings.explorer.style = 'totalcmd';
device.space = (_) => (107374182400, 536870912000); // 100 GB / 500 GB
await open(t);
expect(find.byType(TcKeyBar), findsOneWidget);
for (final k in TcKey.values) {
expect(find.byTooltip('${k.key} ${k.label}'), findsOneWidget);
}
expect(find.textContaining('남은 100'), findsWidgets);
await t.tap(find.byTooltip('F7 새 폴더'));
await t.pumpAndSettle();
expect(find.byType(Dialog), findsOneWidget, reason: '새 폴더 이름 창');
});
testWidgets('X-plore 모양에는 아래 단추 줄 · 남은 공간 줄이 없다', (t) async {
device.space = (_) => (107374182400, 536870912000);
await open(t); // 하네스는 X-plore
expect(find.byType(TcKeyBar), findsNothing);
expect(find.textContaining('남은 100'), findsNothing);
});
testWidgets('좁은 창 (412 폭 두 창, 창마다 200 아래): 이름 먼저 · 크기 · 날짜는 이름 아래 · 끌기 손잡이 숨김 (X-plore · TC 같은 줄 부품)', (t) async {
for (final style in ['xplore', 'totalcmd']) {
c.settings.explorer.style = style;
t.view.physicalSize = const Size(412, 900);
t.view.devicePixelRatio = 1;
addTearDown(t.view.reset);
await t.runAsync(() => t.pumpWidget(MaterialApp(home: ExplorerPage(c: c, clock: () => testNow, device: device))));
final name = style == 'totalcmd' ? 'doc' : 'doc.txt';
await settle(t, () => find.text(name).evaluate().isNotEmpty);
// X-plore (줄 52) 는 크기 · 날짜가 이름 아래 한 줄, TC (줄 26) 는 두 줄이 들어가지 않아 이름만
expect(find.textContaining('5B · '), style == 'xplore' ? findsWidgets : findsNothing, reason: '$style: 이름 아래 크기 · 날짜');
expect(t.takeException(), isNull, reason: '$style: 넘침 없음');
expect(find.text('<DIR>'), findsNothing, reason: '$style: 열 (크기 칸) 대신 이름 먼저');
// 선택 모드에서 표시해도 좁은 창에는 끌기 손잡이 (≡) 가 없다
await act(t, () => t.tap(find.byWidgetPredicate((w) => w is Tooltip && (w.message ?? '').startsWith('선택')).first));
await calm(t);
final mark = find.byTooltip('표시 (여러 개 고르기)');
if (mark.evaluate().isNotEmpty) {
await act(t, () => t.tap(mark.first));
await calm(t);
}
expect(find.byIcon(Icons.drag_indicator), findsNothing, reason: '$style: 좁은 창은 끌기 손잡이 숨김');
await t.pumpWidget(const SizedBox());
}
});
}

View File

@ -11,6 +11,9 @@ class TestExplorerDevice implements ExplorerDevice {
/// 드라이브를 열 수 있는지 (null 이면 이 기기)
Future<bool> Function(String path)? probe;
/// 남은 공간 · 전체 (null 이면 이 기기) - 캡처가 실행마다 같게 정한다
(int, int)? Function(String path)? space;
/// 경로 입력 SMB 안내 · 앱 휴지통 · .nomedia 메뉴 (null 이면 이 기기)
bool? android, trash, nomediaOn;
@ -19,6 +22,8 @@ class TestExplorerDevice implements ExplorerDevice {
@override
Future<bool> probeDrive(String path) => (probe ?? _real.probeDrive)(path);
@override
Future<(int, int)?> diskSpace(String path) async => space != null ? space!(path) : await _real.diskSpace(path);
@override
bool get androidPaths => android ?? _real.androidPaths;
@override
bool get androidTrash => trash ?? _real.androidTrash;