package bogue-tutorials
Bogue tutorial — Self-modifying layouts.
Bogue is built upon the simple concept of connection, which allows two remote widgets to communicate, and execute an action triggered by some event. For instance, a Text input widget can connect to a Search engine widget, in order to send it what the user typed at each key press, and in turn the Search engine is connected to the Display widget to tell it to print the results when they arrive.
Things become more involved when the action triggered by the widget wants to modify a Layout to which the widget itself belongs... There are two potential issues:
1. Destructive connection: the original widget may be detroyed by its own action, hence often a new widget will have to be created again.
2. Recursive connection: the action has to take care of recreating a new connection for the new widget: the function which creates the connection, and the function which creates the Layout must be mutually recursive.
In this tutorial we will see different ways of constructing a very simple GUI that has these issues. See here for how to use this tutorial.
The goal
Here we will work out a simple example. Our GUI will print a list of random integers between 1 and 10; if the user clicks on an integer, the list will be replaced by a new list, whose number of elements is precisely the integer that was clicked. (Spoiler: see the Video below!)
------- ------- | 3 | | 5 | ------- ------- | 2 | ==> user clicks on "2" | 3 | ------- ===> we get a new list of 2 elements: ------- | 6 | ------- | 1 | -------
We will propose 4 different ways of doing this. But first, let us introduce some common parts.
In many Bogue
programs it is useful to alias the most common modules, Widget
and Layout
, so let's start with this:
open Bogue
module W = Widget
module L = Layout
Non-GUI first
Most of the time, it is advisable to start by thinking about the non-GUI parts, as this is the core of the program.
For our goal, the program deals with lists of integers. Let us name a data type for this:
type ilist = int list
Now, here is our data creating function. We need a function that creates a new random list of a given length.
let imake len : ilist =
Array.init len (fun _ -> Random.int 9 + 1)
|> Array.to_list
The "disconnected" GUI elements
Let us now turn to the GUI elements. We need to create a Button widget for displaying a given integer. We don't define the button action here, because of the mutually recursive issue, we will add it later.
let make_button i =
W.button ~kind:Button.Trigger (string_of_int i)
This is enough to create a list of widgets associated with our data:
let create_widgets il =
List.map make_button il
With this, we can already display our GUI! But it will do nothing when we click on it, because we didn't create any connection:
let () =
Random.int 9 + 1
|> imake
|> create_widgets
|> L.tower_of_w ~w:200 ~name:"Non-connected GUI"
|> Bogue.of_layout
|> Bogue.run
Some explanation:
- The function
tower_of_w
piles up a list of widgets (our buttons) to create a Layout.
- Our layout is then upgraded into a Bogue GUI with only one window by
Bogue.of_layout
.
If we execute the code above, we see a window like this:
If we click a button, nothing happens, as expected.
Method 1: mutually recursive functions
Let us write a function that connects one button: make_connection
. The parameter i
is the integer held by the button
widget. The connection is triggered by the buttons_up
list of events, and results in:
- creating a new list;
- updating the layout with the new list.
let rec make_connection layout button i =
let action _ _ _ = update_layout layout (imake i) in
W.connect_main button button action Trigger.buttons_up
|> W.add_connection button
Remarks:
- Notice that the connection has no target widget, hence we connect the button to itself.
- This code easily extends to any kind of widget, but, since we dealing with buttons, it is actually better to use the dedicated function:
W.on_button_release
, because it will also handle keyboard selection (TAB+ENTER). Just write instead:
let rec make_connection layout button i =
let release _ = update_layout layout (imake i) in
W.on_button_release ~release button
In order to update the layout, we install the new widgets inside the main (window) layout using set_rooms
, and connect them with make_connection
. We also resize the window accordingly thanks to fit_content
. Notice that, by default, set_rooms
is not executed immediately, but synchronized with Bogue's main loop. Therefore, the call to fit_content
must be synchronized as well to make sure it is executed after set_rooms
.
and update_layout layout il =
let widgets = create_widgets il in
let tower = L.tower_of_w ~w:200 widgets in
L.set_rooms layout [tower];
Sync.push (fun () -> L.fit_content ~sep:0 layout);
List.iter2 (make_connection layout) widgets il
We have now a working GUI! Since the main layout will be populated by update_layout
, we just need to initialize it as an empty
layout. Here we don't care about the initial size (200,400) since it will be immediately modified by update_layout
.
let () =
let layout = L.empty ~w:200 ~h:400 ~name:"Method 1: recursive" () in
let () = Random.int 9 + 1
|> imake
|> update_layout layout in
Bogue.run (Bogue.of_layout layout)
You should see a window like this:
and clicking on the "2" button should replace the list by a 2-element list:
And the new buttons are again clickable to modify the list. Goal achieved!
Method 2: use update events to get rid of recursivity!
Mutually recursive functions can be hard to debug, especially if your program becomes large and the recursivity spreads over many functions.
In this case it is useful to think of the update event method. The button and its parent house share the data via a global variable, and, when clicked, the button just needs to ask its parent to update. (Of course, this programming style feels less "functional".)
let my_list = ref []
We need to create a widget that will receive the update
event; let's call it controller
. This empty
widget just does the logic, it's not associated with any graphical element.
let controller = W.empty ~w:0 ~h:0 ()
Now the only thing that the button should do, when clicked, is to send the update event to the controller, using Update.push
. Since there is no recursivity, we can define the button and its behaviour when clicked at once, using the ~action
parameter.
let make_button i =
W.button ~kind:Button.Trigger (string_of_int i)
~action:(fun _ ->
my_list := imake i;
Update.push controller)
let create_widgets il =
List.map make_button il
let update_layout layout =
let widgets = create_widgets !my_list in
let tower = L.tower_of_w ~w:200 widgets in
L.set_rooms layout [tower];
Sync.push (fun () -> L.fit_content ~sep:0 layout)
The controller's action needs to know which layout
to update, so we have to create its connection after the layout:
let () =
let layout = L.empty ~w:200 ~h:400 ~name:"Method 2: update event" () in
let c = W.connect_main controller controller
(fun _ _ _ -> update_layout layout) [Trigger.update] in
my_list := imake (Random.int 9 + 1);
update_layout layout;
Bogue.run (Bogue.of_layout ~connections:[c] layout)
Notice how, for a change, we didn't manually add the c
connection to the controller
widget; instead we pass it to Bogue.of_layout
which takes care of it. This is equivalent.
Method 3: immediate mode
You don't like connections and events? No problem, if the GUI is not too big, we can use the so-called "immediate" style.
Immediate vs. retained: Internet if full of dubious posts about the difference between the so-called "immediate mode" and "retained mode", and the adepts of the former claim that its detractors don't know what they are talking about. Admittedly, the Wikipedia pages for both are (as of 2023) of really bad quality. I don't want to enter this debate, so you can remove the word "immediate", or replace it with "synchronized" or anything else if you prefer.
The rough idea of the "immediate" programming style is that, instead of considering a button (or other widgets) as an active component which sends a message or executes an action when pressed (which is the role of Bogue's connections), we view it as a passive component which holds a value, and whoever wants to use this value can just immediately take it and act with it. Since a widget then becomes a plain variable, it is much simpler to reason with. In pseudo-code:
if button.is_pressed then do_this
The downside is that we don't know when the button state has been modified, so we need to poll it continuously. Hence we will implement this style by using the before_display
option of Bogue.run
.
OK let's start. The creation of buttons cannot be simpler: same as in the "disconnected" section.
let make_button i =
W.button ~kind:Button.Trigger (string_of_int i)
let create_widgets il =
List.map make_button il
Layouting is also easy. Notice that the update_layout
function is similar to the previous one from Method 2, but now we should not force synchronization (it will be automatic).
let update_layout layout il =
my_list := il;
let tower = L.tower_of_w ~w:200 (create_widgets il) in
L.set_rooms ~sync:false layout [tower];
L.fit_content ~sep:0 layout
Next, we need a function which tells us which button was pressed (if any, so we return an int option
).
For this, we query the internals of our main layout as follows.
let button_clicked room =
let b = L.widget room in
W.get_state b
let get_button_from_tower tower =
let rooms = L.get_rooms tower in
let rec loop2 = function
| r::other_rooms, i::other_ints ->
if button_clicked r then Some i
else loop2 (other_rooms, other_ints)
| _ -> None in
loop2 (rooms, !my_list)
A danger of the "immediate" style is that, if we push the paradigm too far, we may be tempted to recreate the whole GUI at each frame, which is not a good idea (and Bogue will not like it!). To avoid this, our new update_list
function will return None
if no button was pressed.
let update_list layout =
List.hd (L.get_rooms layout)
|> get_button_from_tower
|> Option.map imake
In this way, we will be able to call our update_layout
function only when the result of update_list
is not None.
let () =
let layout = L.empty ~w:200 ~h:400 ~name:"Method 3: immediate mode" () in
update_layout layout (imake (Random.int 9 + 1));
let before_display () =
update_list layout
|> Option.iter (update_layout layout) in
Bogue.run ~before_display (Bogue.of_layout layout)
This works perfectly! No connection, no recursivity, so... do we have a winner here?
Well, you can't win on all fronts; the devil is now transferred into the get_button_from_tower
function. Indeed, this function relies on a particular organisation of the layout tree. If we decide to move our list of buttons to a different box, or include images for decoration, or add tooltips, then the function will probably fail. Bogue's philosophy is rather to not try to keep track of the layout tree. Connections are more robust because they are attached to the widgets, so whatever the layout we construct to host the widgets, the connections will always work.
Method 4: mixed approach
For more complex GUIs we can use a mixed approach, which uses a simple button action for implementing a good get_pressed_button
, and then an immediate style for update_layout
.
let pressed_button = ref None
let get_pressed_button () =
let b = !pressed_button
in pressed_button := None;
b
let make_button i =
W.button ~kind:Button.Trigger (string_of_int i)
~action:(fun _ -> pressed_button := Some i)
let create_widgets il =
List.map make_button il
The new update_list
function is now very simple:
let update_list () =
get_pressed_button ()
|> Option.map imake
We keep the same update_layout
function as in the Immediate Method (except that we don't need the global variable my_list
anymore), and the main call is essentially the same too:
let update_layout layout il =
let tower = L.tower_of_w ~w:200 (create_widgets il) in
L.set_rooms ~sync:false layout [tower];
L.fit_content ~sep:0 layout
let () =
let layout = L.empty ~w:200 ~h:400 ~name:"Method 4: mixed approach" () in
update_layout layout (imake (Random.int 9 + 1));
let before_display () =
update_list ()
|> Option.iter (update_layout layout) in
Bogue.run ~before_display (Bogue.of_layout layout)
So what?
Some words of conclusion.
First, if you went through the four methods above, congrats! You now have a fairly complete overview of how Bogue works, and you should be able to program essentially any kind of GUI.
Next, don't think that this recursivity problem is just an academic issue. (Maybe you noticed the similarity with a file chooser?) In my experience, it happens quite often, even in moderately complex GUIs, that widgets end up having a circular definition.
But, finally, which method is the best? I don't really know! I guess it all depends on your programming taste and the type of GUI you want to construct. Games and tools are very different in spirit. Method 1 is maybe more elegant from the viewpoint of functional programming, but tracking the cycle of depencencies is often hard in real applications. Method 2 is probably the most flexible, the most "Bogue-esque", and adapts to virtually all kinds of situations. On the other hand, if you are not used to think in terms of interacting objects, and prefer the flow of a usual program, I believe that Method 4 is a good choice. The only thing that I don't recommend is the 'layout tree parsing' of get_button_from_tower
from Method 3. Sometimes you need to inspect the layout tree and that's fine if you have a way to certify that the tree structure is invariant, but in most situations you should first ask yourself: is there another way?
Video
Happy Bogue-ing!