WIP 글 뷰어 (6366a2f 의 회귀 고침): 긴 줄을 나눠 그려도 Ctrl+A → Ctrl+C 는 줄 전체 · 나누는 폭을 실제 글자 폭에 · 막대에 컨트롤러 (검토 대기)

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 <noreply@anthropic.com>
next2
RyuJongJin 2026-10-19 21:54:05 +09:00
parent b10158b46b
commit 27baf91876
2 changed files with 93 additions and 2 deletions

View File

@ -84,6 +84,52 @@ class _TextViewerPageState extends State<TextViewerPage> {
_text = t; _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 []; List<(int, String)> _rows = const [];
String? _rowsText; String? _rowsText;
@ -162,14 +208,22 @@ class _TextViewerPageState extends State<TextViewerPage> {
: Column(children: [ : Column(children: [
Expanded( Expanded(
// 고르기 · 복사는 화면 전체에서 (줄마다 고르는 칸을 두면 큰 파일에서 무겁다) // 고르기 · 복사는 화면 전체에서 (줄마다 고르는 칸을 두면 큰 파일에서 무겁다)
child: SelectionArea( child: Focus(
onKeyEvent: _onKey,
canRequestFocus: false,
skipTraversal: true,
child: Listener(
onPointerDown: (_) => _allSelected = false,
child: SelectionArea(
child: LayoutBuilder(builder: (context, box) { child: LayoutBuilder(builder: (context, box) {
// 관리자 지시 (10/19): 줄바꿈 없는 큰 파일 (한 줄이 수십만 자) 을 한 Text 로 그리면 잠깐 멈췄다 - // 관리자 지시 (10/19): 줄바꿈 없는 큰 파일 (한 줄이 수십만 자) 을 한 Text 로 그리면 잠깐 멈췄다 -
// 화면 한 줄에 드는 글자 수 (고정폭 글꼴) 를 보고 아주 긴 줄은 나눠 그린다 // 화면 한 줄에 드는 글자 수 (고정폭 글꼴) 를 보고 아주 긴 줄은 나눠 그린다
final room = box.maxWidth - 16 - (_lineNumbers ? 56 : 0); 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( return Scrollbar(
controller: _scroll,
child: ListView.builder( child: ListView.builder(
controller: _scroll,
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
itemCount: rows.length, itemCount: rows.length,
itemBuilder: (_, i) => Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ itemBuilder: (_, i) => Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
@ -187,6 +241,8 @@ class _TextViewerPageState extends State<TextViewerPage> {
); );
}), }),
), ),
),
),
), ),
if (!_complete) if (!_complete)
SafeArea( SafeArea(

View File

@ -1,6 +1,7 @@
import 'dart:io'; import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:jj_mkvmaker/core/file_ops.dart'; import 'package:jj_mkvmaker/core/file_ops.dart';
import 'package:jj_mkvmaker/core/text_codec.dart'; import 'package:jj_mkvmaker/core/text_codec.dart';
@ -70,6 +71,40 @@ void main() {
expect(find.text('1'), findsOneWidget, reason: '줄 번호는 첫 조각에만'); 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<void>.delayed(const Duration(milliseconds: 30)));
await t.pump();
}
expect(t.widgetList<Text>(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<Scrollbar>(find.byType(Scrollbar));
expect(bar.controller, isNotNull);
expect(bar.controller!.hasClients, isTrue);
});
testWidgets('g 격자 보기: 그림 · 동영상만, 누르면 열기', (t) async { testWidgets('g 격자 보기: 그림 · 동영상만, 누르면 열기', (t) async {
final png = File(p.join(tmp.path, 'a.png'))..writeAsBytesSync([0x89, 0x50]); final png = File(p.join(tmp.path, 'a.png'))..writeAsBytesSync([0x89, 0x50]);
final opened = <String>[]; final opened = <String>[];