Essence»Blog
nakst
Over the past week I've been thinking about UI layouts. In this blog post I'll discuss what I think the best way to do automatic UI layouts is. This system is based of this project's GUI library, but keep in mind not all of these features have been implemented; this blog post is mostly theoretical.

Manual vs automatic

There are 2 types of UI layouts, manual and automatic. In a manual layout, the programmer must specify where each UI element is placed themselves. This is notably used in the Windows Desktop API:

1
2
3
4
CreateWindow("Button", "OK", WS_CHILD | WS_VISIBLE,
   16, 16, 80, 23, /*x, y, width, height*/
   window, NULL, instance, NULL
);


This is as flexible as it's possible to get, but unfortunately it has many drawbacks. For example, you have to do DPI scaling manually, you have to know the correct sizes of each standard control, you have to work out the gaps between elements, etc.

Therefore it's probably better to have automatic UI layouts, where the UI library positions each element for you.

UI element tree

To automatically layout a UI, we need to arrange the elements in a tree-like structure. Each element is responsible for positioning its child elements.

An element can also ask another element how large it'd like to be by measuring it. When an element lays out its children, it'll likely need to measure them as it does so. Measuring an element may also cause it to need to measure its own children. For this reason, measured sizes should be cached to avoid unnecessary recalculation.

The layout element

In our GUI library, the layout element is called a Grid. However, as we'll learn, we need to do more things than just making grids.

For simplicity, we'll have one type of element that is dedicated to providing automatic layouting of its children.

It's basic operation is to measure its child elements and place them in order. It has two main parameters - the inset size and the gap size.



Insets represents the space between the top, left, right, and bottom of the layout element and its contents. The gap size represents the space between each child element. The gap size is constant for the whole grid.

Table layout mode

The layout element will be able to take 3 different layout modes - table, flow and wrap. We'll start with the table layout mode.

In table layout mode, a layout element has a fixed number of columns and rows (collectively called bands). Each child element occupies a single cell (defined by a column and row pair) in the layout element, and each band has a fixed size obtained by measuring its children.

Here's an example with 2 columns and 2 rows:



Element alignment

To obtain the size of a band, the layout measures each child element in that band in the corresponding axis. It then takes the size of the largest reported size. This means that not all of the elements in a band necessarily take up all the space the cell has to offer.

Here's an example of this occurring. Columns are separated by orange lines, and rows by purple lines. Note that some of the smaller elements have some empty space surrounding them in their cell, and that each band is equal in size to its largest element.



When this happens, the programmer can choose to which of the 9 corners of a cell the element is aligned.

  • Top-left
  • Top
  • Top-right
  • Left
  • Center
  • Right
  • Bottom-left
  • Bottom
  • Bottom-right




This means you create layouts like this:



Weighting

Sometimes, a table may not use up all of its available space.



In this case, it's possible that the programmer might want one of the elements to push on its cell's boundaries, and take up the remaining space. Let's say we instruct the element in cell (0, 1) to push vertically.



But what happens if multiple cells have the push layout flag? The remaining space is distributed based on their weights, such that the remaining space they receive is proportional to their weight.

For example, let's consider a single axis of a layout element with size 200px. It contains three elements, A, B and C, which report measured sizes of 10px, 30px and 40px, and have weights of 1, 1 and 3 respectively.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
1. Calculate the remaining space:
    200 - (10 + 30 + 40) = 120px
2. Calculate the total weight:
    1 + 1 + 3 = 5
3. Calculate the weight fraction of each element:
    A: 1/5
    B: 1/5
    C: 3/5
4. Calculate the additional space for each element:
    A: 1/5 * 120 = 24px
    B: 1/5 * 120 = 24px
    C: 3/5 * 120 = 72px
5. Add the additional space to each element:
    A: 10 + 24 = 34px
    B: 30 + 24 = 54px
    C: 40 + 72 = 112px


Expanding, shrinking and collapsing

We saw before in our 2x2 table that we can give each element alignment flags:
  • One of:
    • OS_CELL_H_LEFT
    • OS_CELL_H_CENTER
    • OS_CELL_H_RIGHT
  • One of:
    • OS_CELL_V_TOP
    • OS_CELL_V_CENTER
    • OS_CELL_V_BOTTOM


And, as shown in the previous section on weighting, we can give each cell a remaining-space weight for each axis.

We may now want to further indicate to an element how it should occupy the space provided by its containing cell, rather than just forcing an element to its measured sized and then aligning it within the cell.

To do this, for each axis, we'll introduce EXPAND, SHRINK and COLLAPSE flags, any combination of which can be used in addition to the alignment flags when applied to an element.

When we add the EXPAND flag, an element will expand to match the size of its cell. This makes the alignment flag in the corresponding axis essentially useless.



The COLLAPSE flag will set the reported size of the element to 0.

In this example, all the elements have default flags, except for the bottom label, which has the OS_CELL_H_COLLAPSE flag set.



Because the bottom label has the OS_CELL_H_COLLAPSE flag set, it reports it size as 0 to the table element. So when the leftmost column is picking a size it chooses the size of the top label - as that is the widest reported size it receives. Therefore for bottom label gets clipped.

As the COLLAPSE flag can generate cases in which an element's cell is smaller than its preferred size, you may want the element to SHRINK rather than be clipped.

Consider a button, which has a preferred width of 80px.



Without the OS_CELL_H_SHRINK flag, the button is forced to its preferred width, while the cell is smaller because it reports its width as 0px. When the OS_CELL_H_SHRINK flag is added, the button is happy to shrink past its preferred width.

To summarise:

TypeFlagBehaviourSizing effect
ExpandOS_CELL_H/V_EXPANDAllow the element to expand past its preferred size to match the cell's
size
maximum = infinity, else maximum = preferred
ShrinkOS_CELL_H/V_SHRINKAllow the element to shrink below its preferred size to match the cell's sizeminimum = 0, else minimum = preferred
CollapseOS_CELL_H/V_COLLAPSEReport the element's size when measured as 0pxreported = 0, else reported = preferred


Overflow modes

What happens when a layout element cannot fit all of its elements within its bounds?

Think about it - even if the root layout element reports its size as 800px by 600px, if the window is 640px by 480px it's bounds are going to be 640px by 480px no matter what!

For each axis, there are three strategies:

  • Clip - all the cells that can't fit are clipped
  • Scroll - a scrollbar is provided to move cells in and out of view
  • Compress - the cell at the edge of the layout is compressed until its size reached 0px at which point it is clipped


When using the Compress strategy, the SHRINK element flag also becomes relevant, since a cell can be smaller than the measured size.

Multi-cell positioning

So far we've assumed that each element occupies one cell, and each cell contains one element (or is empty). But sometimes we might want an element to span multiple cells in a table.

Elements still need to have a base cell. This is where their "remaining size" is placed if necessary (after all non-multi-cell positioned elements are considered).

Consider the following 2x6 table, in which the two long lines of text are set to occupy both columns in their row.



On the left, the long lines have their base cells in the first column, while on the right, the long lines have their base cells in the second column.

When these tables are laid out, first the non-multi-cell positioned elements are considered, causing the second column to match the width of the "This is a short line of text" element.

Then the multi-cell positioned elements are considered. The widths of the all columns they occupy are subtracted from their reported width. In this case, this is only the second column, as the first column is at 0px currently. This width is then added to their respective base columns. On the left, this causes the first column to take the remaining size of the long labels, whereas on the right, this causes the second column to further increase in size, leaving the first column at 0px.

Part 2...

This has gotten a bit longer than I expected. I'll be splitting this into a part 2, where we'll discuss wrapping! (and all the awkward problems it brings.)

Thank you for reading this far... please leave any comments with feedback, or ask about parts of the post you didn't understand.

Read part 2.
nakst
Over the last months I haven't added many new features. It's mostly been rewriting and improving existing code.

Primarily, there's a new memory manager, and the GUI's been simplified.
However, there are two new features I want to show off.

Blackjack

I added a small Blackjack game to the OS.



SDL backend

The GUI can now run with a very-much-work-in-progress SDL backend on Windows.



If you want to try it out:
  1. Download the project's source
  2. Copy SDL2, Freetype, and GLEW includes and libraries to the sdl/ folder
  3. Put the DLLs at the root
  4. Run build.bat from a VS command prompt


Your sdl/ folder should contain the following files:



Thanks for reading!
Hopefully there will be some more interesting news next month :)
nakst
I've started to write up some documentation for the OS on the project's wiki.

If you want to help out with the project, I recommend you first read all the current documentation to get an overview of how the API and GUI work. Also see if you can complete the Getting Started tutorial.

After that, here is a list of things you could work on:
  • Rewrite the multi-line textbox control
  • Rewrite the image viewer
  • Making a modern dark-mode visual style
  • ... And possibly more things


If any of those sound interesting, please visit #essence on the Handmade Network Discord where I can give more information. Furthermore, if you have any feedback on the documentation, please talk to me about it there as well.

Thanks!
nakst
I recently decided to make a version of "Notepad" for my OS, and it's mostly done now.
To take a deeper look into using the OS's API, I'll walk you through how you could recreate the program.
(Note: the API is still under constant revision, so any information presented in this blog could change at any time...)

We can start by creating a manifest file. The build system should automatically find it.
Let's add a definition for the program.

text_editor/text_editor.manifest
1
2
3
4
[program]
name = "Text Editor";
shortName = "text_editor";
systemMessageCallback = ProcessSystemMessage;


This specifies the name of the program, its internal name, and the subroutine that will receive system messages. We'll take a look at this later.

Next we include information about how to build the program.

1
2
3
4
5
[build]
output = "Text Editor.esx";
source = "text_editor/main.cpp";
installationFolder = "/Programs/Text Editor/";
Install;


This gives the source file, the output executable, and the installation folder.
It also tells the build system to automatically install it. This just means a few lines will get added to "bin/OS/Installed Programs.dat".
Program installation is nothing fancy in the OS.

Now let's define the menus for the program.
We will be using the default file and edit menus, so we don't need to define those. Just a search menu, and the menubar.
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
[menu menuSearch]
name = "Search";
commandFind;
commandReplace;
Separator;
commandFindNext;
commandFindPrevious;

[menu mainMenubar]
osMenuFile;
osMenuEdit;
menuSearch;


This should be fairly self explanatory.

Now let's define the commands in the search menu.
I'll explain exactly what a "command" is in a bit.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
[command commandFind]
label = "Find...";
shortcut = "Ctrl+F";

[command commandReplace]
label = "Replace...";
shortcut = "Ctrl+H";

[command commandFindNext]
label = "Find next";
shortcut = "F3";
defaultDisabled = true;

[command commandFindPrevious]
label = "Find previous";
shortcut = "Shift+F3";
defaultDisabled = true;


This is a fairly convenient way to define the labels and keyboard shortcuts for different commands.
All the commands we define in the manifest are also automatically added to the default command group, in which they are assigned an index starting from 0.
We disable the find next/previous commands by default, as we only want to enable these once the user has put some text in the find/replace textboxes in the dialogs we'll be making.

There are just a few more commands we'll need.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
[command commandReplaceNext]
label = "Replace next";
defaultDisabled = true;

[command commandReplaceAll]
label = "Replace all";
defaultDisabled = true;
dangerous = true;

[command commandCloseDialog]
label = "Close";

[command commandMatchCase]
label = "Match case";
checkable = true;


These will be needed for the find/replace dialogs.
We mark the "replace all" command as "dangerous". This means buttons bound to that command will glow red when the user hovers over them.
It's purely a visual difference - but it should help the user to understand that the single button will cause a lot of modifications to their document.
The "match case" command is marked as checkable - this is because we'll be using it to create a checkbox. You could also put this command in the search menu, where it would become a checkable menu item.

At the end of our manifest file, we can include the templates for our windows and dialogs.
Currently there is no way to specify the contents of windows in the manifest file, so we'll have to do this in the code.
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
[window mainWindow]
width = 330;
height = 300;
minimumWidth = 250;
minimumHeight = 250;
title = "Text Editor";
menubar = mainMenubar;

[window dialogFind]
width = 360;
height = 100;
resizable = false;
title = "Find";

[window dialogReplace]
width = 360;
height = 130;
resizable = false;
title = "Replace";


As you can see, we can however define the dimensions and titles of the windows/dialogs.
One convenience of the manifest files is that we can exclude any properties we don't care about, and they will be automatically set to sane defaults.
This is why we don't have to mark the mainWindow as resizable - this is the default.

Okay, on to the C++!

text_editor/main.cpp
1
2
3
4
#include "../api/os.h"

#define OS_MANIFEST_DEFINITIONS
#include "../bin/Programs/Text Editor/manifest.h"


We start by including the API's header file, and the automatically-generated definitions from our manifest.

