W TailText: 창 위 경로 줄도 끝 (지금 폴더) 이 보이게 · 폭 재기는 path_label.textWidth 하나로 + 탐색기 캡처 흔들림 고정

- pane_view 의 경로 줄 (폴더 + 파일 목록 · TC 모양 오른쪽): 예전에는 끝을 "…" 로 잘라 지금 폴더가 안 보였다 → TailText (앞을 줄임)
- TailText 의 폭 재기를 path_label 의 textWidth 로 (글 방향 · 다 쓴 TextPainter 놓기 - 예전에는 매번 새로 만들고 놓지 않음)
- AdaptiveRow · ColumnTable 점검: 좁을 때 모양을 바꾸는 다른 곳은 목록 / 자세히 세로 쌓기 · 칩 줄 따로 등 "앞 묶음 · 뒤 묶음" 꼴이 아니라 그대로.
  열 판단은 이미 columnLayout (순수 함수, 312 조합 시험) 하나를 열 머리 · 줄이 같이 씀
- 시험 쪽 흔들림 고정: support/shot_services (shotServices - CPU · 메모리 · 디스크, shotDevice - 드라이브 남은 공간) 를
  check_shot · explorer_shot · explorer_text 가 같이 씀. explorer_text 는 JJ_SHOT_TMP 아래 고정 폴더 · 시각, xplore 는 고정 폴더 ·
  시각 · 압축 안 항목 시각
- 사용자가 보게 될 동작: 탐색기 두 창 · 폴더 + 파일 목록의 오른쪽 위 경로 줄이 "…\Videos\Movies" 처럼 끝이 보임

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
modular
RyuJongJin 2026-10-25 07:32:36 +09:00
parent 114769d247
commit 7970dba271
7 changed files with 75 additions and 47 deletions

View File

