Fourth - UI, ECS, Grid
(1833 words)It’s been a busy week. I’ve started three big things. First was starting to create an entity component system. I’m not going to talk about that much here, because it is nowhere near functional. I can create and destroy entities, and I can assign components to those entities. But there are no systems yet, which is a pretty much necessary part of an ECS, unless you just want to call it an EC!
Second, I also created a logging system, with multiple levels and labels. This is nothing fancy. I thought about using the zig standard library logger, but it didn’t have enough levels for my liking. I like the following levels:
- note - a bit of information that is interesting by not useful
- info - some information that may be useful to record
- msg - an important message
- debug - a debug message, temporary, and easily removed
- warn - a warning message
- err - an error message - something bad happened
- fatal - a very bad message - abort now
I’ve also created a Logger
function that returns a type that
encapsulates the log for a given subsystem. For example, the ECS system
has the following code at the top of the file. This means that within
the ecs zig file, any call to log.note
will output a log message in
the context of the .ecs
system.
const log = ng.Logger(.ecs);
This is another one of those lovely featuers of Zig. You can have a
function that takes enum_literal
type. These are just identifiers made
up on the spot. There is no central repository for them, they just
exist. This also means that you can pass that scope parameter to other
functions, like log_print
in the code below, and then use @tagName
to get a string that can be used in the logging message itself.
pub fn Logger(comptime scope: @Type(.enum_literal)) type {
return struct {
pub fn note(comptime format: []const u8, args: anytype) void {
log_print(.note, scope, format, args);
}
...
};
}
This then allows us to output the severity of the message, the scope of the message, and then the message itself. For example, the x11 platform notes the vendor of the graphics card, because that might be useful. The log line therefore shows:
note ng_x11 OpenGL vendor: AMD
Each part is formatted to a fixed 8-character width, allowing processing by other tools if that becomes necessary.
The biggest amount of work was starting to build up the user interface. I’ve written user interfaces before. Back in my early career, I wrote all the user interface code for Tornado, a computer game by Digital Integration. It had windows, buttons, text, and lots of other stuff. It also had windows that looked like dark glass.
What was most fun was that this was all done before we have a network, so code would be flown over to the other side of the room using frisbee net. You stick the stuff you want to transfer on to a 3.5 inch floppy disk and then throw / spin it over to the other side of the room. Depending on how grumpy and happy you were depended on how early you warned the other person.
Back to the present day. There is a lot of discussions about how to do user interfaces. Retained mode or immediate mode being the biggest question that most people want to ask. Well, I really like how immediate mode works from the point of view of somebody using the code, but I also like how retained mode keeps the structure of the user interface in a simple tree that is retained from one frame to the other. Therefore, I’ve decided to use both.
The API to the user interface is very ‘immediate mode’. If you want to
create a window, just call begin_window
, and then do some things for
that window, and then call end_window
to end building up that window.
If you want to add some text, then call add_text
. To add a button,
add_button
. Take a look.
begin_window(.{ .title = "Debug Window" });
defer end_window();
add_text(.{}, "Hello, World!", .{});
add_text(.{}, "{} fps", .{state.average_frame_rate});
if (add_button(.{ .text = "Button" })) {
log.debug("Button pressed", .{});
}
But the fun part is how these work. Firstly, the arguments to these
functions are just a normal option bag. For a window, it could include
the title, background color, padding of its contents, etc. For a button,
it could include the colors, text, alignment, etc. The text just uses
the zig std.fmt
functions to format any text.
Each of these functions also captures the @returnAddress
. This is
effectively the return address from the function, and essentially
therefore provides a unique address from the caller. This allows two
different calls to add_text
to be disambiguated. If that’s not
possible, because they are being called from a loop, then the option bag
also includes a unique
value that can be used to further separate the
different items.
The UI object themselves are identified internall using a handle. Handle’s are a 32-bit enum that is used as an index into an array of objects. We also place each internal object into tree of objects. As you call functions, a stack of UI Objects is maintained. Add a window, and you push that window onto the stack. All subsequent objects that are added and then placed inside the object at the top of that stack.
This works great for the first time around, but what happens on the second or subsequent frames? Well, remember the return address and unique number? Those are used to do a quick search through the current top of stack to find the object that was referenced. These can then be updated as necessary, for example to keep the fps value fresh, or just flagged as being active.
This also means that when an object is no long being refreshed, because the caller is not calling the function anymore, it will be marked as inactive. These will then be removed from the tree. Windows are slightly different mainly because I like it when windows in games appear in the same place on screen that they were last, before they were closed. So windows that are not shown are always retained. This may sound like a burden, but there are very few windows that can be displayed and so the memory requirements are pretty trivial.
All the above gets us a tree of objects, but we still need to do three more things:
- interact with them
- determine where to draw them
- draw them
Because I only started the user interface code this week, all three of these are sort of there but also not there at the same time.
We can click on and drag windows around. Clicking on a window moves to the front of the window stack. Clicking on the edge of a window allows the window to be resized. But we can’t interact with any of that windows objects, like buttons. That’s a task for this week.
We can determine where an object should be drawn. This works on a very simple principle of having vertical and horizontal boxes at the moment, but there is a lot more work to be done here.
And we can draw windows and the objects inside them. The windows update the scissor rectangle so that any objects that don’t fit will be clipped, but apart from that, it is only partially complete. For example, I have a bug where the scissor rectangle works for a window, but if you have another window behind it, the scissoring for the other window is added to this window. There is probably something I’m not doing correctly with OpenGL and probably a single line fix. I’ll probably have to read some OpenGL documentation.
The final quirky thing I did was add a Step
into the build.zig that
checks all the source code line in the project are less than 96
characters wide. In the spirit of Zig, this is a hard error - it will
not compile the project if just a single line is too long, even in
build.zig itself. Why? Oh, I dislike having long source code lines. I’ve
found that if the code goes off the right of the screen, especially if
your editor is not configured for wrapping such lines, that you can
easily miss errors. Therefore, I’ve banned it.
const check_format_step = try b.allocator.create(std.Build.Step);
check_format_step.* = std.Build.Step.init(.{
.id = std.Build.Step.Id.custom,
.name = "check format",
.owner = exe.step.owner,
.makeFn = check_format,
});
exe.step.dependOn(check_format_step);
In the main function of build.zig, I create a custom build step that
will involve the check_format
function and make sure that the
executable depends on this step.
fn check_format(step: *std.Build.Step, options: std.Build.Step.MakeOptions) anyerror!void {
_ = options;
const cwd = std.fs.cwd();
const dir = try cwd.openDir(".", .{
.iterate = true,
});
const b = step.owner;
var iter = try dir.walk(b.allocator);
while (try iter.next()) |entry| {
if (entry.kind == .file) {
if (std.mem.endsWith(u8, entry.path, ".zig")) {
const file = try cwd.openFile(entry.path, .{});
defer file.close();
const content = try file.readToEndAlloc(b.allocator, std.math.maxInt(usize));
defer b.allocator.free(content);
var lines = std.mem.splitScalar(u8, content, '\n');
var line_number: usize = 1;
while (lines.next()) |line| {
if (line.len > 95) {
const trimmed_line = std.mem.trim(u8, line, " ");
try step.addError("Line too long: {s}:{}\n {s}", .{
entry.path,
line_number,
trimmed_line,
});
}
line_number += 1;
}
}
}
}
}
The check_format
function itself is very simple. It first gets the
current working directory that is iteratable. It then walks this
directory and therefore also all sub-directories. For any entry that is
a file, that is a .zig
file, we open up that file, read the content,
split that content by carriage return characters. For each of these
lines we just check the line length. If there is an error, then we add
an error to this step stating the file and line number and a trimmed
version of this line (because spaces are not necessary in this error).
You may think I’m slightly crazy for doing this and imposing this restriction on my code. Well, yes, I’m probably slightly crazy, but it also forces you to think about how you structure your code more long liners do.
Any way, we are now up to 11820 lines of code.
-------------------------------------------------------------------------------
Language files blank comment code
-------------------------------------------------------------------------------
Zig 21 1273 1212 11766
-------------------------------------------------------------------------------
There was also no huge block of data like in the last couple of reports. However, we’ve added 2663 lines of code this week.2663 lines of code this week. That’s more actual code than last week, which is surprising. My guess is that next week will be much slowere as bugs in the user interface code start causing problems.
And the new Zig 0.14 should be published next week. I’m already following the master branch, which is sometimes scary, but mostly just fun. ‘Oh, its stopped compiling… oh, they’ve changed the format of the .zon file. What is a fingerprint? I guess I’ll find out when they publish some documentation.