Now, since the program will handle multiple documents from a single process, we need to make a structure to store all the information about one "Instance" of the program.

1
2
3
4
5
6
7
struct Instance {
	OSObject window, textbox, 
		 findDialog, replaceDialog,
		 findTextbox, replaceTextbox;
	OSString findString, replaceString;
	OSCommand *commands;
};


The API provides opaque handles to its objects, such as windows and controls through the type "OSObject".
The instance will store a handle to the window, the main textbox, the find/replace dialogs, and the textboxes within the find/replace dialogs.
We also have two strings (a buffer + byte count), for the current find query, and the replacement text.
Finally, we store an array of commands. When each instance is created we will create this array, and in it the state of each command can be stored. This includes whether the command is disabled, checked, the notification callback, etc.

It's also important to note at this point that the API also has a notion of instances.
It will have its own structure for each instance of the program that is started, where it keeps its own information. This will include, for example, the builtin commands (copy, paste, save file, etc.). Hopefully it's clear whether the API's instance object or the program's instance structure is being referred to when they're used. Now, onto the first of the two subroutines that make up this program.
1
2
3
4
OSCallbackResponse ProcessSystemMessage(OSObject, OSMessage *message) {
	...
	return OS_CALLBACK_NOT_HANDLED;
}


This is the subroutine in which the API will send system messages to us.
The name of this subroutine is determined in the program section of the manifest, as shown above.

We only need to handle one system message.

1
2
3
4
if (message->type == OS_MESSAGE_CREATE_INSTANCE) {
	...
	return OS_CALLBACK_HANDLED;
}


This is sent when we need to start a new instance of our program.
For example, the program executable might be double-clicked in the file manager, or a text file gets opened (not implemented yet).

Before we can create the window, we should start an allocation block.

1
2
3
OSStartGUIAllocationBlock(16384);
...
OSEndGUIAllocationBlock();


This will allocate a 16KB chunk of memory. This will then be split up as we create the window and its contents.
When the window is closed, this memory chunk will get deallocated.

Okay, let's allocate an Instance.

1
Instance *instance = (Instance *) OSHeapAllocate(sizeof(Instance), true /*clear to zero*/);


...and all the commands in the default command group (those are the ones in our manifest).

1
2
instance->commands = OSCreateCommands(osDefaultCommandGroup);
OSSetCommandGroupNotificationCallback(instance->commands, ProcessNotification);


We also set their notification callback.
A notification callback is the subroutine to which "notifications" are sent. Notifications range from things like invoking a command, to a list view wanting a row repainted.
The "context" parameter of the notification is automatically assigned to the index of the command in the command array.
We'll see how this works in a moment.

Here's where we create the API instance - "instanceObject".
1
OSObject instanceObject = OSCreateInstance(instance, message, instance->commands);


We pass in a pointer to our Instance structure - "instance". This is so whenever we receive a notification we will get a pointer to our Instance structure in the "instanceContext" field.
We also pass in the create instance message structure we received. This contains flags and other information that the creating process set.
Finally we pass in a pointer to our command array. This is so the API is aware of our commands - including useful things like making keyboard shortcuts work.

Great! Now we can make our window.

1
instance->window = OSCreateWindow(mainWindow, instanceObject);


We pass in the window template from our manifest, and the API instance object.

In the window we need a textbox, so let's make that.

1
instance->textbox = OSCreateTextbox((OSTextboxStyle) (OS_TEXTBOX_STYLE_MULTILINE | OS_TEXTBOX_STYLE_NO_BORDER), OS_TEXTBOX_WRAP_MODE_NONE);


We add it to a 1x1 grid so that it fills it ("OS_CELL_FILL").

1
2
OSObject grid = OSCreateGrid(1 /*columns*/, 1 /*rows*/, OS_GRID_STYLE_LAYOUT /*no border or padding between cells*/);
OSAddControl(grid, 0 /*column*/, 0 /*row*/, instance->textbox, OS_CELL_FILL);


We then set the grid as the root of the window, and then make the textbox the default focused control.

1
2
OSSetRootGrid(window, grid);
OSSetFocusedControl(textbox, true /*default*/);


By setting it as the default focused control, if the user presses the "escape" key in the window, the keyboard focus will go to the textbox.

