Let's Build Image Viewer

April 28, 2026

This is the first article in the series of the articles where are are going to build Image viewer - imager. The idea is simple, we will create a minimal image viewer program and then extend the functionalities of it via "extension articles". Let's see how things will go! I'll update the following list as I publish the article(s) in this series.

  1. Let's Build Image Viewer

tl;dc: Refer this gist to get the final version of imager.c code.

Go ahead and create a file with name imager.c and write the following stub code.

#include <gtk/gtk.h>

static void activate(GtkApplication* app, gpointer data) {
    GtkWidget* window = gtk_application_window_new(app);
    gtk_window_set_title(GTK_WINDOW(window), "Imager");
    gtk_window_set_default_size(GTK_WINDOW(window), 600, 800);
    
    gtk_window_present(GTK_WINDOW(window));
}

int main(int argc, char** argv) {
    GtkApplication* app = gtk_application_new("dev.marichi.imager", G_APPLICATION_DEFAULT_FLAGS);
    g_signal_connect(app, "activate", G_CALLBACK(activate), NULL);
    int status = g_application_run(G_APPLICATION(app), argc, argv);
    
    return status;
}

If you need brief explanation on this code, refer the Let's Build Terminal Pt. 1 article. Although, it is from "Let's Build Terminal" series but the first part is generic enough to understand such a code.

Use following command to compile and run the program.

gcc stub.c `pkg-config --cflags --libs gtk4` && ./a.out

Let's add the header bar. The plan is to have a "Open" button in header bar that allow us to select the image file to open and view.

We can create a header bar using gtk_header_bar_new() function and then give a title to it using gtk_header_bar_set_title_widget(). Finally, we can use gtk_window_set_titlebar() function to set the header bar as title bar of the window.

#include <gtk/gtk.h>

static void activate(GtkApplication* app, gpointer data) {
    GtkWidget* window = gtk_application_window_new(app);
    gtk_window_set_title(GTK_WINDOW(window), "Imager");
    gtk_window_set_default_size(GTK_WINDOW(window), 600, 800);
    
    // Header bar.
    GtkWidget* header_bar = gtk_header_bar_new();
    gtk_header_bar_set_title_widget(GTK_HEADER_BAR(header_bar), gtk_label_new("Imager"));
    
    gtk_window_set_titlebar(GTK_WINDOW(window), header_bar);
    
    gtk_window_present(GTK_WINDOW(window));
}

int main(int argc, char** argv) {
    GtkApplication* app = gtk_application_new("dev.marichi.imager", G_APPLICATION_DEFAULT_FLAGS);
    g_signal_connect(app, "activate", G_CALLBACK(activate), NULL);
    int status = g_application_run(G_APPLICATION(app), argc, argv);
    
    return status;
}

Now, go ahead and create a button using gtk_button_new_with_label() function and then pack it to the header bar.

#include <gtk/gtk.h>

static void activate(GtkApplication* app, gpointer data) {
    GtkWidget* window = gtk_application_window_new(app);
    gtk_window_set_title(GTK_WINDOW(window), "Imager");
    gtk_window_set_default_size(GTK_WINDOW(window), 600, 800);
    
    // Header bar.
    GtkWidget* header_bar = gtk_header_bar_new();
    gtk_header_bar_set_title_widget(GTK_HEADER_BAR(header_bar), gtk_label_new("Imager"));
    
    // Button.
    GtkWidget* button = gtk_button_new_with_label("Open");
    gtk_header_bar_pack_end(GTK_HEADER_BAR(header_bar), button);
    
    gtk_window_set_titlebar(GTK_WINDOW(window), header_bar);
    
    gtk_window_present(GTK_WINDOW(window));
}

int main(int argc, char** argv) {
    GtkApplication* app = gtk_application_new("dev.marichi.imager", G_APPLICATION_DEFAULT_FLAGS);
    g_signal_connect(app, "activate", G_CALLBACK(activate), NULL);
    int status = g_application_run(G_APPLICATION(app), argc, argv);
    
    return status;
}

Run the program and it should produce output like this.

When user click on button, it should open a file dialog to select the image. We need to pass the window instance as user data because it is needed to load the image into the window.

#include <gtk/gtk.h>

static void _on_open_clicked(GtkWidget* button, gpointer data) {

}

static void activate(GtkApplication* app, gpointer data) {
    GtkWidget* window = gtk_application_window_new(app);
    gtk_window_set_title(GTK_WINDOW(window), "Imager");
    gtk_window_set_default_size(GTK_WINDOW(window), 600, 800);
    
    // Header bar.
    GtkWidget* header_bar = gtk_header_bar_new();
    gtk_header_bar_set_title_widget(GTK_HEADER_BAR(header_bar), gtk_label_new("Imager"));
    
    // Button.
    GtkWidget* button = gtk_button_new_with_label("Open");
    g_signal_connect(button, "clicked", G_CALLBACK(_on_open_clicked), window);
    gtk_header_bar_pack_end(GTK_HEADER_BAR(header_bar), button);
    
    gtk_window_set_titlebar(GTK_WINDOW(window), header_bar);
    
    gtk_window_present(GTK_WINDOW(window));
}

int main(int argc, char** argv) {
    GtkApplication* app = gtk_application_new("dev.marichi.imager", G_APPLICATION_DEFAULT_FLAGS);
    g_signal_connect(app, "activate", G_CALLBACK(activate), NULL);
    int status = g_application_run(G_APPLICATION(app), argc, argv);
    
    return status;
}

Let's cast and save the window passed as user data and create a file dialog using gtk_file_dialog_new() function.

#include <gtk/gtk.h>

static void _on_open_clicked(GtkWidget* button, gpointer data) {
    GtkWindow* window = GTK_WINDOW(data);
    
    GtkFileDialog* file_dialog = gtk_file_dialog_new();
}

static void activate(GtkApplication* app, gpointer data) {
    GtkWidget* window = gtk_application_window_new(app);
    gtk_window_set_title(GTK_WINDOW(window), "Imager");
    gtk_window_set_default_size(GTK_WINDOW(window), 600, 800);
    
    // Header bar.
    GtkWidget* header_bar = gtk_header_bar_new();
    gtk_header_bar_set_title_widget(GTK_HEADER_BAR(header_bar), gtk_label_new("Imager"));
    
    // Button.
    GtkWidget* button = gtk_button_new_with_label("Open");
    g_signal_connect(button, "clicked", G_CALLBACK(_on_open_clicked), window);
    gtk_header_bar_pack_end(GTK_HEADER_BAR(header_bar), button);
    
    gtk_window_set_titlebar(GTK_WINDOW(window), header_bar);
    
    gtk_window_present(GTK_WINDOW(window));
}

int main(int argc, char** argv) {
    GtkApplication* app = gtk_application_new("dev.marichi.imager", G_APPLICATION_DEFAULT_FLAGS);
    g_signal_connect(app, "activate", G_CALLBACK(activate), NULL);
    int status = g_application_run(G_APPLICATION(app), argc, argv);
    
    return status;
}

In order to actually see the file dialog, we need to call the gtk_file_dialog_open() function. We need to pass 5 arguments - dialog itself, window as parent, NULL as cancellable operation, callback function, and user data.

#include <gtk/gtk.h>

static void _on_open_clicked(GtkWidget* button, gpointer data) {
    GtkWindow* window = GTK_WINDOW(data);
    
    GtkFileDialog* file_dialog = gtk_file_dialog_new();
    
    gtk_file_dialog_open(file_dialog, window, NULL, _on_file_opened, window);
}

static void activate(GtkApplication* app, gpointer data) {
    GtkWidget* window = gtk_application_window_new(app);
    gtk_window_set_title(GTK_WINDOW(window), "Imager");
    gtk_window_set_default_size(GTK_WINDOW(window), 600, 800);
    
    // Header bar.
    GtkWidget* header_bar = gtk_header_bar_new();
    gtk_header_bar_set_title_widget(GTK_HEADER_BAR(header_bar), gtk_label_new("Imager"));
    
    // Button.
    GtkWidget* button = gtk_button_new_with_label("Open");
    g_signal_connect(button, "clicked", G_CALLBACK(_on_open_clicked), window);
    gtk_header_bar_pack_end(GTK_HEADER_BAR(header_bar), button);
    
    gtk_window_set_titlebar(GTK_WINDOW(window), header_bar);
    
    gtk_window_present(GTK_WINDOW(window));
}

int main(int argc, char** argv) {
    GtkApplication* app = gtk_application_new("dev.marichi.imager", G_APPLICATION_DEFAULT_FLAGS);
    g_signal_connect(app, "activate", G_CALLBACK(activate), NULL);
    int status = g_application_run(G_APPLICATION(app), argc, argv);
    
    return status;
}

We need to define the _on_file_opened() function. But is a asnyc callback. Due to this it accepts these parameters - source object that has started asnyc operation (file dialog) of type GObject*, result of the operation of type GAsyncResult*, and user data.

#include <gtk/gtk.h>

static void _on_file_opened(GObject* source_object, GAsyncResult* res, gpointer data) {

}

static void _on_open_clicked(GtkWidget* button, gpointer data) {
    GtkWindow* window = GTK_WINDOW(data);
    
    GtkFileDialog* file_dialog = gtk_file_dialog_new();
    
    gtk_file_dialog_open(file_dialog, window, NULL, _on_file_opened, window);
}

static void activate(GtkApplication* app, gpointer data) {
    GtkWidget* window = gtk_application_window_new(app);
    gtk_window_set_title(GTK_WINDOW(window), "Imager");
    gtk_window_set_default_size(GTK_WINDOW(window), 600, 800);
    
    // Header bar.
    GtkWidget* header_bar = gtk_header_bar_new();
    gtk_header_bar_set_title_widget(GTK_HEADER_BAR(header_bar), gtk_label_new("Imager"));
    
    // Button.
    GtkWidget* button = gtk_button_new_with_label("Open");
    g_signal_connect(button, "clicked", G_CALLBACK(_on_open_clicked), window);
    gtk_header_bar_pack_end(GTK_HEADER_BAR(header_bar), button);
    
    gtk_window_set_titlebar(GTK_WINDOW(window), header_bar);
    
    gtk_window_present(GTK_WINDOW(window));
}

