Skip to content

Instantly share code, notes, and snippets.

@sma
Last active October 3, 2024 19:12
Show Gist options
  • Save sma/ecb425de4c2b1252868998e340125045 to your computer and use it in GitHub Desktop.
Save sma/ecb425de4c2b1252868998e340125045 to your computer and use it in GitHub Desktop.
For fun, I wrote a Flutter-like framework for command line applications

Terminal Flutter

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.

We Need Widgets

Widget

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 Elements from immutable Widget and to use RenderObjects 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 {}

StatelessWidget, StatefulWidget

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.

SingleChildWidget, MultiChildWidget and RenderWidget

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

Text Widget

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;
}

SizedBox

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;
}

Columns and Rows

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});
}

Minesweeper

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.

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

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)),
          ],
        ),
      ],
    );
  }
}

Detail Widgets

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 Rows 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,
                  ),
                );
              }),
            ],
          );
        }),
      ],
    );
  }
}

The main Function

To start the application, I provide the usual runApp function:

void runApp(Widget app) {
  // will be shown later
}

void main() {
    runApp(Minesweeper());
}

Implementation

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.

The Algorithm

To layout the widget tree, I use three phases:

  1. All widgets are asked to build themselves. This should only affect StatelessWidget and StatefulWidget. The latter also get their states reassigned in this phase. I will use the BuildContext to persist those states. We'll see this in just a minute.
  2. 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.
  3. 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.

Widget

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);
}

RenderWidget

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() {}
}

Text

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));
    }
  }
}

SingleChildWidget

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);
  }
}

SizedBox & Expanded

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;
  }
}

Spacer

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) {}
}

MultiChildWidget

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);
    }
  }
}

Column (and Row)

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.

StatelessWidget

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);
  }
}

StatefulWidget

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 Keys 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);
  }
}

runApp

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.

Screen

The Screen class provides a buffer for a "virtual" screen that can be cleared, then set and finally updateed 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.

Keyboard Input

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.

Summary

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.

@yessGlory17
Copy link

I am doing a similar project for javascript. Flutter Style Javascript

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment