WIP 글 뷰어: 줄바꿈 없는 큰 파일은 화면 폭에 맞춰 나눠 그려 멈추지 않게 (검토 대기)

관리자 지시 (10/19, 사용자 "오늘 못했던 것 진행" 2번 - 데이터 검토에서 남긴 작은 점):
줄바꿈 없는 큰 파일 (한 줄 수십만 자) 을 한 Text 로 그려 잠깐 멈췄다.
- 화면 한 줄에 드는 글자 수 (고정폭 글꼴 · 글자 크기 · 줄 번호 칸) 를 보고, 그 8배보다 긴 줄만 4배 (화면 네 줄쯤) 씩 나눠 그린다.
  보통 줄은 그대로 (화면에서 저절로 줄바꿈). 줄 번호는 첫 조각에만. 이모지 같은 두 단위 글자는 반으로 자르지 않는다.
- 나눈 줄은 글 · 한 줄 글자 수가 같으면 다시 만들지 않는다 (viewerRows).

시험: xplore_tools_test (새 2: viewerRows - 글 그대로 · 줄 번호 · 8배까지 그대로 · 이모지 / 200KB 한 줄 파일을 412 폭에서 여러 짧은 조각으로) 4 통과,
임시 폴더 흔적 0 (tool/test_temp_check.ps1), analyze 문제 없음.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
next2
RyuJongJin 2026-10-19 20:50:59 +09:00
parent 53f5c432bb
commit 6366a2f880
2 changed files with 84 additions and 6 deletions

View File

@ -84,9 +84,22 @@ class _TextViewerPageState extends State<TextViewerPage> {
_text = t;
}
/// 그릴 줄 (줄 번호 · 글) - 글 · 한 줄 글자 수가 같으면 다시 만들지 않는다
List<(int, String)> _rows = const [];
String? _rowsText;
int _rowsPer = 0;
List<(int, String)> _rowsFor(int perRow) {
if (!identical(_rowsText, _text) || _rowsPer != perRow) {
_rowsText = _text;
_rowsPer = perRow;
_rows = viewerRows(_text, perRow);
}
return _rows;
}
@override
Widget build(BuildContext context) {
final lines = _text.split('\n');
// 좁은 화면 (폰 세로) 에서는 줄 번호 · 글자 크기를 ⋮ 메뉴로 (412 에서 파일 이름이 다 가려졌다)
final narrow = MediaQuery.sizeOf(context).width < 600;
return Scaffold(
@ -150,23 +163,29 @@ class _TextViewerPageState extends State<TextViewerPage> {
Expanded(
// 고르기 · 복사는 화면 전체에서 (줄마다 고르는 칸을 두면 큰 파일에서 무겁다)
child: SelectionArea(
child: Scrollbar(
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));
return Scrollbar(
child: ListView.builder(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
itemCount: lines.length,
itemCount: rows.length,
itemBuilder: (_, i) => Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
if (_lineNumbers)
SizedBox(
width: 48,
child: Text('${i + 1}',
child: Text(rows[i].$1 > 0 ? '${rows[i].$1}' : '',
textAlign: TextAlign.right,
style: TextStyle(fontSize: _font - 2, color: JjColors.textDim, fontFamily: 'monospace')),
),
if (_lineNumbers) const SizedBox(width: 8),
Expanded(child: Text(lines[i], style: TextStyle(fontSize: _font, fontFamily: 'monospace'))),
Expanded(child: Text(rows[i].$2, style: TextStyle(fontSize: _font, fontFamily: 'monospace'))),
]),
),
),
);
}),
),
),
if (!_complete)
@ -187,3 +206,27 @@ class _TextViewerPageState extends State<TextViewerPage> {
);
}
}
/// 글 뷰어가 그릴 줄: (줄 번호 - 나눈 조각의 둘째부터는 0, 글). 화면 한 줄에 [perRow] 자가 들 때,
/// 그보다 8배 넘게 긴 줄은 4배 (화면 네 줄쯤) 씩 나눈다 - 보통 줄은 그대로 (화면에서 저절로 줄바꿈)
List<(int, String)> viewerRows(String text, int perRow) {
final out = <(int, String)>[];
final lines = text.split('\n');
final piece = perRow * 4;
for (var i = 0; i < lines.length; i++) {
final line = lines[i];
if (line.length <= perRow * 8) {
out.add((i + 1, line));
continue;
}
var at = 0;
while (at < line.length) {
var end = (at + piece).clamp(0, line.length);
// 두 단위 글자 (이모지 등) 를 반으로 자르지 않게
if (end < line.length && end > at + 1 && (line.codeUnitAt(end) & 0xFC00) == 0xDC00) end--;
out.add((at == 0 ? i + 1 : 0, line.substring(at, end)));
at = end;
}
}
return out;
}

View File

@ -35,6 +35,41 @@ void main() {
expect(find.text('CP949 (EUC-KR)'), findsOneWidget);
});
test('h 글 뷰어: 아주 긴 줄만 나눠 그린다 (줄 번호는 첫 조각에만, 이모지를 반으로 자르지 않음)', () {
expect(viewerRows('a\nbb', 10), [(1, 'a'), (2, 'bb')]);
final long = 'x' * 1000;
final rows = viewerRows('first\n$long\nlast', 10);
expect(rows.first, (1, 'first'));
expect(rows.last, (3, 'last'));
final mid = rows.sublist(1, rows.length - 1);
expect(mid.map((r) => r.$2).join(), long, reason: '글은 그대로 (빠지거나 겹치지 않음)');
expect(mid.first.$1, 2);
expect(mid.skip(1).every((r) => r.$1 == 0), isTrue);
expect(mid.every((r) => r.$2.length <= 40), isTrue, reason: '화면 네 줄쯤씩');
expect(viewerRows('y' * 80, 10).length, 1, reason: '8배까지는 그대로 (화면에서 줄바꿈)');
// 나누는 자리에 두 단위 글자 (😀) 가 걸리면 앞으로 당긴다
final emoji = '${'z' * 39}😀${'z' * 100}';
final cut = viewerRows(emoji, 10);
expect(cut.map((r) => r.$2).join(), emoji);
expect(cut.every((r) => !r.$2.endsWith('\uD83D')), isTrue);
});
testWidgets('h 글 뷰어: 줄바꿈 없는 큰 파일 (200KB 한 줄) 도 나눠 그려 멈추지 않는다', (t) async {
final f = File(p.join(tmp.path, 'one.txt'))..writeAsStringSync('${'0123456789' * 20000}END');
t.view.physicalSize = const Size(412, 900);
t.view.devicePixelRatio = 1;
addTearDown(t.view.reset);
await t.pumpWidget(MaterialApp(home: TextViewerPage(path: f.path)));
for (var i = 0; i < 40 && find.textContaining('0123456789').evaluate().isEmpty; i++) {
await t.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 30)));
await t.pump();
}
final texts = t.widgetList<Text>(find.textContaining('0123456789')).toList();
expect(texts.length, greaterThan(1), reason: '한 줄을 여러 조각으로');
expect(texts.every((x) => (x.data ?? '').length < 2000), isTrue, reason: '조각마다 짧게');
expect(find.text('1'), findsOneWidget, reason: '줄 번호는 첫 조각에만');
});
testWidgets('g 격자 보기: 그림 · 동영상만, 누르면 열기', (t) async {
final png = File(p.join(tmp.path, 'a.png'))..writeAsBytesSync([0x89, 0x50]);
final opened = <String>[];