WIP X-plore P3: 저장 공간 분석에 [칸 그림] (treemap, 87 위) (검토 대기)

- 저장 공간 분석 화면에 세 번째 보기 [칸 그림]: 지금 폴더 안을 크기만큼의 칸으로 (squarified treemap).
  큰 폴더 30개는 따로, 나머지 폴더는 한 칸, 이 폴더 바로 안의 파일도 한 칸 (회색, 누를 수 없음). 칸 = 이름 · 크기 · %.
  폴더 칸을 누르면 그 안으로 (← 로 위로, [그 자리로] 는 예전 그대로). 지우기는 여기서 하지 않음 (87 결정 그대로).
- 시험: storage_chart_test (넓이 비례 · 겹치지 않음 · 누르기).
- 캡처: integration_test/windows_xplore_tools_shot_test.dart → release\test\shots_xplore_tools
  (격자 · 글 뷰어 · 일괄 이름 날짜 · 칸 그림, ko/en × 412/960).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
xplore
RyuJongJin 2026-10-18 00:55:29 +09:00
parent e512d75d57
commit 60398d22e8
4 changed files with 317 additions and 2 deletions

View File

@ -0,0 +1,123 @@
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:image/image.dart' as img;
import 'package:integration_test/integration_test.dart';
import 'package:jj_mkvmaker/app/i18n_controller.dart';
import 'package:jj_mkvmaker/core/batch_rename.dart';
import 'package:jj_mkvmaker/core/file_ops.dart';
import 'package:jj_mkvmaker/core/text_codec.dart';
import 'package:jj_mkvmaker/ui/batch_rename_dialog.dart';
import 'package:jj_mkvmaker/ui/explorer_grid.dart';
import 'package:jj_mkvmaker/ui/storage_page.dart';
import 'package:jj_mkvmaker/ui/text_viewer.dart';
import 'package:jj_mkvmaker/ui/theme.dart';
import 'package:path/path.dart' as p;
/// X-plore 비교 g · h · i · P3 (관리자 지시 10/18): 격자 보기 · 글 뷰어 · 일괄 이름 (날짜) · 저장 공간 그림을 PNG 로
/// (JJ_SHOT_DIR, 없으면 임시 폴더). 크기 412 · 960, 언어 ko · en. 시험 파일은 임시 폴더에만.
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
final out = Platform.environment['JJ_SHOT_DIR'] ?? p.join(Directory.systemTemp.path, 'jj_shots_xptools');
for (final lang in ['ko', 'en']) {
for (final (w, h) in [(412, 900), (960, 600)]) {
testWidgets('X-plore 도구 $lang ${w}x$h', (t) async {
t.view.physicalSize = Size(w.toDouble(), h.toDouble());
t.view.devicePixelRatio = 1;
addTearDown(t.view.reset);
await t.runAsync(() => i18n.apply(lang, save: false));
addTearDown(() => i18n.apply('ko', save: false));
final tmp = Directory.systemTemp.createTempSync('jj_xptshot_');
addTearDown(() => tmp.deleteSync(recursive: true));
// 그림 (색이 다른 작은 그림) · 동영상 이름 · 글 (CP949)
final pics = <String>[];
for (var i = 0; i < 7; i++) {
final im = img.Image(width: 64, height: 48);
img.fill(im, color: img.ColorRgb8(40 + i * 30, 120, 200 - i * 20));
if (i == 0) im.exif.exifIfd['DateTimeOriginal'] = img.IfdValueAscii('2024:05:01 12:34:56');
final f = File(p.join(tmp.path, 'IMG_${1000 + i}.jpg'))..writeAsBytesSync(img.encodeJpg(im));
pics.add(f.path);
}
File(p.join(tmp.path, 'clip.mkv')).writeAsBytesSync(List.filled(10, 0));
final txt = File(p.join(tmp.path, 'memo.txt'))
..writeAsBytesSync(encodeText(List.generate(30, (i) => '${i + 1}번째 줄 - 한글 메모 line $i').join('\n'), 'CP949').bytes);
for (final (n, size) in [('Videos', 900000), ('Photos', 400000), ('Docs', 120000), ('Music', 60000)]) {
final d = Directory(p.join(tmp.path, 'disk', n))..createSync(recursive: true);
File(p.join(d.path, 'big.bin')).writeAsBytesSync(List.filled(size, 0));
}
File(p.join(tmp.path, 'disk', 'loose.bin')).writeAsBytesSync(List.filled(80000, 0));
final shotKey = GlobalKey();
final nav = GlobalKey<NavigatorState>();
await t.runAsync(() => t.pumpWidget(RepaintBoundary(
key: shotKey,
child: MaterialApp(navigatorKey: nav, theme: buildTheme(), debugShowCheckedModeBanner: false, home: const Scaffold()),
)));
Future<void> settle([int n = 12]) async {
for (var i = 0; i < n; i++) {
await t.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 60)));
await t.pump();
}
}
Future<void> shot(String name) async {
await settle(4);
final r = shotKey.currentContext!.findRenderObject()! as RenderRepaintBoundary;
final image = await t.runAsync(() => r.toImage());
final bytes = await t.runAsync(() => image!.toByteData(format: ui.ImageByteFormat.png));
Directory(out).createSync(recursive: true);
File(p.join(out, 'xptools_${name}_${lang}_$w.png')).writeAsBytesSync(bytes!.buffer.asUint8List());
}
// g 격자 보기
final entries = [
for (final x in [...pics, p.join(tmp.path, 'clip.mkv'), txt.path])
FileEntry(x, isDir: false, size: File(x).lengthSync(), modified: DateTime(2026, 10, 18)),
];
nav.currentState!.push(MaterialPageRoute<void>(
builder: (_) => ExplorerGridPage(
title: 'Photos',
entries: entries,
imageExts: const ['jpg'],
videoThumb: (_) async => null,
onOpen: (_) async {},
),
));
await settle(20);
await shot('grid');
nav.currentState!.pop();
await settle();
// h 글 뷰어 (CP949 를 스스로 알아봄)
nav.currentState!.push(MaterialPageRoute<void>(builder: (_) => TextViewerPage(path: txt.path)));
await settle(20);
await shot('text');
nav.currentState!.pop();
await settle();
// i 일괄 이름 바꾸기 - [D] (사진은 찍은 때)
showBatchRename(nav.currentContext!, pics.take(4).toList(),
isDir: (_) => false, initial: const RenameRule(template: '[D]_[T]_[C]'));
await settle(30);
await shot('rename_date');
await t.tap(find.text(lang == 'ko' ? '취소' : 'Cancel').last);
await settle();
// P3 저장 공간 그림
nav.currentState!.push(MaterialPageRoute<void>(builder: (_) => StorageAnalysisPage(root: p.join(tmp.path, 'disk'))));
await settle(30);
await t.tap(find.text(lang == 'ko' ? '칸 그림' : 'Treemap'));
await settle();
await shot('storage_chart');
nav.currentState!.pop();
await settle();
await t.pumpWidget(const SizedBox());
await settle(4);
});
}
}
}

