For fun, I recreated a subset of Flutter that is sufficient to build a tiny Minesweeper application for the terminal.
Here is how it looks:
+----------------------+
|Minesweeper 3/12|
| |
| 0 1 2 3 4 5 6 7 8 9 |
|0 |
|1 1 1 1 |
|2 1 1 1 2 3 M 1 |
|3 . . . M M 3 3 2 1 |
|4 . . . . . . . . 2 |
|5 . . . . 2 1 3 . 2 |
|6 . . 3 2 1 1 1 1 |
|7 . . 1 |
+----------------------+
This article outlines my approach and describes in brief how it works.
The main building block and root of the type hierarchy is the Widget
. I omit the concept of keys and I will also simplify Flutter's approach to create mutable Element
s from immutable Widget
and to use RenderObject
s to actually paint something onto a Canvas
. My widgets will do all this theirselves and get mutable state in just a minute.
abstract class Widget {}
There are two important subclasses: StatelessWidget
and StatefulWidget
. The former has a build
method which is used to create more basic widgets that implement the stateless one. The latter has a persistent State
that is kept around even if the widget itself is recreated. That state has the widget's build
method. A BuildContext
is passed to all widgets of the tree.
abstract class StatelessWidget extends Widget {
Widget build(BuildContext context);
}
abstract class StatefulWidget extends Widget {
State<StatefulWidget> createState();
}
abstract class State<W extends StatefulWidget> {
late W widget;
void setState(void Function() execute) => execute();
Widget build(BuildContext context);
}
class BuildContext {}
As you might have noticed, I also simplified the setState
method. Normally, it should trigger a rebuild. I will always rebuild my UI after each key pressed by the user. It wouldn't be difficult to improve this, but I didn't need that feature. For the same reason, I omitted initState
, dispose
, didUpdateWidget
, and other lifecycle methods.
To implement some common widgets, these abstract widgets will come handy:
abstract class SingleChildWidget extends Widget {
SingleChildWidget({this.child});
final Widget? child;
}
abstract class MultiChildWidget extends Widget {
MultiChildWidget({this.children = const []});
final List<Widget> children;
}
abstract class RenderWidget extends Widget {}
The latter is called RenderObjectWidget
in Flutter. As I have no explicit RenderObject
s and simply call the process to display characters "rendering", this is simply a widget without children that is supposed to render something on the screen.
Let's start with the most basic and most important concrete widget: Text
. It renders a string of characters as a single line. I didn't bother to implement line wrapping. I also didn't bother to implement a TextStyle
. Hover, a Text
can be displayed with foreground and background color swapped. I need this to display a "cursor". Most if not all terminals support this mode.
class Text extends RenderWidget {
Text(this.data, {this.reverse = false});
final String data;
final bool reverse;
}
A SizedBox
can give other widgets a fixed width and/or height. Because I'm rendering characters and not pixel, I use int
to represent all values. There are no ½ columns or ⅓ lines of characters.
class SizedBox extends SingleChildWidget {
SizedBox({this.width, this.height, super.child});
final int? width, height;
}
A Column
layouts widgets vertically, a Row
layouts widgets horizontally. I won't support alignments. I support a Spacer
and an Expanded
widget, though, in a slightly different way than Flutter does. The result should be the same, I hope.
class Column extends MultiChildWidget {
Column({super.children});
}
class Row extends MultiChildWidget {
Row({super.children});
}
abstract class Flex {}
class Spacer extends RenderWidget implements Flex {}
class Expanded extends SingleChildWidget implements Flex {
Expanded({super.child});
}
My "business logic" is encapsulated as a BombGrid
class, which doesn't matter much here. I will omit its source code.
class BombGrid {
BombGrid(this.rows, this.columns, this.bombs);
bool get hasLost;
int get markedCells;
BombCell cellAt(int row, int column);
}
class BombCell {
bool get isBomb;
bool get isMarked;
bool get isRevealed;
int get bombsSeen;
void reveal();
void mark();
}
Instead, let's think about how to implement the Minesweeper
application widget.
It is a StatefulWidget
that uses a Column
to display a title, a horizontal ruler with column numbers, and a Row
that combines another ruler with row numbers and an Expanded
widget with a GridView
that displays the bomb field.
The title consists of a Row
containing the Text "Minesweeper", a Spacer
and another Text
containing the number of marked cells to track progress.
The rulers are Row
or Column
widgets that contain Text
wrapped in SizedBox
widgets. They are implemented as StatelessWidget
s.
The application widget's state holds the BombGrid
instance and maintains a cursor position to mark and/or reveal grid cells.
The GridView
is another StatelessWidget
that builds itself with a Column
with a lot of Row
widgets whose contain Text
wrapped in SizedBox
widgets. One of those Text
widgets is reverse
to mark the current cursor position.
Here's a "graphic":
+Column--------------------+
| +Row-------------------+ |
| | Text Spacer Text | |
| +----------------------+ |
| +Row-------------------+ |
| | SizedBox[Text] ... | |
| +----------------------+ |
| +Row-------------------+ |
| +Col+ +Expanded--------+ |
| | T | | +GridView------+ |
| | . | | | ... | |
| | . | | | | |
| | | | | ... | |
| +---+ | +--------------+ |
+--------------------------+
And here is the source. Note that I could and probably should have passed the BombGrid
as an external dependency. Also note that I omitted the interactivity for now. We need to revisit this widget.
class Minesweeper extends StatefulWidget {
State<Minesweeper> createState() => _MinesweeperState();
}
class _MinesweeperState extends State<Minesweeper> {
final _grid = BombGrid(8, 10, 12);
var _row = 0, _column = 0;
Widget build(BuildContext context) {
return Column(
children: [
Row(
children: [
Text('Minesweeper'),
Spacer(),
Text('${_grid.markedCells}/${_grid.bombs}'),
],
),
HorizontalRuler(length: _grid.columns),
Row(
children: [
VerticalRuler(length: _grid.rows),
Expanded(child: GridView(_grid, _row, _column)),
],
),
],
);
}
}
Here's the HorizontalRuler
. The VerticalRuler
class looks nearly the same:
class HorizontalRuler extends StatelessWidget {
HorizontalRuler({required this.length});
final int length;
Widget build(BuildContext context) {
return Row(
children: [
...Iterable.generate(length, (i) {
return SizedBox(width: 2, child: Text('$i'));
})
],
);
}
}
The GridView
looks difficult but it is just two nested loops that arranges Row
s of cells using a Column
, each cell being a Text
wrapped in a SizedBox
. The row
and column
properties specify the cell to highlight:
class GridView extends StatelessWidget {
final BombGrid grid;
final int row, column;
Widget build(BuildContext context) {
return Column(
children: [
...Iterable.generate(grid.rows, (r) {
return Row(
children: [
...Iterable.generate(grid.columns, (c) {
final cell = grid.cellAt(r, c);
return SizedBox(
width: 2,
child: Text(
grid.hasLost && cell.isBomb
? 'B'
: cell.isMarked
? 'M'
: cell.isRevealed
? ' 12345678'[cell.bombsSeen]
: '.',
reverse: r == row && c == column,
),
);
}),
],
);
}),
],
);
}
}
To start the application, I provide the usual runApp
function:
void runApp(Widget app) {
// will be shown later
}
void main() {
runApp(Minesweeper());
}
So far, the Minesweeper
widget should work with the normal Flutter framework (if you overwrite the SizedBox
).
This framework, or at least a subset of that framework, is what I want to recreate now.
My approach is relatively straight forward: I will layout and render the widget tree, wait for keyboard input, process it and rince and repeat by again layouting and rendering the widget tree before waiting for the next keyboard input by the user. Each time, I clear the terminal scren and redraw everything.
To layout the widget tree, I use three phases:
- All widgets are asked to build themselves. This should only affect
StatelessWidget
andStatefulWidget
. The latter also get their states reassigned in this phase. I will use theBuildContext
to persist those states. We'll see this in just a minute. - All widgets are asked to size themselves. Flutter provides minimum and maximum constraints and widgets are expected to find their ideal dimensions within those constraints, but I'm using a simpler approach by only asking the widget without any constraints. This makes multi-line text impossible to layout – but who cares. It's very simple and that's what I'm reaching for.
- All widgets then must position their children. They are allowed to resize their children at this point, too. The child position is relative to the parent widget, as usual.
To eventually render the widgets, I'm using a Screen
instance. Screen
abstracts my terminal access in a way very similar to Curses. I will show its implementation later.
As mentioned above, opposite to Flutter, my Widget
instances aren't immutable. I use them to store the position and dimension determined in the layout phases described above. Therefore, I need a few instances variables which I will prefix with f_
to keep the "polution" of the widget namespace to a minimum.
Here's the "real" class definition of Widget
:
abstract class Widget {
var f_x = 0, f_y = 0, f_width = 0, f_height = 0;
void f_build(BuildContext context);
void f_setSize();
void f_layoutChildren();
void f_render(Screen screen, int dx, int dy);
}
Let's start implementing "build", "layout" and "render" for the RenderWidget
which is the simplest case. Because it has no children, it doesn't need to delegate calls down the widget hierarchy.
abstract class RenderWidget extends Widget {
void f_build(BuildContext context) {}
void f_layoutChildren() {}
}
The text will size itself to the length of the provided string and will render all characters. Because it felt oldschool and efficient, my Screen
will take unicode code units instead of strings of grapheme clusters as input. Also note that dx
and dy
provide the offset computed by the layout of all parent widgets. The widget is required to add its own position to determine the absolute position of the text on the screen, which is then rendered character by character. I'm ignoring overflowing the screen.
class Text extends RenderWidget {
...
void f_setSize() {
f_width = data.length;
f_height = 1;
}
void f_render(Screen screen, int dx, int dy) {
var x = f_x + dx, y = f_y + dy;
for (final code in data.codeUnits) {
screen.set(y, x++, code | (reverse ? 0x10000 : 0));
}
}
}
Widget with a single (optional) child must delegate all method calls to that child. Without a child, the size defaults to zero. When delegating "render", it needs to add its own position.
abstract class SingleChildWidget extends Widget {
void f_build(BuildContext context) => child?.f_build(context);
void f_setSize() {
child?.f_setSize();
f_width = child?.f_width ?? 0;
f_height = child?.f_height ?? 0;
}
void f_layoutChildren() => child?.f_layoutChildren();
void f_render(Screen screen, int dx, int dy) {
child?.f_render(screen, dx + f_x, dy + f_y);
}
}
A SizedBox
is a single child widget. As is the Expanded
widget. The latter can inherit all four methods from its superclass. So we don't have to do anything here. The former needs overwrite the child's size with its own size, though:
class SizedBox extends SingleChildWidget {
...
void f_setSize() {
super.f_setSize();
f_width = width ?? f_width;
f_height = height ?? f_height;
}
}
If RenderWidget
would provide a default implementation for f_setSize
and f_render
, the Spacer
would be done already. However, I decided that I want all decedants to explicitly implement those methods and so here they are. A space has no intrinsic size and doesn't render anything:
class Spacer extends RenderWidget implements Flex {
void f_setSize() => f_width = f_height = 0;
void f_render(Screen screen, int dx, int dy) {}
}
The same point can be made for this widget. I could provide useful implementations for determining the size (as the maxium of all childrens) and layout (make all childrens the same size) but I left that to concrete subclasses. So this is the implementation of MultiChildWidget
:
class MultiChildWidget extends Widget {
...
void f_build(BuildContext context) {
for (final child in children) {
child.f_build(context);
}
}
void f_render(Screen screen, int dx, int dy) {
for (final child in children) {
child.f_render(screen, dx + f_x, dy + f_y);
}
}
}
A column must now layout all child widgets vertically. Its size is computed based on those children. The width is the maximum width of all children. The height is the sum of all children heights. To layout children, I need two phases. First I need to count Flex
widgets so that I can distribute the extra space to those widgets. Because of integer arthmetic, my algorithm is too simplistic, but it works for the case that there is just one Spacer
or Expanded
as with my example. Therefore, I didn't bother to think about how to distribute the fractions.
class Column extends MultiChildWidget {
...
void f_setSize() {
f_width = f_height = 0;
for (final child in children) {
child.f_setSize();
f_width = max(f_width, child.f_width);
f_height += child.f_height;
}
}
void f_layoutChildren() {
var y = 0, h = 0, flex = 0;
for (final child in children) {
if (child is Flex) ++flex;
h += child.f_height;
}
final extra = max(f_height - h, 0);
for (final child in children) {
h = child is Flex ? extra ~/ flex : 0;
child
..f_x = 0
..f_y = y
..f_width = f_width
..f_height += h
..f_layoutChildren();
y += child.f_height;
}
}
}
For the Row
the same algorithm is used with x- and y-axis swapped.
The stateless widget needs to build itself before layout and render can happen. The built subtree must be stored and I'm using a private instance variable in the widget for this.
This widget is very similar to a SingleChildWidget
in this respect and I think, one could refactor both to make use of this similarity.
abstract class StatelessWidget extends Widget {
Widget build(BuildContext context);
late Widget f_widget;
void f_build(BuildContext context) {
f_widget = build(context);
f_widget.f_build(context);
}
void f_setSize() {
f_widget.f_setSize();
f_width = f_widget.f_width;
f_height = f_widget.f_height;
}
void f_layoutChildren() {
f_widget
..f_x = 0
..f_y = 0
..f_layoutChildren();
}
void f_render(Screen screen, int dx, int dy) {
f_widget.f_render(screen, dx + f_x, dy + f_y);
}
}
Last but not least, the stateful widget must be implemented. It is very similar to the StatelessWidget
and I will make it inherit from that class to save some code.
To store states, I use the BuildContext
to which I added a f_getState
method. My solution to associate the state with the correct widget is insufficient for the general case but works for my case where there is just one instance of a Minesweeper
widget. By adding the concept of Key
s to widgets, this could be improved. I could also correctly implement the lifecycle without much additional work if I'd need it.
abstract class StatefulWidget extends StatelessWidget {
State<StatefulWidget> createState();
Widget build(BuildContext context) {
return (context.f_getState(this)..widget = this).build(context);
}
}
class BuildContext {
final _states = <Type, State<StatefulWidget>>{};
State<StatefulWidget> f_getState(StatefulWidget widget) {
return _states.putIfAbsent(widget.runtimeType, widget.createState);
}
}
All methods to build, layout and render my widgets are implemented now. What is still missing is the global function runApp
that, well, runs my app.
For now, it will render it only once:
void runApp(Widget app) {
final screen = Screen.full();
final context = BuildContext();
void render() {
app.f_build(context);
app.f_setSize();
app.f_layoutChildren();
screen.clear();
app.f_render(screen, 0, 0);
screen.update();
}
render();
}
Notice that render
could (and probably should) be a method of BuildContext
. This way, the context
could be re-rendered by setState
which currently is a no-op regarding this functionality.
The Screen
class provides a buffer for a "virtual" screen that can be clear
ed, then set
and finally update
ed to the real terminal screen. It writes everything to stdout
. It could optimize this process by only writing the characters that changed since the last update, but I didn't bother to implement this. A modern terminal is fast enough to render thousands of characters per second.
class Screen {
Screen.full() : this(stdout.terminalLines, stdout.terminalColumns);
Screen(this.lines, this.columns) : _codes = List.filled(lines * columns, 32);
final int lines, columns;
final List<int> _codes;
void clear() => _codes.fillRange(0, _codes.length, 32);
void set(int y, int x, int code) => _codes[y * columns + x] = code;
void update() {
final lines = min(this.lines, stdout.terminalLines);
final columns = min(this.columns, stdout.terminalColumns);
final needsLF = columns < stdout.terminalColumns;
final buf = StringBuffer();
buf.write('\x1b[2J\x1b[H');
for (var y = 0; y < lines; y++) {
for (var x = 0; x < columns; x++) {
buf.writeCharCode(_codes[y * columns + x]);
}
if (needsLF) buf.writeln();
}
stdout.write(buf);
}
}
At this point in time, the Minesweeper
should display itself on the terminal.
Most of the work as been done. You could create a Scaffold
and an AppBar
widget and recreate the "classical" counter app, and if you also create a Stack
widget with a Positioned
and an Align
widget of which a Center
widget would be a special case, you could even create a dead floating action button.
Only one feature is still missing: Keyboard input.
I added a KeyListener
widget that provides an onKey
callback which is then called innermost first with the key pressed by the user. For this, I need to add a f_key(key)
method to every widget. That method should return true
if the key was accepted and processed and false
otherwise. Based on that return, the key is propagated as needed and "bubbles" up the widget hierarchy.
abstract class Widget {
...
bool f_key(String key);
}
abstract class StatelessWidget extends Widget {
...
bool f_key(String key) => f_widget.f_key(key);
}
abstract class SingleChildWidget extends Widget {
...
bool f_key(String key) => child?.f_key(key) ?? false;
}
abstract class MultiChildWidget extends Widget {
...
bool f_key(String key) => children.any((child) => child.f_key(key));
}
abstract class RenderWidget extends Widget {
...
bool f_key(String key) => false;
}
class KeyListener extends SingleChildWidget {
KeyListener({required this.onKey, super.child});
final bool Function(String) onKey;
bool f_key(String key) => (child?.f_key(key) ?? false) || onKey(key);
}
The KeyListener
can now be used in Minesweeper
similar to a GestureDector
widget or a RawKeyboardListener
in Flutter (which would require focus management which I omitted for simplicity) like so:
Widget build(BuildContext context) {
return KeyListener(
onKey: _onKey,
child: Column( ... )
);
}
bool _onKey(String key) {
// I omitted checks for valid coordinates
if (key == 'h') {
setState(() => _column--);
} else if (key == 'j') {
setState(() => _row++);
} else if (key == 'k') {
setState(() => _row--);
} else if (key == 'l') {
setState(() => _column++);
} else if (key == ' ') {
setState(() => _grid.cellAt(_row, _column).reveal());
} else if (key == 'm') {
setState(() => _grid.cellAt(_row, _column).mark());
} else {
return false;
}
return true;
}
}
To feed the key into the widget tree, we have to add the following code snippet to runApp
:
runApp(Widget app) {
...
stdin.echoMode = false;
stdin.lineMode = false;
stdin.listen((event) {
if (app.f_key(utf8.decode(event))) render();
});
}
Notice how I simply re-render everything after processing a key. This way, I don't actually need setState
.
It took me about two hours to create the initial version of the code and about four more hours to clean up the code and write this article (and spell check it). It wasn't too difficult to create a Flutter-like framework and the result might be even somewhat useful. One would probably need more widgets and some error checking. I also missed the hot reload feature while developing this. To tackle multi-line text rendering, I'd have to add constrained layout. That would be a big change. All other omissions shouldn't be difficult to fix.
I found this very interesting, thanks for the write-up. Is the code in a repository somewhere?