Introduction to GTK 4 in C#

Note: this page is a work in progress. I'll try to add more examples when I have time.

Contents

Introduction

GTK is a cross-platform user interface toolkit that first appeared in 1998. GTK is popular in the Linux world, where it forms the foundation of many desktop applications. For example, Ubuntu includes the GNOME desktop, whose applications are written using GTK. GTK was originally a Linux toolkit, but it will also run on macOS and Windows.

In our class we'll use a library called Gir.Core that lets us call GTK 4 from C#, and can be used on any platform (Linux, macOS, Windows).

Hello, GTK

First create a console application:

$ dotnet new console

Now add a reference to the GtkSharp package:

$ dotnet add package GirCore.Gtk-4.0

Now you can write GTK code in your program. Here is a "hello, world" program:

using Gtk;

class MyWindow : ApplicationWindow {
    public MyWindow(Application app) {
        Application = app;
        Title = "hello, world";
        SetDefaultSize(300, 200);
    }
}

class Hello : Application {
    public Hello() : base([]) {
        OnActivate += on_activate;
    }

    void on_activate(Gio.Application app, EventArgs args) {
        MyWindow w = new((Application) app);
        w.Show();
    }

    static void Main(string[] args) {
        new Hello().Run(args.Length, args);
    }
}

The program displays a blank window with title "hello, world".

The program includes a class MyWindow that is a subclass of ApplicationWindow, and a class Hello that is a subclass of Application.

The Main() method creates an instance of Hello, and runs it.

In the Hello() constructor we register an event handler for the Activate event, which will start the application.

The on_activate() handler creates an instance of MyWindow, then calls its Show() method to make it visible.

Drawing with Cairo

Cairo is a library for drawing 2-dimensional graphics. GTK integrates closely with Cairo, and GTK applications commonly use Cairo for visual output.

A DrawingArea is a canvas, i.e. a widget where you can draw any graphics you like using Cairo.

Here is a program that uses Cairo to draw a couple of geometric shapes and some text.

using Cairo;
using Gtk;

class MyWindow : ApplicationWindow {
    public MyWindow(Application app) {
        Application = app;
        Title = "hello, cairo";
        SetDefaultSize(400, 400);

        DrawingArea area = new();
        area.SetDrawFunc(draw);
        Child = area;
    }

    void draw(DrawingArea area, Context c, int width, int height) {
        // draw a triangle
        c.SetSourceRgb(0, 0, 0);    // black
        c.LineWidth = 5;
        c.MoveTo(100, 50);
        c.LineTo(150, 150);
        c.LineTo(50, 150);
        c.ClosePath();
        c.StrokePreserve();     // draw outline, preserving path
        c.SetSourceRgb(0.56, 0.93, 0.56);     // light green
        c.Fill();               // fill the inside

        // draw a circle
        c.SetSourceRgb(0, 0, 1);  // blue
        c.Arc(xc: 300, yc: 100, radius: 50, angle1: 0.0, angle2: 2 * Math.PI);
        c.Fill();

        // draw a rectangle
        c.SetSourceRgb(0, 0, 0);  // black
        c.Rectangle(x: 100, y: 200, width: 200, height: 100);
        c.Stroke();

        // draw text centered in the rectangle
        (int cx, int cy) = (200, 250);  // center of rectangle
        string s = "hello, cairo";
        c.SetFontSize(30);
        c.TextExtents(s, out TextExtents te);
        c.MoveTo(cx - (te.Width / 2 + te.XBearing), cy - (te.Height / 2 + te.YBearing));
        c.ShowText(s);
    }
}

class Hello : Application {
    public Hello() : base([]) {
        OnActivate += on_activate;
    }

    void on_activate(Gio.Application app, EventArgs args) {
        MyWindow w = new((Application) app);
        w.Show();
    }

    static void Main(string[] args) {
        new Hello().Run(args.Length, args);
    }
}

The output looks like this:

Timeouts and animation

The function GLib.Functions.TimeoutAdd() lets you register a timeout handler that will be called at regular intervals. To perform animation, you can register a timeout handler to be called many times per second. In your handler, typically you will update a model, then call QueueDraw() to redraw the view.

The program below displays a player that can move left, move right, and jump. It uses a model-view architecture: the Game class holds the model, which is just the player's current position and velocity. The window class MyWindow handles keyboard input, and has a timeout handler that updates the model and redraws the view.

using Cairo;
using static Gdk.Constants;
using static Gdk.Functions;
using static GLib.Functions;
using Gtk;
using Pixbuf = GdkPixbuf.Pixbuf;

class Game {
    public int player_x = 200, player_y = 400;
    int dy = 0;     // vertical velocity in pixels/tick

    public void tick(bool move_left, bool move_right) {
        if (move_left)
            player_x -= 5;
        else if (move_right)
            player_x += 5;

        player_y += dy;
        if (player_y >= 400) {  // hit the ground
            dy = 0;     // stop falling
            player_y = 400;
        }
        else
            dy += 2;    // accelerate downward
    }

    public void jump() {
        if (player_y == 400 && dy == 0)
            dy = -20;
    }
}

class MyWindow : ApplicationWindow {
    Game game = new();

    DrawingArea area;
    Pixbuf player = Pixbuf.NewFromFile("player.png")!;
    HashSet<uint> keys = [];

    public MyWindow(Application app) {
        Application = app;
        Title = "game";
        SetDefaultSize(600, 600);
        
        area = new();
        area.SetDrawFunc(draw);
        Child = area;

        EventControllerKey key_controller = new();
        key_controller.OnKeyPressed += on_key_pressed;
        key_controller.OnKeyReleased += on_key_released;
        AddController(key_controller);

        TimeoutAdd(0, 30, on_timeout);    // 1 tick per 30 ms
    }

    void draw(DrawingArea area, Context c, int width, int height) {
        c.SetSourceRgb(0, 0, 0);    // black
        c.MoveTo(0, 400);
        c.LineTo(600, 400);
        c.Stroke();

        CairoSetSourcePixbuf(c, player, game.player_x, game.player_y - player.Height);
        c.Paint();
    }

    bool on_key_pressed(EventControllerKey sender, EventControllerKey.KeyPressedSignalArgs args) {
        if (args.Keyval == KEY_space)
            game.jump();
        else
            keys.Add(args.Keyval);
        return true;
    }

    void on_key_released(EventControllerKey sender, EventControllerKey.KeyReleasedSignalArgs args) {
        keys.Remove(args.Keyval);
    }

    bool on_timeout() {
        game.tick(keys.Contains(KEY_Left), keys.Contains(KEY_Right));
        area.QueueDraw();
        return true;
    }
}

class Hello : Application {
    public Hello() : base([]) {
        OnActivate += on_activate;
    }

    void on_activate(Gio.Application app, EventArgs args) {
        MyWindow w = new((Application) app);
        w.Show();
    }

    static void Main(string[] args) {
        new Hello().Run(args.Length, args);
    }
}

Here is a screenshot of the player jumping:

Containers

using Gtk;
using static Gtk.Orientation;

class MyWindow : ApplicationWindow {
    public MyWindow(Application app) {
        Application = app;
        Title = "containers";

        Box top_hbox = Box.New(Horizontal, 5);
        top_hbox.Append(Label.New("one"));
        top_hbox.Append(Button.NewWithLabel("button 1"));
        top_hbox.Append(Label.New("two"));
        top_hbox.Append(Button.NewWithLabel("button 2"));

        Box left_vbox = Box.New(Vertical, 5);
        left_vbox.Append(Label.New("three"));
        left_vbox.Append(Button.NewWithLabel("button 3"));
        left_vbox.Append(Label.New("four"));
        Button button4 = Button.NewWithLabel("button 4");
        button4.Vexpand = true;
        left_vbox.Append(button4);

        Grid grid = new();
        grid.ColumnSpacing = 5;
        grid.RowSpacing = 3;
        grid.Attach(Label.New("field 1:"), 0, 0, 1, 1);
        grid.Attach(new Entry(), 1, 0, 1, 1);
        grid.Attach(Label.New("field 2:"), 0, 1, 1, 1);
        grid.Attach(new Entry(), 1, 1, 1, 1);
        Picture cube = Picture.NewForFilename("cube.png");
        cube.Halign = cube.Valign = Align.Center;
        grid.Attach(cube, 0, 2, 2, 1);

        Box hbox2 = Box.New(Horizontal, 5);
        hbox2.Append(left_vbox);
        hbox2.Append(Separator.New(Vertical));
        hbox2.Append(grid);

        Box vbox = Box.New(Vertical, 5);
        vbox.Append(top_hbox);
        vbox.Append(Separator.New(Horizontal));
        vbox.Append(hbox2);
        vbox.MarginTop = vbox.MarginBottom = vbox.MarginStart = vbox.MarginEnd = 8;
        Child = vbox;
    }
}