121
lib/ui/storage_chart.dart Normal file
View File

@ -0,0 +1,121 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import '../core/file_ops.dart' show formatSize;
import 'theme.dart';
/// X-plore 비교 P3 (87 위): 저장 공간 그림 - 크기만큼 넓이를 차지하는 칸 (squarified treemap).
/// 칸을 누르면 그 폴더 안으로 ([path] 가 없는 칸 - 이 폴더의 파일 · 나머지 - 은 누를 수 없음).
class StorageBlock {
final String label;
final int size;
final String? path;
const StorageBlock(this.label, this.size, [this.path]);
}
class StorageTreemap extends StatelessWidget {
final List<StorageBlock> blocks;
final void Function(StorageBlock b) onTap;
const StorageTreemap({super.key, required this.blocks, required this.onTap});
/// 칸 색 (크기 차례대로 돌려 씀)
static const _colors = [
Color(0xFF2E7D9A), Color(0xFF7E57C2), Color(0xFF43A047), Color(0xFFEF6C00),
Color(0xFFC2185B), Color(0xFF00897B), Color(0xFF5C6BC0), Color(0xFF8D6E63),
];
/// [blocks] (큰 것부터) 를 [area] 안에 나눠 놓은 사각형들. 크기가 0 인 것은 빠짐
static List<(StorageBlock, Rect)> layout(List<StorageBlock> blocks, Rect area) {
final items = [for (final b in blocks) if (b.size > 0) b]..sort((a, b) => b.size.compareTo(a.size));
final total = items.fold<int>(0, (s, b) => s + b.size);
if (items.isEmpty || total == 0 || area.isEmpty) return const [];
final scale = area.width * area.height / total;
final out = <(StorageBlock, Rect)>[];
var rect = area;
var i = 0;
while (i < items.length) {
final side = math.min(rect.width, rect.height);
// 한 줄에 넣을 것: 가장 나쁜 가로세로 비가 더 나빠지기 전까지
final row = <StorageBlock>[items[i]];
double worst(List<StorageBlock> r) {
final s = r.fold<double>(0, (t, b) => t + b.size * scale);
final mx = r.map((b) => b.size * scale).reduce(math.max), mn = r.map((b) => b.size * scale).reduce(math.min);
return math.max(side * side * mx / (s * s), (s * s) / (side * side * mn));
}
var j = i + 1;
while (j < items.length && worst([...row, items[j]]) <= worst(row)) {
row.add(items[j]);
j++;
}
final rowArea = row.fold<double>(0, (t, b) => t + b.size * scale);
final thick = side == 0 ? 0.0 : rowArea / side;
var at = 0.0;
final horizontal = rect.width >= rect.height; // 넓으면 왼쪽에 세로 줄, 높으면 위에 가로 줄
for (final b in row) {
final len = thick == 0 ? 0.0 : b.size * scale / thick;
out.add((
b,
horizontal
? Rect.fromLTWH(rect.left, rect.top + at, thick, len)
: Rect.fromLTWH(rect.left + at, rect.top, len, thick),
));
at += len;
}
rect = horizontal
? Rect.fromLTRB(rect.left + thick, rect.top, rect.right, rect.bottom)
: Rect.fromLTRB(rect.left, rect.top + thick, rect.right, rect.bottom);
i = j;
}
return out;
}
@override
Widget build(BuildContext context) {
return LayoutBuilder(builder: (_, c) {
final cells = layout(blocks, Offset.zero & Size(c.maxWidth, c.maxHeight));
if (cells.isEmpty) return const SizedBox();
final total = blocks.fold<int>(0, (s, b) => s + math.max(0, b.size));
return Stack(children: [
for (var k = 0; k < cells.length; k++)
Positioned.fromRect(
rect: cells[k].$2.deflate(1),
child: _cell(cells[k].$1, k, total),
),
]);
});
}
Widget _cell(StorageBlock b, int k, int total) {
final color = b.path == null ? JjColors.panelHigh : _colors[k % _colors.length];
final pct = total == 0 ? 0 : (b.size * 100 / total);
return Tooltip(
message: '${b.label} · ${formatSize(b.size)} · ${pct.toStringAsFixed(1)}%',
child: Material(
color: color,
borderRadius: BorderRadius.circular(3),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: b.path == null ? null : () => onTap(b),
child: LayoutBuilder(
builder: (_, c) => c.maxWidth < 44 || c.maxHeight < 30
? const SizedBox()
: Padding(
padding: const EdgeInsets.all(4),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(b.label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 12, color: Colors.white, fontWeight: FontWeight.w600)),
if (c.maxHeight >= 46)
Text('${formatSize(b.size)} · ${pct.toStringAsFixed(pct < 10 ? 1 : 0)}%',
maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 11, color: Colors.white70)),
]),
),
),
),
),
);
}
}