int main(int argc, char** argv) {
    GtkApplication* app = gtk_application_new("dev.marichi.imager", G_APPLICATION_DEFAULT_FLAGS);
    g_signal_connect(app, "activate", G_CALLBACK(activate), NULL);
    int status = g_application_run(G_APPLICATION(app), argc, argv);
    
    return status;
}

Again, let's get the values of dialog and window from the function parameters.

#include <gtk/gtk.h>

static void _on_file_opened(GObject* source_object, GAsyncResult* res, gpointer data) {
    GtkFileDialog* file_dialog = GTK_FILE_DIALOG(source_object);
    GtkWindow* window = GTK_WINDOW(data);
}

static void _on_open_clicked(GtkWidget* button, gpointer data) {
    GtkWindow* window = GTK_WINDOW(data);
    
    GtkFileDialog* file_dialog = gtk_file_dialog_new();
    
    gtk_file_dialog_open(file_dialog, window, NULL, _on_file_opened, window);
}

static void activate(GtkApplication* app, gpointer data) {
    GtkWidget* window = gtk_application_window_new(app);
    gtk_window_set_title(GTK_WINDOW(window), "Imager");
    gtk_window_set_default_size(GTK_WINDOW(window), 600, 800);
    
    // Header bar.
    GtkWidget* header_bar = gtk_header_bar_new();
    gtk_header_bar_set_title_widget(GTK_HEADER_BAR(header_bar), gtk_label_new("Imager"));
    
    // Button.
    GtkWidget* button = gtk_button_new_with_label("Open");
    g_signal_connect(button, "clicked", G_CALLBACK(_on_open_clicked), window);
    gtk_header_bar_pack_end(GTK_HEADER_BAR(header_bar), button);
    
    gtk_window_set_titlebar(GTK_WINDOW(window), header_bar);
    
    gtk_window_present(GTK_WINDOW(window));
}

int main(int argc, char** argv) {
    GtkApplication* app = gtk_application_new("dev.marichi.imager", G_APPLICATION_DEFAULT_FLAGS);
    g_signal_connect(app, "activate", G_CALLBACK(activate), NULL);
    int status = g_application_run(G_APPLICATION(app), argc, argv);
    
    return status;
}

We need to call the gtk_file_dialog_open_finish() function. This function finish the gtk_file_dialog_open() function and return either the selected file or an error.

#include <gtk/gtk.h>

static void _on_file_opened(GObject* source_object, GAsyncResult* res, gpointer data) {
    GtkFileDialog* file_dialog = GTK_FILE_DIALOG(source_object);
    GtkWindow* window = GTK_WINDOW(data);
    
    GFile* file = NULL;
    GError* error = NULL;
    
    file = gtk_file_dialog_open_finish(file_dialog, res, &error);
}

static void _on_open_clicked(GtkWidget* button, gpointer data) {
    GtkWindow* window = GTK_WINDOW(data);
    
    GtkFileDialog* file_dialog = gtk_file_dialog_new();
    
    gtk_file_dialog_open(file_dialog, window, NULL, _on_file_opened, window);
}

static void activate(GtkApplication* app, gpointer data) {
    GtkWidget* window = gtk_application_window_new(app);
    gtk_window_set_title(GTK_WINDOW(window), "Imager");
    gtk_window_set_default_size(GTK_WINDOW(window), 600, 800);
    
    // Header bar.
    GtkWidget* header_bar = gtk_header_bar_new();
    gtk_header_bar_set_title_widget(GTK_HEADER_BAR(header_bar), gtk_label_new("Imager"));
    
    // Button.
    GtkWidget* button = gtk_button_new_with_label("Open");
    g_signal_connect(button, "clicked", G_CALLBACK(_on_open_clicked), window);
    gtk_header_bar_pack_end(GTK_HEADER_BAR(header_bar), button);
    
    gtk_window_set_titlebar(GTK_WINDOW(window), header_bar);
    
    gtk_window_present(GTK_WINDOW(window));
}

int main(int argc, char** argv) {
    GtkApplication* app = gtk_application_new("dev.marichi.imager", G_APPLICATION_DEFAULT_FLAGS);
    g_signal_connect(app, "activate", G_CALLBACK(activate), NULL);
    int status = g_application_run(G_APPLICATION(app), argc, argv);
    
    return status;
}

If we get the error, we need to stop and free the error memory using g_error_free(). We can also print the error message using g_printerr() function.

#include <gtk/gtk.h>

static void _on_file_opened(GObject* source_object, GAsyncResult* res, gpointer data) {
    GtkFileDialog* file_dialog = GTK_FILE_DIALOG(source_object);
    GtkWindow* window = GTK_WINDOW(data);
    
    GFile* file = NULL;
    GError* error = NULL;
    
    file = gtk_file_dialog_open_finish(file_dialog, res, &error);
    
    if (error) {
        g_printerr("Error opening file %s\n", error->message);
        g_error_free(error);
        return;
    }
}

static void _on_open_clicked(GtkWidget* button, gpointer data) {
    GtkWindow* window = GTK_WINDOW(data);
    
    GtkFileDialog* file_dialog = gtk_file_dialog_new();
    
    gtk_file_dialog_open(file_dialog, window, NULL, _on_file_opened, window);
}

static void activate(GtkApplication* app, gpointer data) {
    GtkWidget* window = gtk_application_window_new(app);
    gtk_window_set_title(GTK_WINDOW(window), "Imager");
    gtk_window_set_default_size(GTK_WINDOW(window), 600, 800);
    
    // Header bar.
    GtkWidget* header_bar = gtk_header_bar_new();
    gtk_header_bar_set_title_widget(GTK_HEADER_BAR(header_bar), gtk_label_new("Imager"));
    
    // Button.
    GtkWidget* button = gtk_button_new_with_label("Open");
    g_signal_connect(button, "clicked", G_CALLBACK(_on_open_clicked), window);
    gtk_header_bar_pack_end(GTK_HEADER_BAR(header_bar), button);
    
    gtk_window_set_titlebar(GTK_WINDOW(window), header_bar);
    
    gtk_window_present(GTK_WINDOW(window));
}

int main(int argc, char** argv) {
    GtkApplication* app = gtk_application_new("dev.marichi.imager", G_APPLICATION_DEFAULT_FLAGS);
    g_signal_connect(app, "activate", G_CALLBACK(activate), NULL);
    int status = g_application_run(G_APPLICATION(app), argc, argv);
    
    return status;
}

But, if we have file, we can proceed with rendering the image on the window. Let's create a picture widget using gtk_picture_new() function and then set it as child of the window.

#include <gtk/gtk.h>

static void _on_file_opened(GObject* source_object, GAsyncResult* res, gpointer data) {
    GtkFileDialog* file_dialog = GTK_FILE_DIALOG(source_object);
    GtkWindow* window = GTK_WINDOW(data);
    
    GFile* file = NULL;
    GError* error = NULL;
    
    file = gtk_file_dialog_open_finish(file_dialog, res, &error);
    
    if (error) {
        g_printerr("Error opening file %s\n", error->message);
        g_error_free(error);
        return;
    }
    
    if (file) {
        GtkWidget* picture = gtk_picture_new();
        
        gtk_window_set_child(window, picture);
    }
}

static void _on_open_clicked(GtkWidget* button, gpointer data) {
    GtkWindow* window = GTK_WINDOW(data);
    
    GtkFileDialog* file_dialog = gtk_file_dialog_new();
    
    gtk_file_dialog_open(file_dialog, window, NULL, _on_file_opened, window);
}

static void activate(GtkApplication* app, gpointer data) {
    GtkWidget* window = gtk_application_window_new(app);
    gtk_window_set_title(GTK_WINDOW(window), "Imager");
    gtk_window_set_default_size(GTK_WINDOW(window), 600, 800);
    
    // Header bar.
    GtkWidget* header_bar = gtk_header_bar_new();
    gtk_header_bar_set_title_widget(GTK_HEADER_BAR(header_bar), gtk_label_new("Imager"));
    
    // Button.
    GtkWidget* button = gtk_button_new_with_label("Open");
    g_signal_connect(button, "clicked", G_CALLBACK(_on_open_clicked), window);
    gtk_header_bar_pack_end(GTK_HEADER_BAR(header_bar), button);
    
    gtk_window_set_titlebar(GTK_WINDOW(window), header_bar);
    
    gtk_window_present(GTK_WINDOW(window));
}

int main(int argc, char** argv) {
    GtkApplication* app = gtk_application_new("dev.marichi.imager", G_APPLICATION_DEFAULT_FLAGS);
    g_signal_connect(app, "activate", G_CALLBACK(activate), NULL);
    int status = g_application_run(G_APPLICATION(app), argc, argv);
    
    return status;
}

In order to actually render the picture in this widget, we need to use texture widget from the GDK library. We can create texture of selected file using gdk_texture_new_from_file() function and then use it with picture instance. The gdk_texture_new_from_file() function accepts file path and error struct. We can get the path of the selected file using g_file_get_path() function.

#include <gtk/gtk.h>

static void _on_file_opened(GObject* source_object, GAsyncResult* res, gpointer data) {
    GtkFileDialog* file_dialog = GTK_FILE_DIALOG(source_object);
    GtkWindow* window = GTK_WINDOW(data);
    
    GFile* file = NULL;
    GError* error = NULL;
    
    file = gtk_file_dialog_open_finish(file_dialog, res, &error);
    
    if (error) {
        g_printerr("Error opening file %s\n", error->message);
        g_error_free(error);
        return;
    }
    
    if (file) {
        char* path = g_file_get_path(file);
    
        GtkWidget* picture = gtk_picture_new();
        
        gtk_window_set_child(window, picture);
        
        GdkTexture* texture = gdk_texture_new_from_file(file, &error);
        
        if (error) {
        
        } else {
        
        }
    }
}

