Last active
February 25, 2024 09:51
-
-
Save sma/a63ec3810a1fae4dc18b761e027586f6 to your computer and use it in GitHub Desktop.
an immediate mode UI for Flutter
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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