For a bit of retro fun, I created an AText widget that paints a text using a crisp pixel font without antialiasing, something Text isn't capable of. Here's the API:
class AText(
final String data, {
final TextStyle? style,
})
For compatibility, I support a TextStyle but ignore everything but the color property. The font has a width and height of 8 pixels.
Now I want to also create an AEditableText widget for text input. The original EditableText plus its RenderEditable clocks at over 10000 lines of code. So, how to approach this?
Here's the API I came up with:
class AEditableText({
super.key,
required final TextEditingController controller,
required final FocusNode focusNode,
final TextStyle? style,
final Color? cursorColor,
}) extends StatefulWidget {
...
}
I'll only support a single line of text which cannot scroll. I'll probably also only support a subset of keyboard shortcuts and minimal mouse interaction.
For now, I draw an always left-align text, wrapped in a ListenableBuilder so that it automatically repaints if something is changed. There's no other decoration.
To display the cursor or selection, I use a Stack that places a ColoredBox at the correct position under the text, but only if the widget has the keyboard focus and the selection is valid. I'm padding the text to make space for the cursor.
static const charWidth = 8.0, charHeight = 8.0;
TextEditingValue get _value => widget.controller.value;
set _value(TextEditingValue value) {
widget.controller.value = value;
}
@override
Widget build(BuildContext context) {
return Focus(
focusNode: widget.focusNode,
child: ListenableBuilder(
listenable: Listenable.merge([widget.focusNode, widget.controller]),
builder: (context, child) {
final selection = _value.selection;
final showSelection =
widget.focusNode.hasPrimaryFocus &&
selection.isValid &&
selection.isNormalized;
return Stack(
alignment: .centerLeft,
children: [
if (showSelection)
Positioned(
left: selection.start * charWidth,
width: selection.isCollapsed
? charWidth
: (selection.end - selection.start) * charWidth,
height: charHeight,
child: ColoredBox(color: widget.cursorColor ?? Colors.orange),
),
Padding(
padding: .only(right: charWidth),
child: AText(_value.text, style: widget.style),
),
],
);
},
),
);
}
Not using a RenderObject means we cannot compute an intrinsic size for the AEditableText but that's a price I'm willing to pay for simplicity.
Right now, you can use TAB to focus the widget and should see a block cursor. To enter text, we have to attach a TextInputConnection. To move the cursor, we have to listen for keyboard events and/or use a GestureDetector. I'll start with the latter because that's the easiest.
I wrap the ListenableBuilder with a child of Focus like so:
return Focus(
focusNode: widget.focusNode,
child: GestureDetector(
behavior: .opaque,
onTapDown: (details) {
widget.focusNode.requestFocus();
final dx = details.localPosition.dx;
final length = _value.text.length;
final offset = (dx ~/ charWidth).clamp(0, length);
_value = _value.copyWith(selection: .collapsed(offset: offset));
},
child: ListenableBuilder(
...
Tapping the widget will request the focus. This is a no-op if it is already focused. Then, I'll compute the character that was tapped and move the cursor there. By updating the TextEditingValue, the controller will notify all listeners including my ListenableBuilder that something has changed and the cursor will be redrawn at the new position.
To drag a selection, add this to the GestureDetector:
onPanUpdate: (details) {
final dx = details.localPosition.dx;
final length = _value.text.length;
final offset = (dx ~/ charWidth).clamp(0, length);
_value = _value.copyWith(
selection: _value.selection.copyWith(extentOffset: offset),
);
},
This time, we don't set the start offset but the extend offset which is the "other end" of the selection. This way, it doesn't matter wether you drag from left to right or vise versa.
Feel free to implement word selection by double tapping. Or add a facy magnifying zoom when being on mobile and/or larger selection handles.
To implement cursor movement by keyboard, I'll use a CallbackShortcuts widget, because that's a bit easier than creating (or reusing existing) Intents and Actions.
Widget build(BuildContext context) {
return CallbackShortcuts(
bindings: _bindings,
child: Focus(
...
...
late final _bindings = <ShortcutActivator, VoidCallback>{
SingleActivator(.arrowLeft): _moveBack,
SingleActivator(.arrowRight): _moveForward,
};
void _moveBack() {
final s = _value.selection;
if (s.isCollapsed) {
if (s.start > 0) _setBase(s.start - 1);
} else {
_setBase(s.start);
}
}
void _moveForward() {
final s = _value.selection;
if (s.isCollapsed) {
if (s.end < _textLength) _setBase(s.end + 1);
} else {
_setBase(s.end);
}
}
int get _textLength => _value.text.length;
void _setBase(int offset) {
_value = _value.copyWith(
selection: .collapsed(offset: offset),
);
}
Let's also support Pos1 and Home:
late final _bindings = <ShortcutActivator, VoidCallback>{
...
SingleActivator(.home): _moveStart,
SingleActivator(.end): _moveEnd,
if (defaultTargetPlatform case .macOS) ...{
SingleActivator(.arrowLeft, meta: true): _moveStart,
SingleActivator(.arrowRight, meta: true): _moveEnd,
},
};
...
void _moveStart() => _setBase(0);
void _moveEnd() => _setBase(_textLength);
Typically, you can span a selection by pressing Shift while moving the cursor. Like with the GestureDector callbacks, this modifies the extent, not the base.
BTW, I can simplify those methods by using my new helpers:
onTapDown: (details) {
final offset = details.localPosition.dx ~/ charWidth;
_setBase(offset.clamp(0, _textLength));
},
onPanUpdate: (details) {
final offset = details.localPosition.dx ~/ charWidth;
_setExtent(offset.clamp(0, _textLength));
},
Here are shortcut key definitions:
late final _bindings = <ShortcutActivator, VoidCallback>{
...
SingleActivator(.arrowLeft, shift: true): _selectBack,
SingleActivator(.arrowRight, shift: true): _selectForward,
SingleActivator(.home, shift: true): _selectStart,
SingleActivator(.end, shift: true): _selectEnd,
if (defaultTargetPlatform case .macOS) ...{
...
SingleActivator(.arrowLeft, meta: true, shift: true): _selectStart,
SingleActivator(.arrowRight, meta: true, shift: true): _selectEnd,
},
};
...
void _selectBack() { final s = _value.selection; if (s.extentOffset > 0) { _setExtent(s.extentOffset - 1); } }
void _selectForward() {
final s = _value.selection;
if (s.extentOffset < _textLength) {
_setExtent(s.extentOffset + 1);
}
}
void _selectStart() => _setExtent(0);
void _selectEnd() => _setExtent(_textLength);
void _setExtent(int offset) {
_value = _value.copyWith(
selection: _value.selection.copyWith(extentOffset: offset),
);
}
Next, we have to support Backspace and Delete keys. If there's a selection, the selected text is deleted. Otherwise the character before or under the cursor is deleted.
late final _bindings = <ShortcutActivator, VoidCallback>{
...
if (defaultTargetPlatform case .macOS || .windows || .linux) ...{
SingleActivator(.backspace): _deleteBack,
SingleActivator(.delete): _deleteForward,
}
};
...
void _deleteBack() {
final s = _value.selection;
if (s.isCollapsed) {
if (s.start > 0) _delete(s.start - 1, s.end);
} else {
_delete(s.start, s.end);
}
}
void _deleteForward() {
final s = _value.selection;
if (s.isCollapsed) {
if (s.end < _textLength) _delete(s.start, s.end + 1);
} else {
_delete(s.start, s.end);
}
}
void _delete(int start, int end) {
_value = _value.replaced(.new(start: start, end: end), '');
}
Last but not least, to support entering text, I use a TextEditingConnection to sync the controller's TextEditingValues with the native platform and to receive edits in form of TextEditingValues.
The State needs a TextInputClient mixin. It has to implement 8 methods, then. It has has to manange a TextInputConnection. If the Focus widget receives the focus, the connection is established and it is closed once the focus is lost.
class _AEditableTextState extends State<AEditableText>
with TextInputClient { // <--- new
TextInputConnection? _connection;
@override
void dispose() {
_onFocusChange(false);
super.dispose();
}
void _onFocusChange(bool focused) {
if (focused) {
_connection = TextInput.attach(this, const TextInputConfiguration())
..setEditingState(_value)
..show();
} else {
_connection?.close();
_connection = null;
}
}
@override
Widget build(BuildContext context) {
...
child: Focus(
focusNode: widget.focusNode,
onFocusChange: _onFocusChange, // <--- new
...
}
...
// ---- TextInputClient ----
@override
TextEditingValue? get currentTextEditingValue => _value;
@override
void updateEditingValue(TextEditingValue value) => _value = value;
@override
void connectionClosed() => _connection = null;
@override
AutofillScope? get currentAutofillScope => null;
@override
void performAction(TextInputAction action) {}
@override
void performPrivateCommand(String action, Map<String, dynamic> data) {}
@override
void showAutocorrectionPromptRect(int start, int end) {}
@override
void updateFloatingCursor(RawFloatingCursorPoint point) {}
}
You can now enter text. The native part can query the current TextEditingValue, it can update it, and in case it decides to close the connection, I'll clean up. I ignore all those other fancy features like auto fill, auto correct, or special commands. I'm also ignoring the wish to show additional cursors.
There's one piece missing, though. If I change the selection, I need to explicitly tell this the native part while making sure that I don't create a loop and telling it something I got from the native part.
I'll therefore change the _value setter to also update the connection and then change updateEditingValue to not use _value:
set _value(TextEditingValue value) {
widget.controller.value = value;
_connection?.setEditingState(value);
}
@override
void updateEditingValue(TextEditingValue value) =>
widget.controller.value = value;
I've successfully sync'd both mechanisms. But we're not done. I'd love to support cut, copy, and paste; and undo and redo. On macOS, I'm also used to using the kill-ring with ^K (kill) and ^Y (yank).
And while I refrain from IME, on macOS, long-pressing certain letters opens a small window to select umlauts or other special characters. Right now, this window opens in the bottom-left corner. Let's fix that. We need to update the connection's caret geometry so it knows where to place that window.
void _updateCaretGeometry() {
if (_connection case final connection? when connection.attached) {
if (context.findRenderObject() case RenderBox box when box.hasSize) {
final caretRect = Rect.fromLTWH(
_value.selection.start * charWidth,
0,
charWidth,
charHeight,
);
connection
..setEditableSizeAndTransform(box.size, box.getTransformTo(null))
..setCaretRect(caretRect)
..setComposingRect(caretRect);
}
}
}
And this has to be called each time the cursor updates. I hope the framework doesn't mind if I call this each time the cursor is painted, regardless of whether the position has changed or not. So I'll add this to the ListenableBuilder's build function right before return Stack:
if (showSelection) scheduleMicrotask(_updateCaretGeometry);
The other functions should interact with the Flutter window's platform menu and hence with the platform itself and that's another can of worms, I won't open here. But just to satisfy my muscle memory:
late final _bindings = <ShortcutActivator, VoidCallback>{
...
if (defaultTargetPlatform case .macOS) ...{
...
SingleActivator(.keyX, meta: true): _cut,
SingleActivator(.keyC, meta: true): _copy,
SingleActivator(.keyV, meta: true): _paste,
},
if (defaultTargetPlatform case .windows || .linux) ...{
SingleActivator(.keyX, control: true): _cut,
SingleActivator(.keyC, control: true): _copy,
SingleActivator(.keyV, control: true): _paste,
},
};
...
void _cut() {
if (_value.selection.isCollapsed) return;
_copy();
_deleteForward();
}
void _copy() {
if (_value.selection.isCollapsed) return;
final text = _value.selection.textInside(_value.text);
Clipboard.setData(.new(text: text));
}
void _paste() {
Clipboard.getData(Clipboard.kTextPlain).then((data) {
if (data case final data?) {
if (data.text case final text?) _replace(text);
}
});
}
void _replace(String text) {
final s = _value.selection;
final r = TextRange(start: s.start, end: s.end);
_value = _value.replaced(r, text);
}
For undo/redo, I can use an UndoHistory widget but that expects the system to fire UndoTextIntent and RedoTextIntent which I don't do because to used a shortcut omiting intents. So I have to synthesize them. Here's the new build implementation:
@override
Widget build(BuildContext context) {
return UndoHistory(
onTriggered: (value) => _value = value,
value: widget.controller,
focusNode: widget.focusNode,
child: CallbackShortcuts(
bindings: _bindings,
child: Focus(
...
late final _bindings = <ShortcutActivator, VoidCallback>{
...
if (defaultTargetPlatform case .macOS) ...{
...
SingleActivator(.keyZ, meta: true): _undo,
SingleActivator(.keyZ, meta: true, shift: true): _redo,
},
if (defaultTargetPlatform case .windows || .linux) ...{
...
SingleActivator(.keyZ, control: true): _undo,
SingleActivator(.keyZ, control: true, shift: true): _redo,
},
...
};
void _undo() {
if (primaryFocus?.context case final context?) {
Actions.maybeInvoke(context, UndoTextIntent(.keyboard));
}
}
void _redo() {
if (primaryFocus?.context case final context?) {
Actions.maybeInvoke(context, RedoTextIntent(.keyboard));
}
}
So, it takes ~330 lines of code to implement a simple single-line text input that works on desktop and web and doesn't break on mobile (I hope).