From 27baf918764eb6c92edc4a30254ca984404b4382 Mon Sep 17 00:00:00 2001 From: RyuJongJin <266630912+RyuJongJin@users.noreply.github.com> Date: Mon, 19 Oct 2026 21:54:05 +0900 Subject: [PATCH] =?UTF-8?q?WIP=20=EA=B8=80=20=EB=B7=B0=EC=96=B4=20(6366a2f?= =?UTF-8?q?=20=EC=9D=98=20=ED=9A=8C=EA=B7=80=20=EA=B3=A0=EC=B9=A8):=20?= =?UTF-8?q?=EA=B8=B4=20=EC=A4=84=EC=9D=84=20=EB=82=98=EB=88=A0=20=EA=B7=B8?= =?UTF-8?q?=EB=A0=A4=EB=8F=84=20Ctrl+A=20=E2=86=92=20Ctrl+C=20=EB=8A=94=20?= =?UTF-8?q?=EC=A4=84=20=EC=A0=84=EC=B2=B4=20=C2=B7=20=EB=82=98=EB=88=84?= =?UTF-8?q?=EB=8A=94=20=ED=8F=AD=EC=9D=84=20=EC=8B=A4=EC=A0=9C=20=EA=B8=80?= =?UTF-8?q?=EC=9E=90=20=ED=8F=AD=EC=97=90=20=C2=B7=20=EB=A7=89=EB=8C=80?= =?UTF-8?q?=EC=97=90=20=EC=BB=A8=ED=8A=B8=EB=A1=A4=EB=9F=AC=20(=EA=B2=80?= =?UTF-8?q?=ED=86=A0=20=EB=8C=80=EA=B8=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 28 지적 · 관리자 지시 (10/19): ① 회귀: 줄바꿈 없는 긴 줄을 Ctrl+A → Ctrl+C 하면 화면에 그려진 조각만 복사됐다 (a74e7be 11,091자 → 6366a2f 1,513자). 나눈 조각이 ListView.builder 의 줄이라 화면 밖 조각은 고르기에 들지 않았다. → Ctrl+A 를 누른 뒤 (다른 키 · 누르기 전까지) 의 Ctrl+C 는 읽은 글 전체를 복사한다 (화면의 강조는 앱 단축키 그대로). 나누는 것은 화면만. 위 막대 [전체 복사] 는 원래부터 글 전체. ② (예전부터) Windows 에서 Scrollbar 에 컨트롤러가 없어 "ScrollPosition 없음" assert (데스크톱 ListView 는 PrimaryScrollController 를 쓰지 않음) → 같은 ScrollController 를 Scrollbar · ListView 에. ③ 보기: 4줄마다 줄 끝이 비어 보이던 것 - 글자 폭을 어림 (글자 크기 × 0.62) 대신 TextPainter 로 고정폭 글자 폭을 재서 (글자 크기 · 기기 글자 배율) 나눈다. (한글 · 한자처럼 두 칸 글자가 섞인 줄은 여전히 조금 빌 수 있음) 시험: xplore_tools_test (새 1: 11,090자 한 줄 + "\nend" 를 412 에서 열고 Ctrl+A · Ctrl+C → 11,094자 그대로 · 막대 컨트롤러 붙음 - 고치기 전 코드에서는 1,513자로 실패) 5 통과, explorer_test X-plore g · h 통과, 임시 폴더 흔적 0, analyze 문제 없음. Co-Authored-By: Claude Opus 5.5 --- lib/ui/text_viewer.dart | 60 +++++++++++++++++++++++++++++++++++-- test/xplore_tools_test.dart | 35 ++++++++++++++++++++++ 2 files changed, 93 insertions(+), 2 deletions(-) diff --git a/lib/ui/text_viewer.dart b/lib/ui/text_viewer.dart index 444709c..108c6ad 100644 --- a/lib/ui/text_viewer.dart +++ b/lib/ui/text_viewer.dart @@ -84,6 +84,52 @@ class _TextViewerPageState extends State { _text = t; } + /// 28 지적 (10/19): Scrollbar 에 컨트롤러를 붙인다 (데스크톱에서 "ScrollPosition 없음" assert · 막대가 안 그려짐) + final _scroll = ScrollController(); + + /// 28 지적 (10/19, 6366a2f 의 회귀): 긴 줄을 나눠 그리면 화면 밖 조각은 고르기에 들지 않아 Ctrl+A → Ctrl+C 가 보이는 조각만 복사했다. + /// Ctrl+A 를 누른 뒤 (다른 키 · 누르기 전까지) 의 Ctrl+C 는 읽은 글 전체를 복사한다 - 고르기 · 복사는 원래 줄 기준 + bool _allSelected = false; + + KeyEventResult _onKey(FocusNode _, KeyEvent e) { + if (e is! KeyDownEvent) return KeyEventResult.ignored; + final k = e.logicalKey; + if (k == LogicalKeyboardKey.controlLeft || k == LogicalKeyboardKey.controlRight || k == LogicalKeyboardKey.metaLeft || + k == LogicalKeyboardKey.metaRight || k == LogicalKeyboardKey.shiftLeft || k == LogicalKeyboardKey.shiftRight) { + return KeyEventResult.ignored; + } + final ctrl = HardwareKeyboard.instance.isControlPressed || HardwareKeyboard.instance.isMetaPressed; + if (ctrl && k == LogicalKeyboardKey.keyA) { + _allSelected = true; // 화면의 고르기 (강조) 는 그대로 앱의 단축키가 한다 + return KeyEventResult.ignored; + } + if (ctrl && k == LogicalKeyboardKey.keyC && _allSelected) { + Clipboard.setData(ClipboardData(text: _text)); + return KeyEventResult.handled; + } + _allSelected = false; + return KeyEventResult.ignored; + } + + @override + void dispose() { + _scroll.dispose(); + super.dispose(); + } + + /// 고정폭 글자 하나의 실제 폭 (글자 크기 · 기기 글자 배율) - 나누는 폭을 실제 줄바꿈 폭에 맞추려고 (28 지적: 4줄마다 줄 끝이 비었다) + double _charWidth(BuildContext context) { + final tp = TextPainter( + text: TextSpan(text: 'M' * 40, style: DefaultTextStyle.of(context).style.merge(TextStyle(fontSize: _font, fontFamily: 'monospace'))), + textDirection: TextDirection.ltr, + textScaler: MediaQuery.textScalerOf(context), + maxLines: 1, + )..layout(); + final w = tp.width / 40; + tp.dispose(); + return w > 0 ? w : _font * 0.62; + } + /// 그릴 줄 (줄 번호 · 글) - 글 · 한 줄 글자 수가 같으면 다시 만들지 않는다 List<(int, String)> _rows = const []; String? _rowsText; @@ -162,14 +208,22 @@ class _TextViewerPageState extends State { : Column(children: [ Expanded( // 고르기 · 복사는 화면 전체에서 (줄마다 고르는 칸을 두면 큰 파일에서 무겁다) - child: SelectionArea( + child: Focus( + onKeyEvent: _onKey, + canRequestFocus: false, + skipTraversal: true, + child: Listener( + onPointerDown: (_) => _allSelected = false, + child: SelectionArea( child: LayoutBuilder(builder: (context, box) { // 관리자 지시 (10/19): 줄바꿈 없는 큰 파일 (한 줄이 수십만 자) 을 한 Text 로 그리면 잠깐 멈췄다 - // 화면 한 줄에 드는 글자 수 (고정폭 글꼴) 를 보고 아주 긴 줄은 나눠 그린다 final room = box.maxWidth - 16 - (_lineNumbers ? 56 : 0); - final rows = _rowsFor((room / (_font * 0.62)).floor().clamp(20, 4000)); + final rows = _rowsFor((room / _charWidth(context)).floor().clamp(20, 4000)); return Scrollbar( + controller: _scroll, child: ListView.builder( + controller: _scroll, padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), itemCount: rows.length, itemBuilder: (_, i) => Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -187,6 +241,8 @@ class _TextViewerPageState extends State { ); }), ), + ), + ), ), if (!_complete) SafeArea( diff --git a/test/xplore_tools_test.dart b/test/xplore_tools_test.dart index 8cccfca..dcf15e4 100644 --- a/test/xplore_tools_test.dart +++ b/test/xplore_tools_test.dart @@ -1,6 +1,7 @@ import 'dart:io'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:jj_mkvmaker/core/file_ops.dart'; import 'package:jj_mkvmaker/core/text_codec.dart'; @@ -70,6 +71,40 @@ void main() { expect(find.text('1'), findsOneWidget, reason: '줄 번호는 첫 조각에만'); }); + testWidgets('h 글 뷰어 (28 지적 · 6366a2f 회귀): 긴 줄을 나눠 그려도 Ctrl+A → Ctrl+C 는 줄 전체 · 막대 컨트롤러', (t) async { + final line = List.generate(1109, (i) => 'abcdefghi${i % 10}').join(); + final f = File(p.join(tmp.path, 'long.txt'))..writeAsStringSync('$line\nend'); + t.view.physicalSize = const Size(412, 900); + t.view.devicePixelRatio = 1; + addTearDown(t.view.reset); + String? copied; + t.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, (call) async { + if (call.method == 'Clipboard.setData') copied = (call.arguments as Map)['text'] as String?; + return null; + }); + addTearDown(() => t.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, null)); + await t.pumpWidget(MaterialApp(home: TextViewerPage(path: f.path))); + for (var i = 0; i < 40 && find.textContaining('abcdefghi0').evaluate().isEmpty; i++) { + await t.runAsync(() => Future.delayed(const Duration(milliseconds: 30))); + await t.pump(); + } + expect(t.widgetList(find.textContaining('abcdefghi')).length, greaterThan(1), reason: '화면에는 나눠 그림'); + await t.tapAt(const Offset(150, 140)); + await t.pump(); + await t.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await t.sendKeyEvent(LogicalKeyboardKey.keyA); + await t.sendKeyEvent(LogicalKeyboardKey.keyC); + await t.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await t.pump(); + expect(copied, isNotNull); + expect(copied!.length, line.length + 4, reason: '줄 전체 + "\nend" (보이는 조각만이 아님)'); + expect(copied, '$line\nend'); + // 막대에 컨트롤러가 붙어 있다 (데스크톱에서 ScrollPosition 없음 assert 가 나지 않게) + final bar = t.widget(find.byType(Scrollbar)); + expect(bar.controller, isNotNull); + expect(bar.controller!.hasClients, isTrue); + }); + testWidgets('g 격자 보기: 그림 · 동영상만, 누르면 열기', (t) async { final png = File(p.join(tmp.path, 'a.png'))..writeAsBytesSync([0x89, 0x50]); final opened = [];