Introduction: Embedded Window Manager
This project shows how to implement a window manager with movable overlapped windows on an embedded micro-controller with a LCD panel and a touch screen. There are commercially available software packages to do this but they cost money and are closed source. This one, called MiniWin, is free and open-source. It's written in fully compliant C99 and can be used in a C or C++ application. The goals of MiniWin are to be easy to use, easy to modify, expandable, portable to a wide range of hardware, and not too resource hungry.
As well as providing the code to manage your windows MiniWin has a collection of user interface controls - buttons, sliders, progress bars, trees etc. You can have multiple windows of different types or multiple instances of the same type. Windows can be moved around, resized, maximised, minimised, closed - all the usual things you do with windows in bigger window managers. TrueType fonts with kerning and anti-aliasing (makes text look smooooooth) are also supported for attractive text rendering.
In each window you have a client area (your space inside the border and below the top bar). On this you can add controls to make a dialog or you can use the in-built graphics library to draw whatever you want. All the graphics library functions are window aware. You don't have to worry about where your window is, what's overlapping it or if it's minimised.
In addition to making your own windows there are also some standard dialogs included that are very easy to instantiate - for example confirmation dialogs (just an OK or Yes/No buttons), time/date setters, file choosers, colour choosers etc.
MiniWin uses a standard windows manager design message queue system. Windows can interact with each other and the window manager via messages. You don't call functions to do things directly, you add a message to the queue and the window manager will enact it for you.
MiniWin has been ported to standard development boards with touch screen displays from micro-controller vendors ST, NXP and Renesas. There are hardware drivers and example projects for all these devices. In addition MiniWin can be built for Windows or Linux so that you can simulate your user interface code before you get your embedded hardware.
MiniWin has a code generator. You can specify your windows and controls in a simple to create human readable JSON file and the code generator parses the file and creates the code for you (there are lots of examples to follow). It creates Windows or Linux complete simulator applications that can just be built and there's your simulated LCD display with your MiniWin windows working. You can take exactly the same generated code and drop it into an embedded project and have the same code showing the same windows and controls moments later on your embedded hardware.
MiniWin requires no operating support on the embedded device. It all runs in a single thread. MiniWin can be integrated with a RTOS running on an embedded processor and there are examples integrating MiniWin with FreeRTOS.
This instructable shows how to get MiniWin up and running on a STM32 M4 processor using the cheap STM32F429 Discovery board which comes with a QVGA touch screen display already attached. These are easily available from your electronics component supplier.
MiniWin runs on mid-range micro-controllers and above.
Supplies
STM32F429I-DISC1 development board and a micro USB cable
STM32CubeIDE download which is free.
Step 1: Getting the Code
First of all you need STM32CubeIDE installed. You get that from ST's website. You have to register and it takes a while to download and install it. It's all free.
While that's installing download the MiniWin source and unzip it. It's big, but you'll only be using a small part of it. Click the green 'Clone or Download' button here...
https://github.com/miniwinwm/miniwinwm
then choose Download Zip. Unzip the contents.
Step 2: Building an Example Project
First lets build one of the example projects. A good one is called MiniWinSimple. Start up STM32CubeIDE then do this:
- Choose File|Import...
- Open up General and choose Existing Project into Workspace. Next.
- Click Browse and navigate to where you unzipped MiniWin. Then go to STM32CubeIDE\MiniWinSimple\STM32F429 folder. Click Select Folder.
- In Project: tick MiniWinSimple_STM32F429 then click Finish.
- MiniWinSimple_STM32F429 project will appear in your Project Explorer. Select it then build it with Project|Build Project.
- Now plug in your USB cable to the board and your computer and run it using Run|Debug and when it's downloaded choose Run|Resume . You'll get a screen calibration display the first time so touch the centre of the 3 crosses on the LCD display. You can now interact with the window on the display.
To move a window drag it by its title bar. To resize a window use the white triangle icon at the left of the title bar. MiniWin windows cannot be resized by dragging the borders as the displays MiniWin is used on are too small. To minimise, maximise or close a window use the icons at the right hand end of the title bar (close might be disabled). When a window is minimised you cannot move the minimised icons around. They build up from the bottom left to right.
Step 3: Running the Code Generator
Now we will change the example project by generating some windows of our own and dropping the new code in. To do this we'll run the code generator.
- Open a command prompt and go to the folder where you unzipped MiniWin and then to Tools\CodeGen folder.
- The executable for Windows CodeGen.exe is already available. For Linux you have to build it by typing make. (You can also build it from source for Windows if you are worried running a downloaded executable but you need the compiler and development environment installed. See the MiniWin documentation in the docs folder for details).
- In this folder are some example JSON files. We'll use example_empty.json. You need to edit it first to set it up for Windows or Linux. Open it up in an editor and at the top where you'll find "TargetType" change the "Linux" or "Windows" value to what you are running the code generator on.
- Now type codegen example_empty.json in the command prompt.
- Go to your project in STM32CubeIDE and open up folder MiniWinSimple_Common. Delete all the files in there.
- We left the "TargetName" in the JSON file as default at "MiniWinGen" so that's the name of our folder of generated code. Go to the folder where you unzipped MiniWin and then MiniWinGen_Common folder. Now select all these files and drag and drop then into STM32CubeIDE in your project's MiniWinSimple_Common folder .
- Now rebuild and rerun the project in STM32CubeIDE and your new design window will appear. The button in the window has gone because example_empty.json doesn't define any.
Step 4: Adding a Window
We'll now add a second window to the JSON configuration file and regenerate the code.
1. Open example_empty.json in a text editor.
2. Under the "Windows" section there is an array of windows definitions which currently has only one window. Copy all this...
{ "Name": "W1", "Title": "Window 1", "X": 10, "Y": 15, "Width": 200, "Height": 180, "Border": true, "TitleBar": true, "Visible": true, "Minimised": false }
and paste it in again with a comma separating the 2 definitions.
3. Change "W1" to "W2" and "Window 1" to "Window 2". Change "X", "Y", "Width" and "Height" to some different values keeping in mind the screen resolution is 240 wide by 320 high.
4. Save the file and run the code generator again.
5. Copy the files as in the previous step, rebuild and rerun. You now will have 2 windows on your display.
Step 5: Adding a Control
Now we'll add some controls to your new window. Edit the same file as in the previous step.
1. In the specification for window W1 add a comma after the last setting ("Minimised" : false) then add this text
"MenuBar": true, "MenuBarEnabled": true, "MenuItems": ["Fred", "Bert", "Pete", "Alf", "Ian"], "Buttons": [{ "Name": "B1", "Label": "Button1", "X": 10, "Y": 10, "Enabled": true, "Visible": true }]
This section adds a menu bar with 5 items and enables it (menu bars can be globally disabled, try it). It also adds a button that is enabled and visible (they can be created invisible and then made visible in code later).
2. Regenerate the code, copy it across, rebuild, rerun all as before.
Step 6: Making the Controls Do Something
Now we have the basic user interface we need to make it do something. For this example we'll pop up a colour chooser dialog when the button in Window 1 is pressed.
Go to your project in STM32CubeIDE and open the MiniWinSimple_Common folder and then open file W1.c (the name of this file corresponds to the window's "Name" field in the JSON file when the code was generated).
In this file you'll find function window_W1_message_function(). It looks like this:
void window_W1_message_function(const mw_message_t *message)<br>{ MW_ASSERT(message != (void*)0, "Null pointer parameter"); /* Next line stops compiler warnings as variable is currently unused */ (void)window_W1_data; switch (message->message_id) { case MW_WINDOW_CREATED_MESSAGE: /* Add any window initialisation code here */ break; case MW_MENU_BAR_ITEM_PRESSED_MESSAGE: /* Add window menu handling code here */ break; case MW_BUTTON_PRESSED_MESSAGE: if (message->sender_handle == button_B1_handle) { /* Add your handler code for this control here */ } break; default: /* Keep MISRA happy */ break; } }
This is called by the window manager for this window whenever the window manager needs to let the window know that something has happened. In this case we're interested in knowing that the window's only button has been pressed. In the switch statement for message types you'll see a case for MW_BUTTON_PRESSED_MESSAGE. This code runs when the button has been pressed. There is only one button in this window, but there could be more, so a check is made on which button it is. In this case it could only be button B1 (name corresponds to the name for the button in the JSON file again).
So after this case label add the code to pop up a colour chooser dialog, which is this:
mw_create_window_dialog_colour_chooser(10, 10, "Colour", MW_HAL_LCD_RED, false, message->recipient_handle);
The parameters are as follows:
- 10, 10 is the location on the screen of the dialog
- "Colour" is the dialog's title
- MW_HAL_LCD_RED is the default colour the dialog will start with
- false means don't show large size (try setting it to true and see the difference)
- message->recipient handle is who owns this dialog, in this case it's this window. A window's handle is in the function's message parameter. This is the window the dialog response will be sent to.
To find out the value of the colour the user chose the window manager will send our window a message with the chosen colour when the user presses the OK button in the dialog. Therefore we need to intercept this message too with another case in the switch statement which looks like this:
case MW_DIALOG_COLOUR_CHOOSER_OK_MESSAGE: { mw_hal_lcd_colour_t chosen_colour = message->message_data; (void)chosen_colour; } break;
We're not doing anything with the chosen colour yet so just casting it to void to prevent a compiler warning. The final code of this function now looks like this:
void window_W1_message_function(const mw_message_t *message) { MW_ASSERT(message != (void*)0, "Null pointer parameter"); /* Next line stops compiler warnings as variable is currently unused */ (void)window_W1_data; switch (message->message_id) { case MW_WINDOW_CREATED_MESSAGE: /* Add any window initialisation code here */ break; case MW_MENU_BAR_ITEM_PRESSED_MESSAGE: /* Add window menu handling code here */ break; case MW_BUTTON_PRESSED_MESSAGE: if (message->sender_handle == button_B1_handle) { /* Add your handler code for this control here */ mw_create_window_dialog_colour_chooser(10, 10, "Colour", MW_HAL_LCD_RED, false, message->recipient_handle); } break; case MW_DIALOG_COLOUR_CHOOSER_OK_MESSAGE: { mw_hal_lcd_colour_t chosen_colour = message->message_data; (void)chosen_colour; } break; default: /* Keep MISRA happy */ break; } }
Running the code is shown in the image above. You might notice that when a dialog is showing you have to respond to it and dismiss it before you do anything else. This is called modal behaviour. Dialogs in MiniWin and all always globally modal and you can only have one showing at a time. There's more explanation here...
Step 7: Drawing in the Window
So far we've only used controls, and they draw themselves. It's time to do some custom drawing on our window. The part you can draw on is inside the borders (if they are any, they are optional), inside the scroll bars (if defined, also optional) and below the title bar (if there is one, that's optional too). It's called the client area in window terminology.
There's a library of graphics commands in MiniWin that you can use. They are all window aware. That means that you don't have to worry about if the window is visible, partly obscured by other windows, on, partly off or fully off the screen, or if the coordinate of where you are drawing is on the client area or beyond it. It's all taken care of for you. You can't draw outside of your client area.
Drawing on client areas in windows terminology is called painting and every window has a paint function where you do your drawing. You don't call your paint function, the window manager does it for you when needed. It's needed when a window is moved or another window on top has its position or visibility changed. If you need your window repainting because some of the data that the window's contents depend on has changed (i.e. you know that a repaint is required rather than the window manager knowing), then you tell the window manager that a repaint is needed and it calls your paint function. You don't call it yourself. (This is all demonstrated in the next section).
First, you need to find your paint function. The code generator creates it for you and it's just above the message handler function modified in the previous section. Go to your project and open file W1.c again.
In this file you'll find function window_W1_paint_function(). It looks like this:
void window_W1_paint_function(mw_handle_t window_handle, const mw_gl_draw_info_t *draw_info) { MW_ASSERT(draw_info != (void*)0, "Null pointer parameter"); /* Fill window's client area with solid white */ mw_gl_set_fill(MW_GL_FILL); mw_gl_set_solid_fill_colour(MW_HAL_LCD_WHITE); mw_gl_set_border(MW_GL_BORDER_OFF); mw_gl_clear_pattern(); mw_gl_rectangle(draw_info, 0, 0, mw_get_window_client_rect(window_handle).width, mw_get_window_client_rect(window_handle).height); /* Add you window painting code here */ }
This is the bare as-generated code and all it does is fill the client area with solid white. Lets draw a yellow filled circle on the client area. First we have to understand the concept of a graphics context (another windows thing). We set drawing parameters in the graphics context and then call a generic circle drawing routine. Things we have to set in this example are whether the circle has a border, border line style, border colour, whether the circle is filled, fill colour and fill pattern. You can see the code above that does something similar for filling the client area with a borderless solid filled white rectangle. The values in the graphics context are not remembered between each call of the paint function so you have to set up the values every time (they are remembered with the paint function though).
In the code above you can see that fill is on and fill pattern is off, so we don't need to set those again. We need to set border on, border line style to solid, border foreground colour to black and fill colour to yellow like this:
mw_gl_set_fg_colour(MW_HAL_LCD_BLACK); mw_gl_set_solid_fill_colour(MW_HAL_LCD_YELLOW); mw_gl_set_line(MW_GL_SOLID_LINE); mw_gl_set_border(MW_GL_BORDER_ON); mw_gl_circle(draw_info, window_simple_data.circle_x, window_simple_data.circle_y, 25);
Add this code at the comment in this function where it says to add your code. Next we need to draw a circle which is done like this:
mw_gl_circle(draw_info, 30, 30, 15);
This draws a circle at coordinates 30, 30 with radius 15. Rebuild the code and rerun it and you will see a circle in the window as shown above. You'll notice that the circle and the button overlap but the button is on top. This is by design. Controls are always on top of anything you draw on the client area.
Step 8: Window Data
So far we have implemented our own code in Window 1's message function (to handle incoming messages) and its paint function (to draw on the window's client area). Now it's time to link the two. Lets fill the circle drawn in the paint function with the colour the user chooses by the colour chooser when the button was pressed. Remember that we don't call the paint function, the window manager does it, so our message function (which knows the colour chosen) cannot call the paint function directly itself. Instead we need to cache the data and let the window manager know that a repaint is required. The window manager will then call the paint function which can use the cached data.
At the top of W1.c you'll see an empty data structure and an object of this type declared by the code generator like this:
typedef struct { /* Add your data members here */ char dummy; /* Some compilers complain about empty structs; remove this when you add your members */ } window_W1_data_t; static window_W1_data_t window_W1_data;
This is where we cache our data so that it's preserved across calls and is known as the window data. We only need to store the chosen colour in here, like this:
typedef struct { /* Add your data members here */ mw_hal_lcd_colour_t chosen_colour; } window_W1_data_t; static window_W1_data_t window_W1_data = { MW_HAL_LCD_YELLOW };
We'll give it a starting colour of yellow. Now in the message function we'll change the code slightly to save the chosen colour here like this:
case MW_DIALOG_COLOUR_CHOOSER_OK_MESSAGE: { window_W1_data.chosen_colour = message->message_data; } break;
Then we'll change the paint function to use this value when it draws the circle like this:
mw_gl_set_solid_fill_colour(window_W1_data.chosen_colour);
Now we have changed the data that the contents of the window depends on, so we need to let the window manager know that the window needs repainting. We do it in the message function when the dialog OK message is received, like this:
mw_paint_window_client(message->recipient_handle);
This does not cause the window to be painted directly. It is a utility function that sends a message to the window manager that a window needs to be repainted (if you step into it you can see how this happens). The window that needs to be repainted in this case is itself, and the handle to the window is in the message parameter to the message handler function.
The whole file now looks like this if you are unsure where some of the code snippets above go:
#include <stdlib.h> #include "miniwin.h" #include "miniwin_user.h" #include "W1.h" typedef struct { /* Add your data members here */ mw_hal_lcd_colour_t chosen_colour; } window_W1_data_t; static window_W1_data_t window_W1_data = { MW_HAL_LCD_YELLOW }; void window_W1_paint_function(mw_handle_t window_handle, const mw_gl_draw_info_t *draw_info) { MW_ASSERT(draw_info != (void*)0, "Null pointer parameter"); /* Fill window's client area with solid white */ mw_gl_set_fill(MW_GL_FILL); mw_gl_set_solid_fill_colour(MW_HAL_LCD_WHITE); mw_gl_set_border(MW_GL_BORDER_OFF); mw_gl_clear_pattern(); mw_gl_rectangle(draw_info, 0, 0, mw_get_window_client_rect(window_handle).width, mw_get_window_client_rect(window_handle).height); /* Add you window painting code here */ mw_gl_set_fg_colour(MW_HAL_LCD_BLACK); mw_gl_set_solid_fill_colour(window_W1_data.chosen_colour); mw_gl_set_line(MW_GL_SOLID_LINE); mw_gl_set_border(MW_GL_BORDER_ON); mw_gl_circle(draw_info, 30, 30, 15); } void window_W1_message_function(const mw_message_t *message) { MW_ASSERT(message != (void*)0, "Null pointer parameter"); /* Next line stops compiler warnings as variable is currently unused */ (void)window_W1_data; switch (message->message_id) { case MW_WINDOW_CREATED_MESSAGE: /* Add any window initialisation code here */ break; case MW_MENU_BAR_ITEM_PRESSED_MESSAGE: /* Add window menu handling code here */ break; case MW_BUTTON_PRESSED_MESSAGE: if (message->sender_handle == button_B1_handle) { /* Add your handler code for this control here */ mw_create_window_dialog_colour_chooser(10, 10, "Colour", MW_HAL_LCD_RED, false, message->recipient_handle); } break; case MW_DIALOG_COLOUR_CHOOSER_OK_MESSAGE: { window_W1_data.chosen_colour = message->message_data; mw_paint_window_client(message->recipient_handle); } break; default: /* Keep MISRA happy */ break; } }
Build and run again and you should be able to set the fill colour of the circle.
This example of window data uses data that is stored in a static data structure at the top the source file. This is fine if you only have one instance of the window, as we do in this example, but if you have more than one instance then they will all share the same data structure. It's possible to have per-instance data so multiple instances of the same window type have their own data. This is explained in the MiniWin documentation found in the docs directory. The file example uses it to show multiple images in the same window type (as seen in the main image at the very top of this instructable).
Step 9: Some Final Font Fun
MiniWin supports TrueType font rendering. If there's one thing that makes your user interface look good it's attractive fonts. This final step shows how to render a TrueType font in a MiniWin window.
There are two ways of rendering TrueType fonts. One is to draw them directly on your client area as was done for the circle earlier, the other is to add a text box control into your window. We're doing the latter as it's easier.
Now we'll add a text box control into our JSON configuration file. Add it into Window 2's definition so that it looks like this:
<p style="white-space: normal;">like this:</p><pre style="font-size: 13.5px;">{ "Name": "W2", "Title": "Window 2", "X": 50, "Y": 65, "Width": 100, "Height": 80, "Border": true, "TitleBar": true, "Visible": true, "Minimised": false, "TextBoxes": [{ "Name": "TB1", "X": 0, "Y": 0, "Width": 115, "Height": 50, "Justification": "Centre", "BackgroundColour": "MW_HAL_LCD_YELLOW", "ForegroundColour": "MW_HAL_LCD_BLACK", "Font": "mf_rlefont_BLKCHCRY16", "Enabled": true, "Visible": true }] }
A quick word about TrueType fonts in MiniWin. Fonts come in .ttf files. In window managers on bigger computers these are rendered onto your display when they are needed. This takes lots of processing power and memory and is not suitable for small devices. In MiniWin they are pre-processed into bitmaps and linked at compile time at a fixed font size and style (bold, italics etc) i.e. you have to decide what fonts at what size and style you are going to use at compile time. This has been done for you for two example fonts in the MiniWin zip file you downloaded. If you want to use other fonts at other sizes and styles see the MiniWin documentation in the docs folder. There are tools in MiniWin for Windows and Linux for pre-processing .ttf files into source code files you can drop into your project.
And a second quick word - most fonts are copyright, including those you'll find in Microsoft Windows. Use them at will for personal use, but anything you publish you must ensure that the license the fonts are published with allows it, as is the case for the 2 fonts included in MiniWin, but not Microsoft's fonts!
Back to the code! Generate, drop files, build and rerun as before and you will see Window 2 now has some default text on a yellow background in a wacky font. Lets change the text by editing Window 2's source file W2.c.
We need to communicate with the text box we just created and the way you do that like any communication in MiniWin is to send it a message. We want to set the text in the control when the window is created but before it's shown, so we add code in the message handler in the MW_WINDOW_CREATED_MESSAGE case. This is received by the window code just before the window is displayed and is intended for initialisations like this. The code generator created a place holder that looks like this in the message handler function:
case MW_WINDOW_CREATED_MESSAGE: /* Add any window initialisation code here */ break;
Here we are going to post a message to the text box control telling it what text we want it to show by using the mw_post_message function like this:
case MW_WINDOW_CREATED_MESSAGE: /* Add any window initialisation code here */ mw_post_message(MW_TEXT_BOX_SET_TEXT_MESSAGE, message->recipient_handle, text_box_TB1_handle, 0UL, "Twas a dark and stormy night...", MW_CONTROL_MESSAGE); break;
These are the parameters:
- MW_TEXT_BOX_SET_TEXT_MESSAGE - This is the message type we are sending to the control. They are listed in miniwin.h and documented in the documentation.
- message->recipient_handle - This is who the message is from - this window - the handle of which is in the message parameter passed in to the message handler function.
- text_box_TB1_handle - Who we are sending the message to - the handle of the text box control. These are listed in the generated file miniwin_user.h.
- 0UL - Data value, nothing in this case.
- "Twas a dark and stormy night..." - Pointer value - the new text.
- MW_CONTROL_MESSAGE - Recipient type which is a control.
That's it. Rebuild and rerun as usual and you'll get the text box showing as in the image above.
Message posting is fundamental to MiniWin (as it is to all window managers). For more examples look at the example projects in the zip file and for a comprehensive explanation read the section on MiniWin messages in the documentation.
Step 10: Going Further
That's it for this basic introduction to MiniWin. MiniWin can do a lot more than has been demonstrated here. For example, the screen on the board used in this instructable is small and the controls are small and need to be used with a dibber. However, other examples and hardware use larger controls (there are 2 sizes) on larger displays and these can be finger operated.
There are many other types of control than those demonstrated here. For further controls have a look at the various example JSON files in the code generator folder. All control types are covered in these examples.
Windows have a lot of options. The border, title bar and icons are all configurable. You can have scroll bars and scrolling window client areas, multiple instances of the same window type and windows can be naked (only a client area, no border or title bar) which means that they are fixed at compile time in place on the display (see the image in this section with size large icons - these are actually 6 naked windows).
MiniWin uses no dynamic memory. This makes it suitable for small constrained devices and is a requirement for some embedded projects. MiniWin and the code it generates is also fully MISRA 2012 compliant to the 'required' level.
For further information have a look in the docs folder for the documentation and also the other example apps in the zip file. There are examples here showing how to use all the features of MiniWin and how to integrate MiniWin with FatFS and FreeRTOS.