Skip to content

Instantly share code, notes, and snippets.

@sma
Created November 29, 2021 14:14
Show Gist options
  • Save sma/594ddd3fae804f2e7ef9dd554817e8f7 to your computer and use it in GitHub Desktop.
Save sma/594ddd3fae804f2e7ef9dd554817e8f7 to your computer and use it in GitHub Desktop.
A tutorial for Dart on how to create a GUI from scratch that runs in a HTML canvas

Canvas GUI

So you want to create a GUI from scratch using HTML canvas?

Colors

Let's start with an abstraction for ARGB color values:

class Color {
    const Color(this.value);
    final int value;

    String get cssStyle => ...
}

It has a cssStyle getter to convert value into something you can use as a Canvas fillStyle and/or strokeStyle. I omit the implementation of the method here. It could also have methods to access single R, G or B values or to derive lighter or darker color variants.

Let's then create a Colors class as a namespace for constants like red or black to provide a few default colors and make it similar to Flutter – VSC can then display color patches, which is nice:

class Colors {
  static const amber = Color(0xFFFFC107);
  static const black = Color(0xFF000000);
  ...
}

Parts

I will create a kind of immediate mode GUI that uses paint methods that will be called again and again to draw a each frame, 60 times a second. A retained mode GUI that will only redraw dirty regions would be a more traditional way for such a framework and often is more efficient, but it is also more difficult to implement. So, let's keep it as simple as possible.

The basic building block shall be called Part.

class Part {
  double x = 0, y = 0, width = 50, height = 50;
  Color? color;
  Part? parent;
  ...

  void paint(Context context) {
    if (color == null) return;
    context.fill = color
    context.fillRect(x, y, width, height);
  }

  ...
}

A Part knows how to paint a rectangluar region of the canvas.

I made all parts mutable. Because they are continuously drawn, changing the fields is all that is required to change their look on the canvas. As I said, this is as simple as possible.

Context

I will pass a Context to all paint methods which is an alias for a CanvasRenderingContext2D (the type defined by HTML) which I extended slightly.

typedef Context = CanvasRenderingContext2D;

extension on Context {
  set fillColor(Color? color) => fillStyle = color?.cssStyle;

  ...
}

Container

There are parts that have children. I call them ContainerParts. They paint themselves, and then all their children – using the so called painter's algorithm –, adapting the coordinate system so that each child part use coordinates relative to its parent part.

class ContainerPart extends Part {
  List<Part> children = [];

  void add(Part child) {
    children.add(child);
    child.parent = this;
  }

  @override
  void paint(Context context) {
    super.paint(context);
    context.translate(x, y);
    for (final child in children) { child.paint(context); }
    context.translate(-x, -y);
  }
}

Each Part knows its parent and the provided add method automatically updates this field. This parent link will become useful later.

The RootPart

The topmost part is called RootPart. It knows about the HTML canvas element and sets itself up to continuously paint itself and all of its children because it is a ContainerPart.

class RootPart extends ContainerPart {
  RootPart(this.canvas) { _paintFrame(0); }
  final CanvasElement canvas;

  void _paintFrame(num _) {
    width = canvas.width!.toDouble();
    height = canvas.height!.toDouble();
    paint(canvas.context2D);
    window.requestAnimationFrame(_paintFrame);
  }
}

Example

Let's put this in action:

void main() {
  RootPart(document.getElementById('canvas') as CanvasElement)
    ..color = Colors.blue
    ..add(Part()
      ..x = 10
      ..y = 20
      ..color = Colors.red);
}

A Dart web application has a main function. It also has an index.html file which must contain something like <canvas id="canvas"></canvas> and which should have a size, either defined with HTML attributes or some CSS. In this tutorial, I will only talk about the Dart code, though.

Text

Colorful rectangles are nice, but displaying text must also be possible. To make things simple, I will only support single-line text, but it would be possible to create multi-line rich text based on this.

To abstract the text's font, size, and color, I use a TextStyle object, which (again) is very similar to how Flutter does its thing. The cssFont method is used to convert the style into something that can be set as font property of a Context.

class TextStyle {
  const TextStyle({
    this.fontSize = 16,
    this.fontFamily = 'sans-serif',
    this.color = Colors.black,
  });
  final double fontSize;
  final String fontFamily;
  final Color color;

  TextStyle copy({double? fontSize, String? fontFamily, Color? color}) => TextStyle(
        fontSize: fontSize ?? this.fontSize,
        fontFamily: fontFamily ?? this.fontFamily,
        color: color ?? this.color,
      );

  String get cssFont => '${fontSize}px $fontFamily';

  static const standard = TextStyle();
}

The style is inherited through the part hierarchy:

class Part {
  ...

  TextStyle get currentStyle => parent?.currentStyle ?? TextStyle.standard;

  ...
}

Now we can implement the Text part:

class Text extends Part {
  Text(this.text, {this.style});

  String text;
  TextStyle? style;

  @override
  void paint(Context context) {
    super.paint(context);
    final dims = context.textDims(text, currentStyle);
    context.fillColor = currentStyle.color;
    context.fillText(text, x, y + dims.ascent);
  }

  @override
  TextStyle get currentStyle => style ?? super.currentStyle;
}

I left out one last detail. I need to measure the size the text needs and because the HTML canvas API is a bit messy, I abstracted this in a TextDims (not the best name) object like so:

extension on Context {
  TextDims textDims(String text, TextStyle style) {
    font = style.cssFont;
    final metrics = measureText(text);
    return TextDims(
      metrics.width!.toDouble(),
      metrics.fontBoundingBoxAscent!.toDouble(),
      metrics.fontBoundingBoxDescent!.toDouble(),
    );
  }
}

class TextDims {
  TextDims(this.width, this.ascent, this.descent);
  final double width, ascent, descent;
  double get height => ascent + descent;
}

We can now display text for different families and font sizes and colors.

This decorator part can now be used to change the TextStyle for all of its children.

class DefaultTextStyle extends ContainerPart {
  DefaultTextStyle(this.style);
  TextStyle style;

  @override
  TextStyle get currentStyle => style;
}

Layouting

There's a general problem, though. We have to know the pixel size of the text. It would be much nicer, if the Text part would know its own ideal size. More general, each part should be able to determine its ideal size and then each ContainerPart should be able to layout its children, based on those sizes and/or its own size (as set by its parent). This process is called layout.

Let's add these two methods to Part:

class Part {
  ...

  void setIdealSize(Context context) {}

  void layoutChildren(Context context) {}
}

Let's then override the first method for Text and adapt the paint method to not simply display the text in the top/left corner but aligned horizontally and vertically, both defaulting to centering (in retrospect, this could also be part of the TextStyle).

class Text {
  ...
  double alignX = .5, alignY = .5;

  @override
  void setIdealSize(Context context) {
    final dims = context.textDims(text, currentStyle);
    width = dims.width;
    height = dims.height;
  }

  @override
  void paint(Context context) {
    super.paint(context);
    final dims = context.textDims(text, currentStyle);
    final dx = (width - dims.width) * alignX;
    final dy = (height - dims.height) * alignY + dims.ascent;
    context.fillColor = currentStyle.color;
    context.fillText(text, x + dx, y + dy);
  }
}

Let's then implement both methods for ContainerPart in the simplest way possible: A container's ideal size is the maximum of all children sizes. For layout, the container will make all children completely fill the part, to paint them over each other:

class ContainerPart {
  ...

  @override
  void setIdealSize(Context context) {
    width = height = 0;
    for (final child in children) {
      child.setIdealSize(context);
      if (width < child.width) width = child.width;
      if (height < child.height) height = child.height;
    }
  }

  @override
  void layoutChildren(Context context) {
    for (final child in children) {
      child
        ..x = 0
        ..y = 0
        ..width = width
        ..height = height
        ..layoutChildren(context);
    }
  }
}

To activate the layout, add layoutChildren(canvas.context2D); in RootPart._paintFrame just before paint(canvas.context2D);.

This will now display a centered text (ignoring the absolut x, y position of Text on red background, completely hiding the blue background):

void main() {
  RootPart(document.getElementById('canvas') as CanvasElement)
    ..color = Colors.blue
    ..add(Text("Hallo, Welt")
      ..x = 10
      ..y = 20
      ..color = Colors.red);

Layout Containers

Similar to Flutter, we can create parts to layout other parts vertically, horizontally or above each other. I'll show Column and you should then be able to create Row yourself by swapping the axes.

class Column extends ContainerPart {
  double gap = 0, alignX = .5, alignY = .5;

  @override
  void setIdealSize(Context context) {
    width = height = 0;
    for (final child in children) {
      child.setIdealSize(context);
      if (width < child.width) width = child.width;
      height += child.height + gap;
    }
    if (children.isNotEmpty) height -= gap;
  }

  @override
  void layoutChildren(Context context) {
    if (children.isEmpty) return;
    var y = 0.0;
    for (final child in children) {
      child.setIdealSize(context);
      child
        ..x = (width - child.width) * alignX
        ..y = y
        ..layoutChildren(context);
      y += child.height;
      y += gap;
    }
    final dy = (height - y + gap) * alignY;
    for (final child in children) {
      child.y += dy;
    }
  }
}

Here's a new example, demonstrating the Column to display two Text parts:

void main() {
  RootPart(document.getElementById('canvas') as CanvasElement)
    ..color = Colors.blue
    ..add(Column()
      ..alignY = .1
      ..x = 10
      ..y = 20
      ..color = Colors.amber
      ..add(Text("Hallo, Welt")..color = Colors.white)
      ..add(Text("Hello, World")));
}

A Stack can display Positioned or Aligned parts. It's the most sophisticated container so far, but still manageble.

The Align part positions its children (typically it's only one child) based on a value between 0 and 1, 0 meaning left and 1 meaning right and 0.5 meaning center. It shares the default implementation of setIdealSize with the generic container. In layoutChildren, children are positioned according to the container alignment.

class Align extends ContainerPart {
  double alignX = .5, alignY = .5;

  @override
  void layoutChildren(Context context) {
    for (final child in children) {
      child.setIdealSize(context);
      child
        ..x = (width - child.width) * alignX
        ..y = (height - child.height) * alignY
        ..layoutChildren(context);
    }
  }
}

The Position part has optional offsets to the four edges left, right, top, and bottom and will position its children (typically it's only one child) accordingly. Optionally, it also sets the child's width and/or height, but if you provide both a left and right value and/or both a top and bottom value, the fixed width resp. height is ignored and the child is stretched in those axes as required (yeah, I shouldn't have used single letter fields).

class Position extends ContainerPart {
  double? l, r, t, b, w, h;

  @override
  void setIdealSize(Context context) {
    super.setIdealSize(context);
    width = w ?? width;
    height = h ?? height;
  }
}

The Stack's ideal size needs to take the edge offset of positioned children into account. And for layout, when children are aligned or positioned, the correct position and/or size has to be computed as shown. Otherwise, the children are aligned using the stack's default alignment which defaults to centering.

class Stack extends ContainerPart {
  double alignX = .5, alignY = .5;

  @override
  void setIdealSize(Context context) {
    width = height = 0;
    for (final child in children) {
      child.setIdealSize(context);
      var w = child.width, h = child.height;
      if (child is Position) {
        w += max(child.l ?? 0, 0);
        w += max(child.r ?? 0, 0);
        h += max(child.t ?? 0, 0);
        h += max(child.b ?? 0, 0);
      }
      if (width < w) width = w;
      if (height < h) height = h;
    }
  }

  @override
  void layoutChildren(Context context) {
    for (final child in children) {
      child.setIdealSize(context);
      if (child is Align) {
        child.x = (width - child.width) * child.alignX;
        child.y = (height - child.height) * child.alignY;
      } else {
        child.x = (width - child.width) * alignX;
        child.y = (height - child.height) * alignY;
        if (child is Position) {
          if (child.l != null) {
            child.x = child.l!;
            if (child.r != null) {
              child.width = width - child.x - child.r!;
            }
          } else if (child.r != null) {
            child.x = width - child.width - child.r!;
          }
          if (child.t != null) {
            child.y = child.t!;
            if (child.b != null) {
              child.height = height - child.y - child.b!;
            }
          } else if (child.b != null) {
            child.y = height - child.height - child.b!;
          }
        }
      }
      child.layoutChildren(context);
    }
  }
}

The RootPart can now inherit from Stack to make it more flexible.

Padding

A decorator part that insets its children might become useful later.

Therefore, I present this implementation:

class Insets extends ContainerPart {
  Insets([double all = 0]) : l = all, r = all, t = all, b = all;
  Insets.only({
    double l = 0, double r = 0, double t = 0, double b = 0,
    double h = 0, double v = 0,
  }) : l = l + h, r = r + h, t = t + v, b = b + v;
  double l, r, t, b;

  @override
  void setIdealSize(Context context) {
    super.setIdealSize(context);
    width += l + r;
    height += t + b;
  }

  @override
  void layoutChildren(Context context) {
    for (final child in children) {
      child
        ..x = l
        ..y = t
        ..width = width - l - r
        ..height = height - t - b;
    }
  }
}

This should be sufficent, to implement all kinds of layouts. Compared to Flutter, I didn't implement Expanded but you could add it to Column and Row yourself. Instead of the final alignment along the main axis, you need to distribute the remaining space among all Expanded parts.

Make it Interactive

A GUI isn't really useful if it isn't interactive. To react to mouse clicks, the RootPart shall listen to mouse down, mouse move and mouse up events and dispatch them to part which all shall have mouseDown, mouseMove and mouseUp methods. I implement a simple bubbling algorithm by providing the "down" event to all parts under the mouse pointer, beginning with the innermost. A hitTest method will enumerate those parts. The first part that feels responsible to deal with the event will return true. All other must return false. The "move" and the final "up" event are then passed only to that one part.

The following code is added to Part (instead of adding children I could have written different hitTest methods for Part and ContainerPart – I could also introduce a SingleChildPart further remove the need for containers – or I could make every Part support children – there are always design decision):

  Iterable<Part> get children => Iterable.empty();

  Iterable<Part> hitTest(double x, double y) sync* {
    x -= this.x;
    y -= this.y;
    if (x < 0 || y < 0 || x >= width || y >= height) return;
    for (final child in children) {
      yield* child.hitTest(x, y);
    }
    yield this;
  }

  bool mouseDown() => false;

  void mouseMove(bool inside) {}

  void mouseUp(bool inside) {}

Add this to the constructor of RootPart:

    canvas.onMouseDown.listen((event) {
      final parts = hitTest(event.offset.x.toDouble(), event.offset.y.toDouble());
      for (final part in parts) {
        if ((_focus = part).mouseDown()) return;
      }
    });
    canvas.onMouseMove.listen((event) {
      final parts = hitTest(event.offset.x.toDouble(), event.offset.y.toDouble());
      _focus?.mouseMove(parts.contains(_focus));
    });
    canvas.onMouseUp.listen((event) {
      final parts = hitTest(event.offset.x.toDouble(), event.offset.y.toDouble());
      _focus?.mouseUp(parts.contains(_focus));
      _focus = null;
    });

Button

Using this event distribution framework, I can implement a Button that changes its colors if clicked. As usual, the button will fire its callback only if the mouse button is released over the button.

typedef VoidCallback = void Function();

class Button extends ContainerPart {
  VoidCallback? onPressed;
  bool _active = false;

  Color get fillColor => _active ? Colors.red : Colors.white;
  Color get textColor => _active ? Colors.white : Colors.black;

  @override
  TextStyle get currentStyle => super.currentStyle.copy(color: textColor);

  @override
  bool mouseDown() => _active = true;

  @override
  void mouseMove(bool inside) => _active = inside;

  @override
  void mouseUp(bool inside) {
    if (inside) onPressed?.call();
    _active = false;
  }

  @override
  void paint(Context context) {
    const r = 8;
    context.translate(x, y);
    context.beginPath();
    context.moveTo(r, 0);
    context.lineTo(width - r, 0);
    context.arcTo(width, 0, width, r, r);
    context.lineTo(width, height - r);
    context.arcTo(width, height, width - r, height, r);
    context.lineTo(r, height);
    context.arcTo(0, height, 0, height - r, r);
    context.lineTo(0, r);
    context.arcTo(0, 0, r, 0, r);
    context.closePath();
    context.fillColor = fillColor;
    context.fill();
    for (final child in children) {
      child.paint(context);
    }
    context.translate(-x, -y);
  }
}

Making the button rounded added a lot of code to an otherwise simple paint method. I should probably have added this as a fillRRect helper to Context.

Now, this defines a button and makes it obvious, that my way to construct parts could be improved to make it look more declarative. Introducing high-level parts that are built from more low-level parts that render to the canvas would be an obvious choice.

final button = Button()
    ..onPressed = () {
      print('You pressed the button');
    }
    ..add(Insets(12)..add(Text("A Button")));

Text Input

Receiving key strokes is easy, but there could be more than one part interested in those and therefore, I need to have the concept of a focused part and that focus needs to be changable. Therefore each part should announce whether it can be focused and needs to know whether it is currently focused. Let's the RootPart deal with most of this stuff. But then, each part must find it's root:

class Part {
  ...

  RootPart? get rootPart => parent?.rootPart;

  bool get canFocus => false;
  
  bool get hasFocus => rootPart?.focus == this;
}

```dart
class RootPart {
  ...
  Part? focus;

  RootPart? get rootPart => this;

  void focusNext() {
    var skip = focus != null;
    for (final child in allChildren) {
      if (skip && child == focus) {
        skip = false;
      } else if (child.canFocus) {
        focus = child;
        return;
      }
    }
    for (final child in allChildren) {
      if (child == focus) return;
      if (child.canFocus) {
        focus = child;
        return;
      }
    }
  }
}

To move the focus with focusNext, I need to enumerate each and every part in the search of a part that can be focussed. That search must start after the current focussed part and can warp around, hence the two for loops. Here's the missing allChildren method:

class Part {
  ...

  Iterable<Part> get allChildren sync* {
    yield this;
    for (final child in children) { yield* child.allChildren; }
  }
}

Last but not least, let's add this event listener to the constructor of RootPart and provide an empty default implementation of keyPress for Part. The event handler is a bit messy because keyPress doesn't provide backspace, tab, or cursor keys while keydown doesn't translate key code into unicode code units.

    ...
    canvas.onKeyDown.listen((event) {
      if (event.keyCode == 8) {
        _keyDown(event, 8);
      } else if (event.keyCode == 9) {
        event.preventDefault();
        focusNext();
      } else if (event.keyCode >= 37 && event.keyCode <= 40) {
        _keyDown(event, event.keyCode - 36);
      } else if (event.keyCode == 46) {
        _keyDown(event, 127);
      }
    });
    canvas.onKeyPress.listen((event) {
      if (event.charCode >= 32) {
        _keyDown(event, event.charCode);
      }
    });
  }

  ...

  void _keyDown(Event event, int code) {
    if (focus == null) focusNext();
    if (focus != null) {
      focus!.keyDown(code);
      event.preventDefault();
    }
  }

After creating all this, here's a simple text input field. I inherit from Text and overimpose a cursor at the correct index. Then I implement keyDown to modify the text and/or index according to the key pressed. And of course, a TextInput can have a focus.

class TextInput extends Text {
  TextInput([String text = ''])
      : index = text.length,
        super(text);
  int index;

  @override
  bool get canFocus => true;

  @override
  void paint(Context context) {
    super.paint(context);
    if (hasFocus) {
      final dims = context.textDims(text.substring(0, index), currentStyle);
      context.fillColor = Colors.red;
      context.fillRect(x + dims.width - 1, y, 3, dims.height);
    }
  }

  @override
  void keyDown(int code) {
    if (code == 1) {
      if (index > 0) --index;
    } else if (code == 3) {
      if (index < text.length) ++index;
    } else if (code == 8) {
      if (index > 0) {
        text = text.substring(0, index - 1) + text.substring(index--);
      }
    } else if (code == 127) {
      if (index < text.length) {
        text = text.substring(0, index) + text.substring(index + 1);
      }
    } else if (code >= 32) {
      text = text.substring(0, index) + String.fromCharCode(code) + text.substring(index);
      ++index;
    }
  }
}

What Else is Possible?

A DraggablePart as a subclass of Position would be easy to create.

We might want to constraint its drag axis and perhaps its bounds and voila, we created a scroll bar thumb. Placing it in a container and adding two buttons, and we created a traditional scroll bar.

To create (an inefficient) list view, we need to be able to clip the context that paints parts. That's trivial. Then we could use a Column and our scroll bar and the list view is ready for use. Making the view port explicit and rendering only what is visible is a bit more difficult but manageble.

The list view should support a selected item, but that could be easily achieved by highlighting the background of an item.

And if you know how to create a list view, creating a tree view isn't that difficult. Coming up with a good model for the data is more difficult than rendering the data.

Draggable windows are also possible. A window title bar consists of a number of buttons, a title Text and something that can be dragged as discussed above and must again be constrainted, this times to the edges of the RootPart.

Resizing a window using a dedicated resize button (again very traditional) is also easy, with the exception that this draggable doesn't modify the position of its parent part but the size. Just make this configurable.

Overlapping windows are then trivial. And so I get a desktop. They are all displayed as a stack and you need to rearrange the children. Moving a part before or after another part might be methods you want to add to each ContainerPart.

For modal dialogs you need to temporarily disable the rearrange option.

Entering data is possible with my text input. You might want to add more functionality to the keyboard handling. Maintaining a selection would be nice, as would be a way to copy and paste not only within this framework but using the operation system's clipboard. The RootPart can be the mediator here.

No GUI is complete without context and/or pulldown menus. The former can be constructed with the framework already provided. We need to pass the current click position to the handler, though. And we need to convert global into local coordinates and vice versa. But then a context menu is just a column of buttons. A pull down menu is a bit more difficult to implement but a nice excercise for a rainy afternoon.

But I'm done here. Feel free to extend the code on your own.

import 'dart:html';
import 'dart:math';
/// An abstraction for color values.
class Color {
const Color(this.value);
final int value;
String get cssStyle {
final alpha = (value >> 24) & 255;
if (alpha != 255) {
return 'rgba(${(value >> 16) & 255},${(value >> 8) & 255},${value & 255},${alpha / 255})';
}
return '#${value.toRadixString(16).substring(2)}';
}
}
class Colors {
static const amber = Color(0xFFFFC107);
static const black = Color(0xff000000);
static const red = Color(0xFFF44336);
static const blue = Color(0xFF3F51B5);
static const white = Color(0xffffffff);
}
typedef Context = CanvasRenderingContext2D;
extension on Context {
set fillColor(Color? color) => fillStyle = color?.cssStyle;
set strokeColor(Color? color) => strokeStyle = color?.cssStyle;
TextDims textDims(String text, TextStyle style) {
font = style.cssFont;
final metrics = measureText(text);
return TextDims(
metrics.width!.toDouble(),
metrics.fontBoundingBoxAscent!.toDouble(),
metrics.fontBoundingBoxDescent!.toDouble(),
);
}
}
class TextDims {
TextDims(this.width, this.ascent, this.descent);
final double width, ascent, descent;
double get height => ascent + descent;
}
class Part {
double x = 0, y = 0, width = 50, height = 50;
Color? color;
Part? parent;
TextStyle get currentStyle => parent?.currentStyle ?? TextStyle.standard;
void setIdealSize(Context context) {}
void layoutChildren(Context context) {}
void paint(Context context) {
if (color == null) return;
context.fillColor = color;
context.fillRect(x, y, width, height);
}
Iterable<Part> get children => Iterable.empty();
Iterable<Part> hitTest(double x, double y) sync* {
x -= this.x;
y -= this.y;
if (x < 0 || y < 0 || x >= width || y >= height) return;
for (final child in children) {
yield* child.hitTest(x, y);
}
yield this;
}
bool mouseDown() => false;
void mouseMove(bool inside) {}
void mouseUp(bool inside) {}
RootPart? get rootPart => parent?.rootPart;
bool get canFocus => false;
bool get hasFocus => rootPart?.focus == this;
Iterable<Part> get allChildren sync* {
yield this;
for (final child in children) {
yield* child.allChildren;
}
}
void keyDown(int code) {}
}
class ContainerPart extends Part {
@override
List<Part> children = [];
void add(Part child) {
children.add(child);
child.parent = this;
}
@override
void setIdealSize(Context context) {
width = height = 0;
for (final child in children) {
child.setIdealSize(context);
if (width < child.width) width = child.width;
if (height < child.height) height = child.height;
}
}
@override
void layoutChildren(Context context) {
for (final child in children) {
child
..x = 0
..y = 0
..width = width
..height = height
..layoutChildren(context);
}
}
@override
void paint(Context context) {
super.paint(context);
context.translate(x, y);
for (final child in children) {
child.paint(context);
}
context.translate(-x, -y);
}
}
class RootPart extends Stack {
RootPart(this.canvas) {
_paintFrame(0);
canvas.onMouseDown.listen((event) {
final parts = hitTest(event.offset.x.toDouble(), event.offset.y.toDouble());
for (final part in parts) {
if ((_active = part).mouseDown()) break;
}
});
canvas.onMouseMove.listen((event) {
final parts = hitTest(event.offset.x.toDouble(), event.offset.y.toDouble());
_active?.mouseMove(parts.contains(_active));
});
canvas.onMouseUp.listen((event) {
final parts = hitTest(event.offset.x.toDouble(), event.offset.y.toDouble());
_active?.mouseUp(parts.contains(_active));
_active = null;
});
canvas.onKeyDown.listen((event) {
if (event.keyCode == 8) {
_keyDown(event, 8);
} else if (event.keyCode == 9) {
event.preventDefault();
focusNext();
} else if (event.keyCode >= 37 && event.keyCode <= 40) {
event.preventDefault();
_keyDown(event, event.keyCode - 36);
} else if (event.keyCode == 46) {
_keyDown(event, 127);
}
});
canvas.onKeyPress.listen((event) {
if (event.charCode >= 32) {
_keyDown(event, event.charCode);
}
});
}
final CanvasElement canvas;
Part? _active;
void _paintFrame(num _) {
width = canvas.width!.toDouble();
height = canvas.height!.toDouble();
layoutChildren(canvas.context2D);
paint(canvas.context2D);
window.requestAnimationFrame(_paintFrame);
}
@override
RootPart? get rootPart => this;
Part? focus;
void focusNext() {
var skip = focus != null;
for (final child in allChildren) {
if (skip && child == focus) {
skip = false;
} else if (child.canFocus) {
focus = child;
return;
}
}
for (final child in allChildren) {
if (child == focus) return;
if (child.canFocus) {
focus = child;
return;
}
}
}
void _keyDown(Event event, int code) {
if (focus == null) focusNext();
if (focus != null) {
focus!.keyDown(code);
event.preventDefault();
}
}
}
class Text extends Part {
Text(this.text, {this.style});
String text;
TextStyle? style;
double alignX = .5, alignY = .5;
@override
TextStyle get currentStyle => style ?? super.currentStyle;
@override
void setIdealSize(Context context) {
final dims = context.textDims(text, currentStyle);
width = dims.width;
height = dims.height;
}
@override
void paint(Context context) {
super.paint(context);
final dims = context.textDims(text, currentStyle);
final dx = (width - dims.width) * alignX;
final dy = (height - dims.height) * alignY + dims.ascent;
context.fillColor = currentStyle.color;
context.fillText(text, x + dx, y + dy);
}
}
class TextStyle {
const TextStyle({
this.fontSize = 16,
this.fontFamily = 'sans-serif',
this.color = Colors.black,
});
final double fontSize;
final String fontFamily;
final Color color;
TextStyle copy({double? fontSize, String? fontFamily, Color? color}) => TextStyle(
fontSize: fontSize ?? this.fontSize,
fontFamily: fontFamily ?? this.fontFamily,
color: color ?? this.color,
);
String get cssFont => '${fontSize}px $fontFamily';
static const standard = TextStyle();
}
class DefaultTextStyle extends ContainerPart {
DefaultTextStyle(this.style);
TextStyle style;
@override
TextStyle get currentStyle => style;
}
class Column extends ContainerPart {
double gap = 0, alignX = .5, alignY = .5;
@override
void setIdealSize(Context context) {
width = height = 0;
for (final child in children) {
child.setIdealSize(context);
if (width < child.width) width = child.width;
height += child.height + gap;
}
if (children.isNotEmpty) height -= gap;
}
@override
void layoutChildren(Context context) {
if (children.isEmpty) return;
var y = 0.0;
for (final child in children) {
child.setIdealSize(context);
child
..x = (width - child.width) * alignX
..y = y
..layoutChildren(context);
y += child.height;
y += gap;
}
final dy = (height - y + gap) * alignY;
for (final child in children) {
child.y += dy;
}
}
}
class Align extends ContainerPart {
double alignX = .5, alignY = .5;
@override
void layoutChildren(Context context) {
for (final child in children) {
child.setIdealSize(context);
child
..x = (width - child.width) * alignX
..y = (height - child.height) * alignY
..layoutChildren(context);
}
}
}
class Position extends ContainerPart {
double? l, r, t, b, w, h;
@override
void setIdealSize(Context context) {
super.setIdealSize(context);
width = w ?? width;
height = h ?? height;
}
}
class Stack extends ContainerPart {
double alignX = .5, alignY = .5;
@override
void setIdealSize(Context context) {
width = height = 0;
for (final child in children) {
child.setIdealSize(context);
var w = child.width, h = child.height;
if (child is Position) {
w += max(child.l ?? 0, 0);
w += max(child.r ?? 0, 0);
h += max(child.t ?? 0, 0);
h += max(child.b ?? 0, 0);
}
if (width < w) width = w;
if (height < h) height = h;
}
}
@override
void layoutChildren(Context context) {
for (final child in children) {
child.setIdealSize(context);
if (child is Align) {
child.x = (width - child.width) * child.alignX;
child.y = (height - child.height) * child.alignY;
} else {
child.x = (width - child.width) * alignX;
child.y = (height - child.height) * alignY;
if (child is Position) {
if (child.l != null) {
child.x = child.l!;
if (child.r != null) {
child.width = width - child.x - child.r!;
}
} else if (child.r != null) {
child.x = width - child.width - child.r!;
}
if (child.t != null) {
child.y = child.t!;
if (child.b != null) {
child.height = height - child.y - child.b!;
}
} else if (child.b != null) {
child.y = height - child.height - child.b!;
}
}
}
child.layoutChildren(context);
}
}
}
class Insets extends ContainerPart {
Insets([double all = 0])
: l = all,
r = all,
t = all,
b = all;
Insets.only({
double l = 0,
double r = 0,
double t = 0,
double b = 0,
double h = 0,
double v = 0,
}) : l = l + h,
r = r + h,
t = t + v,
b = b + v;
double l, r, t, b;
@override
void setIdealSize(Context context) {
super.setIdealSize(context);
width += l + r;
height += t + b;
}
@override
void layoutChildren(Context context) {
for (final child in children) {
child
..x = l
..y = t
..width = width - l - r
..height = height - t - b;
}
}
}
typedef VoidCallback = void Function();
class Button extends ContainerPart {
VoidCallback? onPressed;
bool _active = false;
Color get fillColor => _active ? Colors.red : Colors.white;
Color get textColor => _active ? Colors.white : Colors.black;
@override
TextStyle get currentStyle => super.currentStyle.copy(color: textColor);
@override
bool mouseDown() => _active = true;
@override
void mouseMove(bool inside) => _active = inside;
@override
void mouseUp(bool inside) {
if (inside) onPressed?.call();
_active = false;
}
@override
void paint(Context context) {
const r = 8;
context.translate(x, y);
context.beginPath();
context.moveTo(r, 0);
context.lineTo(width - r, 0);
context.arcTo(width, 0, width, r, r);
context.lineTo(width, height - r);
context.arcTo(width, height, width - r, height, r);
context.lineTo(r, height);
context.arcTo(0, height, 0, height - r, r);
context.lineTo(0, r);
context.arcTo(0, 0, r, 0, r);
context.closePath();
context.fillColor = fillColor;
context.fill();
for (final child in children) {
child.paint(context);
}
context.translate(-x, -y);
}
}
class TextInput extends Text {
TextInput([String text = ''])
: index = text.length,
super(text);
int index;
@override
bool get canFocus => true;
@override
void paint(Context context) {
super.paint(context);
if (hasFocus) {
final dims = context.textDims(text.substring(0, index), currentStyle);
context.fillColor = Colors.red;
context.fillRect(x + dims.width - 1, y, 3, dims.height);
}
}
@override
void keyDown(int code) {
if (code == 1) {
if (index > 0) --index;
} else if (code == 3) {
if (index < text.length) ++index;
} else if (code == 8) {
if (index > 0) {
text = text.substring(0, index - 1) + text.substring(index--);
}
} else if (code == 127) {
if (index < text.length) {
text = text.substring(0, index) + text.substring(index + 1);
}
} else if (code >= 32) {
text = text.substring(0, index) + String.fromCharCode(code) + text.substring(index);
++index;
}
}
}
void main() {
final button = Button()
..onPressed = () {
print('You pressed the button');
}
..add(Insets(12)..add(Text("A Button")));
RootPart(document.getElementById('canvas') as CanvasElement)
..color = Colors.blue
..add(Position()
..t = 10
..l = 20
..add(Column()
..alignY = .1
..x = 10
..y = 20
..color = Colors.amber
..add(Text("Hallo, Welt")..color = Colors.white)
..add(Insets(10)..add(Text("Hello, World")))
..add(button)
..add(Align()..add(TextInput('Test')))
..add(TextInput('ABC'))));
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment