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 = [];