Creating a custom GTK widget from scratch
I’ll demonstrate how to create a custom GTK 4 widget in C, using GObject, custom rendering, properties, signals, and mouse interaction.
We’ll build a reusable MyGauge widget that displays a circular progress indicator. Unlike a widget assembled from existing GTK controls, this will be a new widget type derived directly from GtkWidget.
Illustration: A circular gauge displaying 73% progress, with a green progress arc on a dark background.
1. Understanding GTK’s widget architecture
GTK 4 uses the GObject type system. Every GTK widget ultimately inherits from GObject.
Our inheritance hierarchy will be:
GObject
↓
GtkWidget
↓
MyGauge
To implement a custom widget, we need to understand several important GTK concepts:
| Concept | Purpose |
|---|---|
GObject |
Type system, properties, signals, object lifecycle |
GtkWidget |
Base class for visual widgets |
GtkWidgetClass |
Virtual methods for rendering and sizing |
GtkSnapshot |
GTK 4 rendering system |
GtkEventController |
Handles user input |
gtk_widget_queue_draw() |
Requests a new render |
gtk_widget_queue_resize() |
Requests a new size calculation |
2. Project structure
We’ll create three files:
my-gtk-project/
├── main.c
├── my-gauge.c
└── my-gauge.h
3. Define the custom widget header
The header defines our public widget type and API.
my-gauge.h
#ifndef MY_GAUGE_H
#define MY_GAUGE_H
#include <gtk/gtk.h>
G_BEGIN_DECLS
#define MY_TYPE_GAUGE (my_gauge_get_type())
G_DECLARE_FINAL_TYPE(
MyGauge,
my_gauge,
MY,
GAUGE,
GtkWidget
)
GtkWidget *my_gauge_new(void);
void my_gauge_set_value(MyGauge *self, double value);
double my_gauge_get_value(MyGauge *self);
G_END_DECLS
#endif
Explanation
G_DECLARE_FINAL_TYPE() declares a new GObject type.
It automatically generates several declarations, including:
MyGaugeandMyGaugeClasstypes.my_gauge_get_type().MY_GAUGE()for casting.MY_IS_GAUGE()for runtime type checking.
The FINAL designation means other classes cannot inherit from MyGauge. If you want to create a widget that other developers can subclass, use G_DECLARE_DERIVABLE_TYPE() instead.
The public API contains three functions: a constructor, a setter and a getter.
4. Implement the widget
my-gauge.c
#include "my-gauge.h"
#include <math.h>
struct _MyGauge {
GtkWidget parent_instance;
double value;
};
G_DEFINE_TYPE(MyGauge, my_gauge, GTK_TYPE_WIDGET)
enum {
PROP_0,
PROP_VALUE,
N_PROPERTIES
};
static GParamSpec *properties[N_PROPERTIES];
/* Render the widget */
static void
my_gauge_snapshot(GtkWidget *widget, GtkSnapshot *snapshot)
{
MyGauge *self = MY_GAUGE(widget);
int width = gtk_widget_get_width(widget);
int height = gtk_widget_get_height(widget);
double cx = width / 2.0;
double cy = height / 2.0;
double radius = MIN(width, height) * 0.35;
graphene_rect_t bounds = GRAPHENE_RECT_INIT(
0, 0, width, height
);
cairo_t *cr = gtk_snapshot_append_cairo(
snapshot, &bounds
);
/* Background circle */
cairo_set_source_rgb(cr, 0.25, 0.28, 0.32);
cairo_set_line_width(cr, 12);
cairo_arc(cr, cx, cy, radius, 0, 2 * G_PI);
cairo_stroke(cr);
/* Progress arc */
cairo_set_source_rgb(cr, 0.25, 0.75, 0.55);
cairo_set_line_width(cr, 12);
cairo_set_line_cap(cr, CAIRO_LINE_CAP_ROUND);
double start = -G_PI / 2;
double end = start + 2 * G_PI * self->value / 100.0;
if (self->value > 0) {
cairo_arc(cr, cx, cy, radius, start, end);
cairo_stroke(cr);
}
/* Percentage text */
char text[32];
g_snprintf(text, sizeof(text), "%.0f%%", self->value);
cairo_set_source_rgb(cr, 1, 1, 1);
cairo_select_font_face(
cr, "Sans",
CAIRO_FONT_SLANT_NORMAL,
CAIRO_FONT_WEIGHT_BOLD
);
cairo_set_font_size(cr, 28);
cairo_text_extents_t extents;
cairo_text_extents(cr, text, &extents);
cairo_move_to(
cr,
cx - (extents.width / 2 + extents.x_bearing),
cy - (extents.height / 2 + extents.y_bearing)
);
cairo_show_text(cr, text);
cairo_destroy(cr);
}
/* Preferred widget size */
static void
my_gauge_measure(
GtkWidget *widget,
GtkOrientation orientation,
int for_size,
int *minimum,
int *natural,
int *minimum_baseline,
int *natural_baseline
)
{
*minimum = 100;
*natural = 200;
if (minimum_baseline)
*minimum_baseline = -1;
if (natural_baseline)
*natural_baseline = -1;
}
/* GObject property getter */
static void
my_gauge_get_property(
GObject *object,
guint property_id,
GValue *value,
GParamSpec *pspec
)
{
MyGauge *self = MY_GAUGE(object);
switch (property_id) {
case PROP_VALUE:
g_value_set_double(value, self->value);
break;
default:
G_OBJECT_WARN_INVALID_PROPERTY_ID(
object, property_id, pspec
);
}
}
/* GObject property setter */
static void
my_gauge_set_property(
GObject *object,
guint property_id,
const GValue *value,
GParamSpec *pspec
)
{
MyGauge *self = MY_GAUGE(object);
switch (property_id) {
case PROP_VALUE:
my_gauge_set_value(
self, g_value_get_double(value)
);
break;
default:
G_OBJECT_WARN_INVALID_PROPERTY_ID(
object, property_id, pspec
);
}
}
/* Register class behavior */
static void
my_gauge_class_init(MyGaugeClass *klass)
{
GtkWidgetClass *widget_class = GTK_WIDGET_CLASS(klass);
GObjectClass *object_class = G_OBJECT_CLASS(klass);
widget_class->snapshot = my_gauge_snapshot;
widget_class->measure = my_gauge_measure;
object_class->get_property = my_gauge_get_property;
object_class->set_property = my_gauge_set_property;
properties[PROP_VALUE] = g_param_spec_double(
"value",
"Value",
"Current gauge value",
0.0,
100.0,
0.0,
G_PARAM_READWRITE | G_PARAM_EXPLICIT_NOTIFY |
G_PARAM_STATIC_STRINGS
);
g_object_class_install_properties(
object_class,
N_PROPERTIES,
properties
);
}
/* Initialize each widget instance */
static void
my_gauge_init(MyGauge *self)
{
self->value = 0.0;
gtk_widget_set_overflow(
GTK_WIDGET(self),
GTK_OVERFLOW_HIDDEN
);
}
/* Public constructor */
GtkWidget *
my_gauge_new(void)
{
return g_object_new(MY_TYPE_GAUGE, NULL);
}
/* Public setter */
void
my_gauge_set_value(MyGauge *self, double value)
{
g_return_if_fail(MY_IS_GAUGE(self));
g_return_if_fail(isfinite(value));
value = CLAMP(value, 0.0, 100.0);
if (self->value == value)
return;
self->value = value;
gtk_widget_queue_draw(GTK_WIDGET(self));
g_object_notify_by_pspec(
G_OBJECT(self),
properties[PROP_VALUE]
);
}
/* Public getter */
double
my_gauge_get_value(MyGauge *self)
{
g_return_val_if_fail(MY_IS_GAUGE(self), 0.0);
return self->value;
}
5. How the implementation works
A. Instance structure
struct _MyGauge {
GtkWidget parent_instance;
double value;
};
Each widget instance has its own memory. parent_instance must be the first member so the object layout is compatible with GTK’s inheritance system.
The value field stores the current percentage.
B. Registering the type
G_DEFINE_TYPE(MyGauge, my_gauge, GTK_TYPE_WIDGET)
This implements the type-registration machinery and connects our new class to GtkWidget.
GTK can then recognize MyGauge as a widget.
C. Drawing with snapshot()
GTK 4 uses a snapshot-based rendering architecture.
widget_class->snapshot = my_gauge_snapshot;
When GTK renders the widget, it calls our my_gauge_snapshot() implementation.
The rendering process is:
Widget value changes
↓
gtk_widget_queue_draw()
↓
GTK schedules a render
↓
my_gauge_snapshot()
↓
GTK renders the scene
GTK 4 does not use the old GTK 3 draw() virtual method. The snapshot API builds render nodes that GTK can efficiently render.
Here, gtk_snapshot_append_cairo() creates a Cairo drawing context within the snapshot. Cairo makes it straightforward to draw arcs and text.
For more complex widgets, GTK’s snapshot primitives can be more efficient than using Cairo for everything.
D. Widget sizing
widget_class->measure = my_gauge_measure;
GTK calls measure() to determine the widget’s preferred dimensions.
Our widget reports a minimum of 100 pixels and a natural size of 200 pixels in both orientations.
The parent layout container then decides how much space to allocate.
For more sophisticated layouts, you may also implement size_allocate() or use a custom GtkLayoutManager.
E. Properties
We register a GObject property named value.
This means the widget can be configured through the standard GObject API:
g_object_set(gauge, "value", 75.0, NULL);
And queried with:
double value;
g_object_get(gauge, "value", &value, NULL);
It also enables property binding and change notifications:
g_signal_connect(
gauge,
"notify::value",
G_CALLBACK(on_value_changed),
NULL
);
6. Create the GTK application
Now we can instantiate the widget and display it in a GTK window.
main.c
#include <gtk/gtk.h>
#include "my-gauge.h"
static void
on_slider_changed(GtkRange *range, gpointer user_data)
{
MyGauge *gauge = MY_GAUGE(user_data);
double value = gtk_range_get_value(range);
my_gauge_set_value(gauge, value);
}
static void
activate(GtkApplication *app, gpointer user_data)
{
GtkWidget *window = gtk_application_window_new(app);
gtk_window_set_title(GTK_WINDOW(window), "Custom GTK Widget");
gtk_window_set_default_size(GTK_WINDOW(window), 350, 350);
GtkWidget *box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 15);
gtk_widget_set_margin_top(box, 20);
gtk_widget_set_margin_bottom(box, 20);
gtk_widget_set_margin_start(box, 20);
gtk_widget_set_margin_end(box, 20);
GtkWidget *gauge = my_gauge_new();
my_gauge_set_value(MY_GAUGE(gauge), 73.0);
gtk_widget_set_hexpand(gauge, TRUE);
gtk_widget_set_vexpand(gauge, TRUE);
GtkWidget *slider = gtk_scale_new_with_range(
GTK_ORIENTATION_HORIZONTAL,
0.0, 100.0, 1.0
);
gtk_range_set_value(GTK_RANGE(slider), 73.0);
g_signal_connect(
slider,
"value-changed",
G_CALLBACK(on_slider_changed),
gauge
);
gtk_box_append(GTK_BOX(box), gauge);
gtk_box_append(GTK_BOX(box), slider);
gtk_window_set_child(GTK_WINDOW(window), box);
gtk_window_present(GTK_WINDOW(window));
}
int
main(int argc, char **argv)
{
GtkApplication *app = gtk_application_new(
"com.example.customgauge",
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;
}
7. Compile and run
On Ubuntu or Debian, install the development packages:
sudo apt install build-essential pkg-config libgtk-4-dev
Compile:
gcc main.c my-gauge.c -o my-app \
$(pkg-config --cflags --libs gtk4) -lm
Run:
./my-app
You should see a GTK window containing a circular gauge and a slider. Moving the slider updates the gauge immediately.
8. Add mouse interaction
GTK 4 uses event controllers and gestures rather than the old GTK 3 event signals.
For example, to let users click the gauge to increase its value, add this callback to my-gauge.c:
static void
my_gauge_clicked(
GtkGestureClick *gesture,
int n_press,
double x,
double y,
gpointer user_data
)
{
MyGauge *self = MY_GAUGE(user_data);
double next = self->value + 10.0;
if (next > 100.0)
next = 0.0;
my_gauge_set_value(self, next);
}
Then extend my_gauge_init():
static void
my_gauge_init(MyGauge *self)
{
self->value = 0.0;
gtk_widget_set_overflow(
GTK_WIDGET(self),
GTK_OVERFLOW_HIDDEN
);
GtkGesture *click = gtk_gesture_click_new();
g_signal_connect(
click,
"pressed",
G_CALLBACK(my_gauge_clicked),
self
);
gtk_widget_add_controller(
GTK_WIDGET(self),
GTK_EVENT_CONTROLLER(click)
);
}
Now clicking anywhere inside the widget increases its value by 10%, wrapping back to zero after 100%.
The GtkGestureClick controller belongs to the widget after being added, so you don’t need to unref it manually.
9. Add a custom signal
GObject supports custom signals, which allow other components to respond to widget-specific events.
For example, we can emit value-changed whenever the gauge value changes.
Add a signal enumeration to my-gauge.c:
enum {
SIGNAL_VALUE_CHANGED,
N_SIGNALS
};
static guint signals[N_SIGNALS];
Register the signal in my_gauge_class_init():
signals[SIGNAL_VALUE_CHANGED] = g_signal_new(
"value-changed",
G_TYPE_FROM_CLASS(klass),
G_SIGNAL_RUN_LAST,
0,
NULL,
NULL,
NULL,
G_TYPE_NONE,
1,
G_TYPE_DOUBLE
);
Then add this at the end of my_gauge_set_value():
g_signal_emit(
self,
signals[SIGNAL_VALUE_CHANGED],
0,
self->value
);
Applications can now connect to it:
static void
on_gauge_changed(MyGauge *gauge,
double value,
gpointer user_data)
{
g_print("Gauge value: %.1f\n", value);
}
g_signal_connect(
gauge,
"value-changed",
G_CALLBACK(on_gauge_changed),
NULL
);
The custom signal is useful for communicating meaningful widget events, while the existing notify::value signal supports generic GObject property observation.
10. Important GTK 4 lifecycle methods
When developing more advanced widgets, you’ll encounter additional virtual methods.
| Method | When GTK calls it |
|---|---|
measure() |
When calculating preferred sizes |
size_allocate() |
When allocating the widget’s size |
snapshot() |
When producing render nodes |
root() |
When the widget becomes attached to a GtkRoot |
unroot() |
When detached from its root |
map() |
When becoming mapped for display |
unmap() |
When becoming unmapped |
dispose() |
During object teardown |
finalize() |
During final object destruction |
For instance, if your widget owns dynamically allocated resources, you should release them at the appropriate point in its lifecycle.
11. Further improvements for a production widget
Our example is intentionally small, but a production-quality GTK widget should also consider:
- Accessibility: expose the gauge’s role, value, and description through GTK’s accessibility APIs.
- Keyboard interaction: make the widget focusable and add keyboard controllers if users can change its value.
- CSS integration: use GTK’s styling system and style classes instead of hardcoded colors.
- High-DPI rendering: use GTK’s coordinate and rendering APIs rather than assuming a particular display resolution.
- Animation: use
gtk_widget_add_tick_callback()to animate transitions. - Text rendering: use Pango for properly shaped, localized, and accessible text instead of Cairo’s basic text API.
- Resource management: implement
dispose()orfinalize()if the widget starts owning resources that require cleanup.
12. Choosing the right approach
GTK offers several ways to create custom widgets.
| Approach | Best for |
|---|---|
Subclass GtkWidget |
Completely custom controls and rendering |
Subclass GtkBox |
Composite widgets containing other controls |
Subclass GtkDrawingArea |
Custom graphics without a full widget subclass |
Custom GtkLayoutManager |
Complex child positioning |
GtkWidget with GtkSnapshot |
Specialized, high-performance visual components |
For a truly custom widget, subclassing GtkWidget directly is the most flexible approach. It gives you control over rendering, sizing, properties, signals, interaction, and lifecycle behavior.
The example above provides the foundation for building more sophisticated widgets such as charts, timelines, media controls, custom editors, and graphical dashboards.