class Hello : Application {
    public Hello() : base([]) {
        OnActivate += on_activate;
    }

    void on_activate(Gio.Application app, EventArgs args) {
        MyWindow w = new((Application) app);
        w.Show();
    }

    static void Main(string[] args) {
        new Hello().Run(args.Length, args);
    }
}

Here is a snapshot of the running program:

Menus

using Gdk;
using Gtk;
using Menu = Gio.Menu;
using SimpleAction = Gio.SimpleAction;
using AGtk;

class MyWindow : ApplicationWindow {
    TextView text_view = new();
    List<SimpleAction> transformActions = [];

    public MyWindow(Application app) {
        Application = app;
        Title = "edit";
        SetDefaultSize(600, 400);

        Menu topMenu = create_menu();
        PopoverMenuBar bar = PopoverMenuBar.NewFromModel(topMenu);

        create_actions();

        Box vbox = Box.New(Orientation.Vertical, 0);
        vbox.Append(bar);

        text_view.Monospace = true;
        text_view.Buffer!.OnChanged += on_text_changed;

        ScrolledWindow scrolled = new();
        scrolled.Child = text_view;
        scrolled.Vexpand = true;
        vbox.Append(scrolled);

        Child = vbox;
    }

    Menu create_menu() {
        Menu fileMenu = new();
        fileMenu.Append("New", "win.new");
        fileMenu.Append("Open...", "win.open");

        // Append a section with no label, which will draw a separator before the section.
        Menu exitSection = new();
        exitSection.Append("Exit", "app.exit");
        fileMenu.AppendSection(null, exitSection);

        Menu viewMenu = new();
        viewMenu.Append("Monospace", "win.monospace");

        Menu transformMenu = new();

        Menu caseSection = new();
        caseSection.Append("Uppercase", "win.uppercase");
        caseSection.Append("Lowercase", "win.lowercase");
        
        Menu orderingSection = new();
        orderingSection.Append("Reverse Words", "win.reverse_words");
        orderingSection.Append("Reverse Characters", "win.reverse_chars");

        transformMenu.AppendSection("Case", caseSection);
        transformMenu.AppendSection("Ordering", orderingSection);

        Menu documentMenu = new();
        documentMenu.Append("Information", "win.information");

        Menu aboutMenu = new();
        aboutMenu.Append("About", "win.about");

        Menu topMenu = new();
        topMenu.AppendSubmenu("File", fileMenu);
        topMenu.AppendSubmenu("View", viewMenu);
        topMenu.AppendSubmenu("Transform", transformMenu);
        topMenu.AppendSubmenu("Document", documentMenu);
        topMenu.AppendSubmenu("Help", aboutMenu);

        return topMenu;
    }

    void create_actions() {
        this.AddAction("new", on_new);
        this.AddAction("open", on_open);

        transformActions = [
            this.AddAction("uppercase", on_uppercase),
            this.AddAction("lowercase", on_lowercase),
            this.AddAction("reverse_words", on_reverse_words),
            this.AddAction("reverse_chars", on_reverse_chars)
        ];
        foreach (SimpleAction a in transformActions)
            a.Enabled = false;

        this.AddToggleAction("monospace", true, on_monospace);

        this.AddAction("information", on_information);
        this.AddAction("about", on_about);
    }

    async void on_new() {
        TextBuffer buf = text_view.Buffer!;
        if (buf.Text != "") {
            AlertDialog d = new();
            d.Message = "Really clear the buffer?";
            d.Buttons = ["Clear", "Cancel"];
            d.DefaultButton = 0;  // index of button chosen if user presses Enter
            d.CancelButton = 1;   // index of button chosen if user presses Escape
            int result = await d.ChooseAsync(this);
            if (result == 0)    // user chose "Clear"
                buf.Text = "";
        }
    }

