JJ_MKVMaker/lib/ui/text_viewer.dart

355 lines
16 KiB
Dart
Raw Permalink 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) => _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: 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(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: const 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);