Now, our program uses a single subroutine - ProcessNotification - to handle all of the notifications.
As noted earlier, we set the callback context for our commands to be their index into the command group.
We want to receive notifications from other sources, such as the textbox, so we need to come up with values to use, so we can identify them as the notification source in the callback.
Let's use negative values so they don't conflict with the commands.
1
2
3
4
5
#define WINDOW_NOTIFICATION (-1)
#define TEXTBOX_NOTIFICATION (-2)
#define INSTANCE_NOTIFICATION (-3)
#define FIND_TEXTBOX_NOTIFICATION (-4)
#define REPLACE_TEXTBOX_NOTIFICATION (-5)


Then we can actually set the notification callbacks...

1
2
3
OSSetObjectNotificationCallback(window, OS_MAKE_NOTIFICATION_CALLBACK(ProcessNotification, (intptr_t) WINDOW_NOTIFICATION));
OSSetObjectNotificationCallback(textbox, OS_MAKE_NOTIFICATION_CALLBACK(ProcessNotification, (intptr_t) TEXTBOX_NOTIFICATION));
OSSetObjectNotificationCallback(instanceObject, OS_MAKE_NOTIFICATION_CALLBACK(ProcessNotification, (intptr_t) INSTANCE_NOTIFICATION));


We'll take a look at what notifications we get from these sources in a bit.

Finally we need to set the window title.

1
OSSetWindowTitle(window, OSLiteral("Untitled"));


This will be prefixed to the title we defined in the manifest.

Okay! We're making some good progress.
We now need to make the notification callback itself.

1
2
3
4
OSCallbackResponse ProcessNotification(OSNotification *notification) {
	...
	return OS_CALLBACK_NOT_HANDLED;
}


For our convenience, we first extract some information from the "notification" structure.

1
2
bool isCommand = notification->type == OS_NOTIFICATION_COMMAND;
Instance *instance = (Instance *) notification->instanceContext;


"isCommand" will indicate whether this notification was sent because of a command invocation (e.g. pressing a button, menu item or keyboard shortcut).
"instance" will contain a pointer to our instance structure. "notification->instance" will contain the API instance object.

Now we can switch on the notification source:

1
2
switch ((intptr_t) notification->context) {
}


The first notification we need to handle is when the contents of the textbox is modified.
We will need to mark the instance as modified, so that the "Save" command will be enabled, and attempting to use the "Open" command will prompt the user to save.

1
2
3
4
5
6
case TEXTBOX_NOTIFICATION: {
	if (notification->type == OS_NOTIFICATION_MODIFIED) {
		OSMarkInstanceModified(notification->instance);
		return OS_CALLBACK_HANDLED;
	}
} break;


We don't actually have to do any save/open confirmation dialog nonsense, since it's all handled by the API through the builtin commands.
It will just send us notifications telling exactly what to do.
Speaking of which...

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
case INSTANCE_NOTIFICATION: {
	if (notification->type == OS_NOTIFICATION_NEW_FILE) {
		// Clear the text in the textbox.
		OSSetText(instance->textbox, OSLiteral(""), OS_RESIZE_MODE_IGNORE);

		// Move the caret to the start of the textbox.
		OSTextboxSetSelection(instance->textbox, 0, 0);
	} else if (notification->type == OS_NOTIFICATION_OPEN_FILE) {
		// Load the file.
		size_t fileSize;
		char *text = (char *) OSReadEntireFile(notification->fileDialog.path, notification->fileDialog.pathBytes, &fileSize); 

		if (text) {
			// Set the textbox's text.
			OSSetText(instance->textbox, text, fileSize, OS_RESIZE_MODE_IGNORE);

			// Move the caret to the start of the textbox.
			OSTextboxSetSelection(instance->textbox, 0, 0);

			// And deallocate the text.
			OSHeapFree(text);
		} else {
			// If we couldn't read the file, cause a error dialog to appear.
			return OS_CALLBACK_REJECTED;
		}
	} else if (notification->type == OS_NOTIFICATION_SAVE_FILE) {
		// Get the text in the textbox.
		OSString text;
		OSGetText(instance->textbox, &text);

		// Open the file we'll be saving to.
		OSNodeInformation node;
		OSError error = OSOpenNode((char *) notification->fileDialog.path, notification->fileDialog.pathBytes, 
				OS_OPEN_NODE_WRITE_EXCLUSIVE | OS_OPEN_NODE_RESIZE_EXCLUSIVE | OS_OPEN_NODE_CREATE_DIRECTORIES, &node);

		if (error == OS_SUCCESS) {
			// Write the contents of the textbox and close the file handle.
			error = text.bytes == OSWriteFileSync(node.handle, 0, text.bytes, text.buffer) ? OS_SUCCESS : OS_ERROR_UNKNOWN_OPERATION_FAILURE; 
			OSCloseHandle(node.handle);
		}

		if (error != OS_SUCCESS) {
			// If there was an error, cause a error dialog to appear.
			notification->fileDialog.error = error;
			return OS_CALLBACK_REJECTED;
		}
	} else {
		return OS_CALLBACK_NOT_HANDLED;
	}

	return OS_CALLBACK_HANDLED;
} break;


