import 'dart:io'; import 'dart:typed_data'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:path/path.dart' as p; import '../core/charset_detector.dart'; import '../core/file_ops.dart' show formatSize; import '../core/text_codec.dart'; import '../l10n/tr.dart'; import 'theme.dart'; import 'jj_title_bar.dart'; /// X-plore 비교 h: 글 뷰어 - 글 파일 전체를 읽기 전용으로. 큰 파일은 앞에서부터 나눠 읽고 [더 읽기], /// 글자 코드는 스스로 판별 (UTF-8 · UTF-16 · CP949 · Shift-JIS · GBK) 하고 위에서 바꿀 수 있다. 줄 번호 · 글자 크기 · 복사. class TextViewerPage extends StatefulWidget { final String path; /// 한 번에 읽는 양 (시험에서 줄인다) final int chunk; const TextViewerPage({super.key, required this.path, this.chunk = 256 * 1024}); @override State createState() => _TextViewerPageState(); } class _TextViewerPageState extends State { final _bytes = BytesBuilder(copy: false); int _size = 0; String? _charset; /// 사용자가 위에서 고른 글자 코드 (고르기 전에는 더 읽을 때마다 다시 판별 - 앞 조각만으로는 틀릴 수 있다) bool _picked = false; String _text = ''; Object? _error; double _font = 13; bool _lineNumbers = true; @override void initState() { super.initState(); _more(); } bool get _complete => _bytes.length >= _size; Future _more() async { try { final f = File(widget.path); _size = await f.length(); final from = _bytes.length; final to = (from + widget.chunk).clamp(0, _size); await for (final x in f.openRead(from, to)) { _bytes.add(x); } final all = _bytes.toBytes(); if (!_picked) _charset = detectCharset(Uint8List.sublistView(all, 0, all.length.clamp(0, 256 * 1024))); _decode(all); } catch (e) { _error = e; } if (mounted) setState(() {}); } void _decode(Uint8List all) { String t; // 나눠 읽은 끝에서 두 바이트 글자 (CP949 등) 가 잘리면 글자 코드 풀이가 실패한다 - 끝 바이트를 다음 조각까지 미룬다 var cut = 0; while (true) { try { t = decodeText(Uint8List.sublistView(all, 0, all.length - cut), _charset!); break; } on FormatException { if (_complete || cut >= 3 || cut >= all.length) rethrow; cut++; } } // 나눠 읽은 끝에서 글자가 반쯤 잘렸으면 다음 조각과 함께 읽을 때까지 뺀다 if (!_complete) { while (t.endsWith('�')) { t = t.substring(0, t.length - 1); } } _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) => _measure(context, 'M') ?? _font * 0.62; /// 넓은 글자 (한글 · 한자 · 가나 · 전각) 하나가 고정폭 글자 몇 개 폭인지 (관리자 지시 10/19: 넓은 글자가 섞인 줄도 줄 끝이 비지 않게) double _wideRatio(BuildContext context) { // l10n-skip: 폭을 재는 넓은 글자 (화면 글이 아님) final m = _measure(context, 'M'), w = _measure(context, '가'); return m == null || w == null ? 2 : w / m; } double? _measure(BuildContext context, String ch) { final tp = TextPainter( text: TextSpan(text: ch * 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 : null; } /// 그릴 줄 (줄 번호 · 글) - 글 · 폭 · 글자 크기가 같으면 다시 만들지 않는다 List<(int, String)> _rows = const []; String? _rowsText; String _rowsKey = ''; /// [room] 폭에 [style] 로 그릴 때의 줄. 아주 긴 줄은 화면에서 실제로 네 줄이 끝나는 자리 (TextPainter 로 그 글꼴 그대로 줄을 바꿔 봄) 에서 자른다 - /// 관리자 지시 (10/19): Windows 의 'monospace' 는 고정폭이 아니라 글자 수로 세면 넷째 줄 끝이 비었다 (영문 · 한글 섞인 줄 모두) List<(int, String)> _rowsFor(BuildContext context, double room) { final style = DefaultTextStyle.of(context).style.merge(TextStyle(fontSize: _font, fontFamily: 'monospace')); final scaler = MediaQuery.textScalerOf(context); final perRow = (room / _charWidth(context)).floor().clamp(20, 4000); final key = '$room|$_font|${scaler.scale(100)}|$perRow'; if (identical(_rowsText, _text) && _rowsKey == key) return _rows; _rowsText = _text; _rowsKey = key; final tp = TextPainter(textDirection: Directionality.of(context), textScaler: scaler); try { _rows = viewerRows(_text, perRow, wide: _wideRatio(context), cut: (line, from) { var size = perRow * 8; while (true) { var end = (from + size).clamp(0, line.length); // 28 지적 (10/19): 재는 조각이 이모지 같은 두 단위 글자의 가운데에서 끝나면 반쪽 글자라 TextSpan 이 // "not well-formed UTF-16" 으로 실패했다 (화면은 정상이나 오류가 쏟아지고 더 큰 조각을 다시 잼) - 글자 앞에서 자른다 if (end < line.length && end > from + 1 && (line.codeUnitAt(end) & 0xFC00) == 0xDC00) end--; tp.text = TextSpan(text: line.substring(from, end), style: style); tp.layout(maxWidth: room); final m = tp.computeLineMetrics(); if (m.length > 4) { // 다섯째 줄의 시작 = 네 줄의 끝 final at = tp.getPositionForOffset(Offset(0, m[4].baseline - m[4].ascent / 2)).offset; if (at > 0) return from + at; } if (end >= line.length) return line.length; size *= 2; } }); } finally { tp.dispose(); } return _rows; } @override Widget build(BuildContext context) { // 좁은 화면 (폰 세로) 에서는 줄 번호 · 글자 크기를 ⋮ 메뉴로 (412 에서 파일 이름이 다 가려졌다) final narrow = MediaQuery.sizeOf(context).width < 600; return Scaffold( appBar: jjTitleBar( context, title: p.basename(widget.path), actions: [ if (_charset != null) DropdownButton( value: supportedCharsets.containsKey(_charset) ? _charset : 'UTF-8', underline: const SizedBox(), items: [for (final e in supportedCharsets.entries) DropdownMenuItem(value: e.key, child: Text(e.value))], onChanged: (v) => setState(() { _charset = v; _picked = true; _decode(_bytes.toBytes()); }), ), if (!narrow) ...[ IconButton( tooltip: tr('줄 번호'), icon: Icon(_lineNumbers ? Icons.format_list_numbered : Icons.notes), onPressed: () => setState(() => _lineNumbers = !_lineNumbers), ), IconButton(tooltip: tr('글자 작게'), icon: const Icon(Icons.text_decrease), onPressed: () => setState(() => _font = (_font - 1).clamp(9, 30))), IconButton(tooltip: tr('글자 크게'), icon: const Icon(Icons.text_increase), onPressed: () => setState(() => _font = (_font + 1).clamp(9, 30))), ], IconButton( tooltip: tr('전체 복사'), icon: const Icon(Icons.copy), onPressed: () { Clipboard.setData(ClipboardData(text: _text)); ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(tr('복사했습니다')))); }, ), if (narrow) PopupMenuButton( tooltip: tr('더 보기'), onSelected: (v) => setState(() { switch (v) { case 'lines': _lineNumbers = !_lineNumbers; case 'smaller': _font = (_font - 1).clamp(9, 30); case 'bigger': _font = (_font + 1).clamp(9, 30); } }), itemBuilder: (_) => [ CheckedPopupMenuItem(value: 'lines', checked: _lineNumbers, child: Text(tr('줄 번호'))), PopupMenuItem(value: 'smaller', child: Text(tr('글자 작게'))), PopupMenuItem(value: 'bigger', child: Text(tr('글자 크게'))), ], ), ], ), body: _error != null ? Center(child: Text(trf('읽지 못했습니다: {0}', [_error]))) : _charset == null ? const Center(child: CircularProgressIndicator()) : Column(children: [ Expanded( // 고르기 · 복사는 화면 전체에서 (줄마다 고르는 칸을 두면 큰 파일에서 무겁다) 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(context, room); 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: [ if (_lineNumbers) SizedBox( width: 48, 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(rows[i].$2, style: TextStyle(fontSize: _font, fontFamily: 'monospace'))), ]), ), ); }), ), ), ), ), if (!_complete) SafeArea( top: false, child: Padding( padding: const EdgeInsets.all(6), child: Row(children: [ Expanded( child: Text(trf('{0} / {1} 읽음', [formatSize(_bytes.length), formatSize(_size)]), style: TextStyle(fontSize: 12, color: JjColors.textDim)), ), FilledButton.tonal(onPressed: _more, child: Text(tr('더 읽기'))), ]), ), ), ]), ); } } /// 글 뷰어가 그릴 줄: (줄 번호 - 나눈 조각의 둘째부터는 0, 글). 화면 한 줄에 고정폭 글자 [perRow] 개가 들 때, /// 그보다 8배 넘게 긴 줄은 화면 네 줄 분량씩 나눈다 - 보통 줄은 그대로 (화면에서 저절로 줄바꿈). /// 넓은 글자 (한글 · 한자 · 가나 · 전각 · 이모지) 는 고정폭 글자 [wide] 개 폭으로 세어 (관리자 지시 10/19) 넓은 글자가 섞여도 네 줄이 꽉 차게. /// [cut] 이 있으면 (화면) 조각의 끝을 그것이 정한다 - 글꼴로 실제 줄을 바꿔 본 자리 (글자 수 어림보다 정확) List<(int, String)> viewerRows(String text, int perRow, {double wide = 2, int Function(String line, int from)? cut}) { final out = <(int, String)>[]; final lines = text.split('\n'); final budget = perRow * 4.0; 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) { final byLayout = cut?.call(line, at); if (byLayout != null && byLayout > at) { var end = byLayout.clamp(at + 1, line.length); // 두 단위 글자 (이모지 등) 를 반으로 자르지 않게 if (end < line.length && (line.codeUnitAt(end) & 0xFC00) == 0xDC00) end++; out.add((at == 0 ? i + 1 : 0, line.substring(at, end))); at = end; continue; } var end = at; var used = 0.0; while (end < line.length) { final c = line.codeUnitAt(end); // 두 단위 글자 (이모지 등) 는 한 덩어리로 - 반으로 자르지 않게 final pair = (c & 0xFC00) == 0xD800 && end + 1 < line.length && (line.codeUnitAt(end + 1) & 0xFC00) == 0xDC00; final w = pair || isWideChar(c) ? wide : 1.0; if (used + w > budget + 1e-6 && end > at) break; used += w; end += pair ? 2 : 1; } out.add((at == 0 ? i + 1 : 0, line.substring(at, end))); at = end; } } return out; } /// 고정폭 글꼴에서 두 칸을 쓰는 글자인지 (한글 자모 · 음절, 한자 · 가나 · 한중일 기호, 전각) bool isWideChar(int c) => (c >= 0x1100 && c <= 0x115F) || (c >= 0x2E80 && c <= 0xA4CF) || (c >= 0xAC00 && c <= 0xD7A3) || (c >= 0xF900 && c <= 0xFAFF) || (c >= 0xFE30 && c <= 0xFE4F) || (c >= 0xFF00 && c <= 0xFF60) || (c >= 0xFFE0 && c <= 0xFFE6);