    async void on_open() {
        FileDialog dialog = new();
        Gio.File? file = null;
        try {
            file = await dialog.OpenAsync(this);
        } catch (GLib.GException) {
            // user cancelled dialog
        }
        if (file != null)
            using (StreamReader sr = new(file.GetPath()!))
                text_view.Buffer!.Text = sr.ReadToEnd();
    }

    void on_monospace(bool val) {
        text_view.Monospace = val;
    }

    void transform(Func<string, string> f) {
        TextBuffer buf = text_view.Buffer!;
        buf.Text = f(buf.Text!);
    }

    void on_uppercase() {
        transform(text => text.ToUpper());
    }

    void on_lowercase() {
        transform(text => text.ToLower());
    }

    void on_reverse_words() {
        transform(text => string.Join(" ", text.Split().Reverse()));
    }

    void on_reverse_chars() {
        transform(text => string.Join("", text.ToCharArray().Reverse()));
    }

    void on_information() {
        string text = text_view.Buffer!.Text!;
        int lines = text.Split('\n').Length;
        int words = text.Split().Where(w => w != "").Count();

        AlertDialog d = new();
        d.Message = $"{lines} lines, {words} words, {text.Length} characters";
        d.Show(this);
    }
    
    void on_about() {
        AboutDialog d = new();
        d.TransientFor = this;
        d.Authors = ["Adam Dingle"];
        d.ProgramName = "Menu Demo";
        d.Version = "0.1.0";
        d.Comments = "Program to demonstrate GTK menus";
        d.Copyright = "© 2025 Adam Dingle";

        if (Resources.GetTexture("logo.png") is Texture t)
            d.Logo = t;

        d.Show();
    }

    void on_text_changed(TextBuffer sender, EventArgs args) {
        // Enable the Transform menu items if the buffer is non-empty.
        bool enable = text_view.Buffer!.Text! != "";

        foreach (SimpleAction a in transformActions)
            a.Enabled = enable;
    }
}

class Hello : Application {
    public Hello() : base([]) {
        OnActivate += on_activate;
    }

    void on_activate(Gio.Application app, EventArgs args) {
        this.AddAction("exit", Quit);

        MyWindow w = new((Application) app);
        w.Show();
    }

    static void Main(string[] args) {
        new Hello().Run(args.Length, args);
    }
}

The code above adds actions using AddAction() and AddToggleAction(), which are convenient extension methods provided by the AGtk package. To use these methods, add the AGtk package to your project:

$ dotnet add package AGtk

You will also need to write "using AGtk;" at the top of your program.

The code for the About dialog calls Resources.GetTexture() to retrieve an image from an embedded resource in the program's assembly. This is a helper method found in the AGtk package. To embed an image (such as logo.png) as an resource, include text such as this in your .csproj file:

  <ItemGroup>
    <EmbeddedResource Include="logo.png" />
  </ItemGroup>

Here is a screenshot of the running program:

Column views

using Gtk;
using static Gtk.Orientation;
using Menu = Gio.Menu;
using AGtk;

class MyWindow : ApplicationWindow {
    AGtk.ColView col_view;
    SearchEntry entry;

    public MyWindow(Application app) {
        Application = app;
        Title = "movies";

        col_view = new AGtk.ColView(["year", "title", "director"]);
        col_view.FilterColumn = 1;
        add_movies();

        col_view.OnSelectionChanged += on_selection_changed;
        col_view.OnActivate += on_activate;
        col_view.OnRightClick += on_right_click;

        entry = new SearchEntry();
        entry.OnSearchChanged += on_search_changed;

        ScrolledWindow scrolled = ScrolledWindow.New();
        scrolled.Child = col_view;
        scrolled.Vexpand = true;

        Box vbox = Box.New(Orientation.Vertical, 0);
        vbox.Append(scrolled);
        vbox.Append(entry);
        Child = vbox;

        SetDefaultSize(500, 400);

        this.AddAction("edit", on_edit);
        this.AddAction("delete", on_delete);
    }

    void on_selection_changed(uint rowIndex) {
        int year = (int) col_view.Rows[rowIndex].Values[0];
        string title = (string) col_view.Rows[rowIndex].Values[1];
        Console.WriteLine($"selection changed: {year} - {title}");
    }