static void _on_open_clicked(GtkWidget* button, gpointer data) {
    GtkWindow* window = GTK_WINDOW(data);
    
    GtkFileDialog* file_dialog = gtk_file_dialog_new();
    
    gtk_file_dialog_open(file_dialog, window, NULL, _on_file_opened, window);
}

static void activate(GtkApplication* app, gpointer data) {
    GtkWidget* window = gtk_application_window_new(app);
    gtk_window_set_title(GTK_WINDOW(window), "Imager");
    gtk_window_set_default_size(GTK_WINDOW(window), 600, 800);
    
    // Header bar.
    GtkWidget* header_bar = gtk_header_bar_new();
    gtk_header_bar_set_title_widget(GTK_HEADER_BAR(header_bar), gtk_label_new("Imager"));
    
    // Button.
    GtkWidget* button = gtk_button_new_with_label("Open");
    g_signal_connect(button, "clicked", G_CALLBACK(_on_open_clicked), window);
    gtk_header_bar_pack_end(GTK_HEADER_BAR(header_bar), button);
    
    gtk_window_set_titlebar(GTK_WINDOW(window), header_bar);
    
    gtk_window_present(GTK_WINDOW(window));
}

int main(int argc, char** argv) {
    GtkApplication* app = gtk_application_new("dev.marichi.imager", G_APPLICATION_DEFAULT_FLAGS);
    g_signal_connect(app, "activate", G_CALLBACK(activate), NULL);
    int status = g_application_run(G_APPLICATION(app), argc, argv);
    
    return status;
}

Again, if we get the error, then we need to stop by printing the message and free the error memory.

#include <gtk/gtk.h>

static void _on_file_opened(GObject* source_object, GAsyncResult* res, gpointer data) {
    GtkFileDialog* file_dialog = GTK_FILE_DIALOG(source_object);
    GtkWindow* window = GTK_WINDOW(data);
    
    GFile* file = NULL;
    GError* error = NULL;
    
    file = gtk_file_dialog_open_finish(file_dialog, res, &error);
    
    if (error) {
        g_printerr("Error opening file %s\n", error->message);
        g_error_free(error);
        return;
    }
    
    if (file) {
        char* path = g_file_get_path(file);
    
        GtkWidget* picture = gtk_picture_new();
        
        gtk_window_set_child(window, picture);
        
        GdkTexture* texture = gdk_texture_new_from_file(file, &error);
        
        if (error) {
            g_printerr("Error loading image %s\n", error->message);
            g_error_free(error);
            return;
        } else {
        
        }
    }
}

static void _on_open_clicked(GtkWidget* button, gpointer data) {
    GtkWindow* window = GTK_WINDOW(data);
    
    GtkFileDialog* file_dialog = gtk_file_dialog_new();
    
    gtk_file_dialog_open(file_dialog, window, NULL, _on_file_opened, window);
}

static void activate(GtkApplication* app, gpointer data) {
    GtkWidget* window = gtk_application_window_new(app);
    gtk_window_set_title(GTK_WINDOW(window), "Imager");
    gtk_window_set_default_size(GTK_WINDOW(window), 600, 800);
    
    // Header bar.
    GtkWidget* header_bar = gtk_header_bar_new();
    gtk_header_bar_set_title_widget(GTK_HEADER_BAR(header_bar), gtk_label_new("Imager"));
    
    // Button.
    GtkWidget* button = gtk_button_new_with_label("Open");
    g_signal_connect(button, "clicked", G_CALLBACK(_on_open_clicked), window);
    gtk_header_bar_pack_end(GTK_HEADER_BAR(header_bar), button);
    
    gtk_window_set_titlebar(GTK_WINDOW(window), header_bar);
    
    gtk_window_present(GTK_WINDOW(window));
}

int main(int argc, char** argv) {
    GtkApplication* app = gtk_application_new("dev.marichi.imager", G_APPLICATION_DEFAULT_FLAGS);
    g_signal_connect(app, "activate", G_CALLBACK(activate), NULL);
    int status = g_application_run(G_APPLICATION(app), argc, argv);
    
    return status;
}

In else block, we'll call gtk_picture_set_paintable() to set the texture to make it printable by casting it using GDK_PIAINTABLE() macro. We also need to unref the texture as we are done with it.

#include <gtk/gtk.h>

static void _on_file_opened(GObject* source_object, GAsyncResult* res, gpointer data) {
    GtkFileDialog* file_dialog = GTK_FILE_DIALOG(source_object);
    GtkWindow* window = GTK_WINDOW(data);
    
    GFile* file = NULL;
    GError* error = NULL;
    
    file = gtk_file_dialog_open_finish(file_dialog, res, &error);
    
    if (error) {
        g_printerr("Error opening file %s\n", error->message);
        g_error_free(error);
        return;
    }
    
    if (file) {
        char* path = g_file_get_path(file);
    
        GtkWidget* picture = gtk_picture_new();
        
        gtk_window_set_child(window, picture);
        
        GdkTexture* texture = gdk_texture_new_from_file(file, &error);
        
        if (error) {
            g_printerr("Error loading image %s\n", error->message);
            g_error_free(error);
            return;
        } else {
            gtk_picture_set_paintable(GTK_PICTURE(picture), GDK_PAINTABLE(texture));
            g_object_unref(texture);
        }
    }
}

static void _on_open_clicked(GtkWidget* button, gpointer data) {
    GtkWindow* window = GTK_WINDOW(data);
    
    GtkFileDialog* file_dialog = gtk_file_dialog_new();
    
    gtk_file_dialog_open(file_dialog, window, NULL, _on_file_opened, window);
}

static void activate(GtkApplication* app, gpointer data) {
    GtkWidget* window = gtk_application_window_new(app);
    gtk_window_set_title(GTK_WINDOW(window), "Imager");
    gtk_window_set_default_size(GTK_WINDOW(window), 600, 800);
    
    // Header bar.
    GtkWidget* header_bar = gtk_header_bar_new();
    gtk_header_bar_set_title_widget(GTK_HEADER_BAR(header_bar), gtk_label_new("Imager"));
    
    // Button.
    GtkWidget* button = gtk_button_new_with_label("Open");
    g_signal_connect(button, "clicked", G_CALLBACK(_on_open_clicked), window);
    gtk_header_bar_pack_end(GTK_HEADER_BAR(header_bar), button);
    
    gtk_window_set_titlebar(GTK_WINDOW(window), header_bar);
    
    gtk_window_present(GTK_WINDOW(window));
}

int main(int argc, char** argv) {
    GtkApplication* app = gtk_application_new("dev.marichi.imager", G_APPLICATION_DEFAULT_FLAGS);
    g_signal_connect(app, "activate", G_CALLBACK(activate), NULL);
    int status = g_application_run(G_APPLICATION(app), argc, argv);
    
    return status;
}

Don't forget to free the path memory and unref the file object.

#include <gtk/gtk.h>

static void _on_file_opened(GObject* source_object, GAsyncResult* res, gpointer data) {
    GtkFileDialog* file_dialog = GTK_FILE_DIALOG(source_object);
    GtkWindow* window = GTK_WINDOW(data);
    
    GFile* file = NULL;
    GError* error = NULL;
    
    file = gtk_file_dialog_open_finish(file_dialog, res, &error);
    
    if (error) {
        g_printerr("Error opening file %s\n", error->message);
        g_error_free(error);
        return;
    }
    
    if (file) {
        char* path = g_file_get_path(file);
    
        GtkWidget* picture = gtk_picture_new();
        
        gtk_window_set_child(window, picture);
        
        GdkTexture* texture = gdk_texture_new_from_file(file, &error);
        
        if (error) {
            g_printerr("Error loading image %s\n", error->message);
            g_error_free(error);
            return;
        } else {
            gtk_picture_set_paintable(GTK_PICTURE(picture), GDK_PAINTABLE(texture));
            g_object_unref(texture);
        }
        
        g_free(path);
        g_object_unref(file);
    }
}

static void _on_open_clicked(GtkWidget* button, gpointer data) {
    GtkWindow* window = GTK_WINDOW(data);
    
    GtkFileDialog* file_dialog = gtk_file_dialog_new();
    
    gtk_file_dialog_open(file_dialog, window, NULL, _on_file_opened, window);
}

static void activate(GtkApplication* app, gpointer data) {
    GtkWidget* window = gtk_application_window_new(app);
    gtk_window_set_title(GTK_WINDOW(window), "Imager");
    gtk_window_set_default_size(GTK_WINDOW(window), 600, 800);
    
    // Header bar.
    GtkWidget* header_bar = gtk_header_bar_new();
    gtk_header_bar_set_title_widget(GTK_HEADER_BAR(header_bar), gtk_label_new("Imager"));
    
    // Button.
    GtkWidget* button = gtk_button_new_with_label("Open");
    g_signal_connect(button, "clicked", G_CALLBACK(_on_open_clicked), window);
    gtk_header_bar_pack_end(GTK_HEADER_BAR(header_bar), button);
    
    gtk_window_set_titlebar(GTK_WINDOW(window), header_bar);
    
    gtk_window_present(GTK_WINDOW(window));
}

int main(int argc, char** argv) {
    GtkApplication* app = gtk_application_new("dev.marichi.imager", G_APPLICATION_DEFAULT_FLAGS);
    g_signal_connect(app, "activate", G_CALLBACK(activate), NULL);
    int status = g_application_run(G_APPLICATION(app), argc, argv);
    
    return status;
}

And with these changes, we now have the image viewer. Go ahead and run the program to confirm the image viewer!

Yay!

Let's Build the Terminal Pt. 3

April 27, 2026

This is the third article in the series of the articles where we are going to build the terminal in C using GTK4 and VTE. The series is divided into three articles.

  1. Let's Build the Terminal Pt. 1
  2. Let's Build the Terminal Pt. 2
  3. Let's Build the Terminal Pt. 3

tl;dc: Refer this gist to get the final version of terminal.c code.