View File

@ -4,6 +4,7 @@ import '../core/file_ops.dart';
import '../core/storage_scan.dart';
import '../core/vfs.dart';
import '../l10n/tr.dart';
import 'storage_chart.dart';
import 'theme.dart';
/// 87: 저장 공간 분석 - 고른 폴더 · 드라이브 아래의 큰 폴더 · 큰 파일 차례.
@ -30,7 +31,7 @@ class _StorageAnalysisPageState extends State<StorageAnalysisPage> {
var _dirs = 0, _bytes = 0;
var _stop = false;
late String _cur = widget.root;
var _tab = 0; // 0 큰 폴더 · 1 큰 파일
var _tab = 0; // 0 큰 폴더 · 1 큰 파일 · 2 그림 (X-plore 비교 P3)
@override
void initState() {
@ -144,10 +145,11 @@ class _StorageAnalysisPageState extends State<StorageAnalysisPage> {
child: Wrap(spacing: 6, children: [
ChoiceChip(label: Text(tr('큰 폴더')), selected: _tab == 0, onSelected: (_) => setState(() => _tab = 0)),
ChoiceChip(label: Text(tr('큰 파일')), selected: _tab == 1, onSelected: (_) => setState(() => _tab = 1)),
ChoiceChip(label: Text(tr('칸 그림')), selected: _tab == 2, onSelected: (_) => setState(() => _tab = 2)),
]),
),
const Divider(height: 1),
Expanded(child: _tab == 0 ? _folders(s) : _files(s)),
Expanded(child: switch (_tab) { 0 => _folders(s), 1 => _files(s), _ => _chart(s) }),
]),
),
);
@ -183,6 +185,25 @@ class _StorageAnalysisPageState extends State<StorageAnalysisPage> {
);
}
/// X-plore 비교 P3: 지금 폴더 안을 크기만큼의 칸으로. 큰 폴더 30개까지 따로, 나머지 폴더는 한 칸, 이 폴더 바로 안의 파일도 한 칸
Widget _chart(StorageScan s) {
final dirs = s.biggestIn(_cur);
final total = s.sizeOf(_cur);
final shown = dirs.take(30).toList();
final rest = dirs.skip(30).fold<int>(0, (t, d) => t + s.sizeOf(d));
final own = total - dirs.fold<int>(0, (t, d) => t + s.sizeOf(d));
final blocks = [
for (final d in shown) StorageBlock(scanName(d), s.sizeOf(d), d),
if (rest > 0) StorageBlock(trf('나머지 폴더 {0}개', [dirs.length - shown.length]), rest),
if (own > 0) StorageBlock(tr('이 폴더의 파일'), own),
];
if (total == 0 || blocks.every((b) => b.size <= 0)) return Center(child: Text(tr('비어 있습니다')));
return Padding(
padding: const EdgeInsets.all(8),
child: StorageTreemap(blocks: blocks, onTap: (b) => setState(() => _cur = b.path!)),
);
}
Widget _files(StorageScan s) {
final list = [for (final f in s.bigFiles) if (isSameOrInside(f.path, _cur)) f];
if (list.isEmpty) return Center(child: Text(tr('큰 파일이 없습니다')));

View File

@ -0,0 +1,50 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:jj_mkvmaker/ui/storage_chart.dart';
/// X-plore 비교 P3 (87 위): 저장 공간 그림 (treemap) - 넓이는 크기에 비례, 겹치지 않고 다 채움, 누르면 그 폴더
void main() {
test('칸 나누기: 넓이 비례 · 영역 안 · 겹치지 않음 · 0 은 빠짐', () {
const area = Rect.fromLTWH(0, 0, 400, 300);
final blocks = [
const StorageBlock('a', 600, 'A'),
const StorageBlock('b', 300, 'B'),
const StorageBlock('c', 100, 'C'),
const StorageBlock('d', 200),
const StorageBlock('z', 0, 'Z'),
];
final cells = StorageTreemap.layout(blocks, area);
expect(cells.map((c) => c.$1.label), ['a', 'b', 'd', 'c'], reason: '큰 것부터, 0 은 빠짐');
const total = 1200;
for (final (b, r) in cells) {
expect(r.width * r.height, closeTo(area.width * area.height * b.size / total, 0.5));
expect(area.inflate(0.01).contains(r.topLeft) && area.inflate(0.01).contains(r.bottomRight - const Offset(0.001, 0.001)), isTrue);
}
for (var i = 0; i < cells.length; i++) {
for (var j = i + 1; j < cells.length; j++) {
final o = cells[i].$2.intersect(cells[j].$2);
expect(o.width <= 0.01 || o.height <= 0.01, isTrue, reason: '${cells[i].$1.label} · ${cells[j].$1.label} 겹침');
}
}
expect(StorageTreemap.layout(const [], area), isEmpty);
});
testWidgets('폴더 칸을 누르면 그 폴더, 경로 없는 칸 (이 폴더의 파일) 은 누를 수 없음', (t) async {
final tapped = <String>[];
await t.pumpWidget(MaterialApp(
home: Scaffold(
body: SizedBox(
width: 400,
height: 300,
child: StorageTreemap(
blocks: const [StorageBlock('Videos', 800, '/v'), StorageBlock('files', 200)],
onTap: (b) => tapped.add(b.path!),
),
),
),
));
await t.tap(find.text('Videos'));
await t.tap(find.text('files'));
expect(tapped, ['/v']);
});
}