Next we need to handle the OS_NOTIFICATION_WINDOW_CLOSE notification from the window.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
case WINDOW_NOTIFICATION: {
	if (notification->type == OS_NOTIFICATION_WINDOW_CLOSE) {
		OSDestroyCommands(instance->commands);
		OSDestroyInstance(notification->instance);
		OSHeapFree(instance->findString.buffer);
		OSHeapFree(instance->replaceString.buffer);
		OSHeapFree(instance);
		return OS_CALLBACK_HANDLED;
	}
} break;


We destroy the command array, and the API instance object.
And then we free our Instance structure and its contents.
The finer details of window/instance closing isn't really finished yet, but it works for the most part at the moment.

Right. Now let's handle the commandFind and commandReplace commands. This is where we're create the find/replace dialogs.
Since commandFind is just a simpler version of commandReplace, we'll just focus on commandReplace for now.
Here's a reference of how we will layout the dialog.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
case commandReplace: {
	// Is this a command notification?
	if (isCommand) { 
		// If the replace dialog was already open, set it as the focused window.
		if (instance->replaceDialog) {
			OSSetFocusedWindow(instance->replaceDialog);
			return OS_CALLBACK_HANDLED;
		}

		// If the find dialog was open, close it.
		if (instance->findDialog) {
			OSCloseWindow(instance->findDialog);
			instance->findDialog = nullptr;
		}

		OSStartGUIAllocationBlock(16384);

		// Create a dialog using the dialogReplace template.
		instance->replaceDialog = OSCreateDialog(notification->instance, nullptr /*don't block input to the main window*/, dialogReplace);

		// Create a 1x2 grid at the root of the dialog.
		OSObject root = OSCreateGrid(1 /*columns*/, 2 /*rows*/, OS_GRID_STYLE_LAYOUT);
		OSSetRootGrid(instance->replaceDialog, root);

		// At the top of the grid, put the options. This is another 1x2 grid.
		OSObject options = OSCreateGrid(1, 2, OS_GRID_STYLE_CONTAINER /*include a border and padding between cells*/);
		OSAddGrid(root, 0 /*column*/, 0 /*row*/, options, OS_CELL_FILL); // Fill the dialog as much as possible.

		// Add the match case checkbox to the options grid.
		OSAddControl(options, 0, 1, OSCreateButton(instance->commands + commandMatchCase, OS_BUTTON_STYLE_NORMAL), OS_CELL_H_LEFT);

		// Create the textboxes grid, 2x2, and add it to the options grid.
		OSObject textboxes = OSCreateGrid(2, 2, OS_GRID_STYLE_CONTAINER_WITHOUT_BORDER);
		OSAddGrid(options, 0, 0, textboxes, OS_CELL_H_FILL);

		// Create the find textbox, set its notification callback, and add it with its label to the grid.
		instance->findTextbox = OSCreateTextbox(OS_TEXTBOX_STYLE_NORMAL, OS_TEXTBOX_WRAP_MODE_NONE);
		OSSetObjectNotificationCallback(instance->findTextbox, OS_MAKE_NOTIFICATION_CALLBACK(ProcessNotification, (intptr_t) FIND_TEXTBOX_NOTIFICATION));
		OSAddControl(textboxes, 0, 0, OSCreateLabel(OSLiteral("Find what:"), false, false), OS_CELL_H_RIGHT /*Align the label to the right*/);
		OSAddControl(textboxes, 1, 0, instance->findTextbox, OS_CELL_H_FILL /*Fill the dialog horizontally with the textbox*/);

		// Repeat for the replace textbox.
		instance->replaceTextbox = OSCreateTextbox(OS_TEXTBOX_STYLE_NORMAL, OS_TEXTBOX_WRAP_MODE_NONE);
		OSSetObjectNotificationCallback(instance->replaceTextbox, OS_MAKE_NOTIFICATION_CALLBACK(ProcessNotification, (intptr_t) REPLACE_TEXTBOX_NOTIFICATION));
		OSAddControl(textboxes, 0, 1, OSCreateLabel(OSLiteral("Replace with:"), false, false), OS_CELL_H_RIGHT);
		OSAddControl(textboxes, 1, 1, instance->replaceTextbox, OS_CELL_H_FILL);

		// Create the commands grid, 4x1.
		OSObject commands = OSCreateGrid(4, 1, OS_GRID_STYLE_CONTAINER_ALT /*use the alternate style*/);
		OSAddGrid(root, 0, 1, commands, OS_CELL_H_FILL);

		// Add a spacer at the start so that the buttons are all aligned to the right.
		OSAddControl(commands, 0, 0, OSCreateSpacer(0, 0), OS_CELL_H_FILL);

		// Create the buttons and add them to the grid.
		// By specifying the command for the button, they will automatically be labelled and disabled/enabled with the command.
		OSObject defaultButton = OSCreateButton(instance->commands + commandReplaceNext, OS_BUTTON_STYLE_NORMAL);
		OSAddControl(commands, 1, 0, defaultButton, OS_FLAGS_DEFAULT);
		OSAddControl(commands, 2, 0, OSCreateButton(instance->commands + commandReplaceAll, OS_BUTTON_STYLE_NORMAL), OS_FLAGS_DEFAULT);
		OSAddControl(commands, 3, 0, OSCreateButton(instance->commands + commandCloseDialog, OS_BUTTON_STYLE_NORMAL), OS_FLAGS_DEFAULT);

		// Set the default button and the focused control (the find textbox).
		OSSetFocusedControl(defaultButton, false);
		OSSetFocusedControl(instance->findTextbox, false);

		// If the user presses the "escape" key in the dialog, route it to do the same thing as commandCloseDialog.
		OSSetCommandNotificationCallback(OSGetDialogCommands(instance->replaceDialog) + osDialogStandardCancel, 
				OS_MAKE_NOTIFICATION_CALLBACK(ProcessNotification, (intptr_t) commandCloseDialog));

		OSEndGUIAllocationBlock();

		return OS_CALLBACK_HANDLED;
	}
} break;


Let's handle the commandCloseDialog command.
This just closes the open dialog when invoked.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
case commandCloseDialog: {
	if (isCommand) {
		if (instance->replaceDialog) {
			OSCloseWindow(instance->replaceDialog);
			instance->replaceDialog = nullptr;
		}

		if (instance->findDialog) {
			OSCloseWindow(instance->findDialog);
			instance->findDialog = nullptr;
		}

		return OS_CALLBACK_HANDLED;
	}
} break;


When the find/replace textboxes are modified, we need to copy their contents into our strings, and also enable/disable the relevant commands.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
case FIND_TEXTBOX_NOTIFICATION: {
	if (notification->type == OS_NOTIFICATION_MODIFIED) {
		OSString text;
		OSGetText(instance->findTextbox, &text);
		OSHeapFree(instance->findString.buffer);
		instance->findString.buffer = (char *) OSHeapAllocate(text.bytes, false);
		OSCopyMemory(instance->findString.buffer, text.buffer, text.bytes);
		instance->findString.bytes = text.bytes;

		OSEnableCommand(instance->commands + commandFindNext, instance->findString.bytes);
		OSEnableCommand(instance->commands + commandFindPrevious, instance->findString.bytes);
		OSEnableCommand(instance->commands + commandReplaceNext, instance->findString.bytes && instance->replaceString.bytes);
		OSEnableCommand(instance->commands + commandReplaceAll, instance->findString.bytes && instance->replaceString.bytes);
	}
} break;

case REPLACE_TEXTBOX_NOTIFICATION: {
	if (notification->type == OS_NOTIFICATION_MODIFIED) {
		OSString text;
		OSGetText(instance->replaceTextbox, &text);
		OSHeapFree(instance->replaceString.buffer);
		instance->replaceString.buffer = (char *) OSHeapAllocate(text.bytes, false);
		OSCopyMemory(instance->replaceString.buffer, text.buffer, text.bytes);
		instance->replaceString.bytes = text.bytes;

		OSEnableCommand(instance->commands + commandReplaceNext, instance->findString.bytes && instance->replaceString.bytes);
		OSEnableCommand(instance->commands + commandReplaceAll, instance->findString.bytes && instance->replaceString.bytes);
	}
} break;


Finally, when we get a find/replace command, we need to actually do it......
I won't explain this as it isn't particularly relevant to this blog post. But rest assured it does finding and replacing :)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
case commandReplaceAll:
case commandReplaceNext:
case commandFindPrevious:
case commandFindNext: {
	if (isCommand) {
		OSString text;
		OSGetText(instance->textbox, &text);

		bool firstPass = true;
		bool matchCase = OSGetCommandCheck(instance->commands + commandMatchCase);
		bool foundMatch = false;
		bool backwards = (intptr_t) notification->context == commandFindPrevious;
		bool replace = (intptr_t) notification->context == commandReplaceNext || (intptr_t) notification->context == commandReplaceAll;
		bool stopAfterMatch = (intptr_t) notification->context != commandReplaceAll;

		OSString query = instance->findString;
		OSString replacement = instance->replaceString;

		uintptr_t byte, byte2, start;
		OSTextboxGetSelection(instance->textbox, &byte, &byte2); 
		if (!backwards && byte2 > byte) byte = byte2;
		else if (backwards && byte > byte2) byte = byte2;
		if (backwards) byte--;
		start = byte;
		
		while (true) {
			if (!backwards) {
				if (firstPass && byte + query.bytes > text.bytes) {
					firstPass = false;
					byte = 0;
				} 

				if (!firstPass && (byte > start || byte + query.bytes > text.bytes)) {
					break;
				}
			} else {
				if (firstPass && byte > text.bytes) {
					firstPass = false;
					byte = text.bytes - query.bytes;
				} 

				if (!firstPass && (byte <= start || byte > text.bytes)) {
					break;
				}
			}

			bool fail = false;

			for (uintptr_t j = 0; j < query.bytes; j++) {
				uint8_t a = query.buffer[j];
				uint8_t b = text.buffer[j + byte];

				if (!matchCase) {
					if (a >= 'a' && a <= 'z') a += 'A' - 'a';
					if (b >= 'a' && b <= 'z') b += 'A' - 'a';
				} 

				if (a != b) {
					fail = true;
					break;
				}
			}

			if (!fail) {
				foundMatch = true;
				OSTextboxSetSelection(instance->textbox, byte, byte + query.bytes); 

				if (replace) {
					OSTextboxRemove(instance->textbox);
					OSTextboxInsert(instance->textbox, replacement.buffer, replacement.bytes);
				}

				if (stopAfterMatch) {
					break;
				}
			}

			if (!backwards) byte++; else byte--;
		}

		if (!foundMatch && stopAfterMatch) {
			OSShowDialogAlert(OSLiteral("Find"),
					OSLiteral("The find query could not be found in the current document."),
					OSLiteral("Make sure that the query is correctly spelt."),
					notification->instance,
					OS_ICON_WARNING, instance->window);
		}

		return OS_CALLBACK_HANDLED;
	}
} break;


And that's it!
You've written a simple text editor for my OS!



The API is still in development, so it's a bit rough around the edges, but I'm very happy with how it seems to be going.
It may seem a bit complicated, but once you understand it writing GUI programs becomes fairly easy, I've found.

If you have a question, please post a comment.

Thanks for reading! :)
nakst
Although I can't remember exactly when I started this project, it was approximately 11 months ago.
Which means we're approaching the 1st year anniversary of development!

By the first anniversary I want to get the OS running on real hardware.
The things I still need to do for this are:
  • AHCI driver (the old one was buggy)
  • Proper VBE graphics mode selection
  • GPT partition table support
  • Possibly EFI booting?
  • Fixing whatever bugs we find!!

That's a worryingly long list, especially as I'm a bit busy this week.
But hopefully I'll manage to get something working :)

For now though, here's a screenshot of some things I've been working on....


I think it's still a little to early to ask for collaborators, but perhaps in the upcoming year it'll be possible to have more developers working on the project - if anyone's interested, of course :)

-----------------------------------

Edit: It works!