---

Let's continue from where we left off in the last article. Following is the code we have in terminal.c file.

#include <gtk/gtk.h>

static void _on_new_tab_clicked(GtkWidget* widget, gpointer data) {
    GtkNotebook* notebook = GTK_NOTEBOOK(data);

    int pages = gtk_notebook_get_n_pages(notebook);

    char title[32];
    g_snprintf(title, sizeof(title), "Tab %d", pages + 1);

    int new_page = gtk_notebook_append_page(notebook, gtk_label_new("New tab content goes here"), gtk_label_new(title)); 

    gtk_notebook_set_current_page(notebook, new_page);
} 

static void activate(GtkApplication* app, gpointer data) {
    GtkWidget* window = gtk_application_window_new(app);
    gtk_window_set_title(GTK_WINDOW(window), "Terminal");
    gtk_window_set_default_size(GTK_WINDOW(window), 600, 800);

    // Container.
    GtkWidget* box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);

    // Notebook.
    GtkWidget* notebook = gtk_notebook_new();
    gtk_widget_set_vexpand(notebook, TRUE);
    gtk_widget_set_hexpand(notebook, TRUE);

    gtk_notebook_append_page(GTK_NOTEBOOK(notebook), gtk_label_new("Page 1 content goes here"), gtk_label_new("Page 1")); 
    gtk_notebook_append_page(GTK_NOTEBOOK(notebook), gtk_label_new("Page 2 content goes here"), gtk_label_new("Page 2")); 
    gtk_box_append(GTK_BOX(box), notebook);

    gtk_window_set_child(GTK_WINDOW(window), box); 

    // Header bar.
    GtkWidget* header_bar = gtk_header_bar_new();
    gtk_header_bar_set_title_widget(GTK_HEADER_BAR(header_bar), gtk_label_new("Terminal"));

    GtkWidget* button = gtk_button_new_with_label("New Tab");
    g_signal_connect(button, "clicked", G_CALLBACK(_on_new_tab_clicked), notebook);
    gtk_header_bar_pack_end(GTK_HEADER_BAR(header_bar), button);

    gtk_window_set_titlebar(GTK_WINDOW(window), header_bar);

    gtk_window_present(GTK_WINDOW(window));
}

int main(int argc, char** argv) {
    GtkApplication* app = gtk_application_new("dev.marichi.terminal", G_APPLICATION_DEFAULT_FLAGS);
    g_signal_connect(app, "activate", G_CALLBACK(activate), NULL);
    int status = g_application_run(G_APPLICATION(app), argc, argv);
    g_object_unref(app);

    return status;
}

And we know the command to compile and run the program.

gcc terminal.c `pkg-config --cflags --libs gtk4` && ./a.out

In this article, we're now going to add actual terminal within Notebook tabs. To load the terminal, we're going to use VTE library header file as vte/vte.h.

#include <gtk/gtk.h>
#include <vte/vte.h>

static void _on_new_tab_clicked(GtkWidget* widget, gpointer data) {
    GtkNotebook* notebook = GTK_NOTEBOOK(data);

    int pages = gtk_notebook_get_n_pages(notebook);

    char title[32];
    g_snprintf(title, sizeof(title), "Tab %d", pages + 1);

    int new_page = gtk_notebook_append_page(notebook, gtk_label_new("New tab content goes here"), gtk_label_new(title)); 

    gtk_notebook_set_current_page(notebook, new_page);
} 

static void activate(GtkApplication* app, gpointer data) {
    GtkWidget* window = gtk_application_window_new(app);
    gtk_window_set_title(GTK_WINDOW(window), "Terminal");
    gtk_window_set_default_size(GTK_WINDOW(window), 600, 800);

    // Container.
    GtkWidget* box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);

    // Notebook.
    GtkWidget* notebook = gtk_notebook_new();
    gtk_widget_set_vexpand(notebook, TRUE);
    gtk_widget_set_hexpand(notebook, TRUE);

    gtk_notebook_append_page(GTK_NOTEBOOK(notebook), gtk_label_new("Page 1 content goes here"), gtk_label_new("Page 1")); 
    gtk_notebook_append_page(GTK_NOTEBOOK(notebook), gtk_label_new("Page 2 content goes here"), gtk_label_new("Page 2")); 
    gtk_box_append(GTK_BOX(box), notebook);

    gtk_window_set_child(GTK_WINDOW(window), box); 

    // Header bar.
    GtkWidget* header_bar = gtk_header_bar_new();
    gtk_header_bar_set_title_widget(GTK_HEADER_BAR(header_bar), gtk_label_new("Terminal"));

    GtkWidget* button = gtk_button_new_with_label("New Tab");
    g_signal_connect(button, "clicked", G_CALLBACK(_on_new_tab_clicked), notebook);
    gtk_header_bar_pack_end(GTK_HEADER_BAR(header_bar), button);

    gtk_window_set_titlebar(GTK_WINDOW(window), header_bar);

    gtk_window_present(GTK_WINDOW(window));
}

int main(int argc, char** argv) {
    GtkApplication* app = gtk_application_new("dev.marichi.terminal", G_APPLICATION_DEFAULT_FLAGS);
    g_signal_connect(app, "activate", G_CALLBACK(activate), NULL);
    int status = g_application_run(G_APPLICATION(app), argc, argv);
    g_object_unref(app);

    return status;
}

When we start the application, it should load with terminal session in the focused tab. When we click on 'Add Tab', we expect that it should add a new tab with terminal session in it. In other words, we are going to create terminal from few places. So, we are going to create a common function create_terminal() for the same and call it from where it is needed. This function should return a widget so that it can be added inside the Notebook page.

#include <gtk/gtk.h>
#include <vte/vte.h>

static GtkWidget* create_terminal() {

}

static void _on_new_tab_clicked(GtkWidget* widget, gpointer data) {
    GtkNotebook* notebook = GTK_NOTEBOOK(data);

    int pages = gtk_notebook_get_n_pages(notebook);

    char title[32];
    g_snprintf(title, sizeof(title), "Tab %d", pages + 1);

    int new_page = gtk_notebook_append_page(notebook, gtk_label_new("New tab content goes here"), gtk_label_new(title)); 

    gtk_notebook_set_current_page(notebook, new_page);
} 

static void activate(GtkApplication* app, gpointer data) {
    GtkWidget* window = gtk_application_window_new(app);
    gtk_window_set_title(GTK_WINDOW(window), "Terminal");
    gtk_window_set_default_size(GTK_WINDOW(window), 600, 800);

    // Container.
    GtkWidget* box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);

    // Notebook.
    GtkWidget* notebook = gtk_notebook_new();
    gtk_widget_set_vexpand(notebook, TRUE);
    gtk_widget_set_hexpand(notebook, TRUE);

    gtk_notebook_append_page(GTK_NOTEBOOK(notebook), gtk_label_new("Page 1 content goes here"), gtk_label_new("Page 1")); 
    gtk_notebook_append_page(GTK_NOTEBOOK(notebook), gtk_label_new("Page 2 content goes here"), gtk_label_new("Page 2")); 
    gtk_box_append(GTK_BOX(box), notebook);

    gtk_window_set_child(GTK_WINDOW(window), box); 

    // Header bar.
    GtkWidget* header_bar = gtk_header_bar_new();
    gtk_header_bar_set_title_widget(GTK_HEADER_BAR(header_bar), gtk_label_new("Terminal"));

    GtkWidget* button = gtk_button_new_with_label("New Tab");
    g_signal_connect(button, "clicked", G_CALLBACK(_on_new_tab_clicked), notebook);
    gtk_header_bar_pack_end(GTK_HEADER_BAR(header_bar), button);

    gtk_window_set_titlebar(GTK_WINDOW(window), header_bar);

    gtk_window_present(GTK_WINDOW(window));
}

int main(int argc, char** argv) {
    GtkApplication* app = gtk_application_new("dev.marichi.terminal", G_APPLICATION_DEFAULT_FLAGS);
    g_signal_connect(app, "activate", G_CALLBACK(activate), NULL);
    int status = g_application_run(G_APPLICATION(app), argc, argv);
    g_object_unref(app);

    return status;
}

Let's create a terminal instance using vte_terminal_new() function.

#include <gtk/gtk.h>
#include <vte/vte.h>

static GtkWidget* create_terminal() {
    VteTerminal* terminal = VTE_TERMINAL(vte_terminal_new());
}

static void _on_new_tab_clicked(GtkWidget* widget, gpointer data) {
    GtkNotebook* notebook = GTK_NOTEBOOK(data);

    int pages = gtk_notebook_get_n_pages(notebook);

    char title[32];
    g_snprintf(title, sizeof(title), "Tab %d", pages + 1);

    int new_page = gtk_notebook_append_page(notebook, gtk_label_new("New tab content goes here"), gtk_label_new(title)); 

    gtk_notebook_set_current_page(notebook, new_page);
} 

static void activate(GtkApplication* app, gpointer data) {
    GtkWidget* window = gtk_application_window_new(app);
    gtk_window_set_title(GTK_WINDOW(window), "Terminal");
    gtk_window_set_default_size(GTK_WINDOW(window), 600, 800);

    // Container.
    GtkWidget* box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);

    // Notebook.
    GtkWidget* notebook = gtk_notebook_new();
    gtk_widget_set_vexpand(notebook, TRUE);
    gtk_widget_set_hexpand(notebook, TRUE);

    gtk_notebook_append_page(GTK_NOTEBOOK(notebook), gtk_label_new("Page 1 content goes here"), gtk_label_new("Page 1")); 
    gtk_notebook_append_page(GTK_NOTEBOOK(notebook), gtk_label_new("Page 2 content goes here"), gtk_label_new("Page 2")); 
    gtk_box_append(GTK_BOX(box), notebook);

    gtk_window_set_child(GTK_WINDOW(window), box); 

    // Header bar.
    GtkWidget* header_bar = gtk_header_bar_new();
    gtk_header_bar_set_title_widget(GTK_HEADER_BAR(header_bar), gtk_label_new("Terminal"));

    GtkWidget* button = gtk_button_new_with_label("New Tab");
    g_signal_connect(button, "clicked", G_CALLBACK(_on_new_tab_clicked), notebook);
    gtk_header_bar_pack_end(GTK_HEADER_BAR(header_bar), button);

    gtk_window_set_titlebar(GTK_WINDOW(window), header_bar);

    gtk_window_present(GTK_WINDOW(window));
}

