diff --git a/lib/ui/app_actions.dart b/lib/ui/app_actions.dart index 48d10f3..bf2728e 100644 --- a/lib/ui/app_actions.dart +++ b/lib/ui/app_actions.dart @@ -26,13 +26,25 @@ bool isCompact(BuildContext context) => MediaQuery.sizeOf(context).width < compa /// 모든 화면의 위쪽 막대: [nav] [middle (남는 너비)] [actions]. /// 좁은 화면에서는 두 줄 - 위: [nav] … [actions], 아래: [middle] 이 한 줄을 다 쓴다. /// [minMiddleWidth] (201): 한 줄일 때 middle 이 이보다 좁아지면 (폰 가로처럼 넓지만 버튼이 폭을 다 차지할 때) 두 줄로. -/// 브라우저 주소 칸처럼 middle 이 꼭 보여야 하는 화면이 준다 (0 이면 폭만으로 정함) +/// 브라우저 주소 칸처럼 middle 이 꼭 보여야 하는 화면이 준다 (0 이면 폭만으로 정함). +/// [twoRows] (242): 화면이 직접 잰 결과 한 줄에 다 들어가지 않으면 두 줄로 (MKV 화면 - 작업 중 버튼이 늘 때). +/// [trailing] (242): middle 오른쪽에 붙는, 줄지 않는 버튼 묶음 - 이것 때문에 middle 이 [minMiddleWidth] 보다 좁아지면 두 줄 +/// (어림한 폭이 실제보다 작아 넘친 버튼이 화면 크기 "100%" 를 덮던 것 - 실제로 재어 정한다) class AppTopBar extends StatefulWidget { final Widget nav; final Widget middle; + final Widget? trailing; final Widget actions; final double minMiddleWidth; - const AppTopBar({super.key, required this.nav, required this.middle, required this.actions, this.minMiddleWidth = 0}); + final bool twoRows; + const AppTopBar( + {super.key, + required this.nav, + required this.middle, + this.trailing, + required this.actions, + this.minMiddleWidth = 0, + this.twoRows = false}); @override State createState() => _AppTopBarState(); @@ -56,13 +68,20 @@ class _AppTopBarState extends State { final nav = widget.nav, middle = widget.middle, actions = widget.actions; final width = MediaQuery.sizeOf(context).width; final narrow = _twoRowsAtOrBelow != null && width <= _twoRowsAtOrBelow!; - if (!isCompact(context) && !narrow) { + if (!isCompact(context) && !narrow && !widget.twoRows) { if (widget.minMiddleWidth > 0) WidgetsBinding.instance.addPostFrameCallback((_) => _check(width)); return Container( height: appBarHeight, color: JjColors.panel, padding: const EdgeInsets.only(left: 8, right: appBarRightPadding), - child: Row(children: [nav, const SizedBox(width: 8), Expanded(child: KeyedSubtree(key: _mid, child: middle)), actions]), + // 242: middle 이 넘쳐도 오른쪽 (화면 크기 "100%" · 설정 · 종료) 을 덮어 그리지 않게 + child: Row(children: [ + nav, + const SizedBox(width: 8), + Expanded(child: ClipRect(child: KeyedSubtree(key: _mid, child: middle))), + if (widget.trailing != null) widget.trailing!, + actions, + ]), ); } return Container( @@ -77,7 +96,11 @@ class _AppTopBarState extends State { actions, ]), ), - SizedBox(height: 48, child: Padding(padding: const EdgeInsets.only(left: 6), child: middle)), + SizedBox( + height: 48, + child: Padding( + padding: const EdgeInsets.only(left: 6), + child: widget.trailing == null ? middle : Row(children: [Expanded(child: middle), widget.trailing!]))), ]), ); } diff --git a/lib/ui/home_page.dart b/lib/ui/home_page.dart index 538ffd1..214a4ee 100644 --- a/lib/ui/home_page.dart +++ b/lib/ui/home_page.dart @@ -278,8 +278,10 @@ class _TopBar extends StatelessWidget { final count = c.checked.isEmpty ? '' : ' (${c.checked.length})'; // 창 너비에 맞춰 차례로 줄인다: 제목 글 → 왼쪽 버튼을 아이콘만 → 재생 · 브라우저를 아이콘만 → CPU · MEM 숨김. // (오른쪽의 화면 크기 · 환경 설정 · 종료 버튼은 어떤 너비에서도 밀려나지 않게) - final compact = isCompact(context); + final compactScreen = isCompact(context); return LayoutBuilder(builder: (context, box) { + // 242: 한 줄에 안 들어가면 이 그리기에서만 두 줄 (좁은 화면) 처럼 잰다 + var compact = compactScreen; final hasAi = c.aiAvailable; final hasPlayer = c.services.createMediaPlayer != null; var title = true, leftText = true, rightText = true, usage = c.usage != null, jobText = true; @@ -307,14 +309,28 @@ class _TopBar extends StatelessWidget { (c.checked.isEmpty ? 0 : 3 * 26) + 8; // 여유 final w = box.maxWidth; - if (compact) title = false; - if (need() > w) title = false; - if (need() > w) jobText = false; - if (need() > w) leftText = false; - if (need() > w) rightText = false; - if (need() > w) usage = false; - if (need() > w) dlBox = false; - if (need() > w) mkvText = false; + void shrink() { + title = leftText = rightText = jobText = dlBox = mkvText = true; + usage = c.usage != null; + dlBox = downloads != null; + if (compact) title = false; + if (need() > w) title = false; + if (need() > w) jobText = false; + if (need() > w) leftText = false; + if (need() > w) rightText = false; + if (need() > w) usage = false; + if (need() > w) dlBox = false; + if (need() > w) mkvText = false; + } + + shrink(); + // 242: 다 줄여도 한 줄에 안 들어가면 (폰 가로에서 작업 중 - 진행 표시 · 취소가 늘 때) 두 줄로 다시 잰다. + // 예전에는 넘친 버튼 (결과 폴더) 이 오른쪽의 화면 크기 "100%" 를 덮었다 + final twoRows = !compact && need() > w; + if (twoRows) { + compact = true; + shrink(); + } // 7: 폰에는 "탐색기에서 끌어다 놓기" 가 없다 final addTip = Platform.isAndroid ? tr('동영상 추가') : tr('동영상 추가 (탐색기에서 끌어다 놓아도 됩니다)'); @@ -359,6 +375,7 @@ class _TopBar extends StatelessWidget { VoidCallback? build0() => !canBuild ? null : (batch.isEmpty ? () => _confirmBuildAll(context) : () => c.buildVideos(batch)); return AppTopBar( + twoRows: twoRows, // 242: 넓은 화면인데 한 줄에 안 들어갈 때도 이 두 줄 배치 nav: const AppNavButtons(onMkvPage: true), actions: AppActions(c: c, onExit: onExit, showUsage: false), middle: SingleChildScrollView( @@ -404,6 +421,10 @@ class _TopBar extends StatelessWidget { } return AppTopBar( + // 242: 작업 중 (진행 표시 · 취소) 이 생기거나 없어지면 다시 잰다 + key: ValueKey(('mkvTopBar', c.busy)), + // 242: 오른쪽 묶음 때문에 왼쪽 (동영상 추가 · AI) 이 아이콘 하나보다 좁아지면 두 줄 + minMiddleWidth: 56, // 모든 화면 공통: [JJ 홈] [MKV 화면] [뒤로] [다운로드 목록] nav: const AppNavButtons(onMkvPage: true), // 화면 크기 · 환경 설정 · 종료: 모든 화면에서 같은 자리 @@ -428,6 +449,12 @@ class _TopBar extends StatelessWidget { ), ), const SizedBox(width: 8), + ], + ), + // 오른쪽 묶음 (줄지 않음): 작업 · 재생 · MKV 만들기 · 결과 폴더 · 다운로드 + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ if (c.busy) ...[ JobIndicator(c: c, compact: !jobText, iconOnly: !jobText), const SizedBox(width: 8), diff --git a/test/batch_test.dart b/test/batch_test.dart index 752a802..d8c8627 100644 --- a/test/batch_test.dart +++ b/test/batch_test.dart @@ -581,4 +581,43 @@ void main() { expect(b.status, JobStatus.cancelled); expect(find.text('모두 취소 (2개)'), findsNothing); }); + + testWidgets('242: 폰 가로 (노트 10+ - 내비게이션 막대를 뺀 약 800×411) 에서 작업 중이어도 위 막대 버튼이 화면 크기 "100%" 를 덮지 않는다', (tester) async { + final w = double.parse(Platform.environment['JJ_W'] ?? '800'); + tester.view.physicalSize = Size(w, 411); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final tool = _HoldTool(); + final dir = Directory.systemTemp.createTempSync('jj_batch242_'); + addTearDown(() => dir.deleteSync(recursive: true)); + final file = (File(p.join(dir.path, 'a.mp4'))..writeAsBytesSync([0])).path; + final c = AppController(PlatformServices(mediaTool: tool, storage: DesktopStorageService())); + late Future building; + await tester.runAsync(() async { + await c.init(); + await c.addVideos([file]); + c.selected = c.videos.first; + building = c.buildVideos([...c.videos]); + for (var i = 0; i < 200 && tool.runs.isEmpty; i++) { + await Future.delayed(const Duration(milliseconds: 10)); + } + }); + expect(c.busy, isTrue); + await tester.pumpWidget(MaterialApp(home: HomePage(c: c))); + // 첫 그림에서 재어 보고 모자라면 다음 그림부터 두 줄 (첫 그림의 넘침 알림은 그 재기) + tester.takeException(); + await tester.pump(); + await tester.pump(); + expect(tester.takeException(), isNull, reason: '두 줄로 바뀐 뒤에는 넘침 없음'); + final pct = tester.getRect(find.text('${(c.settings.uiScale * 100).round()}%')); + for (final tip in ['작업 취소', '결과 폴더 열기: ${outputDirFor(file)}']) { + final f = find.byTooltip(tip); + expect(f, findsWidgets, reason: tip); + final r = tester.getRect(f.first); + expect(r.overlaps(pct), isFalse, reason: '$tip 버튼 $r 이 "100%" $pct 를 덮음'); + } + c.cancel(); + await tester.runAsync(() => building); + await tester.pump(); + }); }