228 lines
5.5 KiB
Dart
228 lines
5.5 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:flutter/material.dart';
|
|
|
|
enum TerminalInputMode { buffered, direct }
|
|
|
|
typedef TerminalBufferedSubmit = FutureOr<void> Function(String command);
|
|
typedef TerminalDirectInputSink = void Function(String input);
|
|
|
|
class TerminalInputController extends ChangeNotifier {
|
|
TerminalInputController({
|
|
required TerminalBufferedSubmit onBufferedSubmit,
|
|
required TerminalDirectInputSink onDirectInput,
|
|
}) : _onBufferedSubmit = onBufferedSubmit,
|
|
_onDirectInput = onDirectInput {
|
|
focusNode.addListener(notifyListeners);
|
|
textController.addListener(_handleTextChanged);
|
|
_applyEditingValue(_bufferedEditingValue);
|
|
}
|
|
|
|
static const String _directInputSentinel = ' ';
|
|
static const int _directInputSelectionOffset = 2;
|
|
|
|
final FocusNode focusNode = FocusNode();
|
|
final TextEditingController textController = TextEditingController();
|
|
final TerminalBufferedSubmit _onBufferedSubmit;
|
|
final TerminalDirectInputSink _onDirectInput;
|
|
|
|
TerminalInputMode _mode = TerminalInputMode.buffered;
|
|
String _bufferedDraft = '';
|
|
String? _composingText;
|
|
bool _isApplyingValue = false;
|
|
bool _isDisposed = false;
|
|
|
|
TerminalInputMode get mode => _mode;
|
|
|
|
bool get isDirectInputEnabled => _mode == TerminalInputMode.direct;
|
|
|
|
String? get composingText => _composingText;
|
|
|
|
String get hintText => isDirectInputEnabled
|
|
? 'Direct mode: keys send immediately'
|
|
: 'Buffered mode: type a command and send';
|
|
|
|
Future<void> submit() async {
|
|
if (isDirectInputEnabled) {
|
|
_composingText = null;
|
|
_onDirectInput('\r');
|
|
_restoreDirectEditingValue();
|
|
notifyListeners();
|
|
return;
|
|
}
|
|
|
|
final command = _bufferedDraft;
|
|
if (command.trim().isEmpty) {
|
|
return;
|
|
}
|
|
|
|
await _onBufferedSubmit(command);
|
|
_bufferedDraft = '';
|
|
_applyEditingValue(_bufferedEditingValue);
|
|
notifyListeners();
|
|
}
|
|
|
|
void toggleMode() {
|
|
setMode(
|
|
isDirectInputEnabled
|
|
? TerminalInputMode.buffered
|
|
: TerminalInputMode.direct,
|
|
);
|
|
}
|
|
|
|
void setMode(TerminalInputMode mode) {
|
|
if (_mode == mode) {
|
|
if (mode == TerminalInputMode.direct) {
|
|
_restoreDirectEditingValue();
|
|
}
|
|
return;
|
|
}
|
|
|
|
_mode = mode;
|
|
_composingText = null;
|
|
_applyEditingValue(
|
|
mode == TerminalInputMode.direct
|
|
? _directEditingValue
|
|
: _bufferedEditingValue,
|
|
);
|
|
notifyListeners();
|
|
}
|
|
|
|
void requestFocus() {
|
|
focusNode.requestFocus();
|
|
}
|
|
|
|
void syncDirectSelection() {
|
|
if (!isDirectInputEnabled) {
|
|
return;
|
|
}
|
|
|
|
_restoreDirectEditingValue();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_isDisposed = true;
|
|
focusNode.removeListener(notifyListeners);
|
|
textController.removeListener(_handleTextChanged);
|
|
focusNode.dispose();
|
|
textController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
void _handleTextChanged() {
|
|
if (_isApplyingValue) {
|
|
return;
|
|
}
|
|
|
|
final value = textController.value;
|
|
|
|
if (!isDirectInputEnabled) {
|
|
_bufferedDraft = value.text;
|
|
final nextComposing = value.composing.isCollapsed
|
|
? null
|
|
: value.composing.textInside(value.text);
|
|
if (_composingText != nextComposing) {
|
|
_composingText = nextComposing;
|
|
notifyListeners();
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (!value.composing.isCollapsed) {
|
|
final nextComposing = value.composing.textInside(value.text);
|
|
if (_composingText != nextComposing) {
|
|
_composingText = nextComposing;
|
|
notifyListeners();
|
|
}
|
|
return;
|
|
}
|
|
|
|
var didChange = false;
|
|
if (_composingText != null) {
|
|
_composingText = null;
|
|
didChange = true;
|
|
}
|
|
|
|
final backspaceCount = _detectBackspaceCount(value.text);
|
|
if (backspaceCount > 0) {
|
|
for (var index = 0; index < backspaceCount; index += 1) {
|
|
_onDirectInput('\x7f');
|
|
}
|
|
_restoreDirectEditingValue();
|
|
if (didChange) {
|
|
notifyListeners();
|
|
}
|
|
return;
|
|
}
|
|
|
|
final insertedText = _extractInsertedText(value.text);
|
|
if (insertedText.isNotEmpty) {
|
|
_onDirectInput(insertedText);
|
|
_restoreDirectEditingValue();
|
|
if (didChange) {
|
|
notifyListeners();
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (value.text != _directInputSentinel ||
|
|
value.selection.baseOffset != _directInputSelectionOffset ||
|
|
value.selection.extentOffset != _directInputSelectionOffset) {
|
|
_restoreDirectEditingValue();
|
|
if (didChange) {
|
|
notifyListeners();
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (didChange) {
|
|
notifyListeners();
|
|
}
|
|
}
|
|
|
|
int _detectBackspaceCount(String text) {
|
|
if (text.length >= _directInputSentinel.length) {
|
|
return 0;
|
|
}
|
|
|
|
return _directInputSentinel.length - text.length;
|
|
}
|
|
|
|
String _extractInsertedText(String text) {
|
|
if (text.isEmpty || text == _directInputSentinel) {
|
|
return '';
|
|
}
|
|
|
|
if (text.startsWith(_directInputSentinel)) {
|
|
return text.substring(_directInputSentinel.length);
|
|
}
|
|
|
|
return text;
|
|
}
|
|
|
|
void _restoreDirectEditingValue() {
|
|
_applyEditingValue(_directEditingValue);
|
|
}
|
|
|
|
void _applyEditingValue(TextEditingValue value) {
|
|
if (_isDisposed) {
|
|
return;
|
|
}
|
|
|
|
_isApplyingValue = true;
|
|
textController.value = value;
|
|
_isApplyingValue = false;
|
|
}
|
|
|
|
TextEditingValue get _bufferedEditingValue => TextEditingValue(
|
|
text: _bufferedDraft,
|
|
selection: TextSelection.collapsed(offset: _bufferedDraft.length),
|
|
);
|
|
|
|
TextEditingValue get _directEditingValue => const TextEditingValue(
|
|
text: _directInputSentinel,
|
|
selection: TextSelection.collapsed(offset: _directInputSelectionOffset),
|
|
);
|
|
}
|