int main(int argc, char** argv) {
    GtkApplication* app = gtk_application_new("dev.marichi.terminal", G_APPLICATION_DEFAULT_FLAGS);
    g_signal_connect(app, "activate", G_CALLBACK(activate), NULL);
    int status = g_application_run(G_APPLICATION(app), argc, argv);
    g_object_unref(app);

    return status;
}

Terminal start with the default shell and with few other things (theme, font, etc). We can get this info via calling g_get_environ() function.

#include <gtk/gtk.h>
#include <vte/vte.h>

static GtkWidget* create_terminal() {
    VteTerminal* terminal = VTE_TERMINAL(vte_terminal_new());

    char** env = g_get_environ();
}

static void _on_new_tab_clicked(GtkWidget* widget, gpointer data) {
    GtkNotebook* notebook = GTK_NOTEBOOK(data);

    int pages = gtk_notebook_get_n_pages(notebook);

    char title[32];
    g_snprintf(title, sizeof(title), "Tab %d", pages + 1);

    int new_page = gtk_notebook_append_page(notebook, gtk_label_new("New tab content goes here"), gtk_label_new(title)); 

    gtk_notebook_set_current_page(notebook, new_page);
} 

static void activate(GtkApplication* app, gpointer data) {
    GtkWidget* window = gtk_application_window_new(app);
    gtk_window_set_title(GTK_WINDOW(window), "Terminal");
    gtk_window_set_default_size(GTK_WINDOW(window), 600, 800);

    // Container.
    GtkWidget* box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);

    // Notebook.
    GtkWidget* notebook = gtk_notebook_new();
    gtk_widget_set_vexpand(notebook, TRUE);
    gtk_widget_set_hexpand(notebook, TRUE);

    gtk_notebook_append_page(GTK_NOTEBOOK(notebook), gtk_label_new("Page 1 content goes here"), gtk_label_new("Page 1")); 
    gtk_notebook_append_page(GTK_NOTEBOOK(notebook), gtk_label_new("Page 2 content goes here"), gtk_label_new("Page 2")); 
    gtk_box_append(GTK_BOX(box), notebook);

    gtk_window_set_child(GTK_WINDOW(window), box); 

    // Header bar.
    GtkWidget* header_bar = gtk_header_bar_new();
    gtk_header_bar_set_title_widget(GTK_HEADER_BAR(header_bar), gtk_label_new("Terminal"));

    GtkWidget* button = gtk_button_new_with_label("New Tab");
    g_signal_connect(button, "clicked", G_CALLBACK(_on_new_tab_clicked), notebook);
    gtk_header_bar_pack_end(GTK_HEADER_BAR(header_bar), button);

    gtk_window_set_titlebar(GTK_WINDOW(window), header_bar);

    gtk_window_present(GTK_WINDOW(window));
}

int main(int argc, char** argv) {
    GtkApplication* app = gtk_application_new("dev.marichi.terminal", G_APPLICATION_DEFAULT_FLAGS);
    g_signal_connect(app, "activate", G_CALLBACK(activate), NULL);
    int status = g_application_run(G_APPLICATION(app), argc, argv);
    g_object_unref(app);

    return status;
}

From this env, get the default shell by calling g_environ_getenv() function and telling that we're interested in fetching the SHELL value, and if there is no, then default to Bash.

#include <gtk/gtk.h>
#include <vte/vte.h>

static GtkWidget* create_terminal() {
    VteTerminal* terminal = VTE_TERMINAL(vte_terminal_new());

    char** env = g_get_environ();

    const char* shell = g_environ_getenv(env, "SHELL");
    if (!shell) {
        shell = "/bin/bash";
    }
}

static void _on_new_tab_clicked(GtkWidget* widget, gpointer data) {
    GtkNotebook* notebook = GTK_NOTEBOOK(data);

    int pages = gtk_notebook_get_n_pages(notebook);

    char title[32];
    g_snprintf(title, sizeof(title), "Tab %d", pages + 1);

    int new_page = gtk_notebook_append_page(notebook, gtk_label_new("New tab content goes here"), gtk_label_new(title)); 

    gtk_notebook_set_current_page(notebook, new_page);
} 

static void activate(GtkApplication* app, gpointer data) {
    GtkWidget* window = gtk_application_window_new(app);
    gtk_window_set_title(GTK_WINDOW(window), "Terminal");
    gtk_window_set_default_size(GTK_WINDOW(window), 600, 800);

    // Container.
    GtkWidget* box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);

    // Notebook.
    GtkWidget* notebook = gtk_notebook_new();
    gtk_widget_set_vexpand(notebook, TRUE);
    gtk_widget_set_hexpand(notebook, TRUE);

    gtk_notebook_append_page(GTK_NOTEBOOK(notebook), gtk_label_new("Page 1 content goes here"), gtk_label_new("Page 1")); 
    gtk_notebook_append_page(GTK_NOTEBOOK(notebook), gtk_label_new("Page 2 content goes here"), gtk_label_new("Page 2")); 
    gtk_box_append(GTK_BOX(box), notebook);

    gtk_window_set_child(GTK_WINDOW(window), box); 

    // Header bar.
    GtkWidget* header_bar = gtk_header_bar_new();
    gtk_header_bar_set_title_widget(GTK_HEADER_BAR(header_bar), gtk_label_new("Terminal"));

    GtkWidget* button = gtk_button_new_with_label("New Tab");
    g_signal_connect(button, "clicked", G_CALLBACK(_on_new_tab_clicked), notebook);
    gtk_header_bar_pack_end(GTK_HEADER_BAR(header_bar), button);

    gtk_window_set_titlebar(GTK_WINDOW(window), header_bar);

    gtk_window_present(GTK_WINDOW(window));
}

int main(int argc, char** argv) {
    GtkApplication* app = gtk_application_new("dev.marichi.terminal", G_APPLICATION_DEFAULT_FLAGS);
    g_signal_connect(app, "activate", G_CALLBACK(activate), NULL);
    int status = g_application_run(G_APPLICATION(app), argc, argv);
    g_object_unref(app);

    return status;
}

We have all the needed info (surprise!). We are ready to spawn the terminal process using vte_terminal_spawn_async() function, It accept so many arguments (13) that I'll only comments for the important one and leave on you to explore further.

#include <gtk/gtk.h>
#include <vte/vte.h>

static GtkWidget* create_terminal() {
    VteTerminal* terminal = VTE_TERMINAL(vte_terminal_new());

    char** env = g_get_environ();

    const char* shell = g_environ_getenv(env, "SHELL");
    if (!shell) {
        shell = "/bin/bash";
    }

    vte_terminal_spawn_async(
        terminal,
        VTE_PTY_DEFAULT,
        NULL, // working directory, NULL being the current one.
        (char* []){ (char *)shell, NULL },
        env,
        G_SPAWN_DEFAULT,
        NULL, NULL,
        NULL,
        -1,
        NULL,
        NULL,
        NULL // any user data we want to pass.
    );
}

static void _on_new_tab_clicked(GtkWidget* widget, gpointer data) {
    GtkNotebook* notebook = GTK_NOTEBOOK(data);

    int pages = gtk_notebook_get_n_pages(notebook);

    char title[32];
    g_snprintf(title, sizeof(title), "Tab %d", pages + 1);

    int new_page = gtk_notebook_append_page(notebook, gtk_label_new("New tab content goes here"), gtk_label_new(title)); 

    gtk_notebook_set_current_page(notebook, new_page);
} 

static void activate(GtkApplication* app, gpointer data) {
    GtkWidget* window = gtk_application_window_new(app);
    gtk_window_set_title(GTK_WINDOW(window), "Terminal");
    gtk_window_set_default_size(GTK_WINDOW(window), 600, 800);

    // Container.
    GtkWidget* box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);

    // Notebook.
    GtkWidget* notebook = gtk_notebook_new();
    gtk_widget_set_vexpand(notebook, TRUE);
    gtk_widget_set_hexpand(notebook, TRUE);

    gtk_notebook_append_page(GTK_NOTEBOOK(notebook), gtk_label_new("Page 1 content goes here"), gtk_label_new("Page 1")); 
    gtk_notebook_append_page(GTK_NOTEBOOK(notebook), gtk_label_new("Page 2 content goes here"), gtk_label_new("Page 2")); 
    gtk_box_append(GTK_BOX(box), notebook);

    gtk_window_set_child(GTK_WINDOW(window), box); 

    // Header bar.
    GtkWidget* header_bar = gtk_header_bar_new();
    gtk_header_bar_set_title_widget(GTK_HEADER_BAR(header_bar), gtk_label_new("Terminal"));

    GtkWidget* button = gtk_button_new_with_label("New Tab");
    g_signal_connect(button, "clicked", G_CALLBACK(_on_new_tab_clicked), notebook);
    gtk_header_bar_pack_end(GTK_HEADER_BAR(header_bar), button);

    gtk_window_set_titlebar(GTK_WINDOW(window), header_bar);

    gtk_window_present(GTK_WINDOW(window));
}

int main(int argc, char** argv) {
    GtkApplication* app = gtk_application_new("dev.marichi.terminal", G_APPLICATION_DEFAULT_FLAGS);
    g_signal_connect(app, "activate", G_CALLBACK(activate), NULL);
    int status = g_application_run(G_APPLICATION(app), argc, argv);
    g_object_unref(app);

    return status;
}

This create a terminal process and attach it to the terminal widget with given shell.

Finally, we need to free the memory that was allocated to env using g_strfreev() function and return a terminal widget as GtkWidget*. We need to cast the terminal using GTK_WIDGET() macro as it is type of VteTerminal*.

