JJ_MKVMaker/lib/ui/text_viewer.dart

289 lines
12 KiB
Dart
Raw Blame History

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';
/// 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<TextViewerPage> createState() => _TextViewerPageState();
}
class _TextViewerPageState extends State<TextViewerPage> {
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<void> _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('<EFBFBD>')) {
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) {
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;
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) {
// 좁은 화면 (폰 세로) 에서는 줄 번호 · 글자 크기를 ⋮ 메뉴로 (412 에서 파일 이름이 다 가려졌다)
final narrow = MediaQuery.sizeOf(context).width < 600;
return Scaffold(
appBar: AppBar(
title: Text(p.basename(widget.path), maxLines: 1, overflow: TextOverflow.ellipsis),
actions: [
if (_charset != null)
DropdownButton<String>(
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<String>(
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((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: [
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: const TextStyle(fontSize: 12, color: JjColors.textDim)),
),
FilledButton.tonal(onPressed: _more, child: Text(tr('더 읽기'))),
]),
),
),
]),
);
}
}
/// 글 뷰어가 그릴 줄: (줄 번호 - 나눈 조각의 둘째부터는 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;
}