190 lines
7.4 KiB
Dart
190 lines
7.4 KiB
Dart
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;
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final lines = _text.split('\n');
|
||
// 좁은 화면 (폰 세로) 에서는 줄 번호 · 글자 크기를 ⋮ 메뉴로 (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: SelectionArea(
|
||
child: Scrollbar(
|
||
child: ListView.builder(
|
||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||
itemCount: lines.length,
|
||
itemBuilder: (_, i) => Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||
if (_lineNumbers)
|
||
SizedBox(
|
||
width: 48,
|
||
child: Text('${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'))),
|
||
]),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
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('더 읽기'))),
|
||
]),
|
||
),
|
||
),
|
||
]),
|
||
);
|
||
}
|
||
}
|