#include <gtk/gtk.h>
#include <vte/vte.h>

static GtkWidget* create_terminal() {
    VteTerminal* terminal = VTE_TERMINAL(vte_terminal_new());

    char** env = g_get_environ();

    const char* shell = g_environ_getenv(env, "SHELL");
    if (!shell) {
        shell = "/bin/bash";
    }

    vte_terminal_spawn_async( 
        terminal,
        VTE_PTY_DEFAULT,
        NULL, // working directory, NULL being the current one.
        (char* []){ (char *)shell, NULL },
        env,
        G_SPAWN_DEFAULT,
        NULL, NULL,
        NULL,
        -1,
        NULL,
        NULL,
        NULL // any user data we want to pass.
    );

    g_strfreev(env);

    return GTK_WIDGET(terminal);
}

static void _on_new_tab_clicked(GtkWidget* widget, gpointer data) {
    GtkNotebook* notebook = GTK_NOTEBOOK(data);

    int pages = gtk_notebook_get_n_pages(notebook);

    char title[32];
    g_snprintf(title, sizeof(title), "Tab %d", pages + 1);

    int new_page = gtk_notebook_append_page(notebook, gtk_label_new("New tab content goes here"), gtk_label_new(title)); 

    gtk_notebook_set_current_page(notebook, new_page);
} 

static void activate(GtkApplication* app, gpointer data) {
    GtkWidget* window = gtk_application_window_new(app);
    gtk_window_set_title(GTK_WINDOW(window), "Terminal");
    gtk_window_set_default_size(GTK_WINDOW(window), 600, 800);

    // Container.
    GtkWidget* box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);

    // Notebook.
    GtkWidget* notebook = gtk_notebook_new();
    gtk_widget_set_vexpand(notebook, TRUE);
    gtk_widget_set_hexpand(notebook, TRUE);

    gtk_notebook_append_page(GTK_NOTEBOOK(notebook), gtk_label_new("Page 1 content goes here"), gtk_label_new("Page 1")); 
    gtk_notebook_append_page(GTK_NOTEBOOK(notebook), gtk_label_new("Page 2 content goes here"), gtk_label_new("Page 2")); 
    gtk_box_append(GTK_BOX(box), notebook);

    gtk_window_set_child(GTK_WINDOW(window), box); 

    // Header bar.
    GtkWidget* header_bar = gtk_header_bar_new();
    gtk_header_bar_set_title_widget(GTK_HEADER_BAR(header_bar), gtk_label_new("Terminal"));

    GtkWidget* button = gtk_button_new_with_label("New Tab");
    g_signal_connect(button, "clicked", G_CALLBACK(_on_new_tab_clicked), notebook);
    gtk_header_bar_pack_end(GTK_HEADER_BAR(header_bar), button);

    gtk_window_set_titlebar(GTK_WINDOW(window), header_bar);

    gtk_window_present(GTK_WINDOW(window));
}

int main(int argc, char** argv) {
    GtkApplication* app = gtk_application_new("dev.marichi.terminal", G_APPLICATION_DEFAULT_FLAGS);
    g_signal_connect(app, "activate", G_CALLBACK(activate), NULL);
    int status = g_application_run(G_APPLICATION(app), argc, argv);
    g_object_unref(app);

    return status;
}

With these changes in create_terminal() function, we are now ready to load the terminal. We just need to replace the tab content with this function call at two places - in "Tab 1", and in _on_new_tab_clicked() function. We can remove the "Tab 2" as we don't need it for now.

#include <gtk/gtk.h>
#include <vte/vte.h>

static GtkWidget* create_terminal() {
    VteTerminal* terminal = VTE_TERMINAL(vte_terminal_new());

    char** env = g_get_environ();

    const char* shell = g_environ_getenv(env, "SHELL");
    if (!shell) {
        shell = "/bin/bash";
    }

    vte_terminal_spawn_async( 
        terminal,
        VTE_PTY_DEFAULT,
        NULL, // working directory, NULL being the current one.
        (char* []){ (char *)shell, NULL },
        env,
        G_SPAWN_DEFAULT,
        NULL, NULL,
        NULL,
        -1,
        NULL,
        NULL,
        NULL // any user data we want to pass.
    );

    g_strfreev(env);

    return GTK_WIDGET(terminal);
}

static void _on_new_tab_clicked(GtkWidget* widget, gpointer data) {
    GtkNotebook* notebook = GTK_NOTEBOOK(data);

    int pages = gtk_notebook_get_n_pages(notebook);

    char title[32];
    g_snprintf(title, sizeof(title), "Tab %d", pages + 1);

    int new_page = gtk_notebook_append_page(notebook, create_terminal(), gtk_label_new(title));

    gtk_notebook_set_current_page(notebook, new_page);
} 

static void activate(GtkApplication* app, gpointer data) {
    GtkWidget* window = gtk_application_window_new(app);
    gtk_window_set_title(GTK_WINDOW(window), "Terminal");
    gtk_window_set_default_size(GTK_WINDOW(window), 600, 800);

    // Container.
    GtkWidget* box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);

    // Notebook.
    GtkWidget* notebook = gtk_notebook_new();
    gtk_widget_set_vexpand(notebook, TRUE);
    gtk_widget_set_hexpand(notebook, TRUE);

    gtk_notebook_append_page(GTK_NOTEBOOK(notebook), create_terminal(), gtk_label_new("Page 1"));
    gtk_box_append(GTK_BOX(box), notebook);

    gtk_window_set_child(GTK_WINDOW(window), box); 

    // Header bar.
    GtkWidget* header_bar = gtk_header_bar_new();
    gtk_header_bar_set_title_widget(GTK_HEADER_BAR(header_bar), gtk_label_new("Terminal"));

    GtkWidget* button = gtk_button_new_with_label("New Tab");
    g_signal_connect(button, "clicked", G_CALLBACK(_on_new_tab_clicked), notebook);
    gtk_header_bar_pack_end(GTK_HEADER_BAR(header_bar), button);

    gtk_window_set_titlebar(GTK_WINDOW(window), header_bar);

    gtk_window_present(GTK_WINDOW(window));
}

int main(int argc, char** argv) {
    GtkApplication* app = gtk_application_new("dev.marichi.terminal", G_APPLICATION_DEFAULT_FLAGS);
    g_signal_connect(app, "activate", G_CALLBACK(activate), NULL);
    int status = g_application_run(G_APPLICATION(app), argc, argv);
    g_object_unref(app);

    return status;
}

Let's run the program and confirm the output. We need to adjust the command as we are now using one more library - VTE - which we need to include in compilation process.

gcc temrinal-4.c `pkg-config --cflags --libs gtk4 vte-2.91-gtk4` && ./a.out

You should see the output like this. Clicking on "New Tab" button should add a new tab with terminal.

Yay!

Let's Build the Terminal Pt. 2

April 25, 2026

This is the second article in the series of the articles where we are going to build the terminal in C using GTK4 and VTE. The series is divided into three articles.

  1. Let's Build the Terminal Pt. 1
  2. Let's Build the Terminal Pt. 2
  3. Let's Build the Terminal Pt. 3

tl;dc: Refer this gist to get the final version of terminal.c code.

---

Let's continue from where we left off in the last article. Following is the code we have in terminal.c file.

#include <gtk/gtk.h> 

static void activate(GtkApplication* app, gpointer data) { 
    GtkWidget* window = gtk_application_window_new(app); 
    gtk_window_set_title(GTK_WINDOW(window), "Terminal"); 
    gtk_window_set_default_size(GTK_WINDOW(window), 600, 800); 

    gtk_window_present(GTK_WINDOW(window)); 
} 

int main(int argc, char** argv) {
    GtkApplication* app = gtk_application_new("dev.marichi.terminal", G_APPLICATION_DEFAULT_FLAGS);
    g_signal_connect(app, "activate", G_CALLBACK(activate), NULL);
    int status = g_application_run(G_APPLICATION(app), argc, argv);
    g_object_unref(app); 

    return status;
}

And we know the command to compile and run the program.

gcc terminal.c `pkg-config --cflags --libs gtk4` && ./a.out

We will continue from here and let's start by adding a header bar using gtk_header_bar_new() function.

#include <gtk/gtk.h>  

static void activate(GtkApplication* app, gpointer data) {
    GtkWidget* window = gtk_application_window_new(app);
    gtk_window_set_title(GTK_WINDOW(window), "Terminal");
    gtk_window_set_default_size(GTK_WINDOW(window), 600, 800);

    // Header bar. 
    GtkWidget* header_bar = gtk_header_bar_new();

    gtk_window_present(GTK_WINDOW(window));
}  

int main(int argc, char** argv) {
    GtkApplication* app = gtk_application_new("dev.marichi.terminal", G_APPLICATION_DEFAULT_FLAGS);
    g_signal_connect(app, "activate", G_CALLBACK(activate), NULL);
    int status = g_application_run(G_APPLICATION(app), argc, argv);
    g_object_unref(app);  

    return status;
}

Let's set the "Terminal" as title of the header bar. We need to pass the "Terminal" as label because gtk_header_bar_set_title_widget() expect it.

#include <gtk/gtk.h>  

static void activate(GtkApplication* app, gpointer data) {
    GtkWidget* window = gtk_application_window_new(app);
    gtk_window_set_title(GTK_WINDOW(window), "Terminal");
    gtk_window_set_default_size(GTK_WINDOW(window), 600, 800);

    // Header bar. 
    GtkWidget* header_bar = gtk_header_bar_new();
    gtk_header_bar_set_title_widget(GTK_HEADER_BAR(header_bar), gtk_label_new("Terminal"));

    gtk_window_present(GTK_WINDOW(window));
}  

int main(int argc, char** argv) {
    GtkApplication* app = gtk_application_new("dev.marichi.terminal", G_APPLICATION_DEFAULT_FLAGS);
    g_signal_connect(app, "activate", G_CALLBACK(activate), NULL);
    int status = g_application_run(G_APPLICATION(app), argc, argv);
    g_object_unref(app);

    return status;
}

