Skip to content

Instantly share code, notes, and snippets.

@sma
Last active February 25, 2024 09:51
Show Gist options
  • Save sma/a63ec3810a1fae4dc18b761e027586f6 to your computer and use it in GitHub Desktop.
Save sma/a63ec3810a1fae4dc18b761e027586f6 to your computer and use it in GitHub Desktop.
an immediate mode UI for Flutter
import 'dart:async';
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
// ----------------------------------------------------------------------------
class Box<T> {
Box(this.value);
T value;
@override
String toString() => '$value';
Box<U> map<U>(U Function(T) f) => Box(f(value));
}
extension on Box<bool> {
void toggle() => value = !value;
}
extension on Box<String> {
void clear() => value = '';
int? get asInt => map(int.tryParse).value;
}
// ----------------------------------------------------------------------------
abstract class IUI extends ChangeNotifier {
var _mouse = Offset.zero;
var _button = 0;
var _layout = Offset.zero;
var _char = '';
late Canvas _canvas;
var _lastButton = 0;
bool get up => _lastButton != 0 && _button == 0;
bool get down => _lastButton == 0 && _button != 0;
var _id = 0;
var _focus = 2;
var _cursor = 0;
int _assignId() => ++_id;
bool _hasFocus(int id, [bool assignIfNot = false]) => //
_focus == id || (assignIfNot && (_focus = id) == id);
void _updateMouse(Offset mouse, int button) {
_mouse = mouse;
_button = button;
notifyListeners();
}
void _addChar(String char) {
_char = char;
notifyListeners();
}
static const backgroundColor = Colors.white;
static const textColor = Colors.black87;
static const primaryColor = Colors.pink;
static const onPrimaryColor = Colors.white;
void iText(String text, [Color color = textColor]) {
final size = iTextAt(_layout, text, color);
_layout += Offset(0, size.height + 8);
}
Size iTextAt(Offset offset, String text, [Color color = textColor]) {
final style = TextStyle(
color: color,
fontSize: 14,
height: 20 / 14,
);
final textPainter = TextPainter(
text: TextSpan(
text: text,
style: style,
),
strutStyle: StrutStyle.fromTextStyle(style, forceStrutHeight: true),
textDirection: TextDirection.ltr,
);
textPainter.layout();
if (color.alpha != 0) textPainter.paint(_canvas, offset);
final size = textPainter.size;
textPainter.dispose();
return size;
}
bool iButton(String text) {
final id = _assignId();
final size = iTextAt(_layout, text, Colors.transparent);
final bounds = _layout & Size(max(size.width + 24, 80), max(size.height + 12, 32));
final armed = bounds.contains(_mouse);
final pressed = armed && _button == 1;
final hasFocus = _hasFocus(id, false);
final paint = Paint()
..color = armed //
? primaryColor.withOpacity(pressed ? 0.8 : 0.9)
: primaryColor;
_canvas.drawRRect(RRect.fromRectXY(bounds, 4, 4), paint);
if (hasFocus) {
_canvas.drawRRect(
RRect.fromRectXY(bounds.inflate(2), 6, 6),
Paint()
..color = textColor
..style = PaintingStyle.stroke
..strokeWidth = 2);
}
iTextAt(
Offset(
_layout.dx + (bounds.width - size.width) / 2,
_layout.dy + (bounds.height - size.height) / 2 + (pressed ? 1 : 0),
),
text,
onPrimaryColor,
);
_layout += Offset(0, bounds.height + 8);
return hasFocus && _char == '\r' || armed && up && (_focus = 0) == 0;
}
bool iInput(Box<String> box, int length) {
final id = _assignId();
final bounds = _layout & Size(length * 8.0, 32);
final hasFocus = _hasFocus(id, down && bounds.contains(_mouse));
if (hasFocus) _cursor = _cursor.clamp(0, box.value.length);
final paint = Paint()
..color = hasFocus ? primaryColor : textColor.withOpacity(0.5)
..style = PaintingStyle.stroke;
final rrect = RRect.fromRectXY(bounds, 4, 4);
_canvas.drawRRect(rrect, paint);
_canvas.save();
_canvas.clipRRect(rrect);
if (hasFocus) {
switch (_char) {
case '\x1c':
if (_cursor > 0) _cursor--;
case '\x1d':
if (_cursor < box.value.length) _cursor++;
case '\x08':
case '\x7f':
if (_cursor > 0) {
box.value = box.value.substring(0, _cursor - 1) + box.value.substring(_cursor);
_cursor--;
}
default:
if (_char.isNotEmpty && _char.codeUnitAt(0) >= 32) {
final value = box.value;
box.value = value.substring(0, _cursor) + _char + value.substring(_cursor);
_cursor++;
}
}
}
iTextAt(bounds.topLeft + const Offset(6, 6), box.value);
if (hasFocus) {
final sz = iTextAt(Offset.zero, box.value.substring(0, _cursor), Colors.transparent);
_canvas.drawRect(bounds.topLeft + Offset(sz.width + 6, 6) & Size(2, sz.height), Paint()..color = primaryColor);
}
_canvas.restore();
_layout += Offset(0, bounds.height + 8);
return hasFocus && _char == '\r';
}
void iList(Box<int> selection, List<String> items, int length, int maxItems) {
final id = _assignId();
final bounds = _layout & Size(length * 8.0, maxItems * 32 + 6);
final hasFocus = _hasFocus(id, down && bounds.contains(_mouse));
final armed = bounds.contains(_mouse);
final pressed = armed && _button == 1;
final index = min(max((_mouse.dy - bounds.top) ~/ 32, 0), items.length - 1);
if (hasFocus) {
switch (_char) {
case '\x1e':
if (selection.value > 0) selection.value--;
case '\x1f':
if (selection.value < items.length - 1) selection.value++;
case ' ':
selection.value = selection.value == -1 ? 0 : -1;
}
}
if (armed && down) selection.value = index;
// border
final paint = Paint()
..color = hasFocus ? primaryColor : textColor.withOpacity(0.5)
..style = PaintingStyle.stroke;
_canvas.drawRRect(RRect.fromRectXY(bounds, 4, 4), paint);
// scrollbar
final percent = min(maxItems / items.length, 1);
final thumb = bounds.topRight + const Offset(-8, 2) & Size(6, (bounds.height - 4) * percent);
_canvas.drawRRect(RRect.fromRectXY(thumb, 3, 3), Paint()..color = textColor.withOpacity(0.5));
// items
final j = min(maxItems, items.length);
for (var i = 0; i < j; i++) {
final selected = selection.value == i;
final color = selected || armed && index == i
? pressed && index == i
? primaryColor.withOpacity(0.8)
: primaryColor
: Colors.transparent;
final rect = bounds.topLeft + Offset(2, i * 32 + 2) & Size(bounds.width - 12, 32);
_canvas.drawRRect(RRect.fromRectXY(rect, 2, 2), Paint()..color = color);
iTextAt(
rect.topLeft + const Offset(6, 6),
items[i],
selected || armed && index == i ? onPrimaryColor : textColor,
);
}
_layout += Offset(0, bounds.height + 8);
}
void unfocus() => _focus = 0;
void setup() {
_needsDraw = false;
_layout = const Offset(16, 16);
_id = 0;
_canvas.drawPaint(Paint()..color = backgroundColor);
}
void draw();
void end() {
_lastButton = _button;
var nextFocus = 0;
switch (_char) {
case '\x09':
nextFocus = 1;
case '\x19':
nextFocus = -1;
}
_char = '';
if (nextFocus != 0) {
_focus = (_focus + nextFocus - 1) % _id + 1;
update();
}
}
var _needsDraw = false;
void update() {
if (!_needsDraw) {
_needsDraw = true;
scheduleMicrotask(notifyListeners);
}
}
Widget get widget => _IUIView(this);
}
class _IUIView extends StatelessWidget {
const _IUIView(this.iui);
final IUI iui;
@override
Widget build(BuildContext context) {
return Listener(
onPointerHover: (event) => iui._updateMouse(event.localPosition, 0),
onPointerMove: (event) => iui._updateMouse(event.localPosition, 1),
onPointerDown: (event) => iui._updateMouse(event.localPosition, 1),
onPointerUp: (event) => iui._updateMouse(event.localPosition, 0),
child: Focus(
autofocus: true,
onKeyEvent: (node, event) {
if (event.character case final char?) {
iui._addChar(char);
} else if (event is KeyDownEvent) {
iui._addChar(switch (event.logicalKey) {
LogicalKeyboardKey.arrowLeft => '\x1c',
LogicalKeyboardKey.arrowRight => '\x1d',
LogicalKeyboardKey.arrowUp => '\x1e',
LogicalKeyboardKey.arrowDown => '\x1f',
_ => '',
});
}
return KeyEventResult.handled;
},
child: CustomPaint(painter: _IUIPainter(iui)),
),
);
}
}
class _IUIPainter extends CustomPainter {
_IUIPainter(this.iui) : super(repaint: iui);
final IUI iui;
@override
void paint(Canvas canvas, Size size) {
iui._canvas = canvas;
iui.setup();
iui.draw();
iui.end();
}
@override
bool shouldRepaint(_IUIPainter oldDelegate) => true;
}
void main() {
runApp(MaterialApp(home: TodoList().widget));
}
// ----------------------------------------------------------------------------
class Hello extends IUI {
@override
void draw() {
iText('Hello, World!');
}
}
class Greet extends IUI {
final greet = Box(false);
@override
void draw() {
if (iButton('Greet')) greet.toggle();
if (greet.value) {
iText('Hello, World!');
}
}
}
class Demo extends IUI {
final input1 = Box('3');
final input2 = Box('12');
final showIt = Box(false);
@override
void draw() {
iInput(input1, 5);
iInput(input2, 6);
if (iButton('Show sum')) {
showIt.toggle();
update();
}
if (showIt.value) {
if ((input1.asInt, input2.asInt) case (final v1?, final v2?)) {
iText('$v1 + $v2 = ${v1 + v2}');
} else {
iText('Invalid input');
}
if (iButton('Reset')) {
input1.clear();
input2.clear();
showIt.toggle();
update();
}
}
}
}
class TodoList extends IUI {
final todos = <String>[];
final todo = Box('');
final selection = Box(-1);
@override
void draw() {
iList(selection, todos, 50, 4);
if (iInput(todo, 50) || iButton('Add')) {
todos.add(todo.value);
update();
}
if (selection.value != -1) {
if (iButton('Remove')) {
todos.removeAt(selection.value);
selection.value = -1;
update();
}
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment