Skip to content

Instantly share code, notes, and snippets.

@sma
Last active September 1, 2026 16:56
Show Gist options
  • Select an option

  • Save sma/f205259cd1d5cfb078bd33146bd9b2c1 to your computer and use it in GitHub Desktop.

Select an option

Save sma/f205259cd1d5cfb078bd33146bd9b2c1 to your computer and use it in GitHub Desktop.
I spend 8h to investigate and write this article, learning a lot

Writing an EditableText from scratch

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).

import 'dart:async';
import 'dart:convert';
import 'dart:math' as math;
import 'dart:typed_data';
import 'dart:ui' as ui;
import 'package:flutter/foundation.dart' show kIsWeb, defaultTargetPlatform;
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
Future<void> main() async {
await AText.ensureInitialized(); // only needed for web
runApp(App());
}
class const App({super.key}) extends StatefulWidget {
@override
State<App> createState() => _AppState();
}
class _AppState extends State<App> {
final c = TextEditingController()
..text = 'Hello, World'
..selection = .collapsed(offset: 4);
@override
Widget build(BuildContext context) {
return AmigaApp(
home: AScreen(
title: AText('Workbench'),
body: Center(
child: Transform.scale(
scale: 1,
filterQuality: .none,
child: Padding(
padding: const .all(32),
child: AWindow(
onClose: () {},
title: AText('Window'),
body: Container(
padding: .all(16),
color: Colors.black,
child: Column(
mainAxisSize: .min,
children: [
Container(
width: 160,
height: 32,
decoration: BoxDecoration(
border: .all(color: Colors.red),
color: Colors.white,
),
margin: const .all(1),
padding: const .all(1),
child: AEditableText(
controller: c,
focusNode: FocusNode(),
cursorColor: Colors.grey,
),
),
Container(
margin: const .all(8),
padding: const .all(1),
color: Colors.white,
child: DefaultTextStyle.merge(
style: TextStyle(fontSize: 16),
child: AEditableText(
controller: c,
focusNode: FocusNode(),
),
),
),
const SizedBox(height: 16),
AText(
'First line',
style: TextStyle(color: Colors.orange),
),
AText(
'Hello,\nSecond line',
style: TextStyle(fontSize: 16, color: Colors.orange),
),
const SizedBox(height: 16),
SingleChildScrollView(
scrollDirection: .horizontal,
child: RawImage(
image: _topaz8,
fit: .fill,
width: 8 * 192,
height: 16,
color: Colors.orange,
filterQuality: .none,
),
),
const SizedBox(height: 16),
ADivider(child: AText('Title')),
ADivider(align: .start, child: AText('Title')),
ADivider(align: .end, child: AText('Title')),
],
),
),
),
),
),
),
),
);
}
}
//////////////////////////////////////////////////////////////////////////////
/// Standard colors.
abstract final class Colors {
static const Color transparent = Color(0x00000000);
static const Color black = Color(0xFF000000);
static const Color white = Color(0xFFFFFFFF);
static const Color blue = Color(0xFF0055AA);
static const Color orange = Color(0xFFFF8800);
static const Color red = Color(0xFFDD2222);
static const Color grey = Color(0xFFAABBCC);
}
/// Like `MaterialApp` but for this look.
class const AmigaApp({super.key, final Widget? home}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
return WidgetsApp(
debugShowCheckedModeBanner: false,
color: Colors.blue,
builder: (context, _) => Directionality(
textDirection: .ltr,
child: DefaultTextStyle(
style: TextStyle(
fontFamily: 'Topaz',
fontSize: 8,
color: Colors.black,
),
child: home ?? const SizedBox(),
),
),
);
}
}
/// Standard backdrop.
///
/// Right now, just a fake but an [AmigaApp] might support multiple screens
/// that support different resolutions and can be dragged and stacked.
/// The screen might also support a menubar.
class const AScreen({super.key, final Widget? title, final Widget? body})
extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Column(children: [_titleBar(), _body()]);
}
Widget _titleBar() {
return Container(
height: 20,
color: Colors.white,
padding: const .only(left: 4, right: 2),
child: Row(
children: [
Expanded(
child: DefaultTextStyle.merge(
style: TextStyle(fontSize: 16, color: Colors.blue),
child: title ?? const SizedBox(),
),
),
const ATitleSeparator(),
const ABackButton(),
const ATitleSeparator(),
const AFrontButton(),
const ATitleSeparator(),
],
),
);
}
Widget _body() {
return Expanded(
child: ColoredBox(color: Colors.blue, child: body),
);
}
}
/// Window frame.
class const AWindow({
super.key,
final Widget? title,
final Widget? body,
final double? width,
final double? height,
final VoidCallback? onClose,
final VoidCallback? onBack,
final VoidCallback? onFront,
final bool resizable = true,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
return ConstrainedBox(
constraints: BoxConstraints(
minWidth: width ?? 30 + 54,
maxWidth: width ?? .infinity,
minHeight: height ?? 20,
maxHeight: height ?? .infinity,
),
child: DecoratedBox(
decoration: BoxDecoration(
border: .all(color: Colors.white, width: 2),
color: Colors.blue,
),
child: Stack(
fit: .passthrough,
children: [
if (title != null ||
onClose != null ||
onBack != null ||
onFront != null)
Positioned(
left: 0,
top: 0,
right: 0,
height: 20,
child: ColoredBox(
color: Colors.white,
child: Padding(
padding: .symmetric(horizontal: 4),
child: Row(
children: [
if (onClose != null) ...[
const ATitleSeparator(),
const ACloseButton(),
const ATitleSeparator(),
const SizedBox(width: 2),
],
Expanded(
child: Padding(
padding: .only(right: 3),
child: ATitleStripes(child: title),
),
),
const ATitleSeparator(),
const ABackButton(),
const ATitleSeparator(),
const AFrontButton(),
const ATitleSeparator(),
],
),
),
),
),
if (body case final body?)
Padding(padding: .fromLTRB(2, 20, 2, 2), child: body),
if (resizable)
Positioned(right: 0, bottom: 0, child: AResizeButton()),
],
),
),
);
}
}
/// A blue gap of 2px width.
class const ATitleSeparator({super.key}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Container(width: 2, height: 20, color: Colors.blue);
}
}
/// A [child] padded with two blue horizontal stripes.
class const ATitleStripes({super.key, final Widget? child})
extends StatelessWidget {
@override
Widget build(BuildContext context) {
final stripes = Padding(
padding: const .symmetric(vertical: 4),
child: Column(
spacing: 4,
mainAxisSize: .min,
children: [
for (var i = 0; i < 2; i++) Container(height: 4, color: Colors.blue),
],
),
);
if (child case final child?) {
return LayoutBuilder(
builder: (context, constraints) {
return Row(
spacing: 2,
children: [
ConstrainedBox(
constraints: constraints.copyWith(
minWidth: 0,
maxWidth: constraints.maxWidth - 2,
),
child: DefaultTextStyle.merge(
style: TextStyle(fontSize: 16, color: Colors.blue),
maxLines: 1,
child: child,
),
),
Expanded(child: stripes),
],
);
},
);
}
return stripes;
}
}
abstract class const AButton({
super.key,
required final VoidCallback? onPressed,
}) extends StatefulWidget {
@override
State<AButton> createState() => _AButtonState();
@protected
Widget build(BuildContext context, bool pressed);
@protected
(Color bg, Color fg, Color b) getColors(bool pressed) {
return pressed
? (Colors.black, Colors.white, Colors.orange)
: (Colors.white, Colors.black, Colors.blue);
}
}
class _AButtonState extends State<AButton> {
var _pressed = false;
@override
Widget build(BuildContext context) {
return GestureDetector(
behavior: .opaque,
onTapDown: (_) {
setState(() => _pressed = true);
},
onTapUp: (d) {
widget.onPressed?.call();
setState(() => _pressed = false);
},
onTapCancel: () {
setState(() => _pressed = false);
},
child: widget.build(context, _pressed),
);
}
}
/// Title bar close button.
class const ACloseButton({super.key, super.onPressed}) extends AButton {
@override
Widget build(BuildContext context, bool pressed) {
final (bg, fg, b) = getColors(pressed);
return Container(
width: 20,
height: 20,
color: bg,
child: Stack(
children: [
Positioned(
left: 2,
top: 2,
width: 16,
height: 16,
child: DecoratedBox(
decoration: BoxDecoration(border: .all(color: b, width: 2)),
),
),
Positioned(
left: 8,
top: 8,
width: 4,
height: 4,
child: ColoredBox(color: fg),
),
],
),
);
}
}
/// Title bar "move to back" button.
class const ABackButton({super.key, super.onPressed}) extends AButton {
@override
Widget build(BuildContext context, bool pressed) {
final (bg, fg, b) = getColors(pressed);
return Container(
width: 22,
height: 20,
color: bg,
child: Stack(
children: [
Positioned(
left: 2,
top: 2,
width: 14,
height: 12,
child: DecoratedBox(
decoration: BoxDecoration(
border: .all(color: b, width: 2),
color: bg,
),
),
),
Positioned(
left: 6,
top: 6,
width: 14,
height: 12,
child: ColoredBox(color: fg),
),
],
),
);
}
}
/// Title bar "move to front" button.
class const AFrontButton({super.key, super.onPressed}) extends AButton {
@override
Widget build(BuildContext context, bool pressed) {
final (bg, fg, b) = getColors(pressed);
return Container(
width: 22,
height: 20,
color: bg,
child: Stack(
children: [
Positioned(
left: 2,
top: 2,
width: 14,
height: 12,
child: ColoredBox(color: fg),
),
Positioned(
left: 6,
top: 4,
width: 14,
height: 14,
child: DecoratedBox(
decoration: BoxDecoration(
border: .all(color: b, width: 2),
color: bg,
),
),
),
],
),
);
}
}
/// A resize button.
class const AResizeButton({super.key, super.onPressed}) extends AButton {
@override
Widget build(BuildContext context, bool pressed) {
final (bg, fg, b) = getColors(pressed);
return Container(
width: 16,
height: 18,
color: bg,
child: Stack(
children: [
Positioned(
left: 2,
top: 2,
width: 6,
height: 6,
child: DecoratedBox(
decoration: BoxDecoration(border: .all(color: b, width: 2)),
),
),
Positioned(
left: 6,
top: 6,
width: 8,
height: 10,
child: DecoratedBox(
decoration: BoxDecoration(border: .all(color: b, width: 2)),
),
),
],
),
);
}
}
//////////////////////////////////////////////////////////////////////////////
/// Converts 1-bit Topaz 8 font into RGBA image.
Uint8List _getPixelData() {
final bytes = base64.decode(
'AAAAAAAAAAAYPDwYGAAYAGxsAAAAAAAAbGz+bP5sbAAYPmA8BnwYAADGzBgwZsYAOGxodtzM'
'dgAYGDAAAAAAAAwYMDAwGAwAMBgMDAwYMAAAZjz/PGYAAAAYGH4YGAAAAAAAAAAYGDAAAAB+'
'AAAAAAAAAAAAGBgAAwYMGDBgwAA8Zm5+dmY8ABg4GBgYGH4APGYGHDBmfgA8ZgYcBmY8ABw8'
'bMz+DB4AfmB8BgZmPAAcMGB8ZmY8AH5mBgwYGBgAPGZmPGZmPAA8ZmY+Bgw4AAAYGAAAGBgA'
'ABgYAAAYGDAMGDBgMBgMAAAAfgAAfgAAMBgMBgwYMAA8ZgYMGAAYAHzG3t7ewHgAGDw8Zn7D'
'wwD8ZmZ8Zmb8ADxmwMDAZjwA+GxmZmZs+AD+ZmB4YGb+AP5mYHhgYPAAPGbAzsZmPgBmZmZ+'
'ZmZmAH4YGBgYGH4ADgYGBmZmPADmZmx4bGbmAPBgYGBiZv4Agsbu/tbGxgDG5vbezsbGADhs'
'xsbGbDgA/GZmfGBg8AA4bMbGxmw8BvxmZnxsZuMAPGZwOA5mPAB+WhgYGBg8AGZmZmZmZj4A'
'w8NmZjw8GADGxsbW/u7GAMNmPBg8ZsMAw8NmPBgYPAD+xowYMmb+ADwwMDAwMDwAwGAwGAwG'
'AwA8DAwMDAw8ABA4bMYAAAAAAAAAAAAAAP4YGAwAAAAAAAAAPAYeZjsA4GBsdmZmPAAAADxm'
'YGY8AA4GNm5mZjsAAAA8Zn5gPAAcNjB4MDB4AAAAO2ZmPMZ84GBsdmZm5gAYADgYGBg8AAYA'
'BgYGBmY84GBmbHhs5gA4GBgYGBg8AAAAZndrY2MAAAB8ZmZmZgAAADxmZmY8AAAA3GZmfGDw'
'AAA9ZmY+BgcAAOx2ZmDwAAAAPmA8BnwACBg+GBgaDAAAAGZmZmY7AAAAZmZmPBgAAABja2s2'
'NgAAAGM2HDZjAAAAZmZmPBhwAAB+TBgyfgAOGBhwGBgOABgYGBgYGBgAcBgYDhgYcABynAAA'
'AAAAAMwzzDPMM8wzfmZmZmZmfgAYABgYPDwYAAw+bGw+DAAAHDYweDAwfgBCPGY8QgAAAMNm'
'PBg8GDwAGBgYABgYGAA8QDxmPAI8AGYAAAAAAAAAfoGdsbGdgX4wSIj4APwAAAAzZsxmMwAA'
'PgYAAAAAAAAAAH5+AAAAAH6BubmxqYF+fgAAAAAAAAA8ZjwAAAAAABgYfhgYAH4A8BgwYPgA'
'AADwGDAY8AAAABgwAAAAAAAAAADGxsbu+sB+9PR0FBQUAAAAGBgAAAAAAAAAAAAAGDAwcDAw'
'MAAAAHCIiHAA+AAAAMxmM2bMAAAgYyYsGTNnASBjJiwbMWIHwCNmLNkzZwEYABgwYGY8ADAI'
'PGZ+w8MADBA8Zn7DwwAYJDxmfsPDAHGOPGZ+w8MAwxg8Zn7DwwA8ZjxmfsPDAB88PG98zM8A'
'PGbAwGY8CDBgEP5geGD+ABgg/mB4YP4AMEj+YHhg/gBmAP5geGD+ADAIfhgYGH4ADBB+GBgY'
'fgAYJH4YGBh+AGYAfhgYGH4A+Gxm9mZs+ABxjsbm1s7GADAIPGbDZjwADBA8ZsNmPAAYJDxm'
'w2Y8AHGOPGbDZjwAwzxmw8NmPAAAYzYcNmMAAD1mz9vzZrwAMAhmZmZmPgAMEGZmZmY+ABgk'
'ZmZmZj4AZgBmZmZmPgAGCMNmPBg8APBgfmNjfmDwfGZmbGZmbGAwCDwGHmY7AAwQPAYeZjsA'
'GCQ8Bh5mOwBxjjwGHmY7ADMAPAYeZjsAPGY8Bh5mOwAAAH4bf9h3AAAAPGZgZjwQMAg8Zn5g'
'PAAMEDxmfmA8ABgkPGZ+YDwAZgA8Zn5gPAAwCDgYGBg8AAwQOBgYGDwAGCQ4GBgYPABmADgY'
'GBg8AGD8GHzGxnwAcY58ZmZmZgAwCDxmZmY8AAwQPGZmZjwAGCQ8ZmZmPABxjjxmZmY8AGYA'
'PGZmZjwAABgAfgAYAAAAAT5na3M+QDAIZmZmZjsADBBmZmZmOwAYJGZmZmY7AGYAZmZmZjsA'
'DBBmZmY8GHDwYHxmZnxg8GYAZmZmPBhw',
);
final pixels = Uint32List(192 * 8 * 8);
for (var i = 0; i < 192; i++) {
for (var j = 0; j < 8; j++) {
final p = i * 8 + j * 192 * 8;
final b = bytes[i * 8 + j];
for (var k = 0; k < 8; k++) {
pixels[p + k] = (b & (1 << (7 - k))) != 0 ? 0xffffffff : 0x00000000;
}
}
}
return pixels.buffer.asUint8List();
}
/// Topaz font sprite atlas with characters 32..127, 160..255 in 1536x8 px.
ui.Image _topaz8 = () {
return ui.decodeImageFromPixelsSync(_getPixelData(), 192 * 8, 8, .bgra8888);
}();
/// Returns the index of the glyph in [_topaz8] used to render code unit [u].
///
/// Code units without a glyph are mapped to 96, an "undefined" box.
int _glyphIndex(int u) {
return u < 32 || u >= 128 && u < 160 || u > 255
? 96
: u >= 160
? u - 64
: u - 32;
}
/// Displays [data] using the Topaz bitmap font.
///
/// Of [style], only `color` and `fontSize` are honored, the latter being
/// either 8 (the font's native size) or 16 (scaled vertically by 2).
///
/// See [Text].
class const AText(
final String data, {
super.key,
final TextStyle? style,
final TextAlign align = .start,
final int? maxLines,
}) extends LeafRenderObjectWidget {
@override
RenderObject createRenderObject(BuildContext context) {
return RenderAText()
..text = data
..style = _resolveStyle(context)
..align = _resolveAlign(context)
..maxLines = _resolveMaxLines(context);
}
@override
void updateRenderObject(BuildContext context, RenderAText renderObject) {
renderObject
..text = data
..style = _resolveStyle(context)
..align = _resolveAlign(context)
..maxLines = _resolveMaxLines(context);
}
TextStyle _resolveStyle(BuildContext context) {
return DefaultTextStyle.of(context).style.merge(style);
}
TextAlign _resolveAlign(BuildContext context) {
return switch (align) {
.start => Directionality.of(context) == .ltr ? .left : .right,
.end => Directionality.of(context) == .ltr ? .right : .left,
_ => align,
};
}
int? _resolveMaxLines(BuildContext context) {
return maxLines ?? DefaultTextStyle.of(context).maxLines;
}
static Future<void> ensureInitialized() async {
if (!kIsWeb) return;
final completer = Completer<ui.Image>();
ui.decodeImageFromPixels(
_getPixelData(),
192 * 8,
8,
.bgra8888,
completer.complete,
);
_topaz8 = await completer.future;
}
}
class RenderAText() extends RenderBox {
String get text => _text;
String _text = '';
set text(String text) {
if (_text == text) return;
_text = text;
markNeedsLayout();
}
TextStyle? get style => _style;
TextStyle? _style;
set style(TextStyle? style) {
if (_style == style) return;
final needsLayout = _style?.fontSize != style?.fontSize;
_style = style;
if (needsLayout) markNeedsLayout();
markNeedsPaint();
}
TextAlign get align => _align;
TextAlign _align = .left;
set align(TextAlign align) {
if (_align == align) return;
_align = align;
markNeedsPaint();
}
int? get maxLines => _maxLines;
int? _maxLines;
set maxLines(int? maxLines) {
assert(maxLines == null || maxLines > 0);
if (_maxLines == maxLines) return;
_maxLines = maxLines;
markNeedsLayout();
}
/// The lines computed by the last [performLayout].
var _lines = const <String>[];
/// Integral factor the 8x8 glyphs are stretched vertically by, 1 for a
/// `fontSize` of 8 (or none) and 2 for a `fontSize` of 16.
double get _scaleY => _style?.fontSize == 16 ? 2 : 1;
/// Width of a single character cell, always the font's native width.
static const _cellWidth = 8.0;
/// Height of a single character cell, 8 or 16 px.
double get _cellHeight => 8 * _scaleY;
/// Breaks [text] into at most [maxLines] lines that fit into [maxWidth],
/// honoring explicit line breaks and preferring to break at spaces.
///
/// Lines beyond [maxLines] are dropped.
List<String> _computeLines(double maxWidth) {
final maxChars = maxWidth.isFinite ? maxWidth ~/ _cellWidth : 0;
final maxLines = _maxLines ?? _text.length + 1;
final lines = <String>[];
for (final paragraph in _text.split('\n')) {
if (lines.length >= maxLines) break;
if (maxChars < 1 || paragraph.length <= maxChars) {
lines.add(paragraph);
continue;
}
var start = 0;
while (paragraph.length - start > maxChars && lines.length < maxLines) {
final end = start + maxChars;
final space = paragraph.lastIndexOf(' ', end);
if (space <= start) {
lines.add(paragraph.substring(start, end));
start = end;
} else {
lines.add(paragraph.substring(start, space));
start = space + 1;
}
}
if (start < paragraph.length && lines.length < maxLines) {
lines.add(paragraph.substring(start));
}
}
return lines;
}
Size _sizeForLines(List<String> lines, BoxConstraints constraints) {
var width = 0.0;
for (final line in lines) {
width = math.max(width, line.length * _cellWidth);
}
return constraints.constrain(Size(width, lines.length * _cellHeight));
}
@override
double computeMinIntrinsicWidth(double height) {
var width = 0.0;
for (final word in _text.split(RegExp(r'[ \n]'))) {
width = math.max(width, word.length * _cellWidth);
}
return width;
}
@override
double computeMaxIntrinsicWidth(double height) {
var width = 0.0;
for (final paragraph in _text.split('\n')) {
width = math.max(width, paragraph.length * _cellWidth);
}
return width;
}
@override
double computeMinIntrinsicHeight(double width) {
return _computeLines(width).length * _cellHeight;
}
@override
double computeMaxIntrinsicHeight(double width) {
return computeMinIntrinsicHeight(width);
}
@override
double? computeDistanceToActualBaseline(TextBaseline baseline) {
// The Topaz glyphs use their two bottom rows for descenders.
return _lines.isEmpty ? null : 6 * _scaleY;
}
@override
Size computeDryLayout(BoxConstraints constraints) {
return _sizeForLines(_computeLines(constraints.maxWidth), constraints);
}
@override
void performLayout() {
_lines = _computeLines(constraints.maxWidth);
size = _sizeForLines(_lines, constraints);
}
@override
void paint(PaintingContext context, Offset offset) {
final scaleY = _scaleY;
final transforms = <RSTransform>[];
final rects = <Rect>[];
// Glyphs are placed in unscaled font units and stretched by the canvas
// below, because an [RSTransform] can only scale uniformly.
var y = offset.dy / scaleY;
for (final line in _lines) {
final free = size.width - line.length * _cellWidth;
var x =
offset.dx +
switch (_align) {
.right => free,
.center => (free / 2).roundToDouble(),
_ => 0.0,
};
for (final u in line.codeUnits) {
if (u != 32) {
transforms.add(
.fromComponents(
rotation: 0,
scale: 1,
anchorX: 0,
anchorY: 0,
translateX: x,
translateY: y,
),
);
rects.add(Rect.fromLTWH(_glyphIndex(u) * 8, 0, 8, 8));
}
x += _cellWidth;
}
y += 8;
}
if (transforms.isEmpty) return;
final canvas = context.canvas;
if (scaleY != 1) {
canvas.save();
canvas.scale(1, scaleY);
}
canvas.drawAtlas(
_topaz8,
transforms,
rects,
null,
null,
null,
Paint()
..filterQuality = .none
..colorFilter = .mode(
_style?.color ?? const Color(0xffffffff),
.srcATop,
),
);
if (scaleY != 1) canvas.restore();
}
}
//////////////////////////////////////////////////////////////////////////////
/// Provides an editable text.
///
/// Only a single line, not scrollable.
/// Currently, selection and cursor share the same color.
class const AEditableText({
super.key,
required final TextEditingController controller,
required final FocusNode focusNode,
final TextStyle? style,
final Color? cursorColor,
}) extends StatefulWidget {
@override
State<AEditableText> createState() => _AEditableTextState();
}
class _AEditableTextState extends State<AEditableText> with TextInputClient {
static const _charWidth = 8.0, _charHeight = 8.0;
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;
}
}
TextEditingValue get _value => widget.controller.value;
set _value(TextEditingValue value) {
widget.controller.value = value;
_connection?.setEditingState(value);
}
var _taps = 0;
var _tapTime = DateTime.now();
@override
Widget build(BuildContext context) {
return UndoHistory(
onTriggered: (value) => _value = value,
value: widget.controller,
focusNode: widget.focusNode,
child: CallbackShortcuts(
bindings: _bindings,
child: Focus(
focusNode: widget.focusNode,
onFocusChange: _onFocusChange,
child: GestureDetector(
behavior: .opaque,
onTapDown: (details) {
widget.focusNode.requestFocus();
// track single, double, and triple taps
final now = DateTime.now();
if (now.difference(_tapTime).inMilliseconds > 300) {
_taps = 1;
} else {
_taps++;
}
_tapTime = now;
// based on number of taps
final offset = details.localPosition.dx ~/ _charWidth;
switch (_taps) {
case 1:
_setBase(offset.clamp(0, _textLength));
case 2:
_selectWord(offset);
default:
_selectAll();
}
},
onPanUpdate: (details) {
final offset = details.localPosition.dx ~/ _charWidth;
_setExtent(offset.clamp(0, _textLength));
},
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;
if (showSelection) scheduleMicrotask(_updateCaretGeometry);
return Stack(
alignment: .centerLeft,
children: [
if (showSelection)
Positioned(
left: selection.start * _charWidth,
width: selection.isCollapsed
? _charWidth
: (selection.end - selection.start) * _charWidth,
height:
DefaultTextStyle.of(context).style
.merge(widget.style)
.fontSize ??
_charHeight,
child: ColoredBox(
color: widget.cursorColor ?? Colors.orange,
),
),
Padding(
padding: .only(right: _charWidth),
child: AText(_value.text, style: widget.style),
),
],
);
},
),
),
),
),
);
}
void _selectWord(int offset) {
final isWord = RegExp(r'[\p{L}\p{N}_]', unicode: true).hasMatch;
final text = _value.text;
var start = offset.clamp(0, text.length - 1);
if (!isWord(text[start]) && start > 0 && isWord(text[start - 1])) {
start--;
}
var end = start;
if (isWord(text[start])) {
while (start > 0 && isWord(text[start - 1])) {
start--;
}
while (end < text.length - 1 && isWord(text[end + 1])) {
end++;
}
} else {
while (start > 0 && !isWord(text[start - 1])) {
start--;
}
while (end < text.length - 1 && !isWord(text[end + 1])) {
end++;
}
}
_setBase(start, extent: end + 1);
}
late final _bindings = <ShortcutActivator, VoidCallback>{
// cursor movement
SingleActivator(.arrowLeft): _moveBack,
SingleActivator(.arrowRight): _moveForward,
SingleActivator(.home): _moveStart,
SingleActivator(.end): _moveEnd,
// selection movement
SingleActivator(.arrowLeft, shift: true): _selectBack,
SingleActivator(.arrowRight, shift: true): _selectForward,
SingleActivator(.home, shift: true): _selectStart,
SingleActivator(.end, shift: true): _selectEnd,
// readline compatible
SingleActivator(.keyA, control: true): _moveStart,
SingleActivator(.keyB, control: true): _moveBack,
SingleActivator(.keyD, control: true): _deleteForward,
SingleActivator(.keyE, control: true): _moveEnd,
SingleActivator(.keyF, control: true): _moveForward,
SingleActivator(.keyH, control: true): _deleteBack,
SingleActivator(.keyK, control: true): _kill,
SingleActivator(.keyY, control: true): _yank,
// platform specific key bindings
if (defaultTargetPlatform case .macOS) ...{
SingleActivator(.arrowLeft, meta: true): _moveStart,
SingleActivator(.arrowRight, meta: true): _moveEnd,
SingleActivator(.arrowLeft, meta: true, shift: true): _selectStart,
SingleActivator(.arrowRight, meta: true, shift: true): _selectEnd,
SingleActivator(.keyA, meta: true): _selectAll,
SingleActivator(.keyX, meta: true): _cut,
SingleActivator(.keyC, meta: true): _copy,
SingleActivator(.keyV, meta: true): _paste,
SingleActivator(.keyZ, meta: true): _undo,
SingleActivator(.keyZ, meta: true, shift: true): _redo,
},
if (defaultTargetPlatform case .windows || .linux) ...{
SingleActivator(.keyX, control: true): _cut,
SingleActivator(.keyC, control: true): _copy,
SingleActivator(.keyV, control: true): _paste,
},
// backspace/delete on desktop
if (defaultTargetPlatform case .macOS || .windows || .linux) ...{
SingleActivator(.backspace): _deleteBack,
SingleActivator(.delete): _deleteForward,
},
};
int get _textLength => _value.text.length;
void _setBase(int offset, {int? extent}) {
_value = _value.copyWith(
selection: .new(baseOffset: offset, extentOffset: extent ?? offset),
);
}
void _setExtent(int offset) {
_value = _value.copyWith(
selection: _value.selection.copyWith(extentOffset: offset),
);
}
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);
}
}
void _moveStart() => _setBase(0);
void _moveEnd() => _setBase(_textLength);
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 _selectAll() => _setBase(0, extent: _textLength);
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), '');
}
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);
}
void _kill() {
final start = _value.selection.start;
killRing = _value.text.substring(start);
_delete(start, _textLength);
}
void _yank() {
_replace(killRing);
}
static String killRing = '';
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));
}
}
// ---- TextInputClient ----
@override
TextEditingValue? get currentTextEditingValue => _value;
@override
void updateEditingValue(TextEditingValue value) =>
widget.controller.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) {}
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);
}
}
}
}
//////////////////////////////////////////////////////////////////////////////
/// A titled divider.
class const ADivider({
super.key,
final Widget? child,
final Color? color,
final TextAlign align = .center,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final divider = Expanded(
child: Container(height: 2, color: color ?? Colors.grey),
);
final TextAlign resolvedAlign = switch (align) {
.left => .left,
.right => .right,
.center || .justify => .center,
.start => Directionality.of(context) == .ltr ? .left : .right,
.end => Directionality.of(context) == .ltr ? .right : .left,
};
if (child case final child?) {
return LayoutBuilder(
builder: (context, constraints) {
return Row(
spacing: 4,
children: [
if (resolvedAlign case .right || .center) divider,
ConstrainedBox(
constraints: constraints.copyWith(
minWidth: 0,
maxWidth: constraints.maxWidth - 8,
),
child: DefaultTextStyle(
style: TextStyle(color: Colors.grey),
child: child,
),
),
if (resolvedAlign case .left || .center) divider,
],
);
},
);
}
return divider;
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment