Weekend experiment: declarative GUI in D

Posted 2020-11-02

Core D Development Statistics

In the community

Community announcements

See more at the announce forum.

What Adam is working on

A couple months ago, I showed a white noise app in D and we used it a bit, but later wanted to add other noise colors and a graphical user interface.

I searched the web for some code to copy/paste for the noise (and found this: https://noisehack.com/generate-noise-web-audio-api/ - one nice thing about D is how easy it is to port code from other languages. Copy/pasting that Javascript almost just worked in D!) and slapped something together with my minigui.d for the interface. Easy enough.

But as I typed the code to instantiate the classes and attaching the event listeners, I wanted to experiment with making it a little more automagical.

The user side

All of this is pre-release preview and is thus subject to change without notice.

Here's the complete program (depends on arsd git master)

1 import arsd.simpleaudio;
2 import arsd.minigui;
3 import std.random;
5 void main() {
6         auto window = new MainWindow("Noise App");
8         auto ao = AudioOutputThread(true);
10         enum Algorithm {
11                 brown,
12                 pink,
13                 white
14         }
16         @Container!HorizontalLayout(
17                 Container!VerticalLayout("default"),
18                 Container!(Style.maxWidth(32))("volume")
19         )
20         struct Control {
21                 private bool paused;
23                 Algorithm algorithm;
25                 @ControlledBy!Button("Start / Stop")
26                 void pause() {
27                         if(paused)
28                                 ao.unpause();
29                         else
30                                 ao.pause();
31                         paused = !paused;
32                 }
34                 @ControlledBy!VerticalSlider(0, 32000, 800)
35                 int volume = 3200; // really a short
36         }
38         Control control;
40         window.addDataControllerWidget(&control);
42         // for pink
43         float b0 = 0.0, b1 = 0.0, b2 = 0.0, b3 = 0.0, b4 = 0.0, b5 = 0.0, b6 = 0.0;
45         // for brown
46         float lastOut = 0.0;
48         ao.addChannel = delegate(short[] buffer) {
49                 const algorithm = control.algorithm;
50                 const volume = control.volume;
51                 foreach(ref item; buffer) {
52                         final switch(algorithm) with(Algorithm) {
53                         case white:
54                                 item = cast(short) uniform(-volume, volume);
55                                 break;
57                         case pink:
58                                 float white = uniform(-1.0, 1.0);
59                                 b0 = 0.99886 * b0 + white * 0.0555179;
60                                 b1 = 0.99332 * b1 + white * 0.0750759;
61                                 b2 = 0.96900 * b2 + white * 0.1538520;
62                                 b3 = 0.86650 * b3 + white * 0.3104856;
63                                 b4 = 0.55000 * b4 + white * 0.5329522;
64                                 b5 = -0.7616 * b5 - white * 0.0168980;
65                                 auto output = b0 + b1 + b2 + b3 + b4 + b5 + b6 + white * 0.5362;
66                                 output *= 0.11; // (roughly) compensate for gain
67                                 b6 = white * 0.115926;
69                                 item = cast(short) (output * volume);
70                                 break;
72                         case brown:
73                                 float white = uniform(-1.0, 1.0);
74                                 float output = (lastOut + (0.02 * white)) / 1.02;
75                                 lastOut = output;
76                                 output *= 3.5; // (roughly) compensate for gain
78                                 item = cast(short) (output * volume);
79                                 break;
80                         }
81                 }
82                 return true;
83         };
85         window.loop();
86 }
88 // some code from from https://noisehack.com/generate-noise-web-audio-api/

And this is what it looks like on my Linux box:

Linux layout - drop-down box on the top-left

On Windows, of course, minigui uses the native controls, so it will look like them there.

Let me now do a deeper dive into the code.

1 @Container!HorizontalLayout(
2         Container!VerticalLayout("default"),
3         Container!(Style.maxWidth(32))("volume")
4 )
5 struct Control {
6         Algorithm algorithm;
8         @ControlledBy!Button("Start / Stop")
9         void pause() {}
11         @ControlledBy!VerticalSlider(0, 32000, 800)
12         int volume = 3200;
13 }
15 Control control;
17 window.addDataControllerWidget(&control);

This code is the user side. At the bottom, the addDataControllerWidget is what actually uses the UDAs to create the UI and piece together the necessary event handlers.

First, the @Container UDA on the struct allows me to define a layout. If this is not present, it will just use the default container of the given parent (which in the case of a window like this would be vertical arrangements of child widgets).

It works by taking a class name and/or style overrides as template arguments, then a string name and a list of children as its other arguments. You can form as much of a tree as you want.

Then, inside the struct, you have your ordinary methods and data members, but with additional UDAs again. Algorithm gets its default widget - a drop-down selector because it is an enum.

I override the method's default with @ControlledBy!Button, indicating a button widget should trigger this call. Here, again, I pass a class as a template argument, then pass some arguments for its constructor. Notably, though, I did not pass the parent argument - the library will do that later when I call addDataControllerWidget. Similarly, I don't set any event handlers since the library does that too.

The placement of widgets is currently associated by their name. The Container named "volume" will receive the widget for the variable volume. Since the others have no specified location, they fall into the "default" container (or just the last one, if there is no specified default). I'll probably tweak this later - remember this is a very young concept - but it already gives some flexibility and convenience together.

Future direction here is to pull more and more data out of the static reflection while keeping it easily customizable for the cases where the automatic default is inadequate.

One small note: Container!(Style.maxWidth(32)) is a special case. Instead of defining a class, I just give a list of individual methods I want to override on its generic base. Style is just an opDispatch struct that yields methods to mix in later.

I kinda regret using virtual functions for these values. Anonymous nested classes, script.d subclasses, and other various tricks make changing the values easy enough... but it still basically assumes there's some static per-class value that is appropriate, which is generally true... but not always. And it limits things like runtime css loading. But nevertheless, that's the way it is right now, so I'm just rolling with it

Anyway, on the inside, this list of overrides is used to generate a new class as-needed. If I were to change the implementation, of course, it could just as well adapt to that as well.

The innards

The implementation of this is actually fairly straightforward, at least at this early stage. That will probably change as I flesh it further out.

The general idea is the UDAs all boil down to simple runtime structures: UI definition tables and class factory functions. The complex work is done by an ordinary runtime function that processes this data. Indeed, you could use much of these facilities with a runtime data definition as well. The benefit of this is that it is inline (minigui is supposed to both be a small library and allow easy addition of gui features with a small amount of code) and that additional factories can be generated and used transparently.

The data binding is also auto-generated and necessary events registered automatically. This currently works by convention - it sets an event based on what widgets it recognizes and sets value based on what types it recognizes. This will need expansion later.

Let's get a bit more into the code.

addDataControllerWidget is a UFCS function that is just a factory for the DataControllerWidget template. It is a child class of Widget (I will probably add some other interfaces later) that takes a pointer to some data struct and a parent widget. It will inspect the type of the pointer to extract the annotations and build the data tree.

ControlledBy is also a factory function to make a ControlledBy_ struct, which actually holds the factory (as its private construct method) and the arguments (as struct members).

The more complex one is the Container. It is a templated class that inherits from the given base, mixes in the given tweaks, then defines opCall. It is the opCall that is used in the UDA. That returns the static data, including a factory function pointer that instantiates the full Container class.

I certainly could have done this differently. My first thought was to attach the classes themselves as UDAs, for example:

struct Control {
	void pause() {}

And I could make that work with the opCall overload, but it would mean retrofitting all my old classes. (Oh how I wish static opCall(this This)() worked!) And even so, I don't love how it works anyway.

So I added the outer items, the Container and ControlledBy, but kept the same opCall interface. Interestingly, we could also do new Container!(...) in procedural code as well to potentially reuse this in more contexts. Will be interesting to keep this in mind as I go.

Anyway, the static opCall returns a ContainerMeta - no template here, all runtime data, just built up at compile time. This gives me a way to build trees with simple variadic function syntax and process it later. Trying to build a full-blown compile time layouter is just too much work for too little benefit, using the runtime data table lets me reuse my existing code and also potentially link it in with runtime files (e.g. a UI designer tool) and script languages.

An open question is how to mutate the UI aside from setting data values, and going in the other direction. Currently, the UI can change the struct, but the struct cannot change the UI. I'm thinking about making the DataControllerWidget interface provide this in the code, as well as having other hooks you can work in manually with properties for additional customization.

This idea is still fairly young - I've done a dialog box generator and a menu bar generator before, but not a live widget like this - but I'm going to keep playing with it.

I think D's real potential in guis isn't so much implementing the low level primitives and widgets, but rather in leveraging D's introspection capabilities in making new convenient APIs.

We'll see where it goes!