@ -6,10 +6,11 @@ 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/services/platform_services.dart';
import 'package:jj_mkvmaker/ui/explorer_page.dart';
import 'package:jj_mkvmaker/ui/theme.dart';
import 'support/shot_services.dart';
/// 파일 탐색기 화면을 실제 글꼴 · 실제 FFmpeg (썸네일 · 동영상 정보) 로 그려 PNG 로 저장 (JJ_SHOT_DIR).
/// 왼쪽 · 오른쪽 창 폴더: JJ_SHOT_LEFT · JJ_SHOT_RIGHT. 화면 배치 확인용.
/// JJ_SHOT_THEME=light 면 밝은 테마로 (⑦ - 파일 이름 끝에 _light)
@ -19,7 +20,8 @@ void main() {
testWidgets('파일 탐색기 화면 그림', (tester) async {
final env = Platform.environment;
final out = env['JJ_SHOT_DIR'] ?? Directory.systemTemp.path;
final c = AppController(PlatformServices.create());
// 관리자 지시 (10/24): 실행마다 다른 값 (폴더 이름 · 시각 · CPU · 메모리) 을 정한 값으로 (캡처가 흔들리지 않게 - 시험 쪽에서만)
final c = AppController(shotServices());
c.settings.explorer.paths = [env['JJ_SHOT_LEFT'] ?? Directory.current.path, env['JJ_SHOT_RIGHT'] ?? Directory.current.path];
final key = GlobalKey();
final light = env['JJ_SHOT_THEME'] == 'light';
@ -39,7 +41,7 @@ void main() {
tester.view.devicePixelRatio = 1;
await tester.runAsync(() => tester.pumpWidget(MaterialApp(
theme: buildTheme(),
home: RepaintBoundary(key: key, child: ExplorerPage(c: c)),
home: RepaintBoundary(key: key, child: ExplorerPage(c: c, device: shotDevice())),
)));
// 목록 · 썸네일 · 동영상 정보가 나올 때까지
for (var i = 0; i < 40; i++) {

View File

@ -7,11 +7,13 @@ 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/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/shot_services.dart';
import 'support/temp_dir.dart';
/// X-plore 비교 40 (관리자 지시 10/20): 412 폭 · 목록 글자 150% · 넓게 (그리고 처음 값) 를 실제 글꼴로 그려 PNG 로 (JJ_SHOT_DIR).
/// 세 스타일 모두. 넘치면 (RenderFlex overflow) 시험이 실패한다
void main() {
@ -21,14 +23,18 @@ void main() {
testWidgets('40: 목록 글자 크기 · 줄 간격 화면 그림 (412 폭)', (tester) async {
final out = Platform.environment['JJ_SHOT_DIR'] ?? Directory.systemTemp.path;
final tmp = Directory.systemTemp.createTempSync('jj_textshot_');
addTearDown(() => tmp.deleteSync(recursive: true));
// 관리자 지시 (10/24): 실행마다 다른 값 (폴더 이름 · 시각 · CPU · 메모리) 을 정한 값으로 (캡처가 흔들리지 않게 - 시험 쪽에서만)
// 트리에 둘레 폴더도 보여 JJ_SHOT_TMP (캡처용으로 따로 둔 폴더) 아래에 둔다
final base = Platform.environment['JJ_SHOT_TMP'] ?? '';
final tmp = testFixedTempDir('jj_textshot_fixed', base: base);
File(p.join(tmp.path, '아주 긴 이름의 동영상 파일 - 글자 크기 시험용 2026 최종본.mp4')).writeAsStringSync('v' * 123456);
File(p.join(tmp.path, 'Very long English file name for the text size check.txt')).writeAsStringSync('t' * 4321);
File(p.join(tmp.path, 'a.jpg')).writeAsStringSync('x');
Directory(p.join(tmp.path, '폴더 하나')).createSync();
Directory(p.join(tmp.path, 'Another folder with a long name')).createSync();
final c = AppController(PlatformServices.create());
// 캡처용 폴더가 따로 있으면 그 폴더의 시각까지 (트리에 보인다). 시스템 임시 폴더면 시험 폴더만
pinTestTimes(base.isEmpty ? tmp : Directory(base));
final c = AppController(shotServices());
c.settings.explorer.paths = [tmp.path, tmp.path];
final key = GlobalKey();
// 폰처럼 (Android 는 트리 펼치기 화살표가 44 로 넓다 - Windows 로 그리면 폰보다 넉넉해 보인다)
@ -47,7 +53,7 @@ void main() {
theme: buildTheme(),
// 기기 글자 크기 (관리자 지시 10/20: 1.3 × 150% 에서 두 창이 모자라면 한 창)
builder: (ctx, child) => MediaQuery(data: MediaQuery.of(ctx).copyWith(textScaler: TextScaler.linear(device)), child: child!),
home: RepaintBoundary(key: key, child: ExplorerPage(c: c)),
home: RepaintBoundary(key: key, child: ExplorerPage(c: c, device: shotDevice())),
)));
for (var i = 0; i < 12; i++) {
await tester.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 250)));

View File

@ -0,0 +1,40 @@
import 'package:jj_mkvmaker/services/platform_services.dart';
import 'package:jj_mkvmaker/services/system_usage.dart';
import '../../test/support/test_explorer_device.dart';
/// 캡처용 (관리자 10/24): 위 막대의 CPU · 메모리는 이 PC 의 실제 값이라 실행마다 달라 캡처가 흔들렸다 - 정한 값 (시험 쪽에서만)
class FixedUsage implements SystemUsage {
const FixedUsage();
@override
UsageSample? read() => const UsageSample(cpu: 0.05, memUsed: 31, memTotal: 100);
@override
(int, int)? disk(String path) => null;
}
/// 캡처용 실제 조립 ([PlatformServices.create] 그대로) 에서 실행마다 다른 값만 정한 값으로:
/// CPU · 메모리 ([FixedUsage]) · 디스크 남은 / 전체 (100 GB / 500 GB). 나머지 (엔진 · 저장소 · 기기) 는 실제 것
PlatformServices shotServices() {
final r = PlatformServices.create();
return PlatformServices(
mediaTool: r.mediaTool,
storage: r.storage,
createPlayer: r.createPlayer,
createMediaPlayer: r.createMediaPlayer,
createRecognizer: r.createRecognizer,
createTranslator: r.createTranslator,
createDownloadBackends: r.createDownloadBackends,
createSubtitleProviders: r.createSubtitleProviders,
createUpscaler: r.createUpscaler,
findLanSmb: r.findLanSmb,
diskSpace: (_) async => (107374182400, 536870912000),
updater: r.updater,
usage: r.usage == null ? null : const FixedUsage(),
models: r.models,
shell: r.shell,
platform: r.platform,
);
}
/// 캡처용 파일 탐색기 기기: 실제 기기 그대로 (목록 · 드라이브 · 휴지통) 에서 드라이브 남은 / 전체 (TC 모양 창 위 줄) 만 정한 값
TestExplorerDevice shotDevice() => TestExplorerDevice()..space = (_) => (107374182400, 536870912000);

View File

@ -10,8 +10,6 @@ import 'package:jj_mkvmaker/app/app_controller.dart';
import 'package:jj_mkvmaker/app/master_lock.dart';
import 'package:jj_mkvmaker/platform/windows/recycle_bin.dart';
import 'package:jj_mkvmaker/services/image_ai.dart';
import 'package:jj_mkvmaker/services/platform_services.dart';
import 'package:jj_mkvmaker/services/system_usage.dart';
import 'package:jj_mkvmaker/services/secret_store.dart';
import 'package:jj_mkvmaker/ui/ai_image_page.dart';
import 'package:jj_mkvmaker/ui/explorer_page.dart';
@ -22,6 +20,7 @@ import 'package:path/path.dart' as p;
import '../test/support/fake_recycle_bin.dart';
import '../test/support/test_explorer_device.dart';
import 'support/shot_services.dart';
import 'support/temp_dir.dart';
/// 기기 감독 (Windows) 확인용: 실제 앱 화면을 띄워 누르고 단계마다 PNG (JJ_SHOT_DIR) 로 남긴다.
@ -69,28 +68,8 @@ void main() {
await settle(t);
}
/// 실제 조립에서 위 막대의 CPU · 메모리 값만 고정 (실행마다 이 PC 의 실제 CPU % 가 달라 캡처가 흔들렸다 - 시험 쪽에서만)
AppController controller() {
final r = PlatformServices.create();
return AppController(PlatformServices(
mediaTool: r.mediaTool,
storage: r.storage,
createPlayer: r.createPlayer,
createMediaPlayer: r.createMediaPlayer,
createRecognizer: r.createRecognizer,
createTranslator: r.createTranslator,
createDownloadBackends: r.createDownloadBackends,
createSubtitleProviders: r.createSubtitleProviders,
createUpscaler: r.createUpscaler,
findLanSmb: r.findLanSmb,
diskSpace: r.diskSpace,
updater: r.updater,
usage: r.usage == null ? null : const _FixedUsage(),
models: r.models,
shell: r.shell,
platform: r.platform,
));
}
/// 실제 조립에서 실행마다 다른 값 (CPU · 메모리 · 디스크) 만 고정 - support/shot_services (시험 쪽에서만)
AppController controller() => AppController(shotServices());
/// 그 글이 화면에 나올 때까지 (목록을 다시 읽는 중일 수 있어) 기다렸다가 누른다
Future<void> tapText(WidgetTester t, String text) async {
@ -336,10 +315,3 @@ Future<int> _purgeRecycled(String dir, {required DateTime since}) async {
}
/// 늘 같은 값을 내는 사용량 원천 (CPU 5% · 메모리 31%) - 캡처가 실행마다 같게
class _FixedUsage implements SystemUsage {
const _FixedUsage();
@override
UsageSample? read() => const UsageSample(cpu: 0.05, memUsed: 31, memTotal: 100);
@override
(int, int)? disk(String path) => null;
}

View File

@ -37,7 +37,8 @@ void main() {
addTearDown(t.view.reset);
await t.runAsync(() => i18n.apply(lang, save: false));
addTearDown(() => i18n.apply('ko', save: false));
final tmp = testTempDir('jj_xpshot_');
// 관리자 지시 (10/24): 실행마다 다른 값 (폴더 이름 · 시각 · CPU · 메모리) 을 정한 값으로 (캡처가 흔들리지 않게 - 시험 쪽에서만)
final tmp = testFixedTempDir('jj_xpshot_fixed');
appTempInto(tmp); // 앱 임시 폴더 (jj_mkvmaker) 도 이 아래로 - 끝에 함께 지움 (관리자 지시 10/20)
final a = Directory(p.join(tmp.path, 'Videos'))..createSync();
final b = Directory(p.join(tmp.path, 'Backup'))..createSync();
@ -47,8 +48,10 @@ void main() {
File(p.join(a.path, 'Show S01', 'ep01.mkv')).writeAsBytesSync(List.filled(5000, 0));
File(p.join(a.path, 'notes.txt')).writeAsStringSync('memo');
File(p.join(a.path, 'Photos.zip')).writeAsBytesSync(ZipEncoder().encode(Archive()
..addFile(ArchiveFile.string('2026/beach.jpg', 'x'))
..addFile(ArchiveFile.string('readme.txt', 'readme'))));
// 압축 안 항목의 시각도 정해 둔다 (처음 값은 만든 때 - 캡처가 흔들림)
..addFile(ArchiveFile.string('2026/beach.jpg', 'x')..lastModTime = _fixedZipTime)
..addFile(ArchiveFile.string('readme.txt', 'readme')..lastModTime = _fixedZipTime)));
pinTestTimes(tmp);
device.volumes = () => [(a.path, 'C:'), (b.path, 'D:')];
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
c.settings
@ -106,3 +109,6 @@ void main() {
}
}
}
/// 캡처용 압축 안 항목 시각 (유닉스 초)
final _fixedZipTime = DateTime(2026, 10, 22, 9).millisecondsSinceEpoch ~/ 1000;

View File

@ -19,6 +19,7 @@ import 'explorer_pane.dart';
import 'explorer_select.dart';
import 'row_view.dart';
import 'tc_drive_space.dart';
import '../tail_text.dart';
/// 약속 (E5d-3): 창 하나를 그리는 데 화면이 주는 것. 창의 틀 (활성 · 놓기) · 창 머리 · 안내 · 표시 막대 · 줄 메뉴 ·
/// 끌어 놓기 · 필터 닫기는 아직 화면의 일이라 여기로 받는다 (나눌 때마다 이 목록이 줄어든다)
@ -226,7 +227,8 @@ class ExplorerListView extends StatelessWidget {
height: 40,
alignment: Alignment.centerLeft,
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Text(ctl.displayPath(dir), maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(color: JjColors.textDim)),
// 경로는 끝 (지금 폴더) 이 보이게 앞을 줄인다 (TailText - 예전에는 끝을 "…" 로 잘라 지금 폴더가 안 보였다)
child: TailText(ctl.displayPath(dir), style: TextStyle(color: JjColors.textDim)),
),
const Divider(height: 1),
if (pane.filterOpen) ExplorerFilterBar(pane: pane, shown: shown.length, onClose: () => parts.closeFilter(pane), onChanged: parts.filterChanged),

View File

@ -1,5 +1,7 @@
import 'package:flutter/material.dart';
import 'path_label.dart' show textWidth;
/// 경로처럼 끝 (지금 폴더) 이 중요한 글 (관리자 지시 10/21, 28 지적): 다 들어가면 그대로, 모자라면 앞을 "…" 로 줄인다.
/// 구분 ([separators] - " › " · "\" · "/") 에서 자르는 것을 먼저 (가장 긴 뒤쪽), 마지막 조각도 안 들어가면 글자 단위로.
/// 지금 글꼴 · 글자 배율로 잰다
@ -39,10 +41,8 @@ class TailText extends StatelessWidget {
@override
Widget build(BuildContext context) => LayoutBuilder(builder: (context, box) {
final s = DefaultTextStyle.of(context).style.merge(style);
final scaler = MediaQuery.textScalerOf(context);
double measure(String t) =>
(TextPainter(text: TextSpan(text: t, style: s), textDirection: TextDirection.ltr, textScaler: scaler, maxLines: 1)..layout()).width;
// 폭 재기는 path_label 의 textWidth 하나로 (지금 글꼴 · 글자 배율 · 글 방향, 다 쓴 TextPainter 는 놓음)
double measure(String t) => textWidth(context, t, style ?? const TextStyle());
final shown = box.maxWidth.isFinite ? fit(text, box.maxWidth, measure, separators: separators) : text;
return Text(shown, style: style, maxLines: 1, overflow: TextOverflow.clip, softWrap: false);
});