    void on_activate(ColumnView sender, ColumnView.ActivateSignalArgs args) {
        uint rowIndex = args.Position;
        int year = (int) col_view.Rows[rowIndex].Values[0];
        string title = (string) col_view.Rows[rowIndex].Values[1];
        Console.WriteLine($"double click: {year} - {title}");
    }

    private void on_right_click(int x, int y, uint rowIndex) {
        col_view.SelectedIndex = rowIndex;

        Menu menu = new();
        menu.Append("Edit", "win.edit");
        menu.Append("Delete", "win.delete");
        PopoverMenu pop = PopoverMenu.NewFromModel(menu);
        
        pop.SetParent(col_view);

        // Display the popup at the position where the user clicked the mouse.
        Gdk.Rectangle rect = new() { X = x, Y = y, Width = 1, Height = 1 };
        pop.SetPointingTo(rect);
        pop.Popup();
    }

    void on_edit() {
        EditWindow w = new(this, col_view.Rows[col_view.SelectedIndex]);
        w.Show();
    }

    void on_delete() {
        col_view.DeleteRow(col_view.SelectedIndex);
    }

    void on_search_changed(SearchEntry sender, EventArgs args) {
        col_view.FilterText = entry.GetText();
    }

    void add_movies() {
        col_view.Add([1981, "Raiders of the Lost Ark", "Steven Spielberg"]);
        col_view.Add([1994, "The Shawshank Redemption", "Frank Darabont"]);
        col_view.Add([1995, "Clueless", "Amy Heckerling"]);
        col_view.Add([2001, "Memento", "Christopher Nolan"]);
        col_view.Add([2002, "Spirited Away", "Hayao Miyazaki"]);
        col_view.Add([2004, "Eternal Sunshine of the Spotless Mind", "Michel Gondry"]);
        col_view.Add([2008, "The Dark Knight", "Christopher Nolan"]);
        col_view.Add([2014, "Whiplash", "Damien Chazelle"]);
    }
}

class EditWindow : Window {
    AGtk.Row row;
    Entry year = new(), title = new(), director = new();

    public EditWindow(Window parent, AGtk.Row row) {
        Title = "edit";
        TransientFor = parent;

        // Set this window to be modal: while it is active, the user cannot
        // interact with other windows.
        Modal = true;

        this.row = row;

        Grid grid = new();
        grid.Attach(Label.New("year:"), 0, 0, 1, 1);
        grid.Attach(Label.New("title:"), 0, 1, 1, 1);
        grid.Attach(Label.New("director:"), 0, 2, 1, 1);

        year.Text_ = row.Values[0].ToString();
        title.Text_ = (string) row.Values[1];
        director.Text_ = (string) row.Values[2];

        grid.Attach(year, 1, 0, 1, 1);
        grid.Attach(title, 1, 1, 1, 1);
        grid.Attach(director, 1, 2, 1, 1);

        Box hbox = Box.New(Horizontal, 1);
        Button ok = Button.NewWithLabel("OK");
        ok.OnClicked += on_ok;
        Button cancel = Button.NewWithLabel("Cancel");
        cancel.OnClicked += on_cancel;
        hbox.Append(ok);
        hbox.Append(cancel);

        Box vbox = Box.New(Vertical, 1);
        vbox.Append(grid);
        vbox.Append(hbox);

        Child = vbox;
    }

    void on_ok(Button sender, EventArgs args) {
        row.Values[0] = int.Parse(year.Text_!);
        row.Values[1] = title.Text_!;
        row.Values[2] = director.Text_!;
        Close();
    }

    void on_cancel(Button sender, EventArgs args) {
        Close();
    }
}

class Hello : Application {
    public Hello() : base([]) {
        OnActivate += on_activate;
    }

    void on_activate(Gio.Application app, EventArgs args) {
        MyWindow w = new((Application) app);
        w.Show();
    }

    static void Main(string[] args) {
        new Hello().Run(args.Length, args);
    }
}

This example displays a multi-column list view using the AGtk.ColView class. The Gtk.ColumnView class has a complicated API. AGtk.ColView is a subclass of Gtk.ColumnView that is much easier to use.

To use AGtk.ColView in your program, add the AGtk package to your project:

$ dotnet add package AGtk

Here is a snapshot of the running program: