MKV 위 막대: 오른쪽 묶음을 실제로 재어 모자라면 두 줄 · 넘쳐도 화면 크기 칸을 덮지 않게 (242)

- 버그 (기기 확인, 노트 10+ 가로): 작업 중 폴더 버튼이 화면 크기 '100%' 를 덮었다 - 어림한 폭이 실제보다 작았음
- AppTopBar trailing (CustomMultiChildLayout 으로 실제 폭) · minMiddleWidth 로 두 줄

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
묶음5
RyuJongJin 2026-10-12 01:04:25 +09:00
parent 5a0bc0ec50
commit c2c523a260
3 changed files with 103 additions and 14 deletions

View File

@ -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<AppTopBar> createState() => _AppTopBarState();
@ -56,13 +68,20 @@ class _AppTopBarState extends State<AppTopBar> {
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<AppTopBar> {
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!]))),
]),
);
}

View File

@ -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,6 +309,10 @@ class _TopBar extends StatelessWidget {
(c.checked.isEmpty ? 0 : 3 * 26) +
8; // 여유
final w = box.maxWidth;
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;
@ -315,6 +321,16 @@ class _TopBar extends StatelessWidget {
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),

View File

@ -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<void> 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<void>.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();
});
}