The header bar is created. We need to call gtk_window_set_titlebar() function to actually set this header bar as titlebar for the window.

#include <gtk/gtk.h>  

static void activate(GtkApplication* app, gpointer data) {
    GtkWidget* window = gtk_application_window_new(app);
    gtk_window_set_title(GTK_WINDOW(window), "Terminal");
    gtk_window_set_default_size(GTK_WINDOW(window), 600, 800);

    // Header bar.
    GtkWidget* header_bar = gtk_header_bar_new();
    gtk_header_bar_set_title_widget(GTK_HEADER_BAR(header_bar), gtk_label_new("Terminal"));

    gtk_window_set_titlebar(GTK_WINDOW(window), header_bar);

    gtk_window_present(GTK_WINDOW(window));
}  

int main(int argc, char** argv) {
    GtkApplication* app = gtk_application_new("dev.marichi.terminal", G_APPLICATION_DEFAULT_FLAGS);
    g_signal_connect(app, "activate", G_CALLBACK(activate), NULL);
    int status = g_application_run(G_APPLICATION(app), argc, argv);
    g_object_unref(app);

    return status;
}

Run the program using the above command and you should see a titlebar in the window.

We are now going to add a notebook. Notebook help you to have multi-tab terminal. Let's create a notebook using gtk_notebook_new() function. I will write it before the header bar code. I'll tell you the reason why I did this later.

#include <gtk/gtk.h>  

static void activate(GtkApplication* app, gpointer data) {
    GtkWidget* window = gtk_application_window_new(app);
    gtk_window_set_title(GTK_WINDOW(window), "Terminal");
    gtk_window_set_default_size(GTK_WINDOW(window), 600, 800);

    // Notebook.
    GtkWidget* notebook = gtk_notebook_new();

    // Header bar.
    GtkWidget* header_bar = gtk_header_bar_new();
    gtk_header_bar_set_title_widget(GTK_HEADER_BAR(header_bar), gtk_label_new("Terminal")); 

    gtk_window_set_titlebar(GTK_WINDOW(window), header_bar);

    gtk_window_present(GTK_WINDOW(window));
}  

int main(int argc, char** argv) {
    GtkApplication* app = gtk_application_new("dev.marichi.terminal", G_APPLICATION_DEFAULT_FLAGS);
    g_signal_connect(app, "activate", G_CALLBACK(activate), NULL);
    int status = g_application_run(G_APPLICATION(app), argc, argv);
    g_object_unref(app);

    return status;
}

Notebook have pages or tab in our case. I'll use word "tab" or "page" interchangeably. Let's add two pages using gtk_notebook_append_page() function. This function accepts the content of the page and the title of the page.

#include <gtk/gtk.h>  

static void activate(GtkApplication* app, gpointer data) {
    GtkWidget* window = gtk_application_window_new(app);
    gtk_window_set_title(GTK_WINDOW(window), "Terminal");
    gtk_window_set_default_size(GTK_WINDOW(window), 600, 800);

    // Notebook.
    GtkWidget* notebook = gtk_notebook_new();
    gtk_notebook_append_page(GTK_NOTEBOOK(notebook), gtk_label_new("Page 1 content goes here"), gtk_label_new("Page 1"));
    gtk_notebook_append_page(GTK_NOTEBOOK(notebook), gtk_label_new("Page 2 content goes here"), gtk_label_new("Page 2"));

    // Header bar.
    GtkWidget* header_bar = gtk_header_bar_new(); 
    gtk_header_bar_set_title_widget(GTK_HEADER_BAR(header_bar), gtk_label_new("Terminal"));

    gtk_window_set_titlebar(GTK_WINDOW(window), header_bar);

    gtk_window_present(GTK_WINDOW(window));
}  

int main(int argc, char** argv) {
    GtkApplication* app = gtk_application_new("dev.marichi.terminal", G_APPLICATION_DEFAULT_FLAGS);
    g_signal_connect(app, "activate", G_CALLBACK(activate), NULL);
    int status = g_application_run(G_APPLICATION(app), argc, argv);
    g_object_unref(app);

    return status;
}

I'm not going to add this notebook directly in the window. Instead, I'll use vertical box as the container and then add notebook to the box container for better layout management.

#include <gtk/gtk.h>  

static void activate(GtkApplication* app, gpointer data) {
    GtkWidget* window = gtk_application_window_new(app);
    gtk_window_set_title(GTK_WINDOW(window), "Terminal");
    gtk_window_set_default_size(GTK_WINDOW(window), 600, 800);

    // Container.
    GtkWidget* box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);

    // Notebook.
    GtkWidget* notebook = gtk_notebook_new();
    gtk_notebook_append_page(GTK_NOTEBOOK(notebook), gtk_label_new("Page 1 content goes here"), gtk_label_new("Page 1"));
    gtk_notebook_append_page(GTK_NOTEBOOK(notebook), gtk_label_new("Page 2 content goes here"), gtk_label_new("Page 2"));
    gtk_box_append(GTK_BOX(box), notebook);

    gtk_window_set_child(GTK_WINDOW(window), box);

    // Header bar.
    GtkWidget* header_bar = gtk_header_bar_new();
    gtk_header_bar_set_title_widget(GTK_HEADER_BAR(header_bar), gtk_label_new("Terminal"));

    gtk_window_set_titlebar(GTK_WINDOW(window), header_bar); 

    gtk_window_present(GTK_WINDOW(window));
}  

int main(int argc, char** argv) {
    GtkApplication* app = gtk_application_new("dev.marichi.terminal", G_APPLICATION_DEFAULT_FLAGS);
    g_signal_connect(app, "activate", G_CALLBACK(activate), NULL);
    int status = g_application_run(G_APPLICATION(app), argc, argv);
    g_object_unref(app);

    return status;
}

Run the program to confirm the output. This is how it looks on my computer. Notice, the notebook is not taking all the available space.

We can fix this issue by expanding on both vertical and horizontal sides using gtk_widget_set_vexpand() and gtk_widget_set_hexpand() functions.

#include <gtk/gtk.h>  

static void activate(GtkApplication* app, gpointer data) {
    GtkWidget* window = gtk_application_window_new(app);
    gtk_window_set_title(GTK_WINDOW(window), "Terminal");
    gtk_window_set_default_size(GTK_WINDOW(window), 600, 800);

    // Container.
    GtkWidget* box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);

    // Notebook.
    GtkWidget* notebook = gtk_notebook_new(); 

    gtk_widget_set_vexpand(notebook, TRUE);
    gtk_widget_set_hexpand(notebook, TRUE);

    gtk_notebook_append_page(GTK_NOTEBOOK(notebook), gtk_label_new("Page 1 content goes here"), gtk_label_new("Page 1"));
    gtk_notebook_append_page(GTK_NOTEBOOK(notebook), gtk_label_new("Page 2 content goes here"), gtk_label_new("Page 2"));
    gtk_box_append(GTK_BOX(box), notebook);

    gtk_window_set_child(GTK_WINDOW(window), box);

    // Header bar.
    GtkWidget* header_bar = gtk_header_bar_new();
    gtk_header_bar_set_title_widget(GTK_HEADER_BAR(header_bar), gtk_label_new("Terminal"));

    gtk_window_set_titlebar(GTK_WINDOW(window), header_bar); 

    gtk_window_present(GTK_WINDOW(window));
}  

int main(int argc, char** argv) {
    GtkApplication* app = gtk_application_new("dev.marichi.terminal", G_APPLICATION_DEFAULT_FLAGS);
    g_signal_connect(app, "activate", G_CALLBACK(activate), NULL);
    int status = g_application_run(G_APPLICATION(app), argc, argv);
    g_object_unref(app);

    return status;
}

Let's make things more interesting by adding a button in header bar on which if we click, it'll add a new tab in notebook. We'll do this in steps.

First, let's add a button to the header bar by creating a button using gtk_button_new_with_label() function and then packing the button to the end in header bar using gtk_header_bar_pack_end() function.

#include <gtk/gtk.h>  

static void activate(GtkApplication* app, gpointer data) {
    GtkWidget* window = gtk_application_window_new(app);
    gtk_window_set_title(GTK_WINDOW(window), "Terminal");
    gtk_window_set_default_size(GTK_WINDOW(window), 600, 800);

    // Container.
    GtkWidget* box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);

    // Notebook.
    GtkWidget* notebook = gtk_notebook_new();

    gtk_widget_set_vexpand(notebook, TRUE);
    gtk_widget_set_hexpand(notebook, TRUE);

    gtk_notebook_append_page(GTK_NOTEBOOK(notebook), gtk_label_new("Page 1 content goes here"), gtk_label_new("Page 1"));
    gtk_notebook_append_page(GTK_NOTEBOOK(notebook), gtk_label_new("Page 2 content goes here"), gtk_label_new("Page 2")); 
    gtk_box_append(GTK_BOX(box), notebook);

    gtk_window_set_child(GTK_WINDOW(window), box);

    // Header bar.
    GtkWidget* header_bar = gtk_header_bar_new();
    gtk_header_bar_set_title_widget(GTK_HEADER_BAR(header_bar), gtk_label_new("Terminal")); 

    // Button.
    GtkWidget* button = gtk_button_new_with_label("New Tab");
    gtk_header_bar_pack_end(GTK_HEADER_BAR(header_bar), button);

    gtk_window_set_titlebar(GTK_WINDOW(window), header_bar);

    gtk_window_present(GTK_WINDOW(window));
}  

int main(int argc, char** argv) {
    GtkApplication* app = gtk_application_new("dev.marichi.terminal", G_APPLICATION_DEFAULT_FLAGS);
    g_signal_connect(app, "activate", G_CALLBACK(activate), NULL);
    int status = g_application_run(G_APPLICATION(app), argc, argv);
    g_object_unref(app);

    return status;
}

Let's attach the "clicked" signal to this button and write the stub callback function. We need to pass the notebook instance as the user data as we need to append the new tab to the notebook when clicked. As notebook is created before the header bar, we can directly pass it as user data. Now, you know the reason why I write the Notebook code before the header bar (Yes, this is quick fix but not a hack or anything)!

#include <gtk/gtk.h>  

static void _on_new_tab_clicked(GtkWidget* widget, gpointer data) {

}

static void activate(GtkApplication* app, gpointer data) {
    GtkWidget* window = gtk_application_window_new(app);
    gtk_window_set_title(GTK_WINDOW(window), "Terminal");
    gtk_window_set_default_size(GTK_WINDOW(window), 600, 800);

    // Container.
    GtkWidget* box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);

    // Notebook.
    GtkWidget* notebook = gtk_notebook_new();
    gtk_widget_set_vexpand(notebook, TRUE);
    gtk_widget_set_hexpand(notebook, TRUE);

    gtk_notebook_append_page(GTK_NOTEBOOK(notebook), gtk_label_new("Page 1 content goes here"), gtk_label_new("Page 1"));
    gtk_notebook_append_page(GTK_NOTEBOOK(notebook), gtk_label_new("Page 2 content goes here"), gtk_label_new("Page 2"));
    gtk_box_append(GTK_BOX(box), notebook);

    gtk_window_set_child(GTK_WINDOW(window), box);

    // Header bar.
    GtkWidget* header_bar = gtk_header_bar_new();
    gtk_header_bar_set_title_widget(GTK_HEADER_BAR(header_bar), gtk_label_new("Terminal"));

    // Button.
    GtkWidget* button = gtk_button_new_with_label("New Tab");
    g_signal_connect(button, "clicked", G_CALLBACK(_on_new_tab_clicked), notebook);
    gtk_header_bar_pack_end(GTK_HEADER_BAR(header_bar), button);

    gtk_window_set_titlebar(GTK_WINDOW(window), header_bar);

    gtk_window_present(GTK_WINDOW(window));
}  

int main(int argc, char** argv) {
    GtkApplication* app = gtk_application_new("dev.marichi.terminal", G_APPLICATION_DEFAULT_FLAGS);
    g_signal_connect(app, "activate", G_CALLBACK(activate), NULL);
    int status = g_application_run(G_APPLICATION(app), argc, argv);
    g_object_unref(app);

    return status;
}

In the callback function, let's first cast the data to notebook type using GTK_NOTEBOOK() macro and then call the gtk_notebook_get_n_pages() function. This function will return total number of pages or tabs exists in given notebook. The reason to plan this function is to give the tab name like "Tab 3", "Tab 4", "Tab 5", .... e.g. increment the number by one and prefix the "Tab " string when clicked on "New Tab" button.

#include <gtk/gtk.h>  

static void _on_new_tab_clicked(GtkWidget* widget, gpointer data) {
    GtkNotebook* notebook = GTK_NOTEBOOK(data);

    int pages = gtk_notebook_get_n_pages(notebook);
} 

static void activate(GtkApplication* app, gpointer data) {
    GtkWidget* window = gtk_application_window_new(app);
    gtk_window_set_title(GTK_WINDOW(window), "Terminal");
    gtk_window_set_default_size(GTK_WINDOW(window), 600, 800);

    // Container.
    GtkWidget* box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);

    // Notebook.
    GtkWidget* notebook = gtk_notebook_new();
    gtk_widget_set_vexpand(notebook, TRUE);
    gtk_widget_set_hexpand(notebook, TRUE);

    gtk_notebook_append_page(GTK_NOTEBOOK(notebook), gtk_label_new("Page 1 content goes here"), gtk_label_new("Page 1")); 
    gtk_notebook_append_page(GTK_NOTEBOOK(notebook), gtk_label_new("Page 2 content goes here"), gtk_label_new("Page 2")); 
    gtk_box_append(GTK_BOX(box), notebook);

    gtk_window_set_child(GTK_WINDOW(window), box); 

    // Header bar.
    GtkWidget* header_bar = gtk_header_bar_new();
    gtk_header_bar_set_title_widget(GTK_HEADER_BAR(header_bar), gtk_label_new("Terminal"));

    GtkWidget* button = gtk_button_new_with_label("New Tab");
    g_signal_connect(button, "clicked", G_CALLBACK(_on_new_tab_clicked), notebook);
    gtk_header_bar_pack_end(GTK_HEADER_BAR(header_bar), button);

    gtk_window_set_titlebar(GTK_WINDOW(window), header_bar);

    gtk_window_present(GTK_WINDOW(window));
}

int main(int argc, char** argv) {
    GtkApplication* app = gtk_application_new("dev.marichi.terminal", G_APPLICATION_DEFAULT_FLAGS);
    g_signal_connect(app, "activate", G_CALLBACK(activate), NULL);
    int status = g_application_run(G_APPLICATION(app), argc, argv);
    g_object_unref(app);

    return status;
}

Let's create a title in plain C code.

#include <gtk/gtk.h>  

static void _on_new_tab_clicked(GtkWidget* widget, gpointer data) {
    GtkNotebook* notebook = GTK_NOTEBOOK(data);

    int pages = gtk_notebook_get_n_pages(notebook);

    char title[32];
    g_snprintf(title, sizeof(title), "Tab %d", pages + 1);
} 

static void activate(GtkApplication* app, gpointer data) {
    GtkWidget* window = gtk_application_window_new(app);
    gtk_window_set_title(GTK_WINDOW(window), "Terminal");
    gtk_window_set_default_size(GTK_WINDOW(window), 600, 800);

    // Container.
    GtkWidget* box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);

    // Notebook.
    GtkWidget* notebook = gtk_notebook_new();
    gtk_widget_set_vexpand(notebook, TRUE);
    gtk_widget_set_hexpand(notebook, TRUE);

    gtk_notebook_append_page(GTK_NOTEBOOK(notebook), gtk_label_new("Page 1 content goes here"), gtk_label_new("Page 1")); 
    gtk_notebook_append_page(GTK_NOTEBOOK(notebook), gtk_label_new("Page 2 content goes here"), gtk_label_new("Page 2")); 
    gtk_box_append(GTK_BOX(box), notebook);

    gtk_window_set_child(GTK_WINDOW(window), box); 

    // Header bar.
    GtkWidget* header_bar = gtk_header_bar_new();
    gtk_header_bar_set_title_widget(GTK_HEADER_BAR(header_bar), gtk_label_new("Terminal"));

    GtkWidget* button = gtk_button_new_with_label("New Tab");
    g_signal_connect(button, "clicked", G_CALLBACK(_on_new_tab_clicked), notebook);
    gtk_header_bar_pack_end(GTK_HEADER_BAR(header_bar), button);

    gtk_window_set_titlebar(GTK_WINDOW(window), header_bar);

    gtk_window_present(GTK_WINDOW(window));
}

int main(int argc, char** argv) {
    GtkApplication* app = gtk_application_new("dev.marichi.terminal", G_APPLICATION_DEFAULT_FLAGS);
    g_signal_connect(app, "activate", G_CALLBACK(activate), NULL);
    int status = g_application_run(G_APPLICATION(app), argc, argv);
    g_object_unref(app);

    return status;
}

You can argue against this code as it is not future proof if someone create X number of tabs that overflow this size. Again, I'm not worry about such a scenario as one need to create many many tabs to overflow this limit and we can always fix such type of issues later.

Let's call gtk_notebook_append_page() to add a new tab with this title and dummy content. This function returns a page number that we need to focus using gtk_notebook_set_current_page() function.

#include <gtk/gtk.h>  

static void _on_new_tab_clicked(GtkWidget* widget, gpointer data) {
    GtkNotebook* notebook = GTK_NOTEBOOK(data);

    int pages = gtk_notebook_get_n_pages(notebook);

    char title[32];
    g_snprintf(title, sizeof(title), "Tab %d", pages + 1);

    int new_page = gtk_notebook_append_page(notebook, gtk_label_new("New tab content goes here"), gtk_label_new(title));

    gtk_notebook_set_current_page(notebook, new_page);
} 

static void activate(GtkApplication* app, gpointer data) {
    GtkWidget* window = gtk_application_window_new(app);
    gtk_window_set_title(GTK_WINDOW(window), "Terminal");
    gtk_window_set_default_size(GTK_WINDOW(window), 600, 800);

    // Container.
    GtkWidget* box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);

    // Notebook.
    GtkWidget* notebook = gtk_notebook_new();
    gtk_widget_set_vexpand(notebook, TRUE);
    gtk_widget_set_hexpand(notebook, TRUE);

    gtk_notebook_append_page(GTK_NOTEBOOK(notebook), gtk_label_new("Page 1 content goes here"), gtk_label_new("Page 1")); 
    gtk_notebook_append_page(GTK_NOTEBOOK(notebook), gtk_label_new("Page 2 content goes here"), gtk_label_new("Page 2")); 
    gtk_box_append(GTK_BOX(box), notebook);

    gtk_window_set_child(GTK_WINDOW(window), box); 

    // Header bar.
    GtkWidget* header_bar = gtk_header_bar_new();
    gtk_header_bar_set_title_widget(GTK_HEADER_BAR(header_bar), gtk_label_new("Terminal"));

    GtkWidget* button = gtk_button_new_with_label("New Tab");
    g_signal_connect(button, "clicked", G_CALLBACK(_on_new_tab_clicked), notebook);
    gtk_header_bar_pack_end(GTK_HEADER_BAR(header_bar), button);

    gtk_window_set_titlebar(GTK_WINDOW(window), header_bar);

    gtk_window_present(GTK_WINDOW(window));
}

int main(int argc, char** argv) {
    GtkApplication* app = gtk_application_new("dev.marichi.terminal", G_APPLICATION_DEFAULT_FLAGS);
    g_signal_connect(app, "activate", G_CALLBACK(activate), NULL);
    int status = g_application_run(G_APPLICATION(app), argc, argv);
    g_object_unref(app);

    return status;
}

Go ahead and run this program. You should see the following output when you click on 'New Tab' button.

Yay!