WIP 글 뷰어 (6366a2f 의 회귀 고침): 긴 줄을 나눠 그려도 Ctrl+A → Ctrl+C 는 줄 전체 · 나누는 폭을 실제 글자 폭에 · 막대에 컨트롤러 (검토 대기)
28 지적 · 관리자 지시 (10/19): ① 회귀: 줄바꿈 없는 긴 줄을 Ctrl+A → Ctrl+C 하면 화면에 그려진 조각만 복사됐다 (next2a74e7be11,091자 →6366a2f1,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>
parent
b10158b46b
commit
27baf91876
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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>[];
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue