So you want to create a GUI from scratch using HTML canvas?
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);
...
}
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.
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;
...
}
There are parts that have children. I call them ContainerPart
s. 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 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);
}
}
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.
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;
}
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);
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 Position
ed or Align
ed 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.
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.
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;
});
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")));
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;
}
}
}
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.