Preferred Zig Library Structure/Logic for my UI Library (zignui)

Hello, I’m new to Zig (not programming) and the manual memory allocation thingie.

I’m in the process of building a GUI library in Zig utilizing Mitchell’s wonderful zig-objc library.
Got memory management figured out and many things seem to work nicely.
This GUI library uses native UI controls and currently works on macOS and iOS-Simulator (iPhone/iPad).

The user of this library will be able to build the UI and never mind the memory allocation the UI entities need. Every UI control has its deferdeinit function for cleaning up the used memory at app termination.

Now to my question about the preferred Zig library structure/logic as I realize that Zig users want 100% control over memory allocations:
Would Zig users even use such a library if the memory allocation stuff is done in the background (inside the library) ?

Asking, because from a UI building point of view, dealing with memory for the GUI is always the same (Application, Window, Button, Label, etc.) and can get quite boring.

If it is preferred that the user of this library has to allocate the memory, I would have to take that code out of the library and make it available to the user.

Which would not be my preferred thing to do as I prefer the memory allocation inside the library, but…

It would be nice if the Zig community could let me know, because I would like to have this sorted before I push my code to Github or somewhere else for public consumption. (Is Github the preferred repo of Zig users?)

Here a simple hello world app utilizing my library named zignui (the gn is pronounced like the Italian gnocchi and it stands for zig native user interface.
(zui and zigui are already in use on Github by zig users, and I did not come up with a better name… sorry…)

const std = @import("std");
const ui = @import("zignui");

pub fn main() !void {
    std.log.info("Starting Hello ZIGNUI Library application...", .{});

    // Initialize application
    var app = ui.Application("com.example.zignui-hello", .regular) catch |err| {
        std.log.err("Failed to initialize application: {}", .{err});
        return err;
    };
    app.quitOnLastWindowClosed(true);

    std.log.info("Application initialized successfully", .{});
    app.setGUIInitializer(gui_init);
    std.log.info("Graphical user interface initialized successfully", .{});
    // Run the application event loop
    app.run();

    std.log.info("Application terminated successfully", .{});
}

fn gui_init(app: anytype) !void {
    // Create main window
    const win_rect = ui.Rect(100, 100, 500, 400); // size is for macOS
    var win = app.Window("main_window", win_rect) catch |err| {
        std.log.err("Error in createWindow: {}", .{err});
        return;
    };

    win.setTitle("Hello Zui Library!");
    win.center();

    // Show the window
    win.show();
}

The app.setGUIInitializer function is needed as it helps a lot with iOS UIScene stuff (which is the preferred iOS way nowadays) and also helps later with having different sizes (or whatever) for different operating systems.

This code compiles for macOS and iOS-simulator as a target utilizing a tool I wrote (ZAC - zig application creator) which uses zig and creates subfolders in the zig-out folder named after the target: aarch64-macos or aarch64-ios-simulator and generates the app for macOS and a different app for the iOS simulator (they have different internal structures).
Of course ZAC will be released alongside the ZIGNUI library to facilitate the creation of Apple operating system apps.
(did not master the deployment of iOS apps on real iPhones yet, as it is quite cumbersome, but in the future Zac should handle this as well…)

There are certainly many things that still need to be done, and my hope is that many Zig users are interested in such a library and are willing to spend their time helping/extending/maintaining it.

BTW: As you already might have recognized, Zignui (or ZIGNUI / zignui…) uses a high-level, platform-agnostic, imperative syntax and not a declarative syntax like most new UI libraries in use today. Why? Because I don’t like the declarative syntax stuff… sorry.

ALSO: Maybe I should add that the library already has several view controls like WebView, MediaView and GraphicView controls.
The GraphicView control uses Apples Metal framework for macOS and iOS and it can show the usual multicolor triangle for both operating systems. (the sizing on iOS is still wrong, but I’m working on it…)
This GraphicView control uses its own event loop for drawing things separate from the application GUI.

Please let me know about the memory allocation question… very much appreciated.
Thanks for reading.

100% control is an exaggeration, we want enough control to cater to our use cases.

The bare minimum for control is choosing the allocator, this is different from managing allocations internally. Which you seem to have conflated with each other, based on your example.

The latter is fine as long as it’s reasonable, and efficient.

There is also nothing stopping you from having both a high level API that manages allocations internally and a lower level API that does not, the latter probably already exists internally.

2 Likes

When I think about, for instance, interacting with a C library, there’s only one thing I don’t really want and that’s to be handed memory with no lifetime guarantee.
If I have memory that I assume will be valid until I free it with the C allocator, and then the library sneakily frees it under my feet, that’s bad.

That could conceivably happen with Zig libraries too, so in the case where a library frees memory internally I would want to be handed a handle rather than a pointer - the simplest way to implement this is to, for instance, have a std.AutoHashMap(u32, Window), then when a Window is created I’m given its key and not a pointer to its value.
Then, the Window can be deleted at any time by the internal library and my code remains aware of that as long as I call .get() on the library’s hash map.

In the case of your library, it already looks like it’s handling it well enough for my tastes.

There is another factor - part of the reason why Zig stresses allocators as a function argument is so that it’s obvious which functions are allocating memory.
I don’t want to be calling one of your library functions inside an event loop, unaware that it’s allocating memory that will not be freed at the end of the frame and is therefore a memory leak.

I’m pretty lenient; in my eyes the bare minimum is having docstrings. For instance, it’s common to see “Caller owns the returned memory” or similar as a docstring, to indicate that the function is returning memory which lasts until you free it.
In your case, if memory is managed internally and has an internal lifetime, tell us about it in the docstrings - say “Allocates memory which lasts until the window is killed” or “Allocates memory which lasts until the current event handler is finished”.

4 Likes

Even if the zig stdlib is moving away from it, it’s still ok for libraries to take a Zig allocator once in the initialization call (for instance in your ui.Application() call and store that in the app object for usage by all other framework features.

Since you’re mainly talking to macOS OS APIs you don’t have a lot of control over memory management anyway, so doing all allocations in the framework through a single allocator passed at init-time is ok IMHO.

One note though about the coding style: I would expect that ui.Application(), ui.Rect(), app.Window() etc… are generic functions which return types and not ‘runtime constructor functions’, since uppercase usually means ‘this is a type’ by Zig’s naming conventions.

4 Likes

Yes, the 100% was an exaggeration, a statement that compared where I’m coming from: using programming languages with ARC or GC…

My hope is that once the library is released, more experienced Zig users look at the code and are willing to use it (if it fits their need) without regret about the internal library decisions made by me (a Zig novice).

Thank you for your valuable input.

I can understand that you don’t want to be calling one of my library functions inside an event loop, unaware that it’s allocating memory that will not be freed at the end of the frame and is therefore a memory leak.
In order to avoid this, there is only one event loop accessible to the user, which is the drawing loop for the GraphicView control for drawing Metal stuff.
If this control is not used, there is no event loop (available to the user) as actions are event driven.

When a control is placed on a window (like a button for example) it’s just this code to initialize the event function:

// Set action handlers for interactive controls
test_button.onClick(buttonClickHandler);

and the event function looks like this:

// Action handler functions
fn buttonClickHandler(context: *anyopaque) void {
    const ui_context = ui.getObjects(context);
    const label = ui_context.label("test_label") orelse {
        std.log.err("Error: Label 'test_label' not found", .{});
        return;
    };
    label.setText("Updated Label");
    std.log.info("🔘 Button was clicked!", .{});
}

Any UI control can be fetched (and used) at runtime in any event function, as all the controls (which are Zig types) are stored in the UI context hash maps.
So, there is no event loop for the user.
This is due to how the macOS Cocoa and iOS CocoaTouch native libraries are done on the operating system side.

My idea of a GUI library is to not stay in the way and do as much as possible on its own, so the user can concentrate on the application logic, not dabbling with UI stuff.

Thank you for your valuable input.

What I did, was to initialize the allocator (inside the library) at application creation, and using deferdeinit (inside the library) to release the memory when the application is terminated.
And yes, the allocator is stored in the app UI object context for usage by all other framework features.

Also yes, ui.Application() , ui.Rect() , app.Window() are all functions that return types.

Thank you for your valuable input.

Hmmm… at least this code doesn’t look like win_rect and win are types, but runtime values/objects:

    const win_rect = ui.Rect(100, 100, 500, 400); // size is for macOS
    var win = app.Window("main_window", win_rect) catch |err| {
        std.log.err("Error in createWindow: {}", .{err});
        return;
    };

    win.setTitle("Hello Zui Library!");
    win.center();

    // Show the window
    win.show();

Interestingly your error message which mentions createWindow looks more ‘ziggy’ :wink:

E.g. this would match the Zig convention:

var win = app.createWindow("main_window", win_rect) ...

Also see here:

Here I would only move the allocator creation out of the library and accept an allocator as parameter to the ‘constructor function’ of your application object, that way the user of your UI framework can pick the allocator instead of hardwiring a specific allocator inside the library.

E.g. usually a Zig application has a single root/main allocator created at the start of the main function so that the user of your framework can pick the root/main allocator (for instance the DebugAllocator vs c_allocator vs wasm_allocator).

Also see the first point here “Are you making a library?”:

1 Like

The Rect function returns a rect struct that resembles the Cocoa NSRect struct…

If this:

var win = app.createWindow("main_window", win_rect) ...

is the Zig convention, Zig users don’t mind the extra “noise” it creates with:

var app = ui.createApplication("application", ...) ...
var win = app.createWindow("main_window", win_rect) ...
var buy = win.createButton("my_button", but_rect) ...

Good to know…
…which in turn means I need to rename a lot of UI objects.

Oh well…

Thanks

I use the std.heap.page_allocator during Application creation (NSApplication/UIApplication API’s).

As this is a GUI library meant to be used on any Apple devices I did not think that some would like to use any other allocators… but what do I know…

Maybe it would become important if someone else decides to modify my library and adding GTK or other UI frameworks to it… not sure.

I will read the documentation you linked.

Thanks.

EDIT
Fixed the wrongly mentioned global allocator above.
Anyway, the std.mem.Allocator is also in use to initialize multiple std.StringHashMap’s.

This would also be in line with the Zig style guide:

var app = ui.application("application", ...) ...
var win = app.window("main_window", win_rect) ...
var buy = win.button("my_button", but_rect) ...

…think of custom allocators which help with debugging or memory profiling. Being able to override memory allocation with custom code is pretty much always a good idea also outside of the Zig world.

You can of course also make the allocator param optional and if none is provided, create the global allocator in your framework just like you do now, so that applications that don’t want to provide a custom allocator can save that one or two lines of code at the start of main().

That’s a good idea, let me look at that.
Thanks

1 Like