GtkSharp

Getting started with GtkSharp

Writing desktop applications in C# against Gtk 4. This is for people using the binding; for how the binding itself is built, see architecture.md.

Everything below is the Gtk 4 way of doing things. If you are porting from gtk-sharp 2/3, most of what you remember has moved: there is no GtkContainer, no ShowAll, no delete-event, no stock items, and button presses arrive through gesture controllers rather than widget signals.


What you need

Platform How to get it
Windows Nothing to do. GtkSharp.targets downloads a gvsbuild runtime into %LOCALAPPDATA%\Gtk\4.22.4 on first build. Set SkipGtkInstall=True to opt out and supply your own.
macOS Nothing to do. If no Gtk 4 is found on the first call, brew install gtk4 is run for you (needs Homebrew). Or install it yourself up front.
Linux Nothing to do. If no Gtk 4 is found, a conda-forge build is fetched with micromamba into ~/.local/share/GtkSharp/4.22.4 (honours XDG_DATA_HOME); if that fails it falls back to your package manager (apt install libgtk-4-1, dnf install gtk4, …), which may prompt for your password.

Set GTKSHARP_SKIP_RUNTIME_INSTALL=1 to disable the macOS/Linux on-demand install and get the normal “library not found” error instead. On Linux the conda source is overridable with GTKSHARP_MICROMAMBA_URL and GTKSHARP_CONDA_CHANNEL, and GTKSHARP_CONDA_PACKAGES (default gtk4) adds companion libraries to the fetch, e.g. gtk4 libadwaita. For the optional libraries — libadwaita-1-0, libgtksourceview-5-0, libwebkitgtk-6.0-4 — install the matching package yourself; only the Gtk core is fetched automatically.

WebKit is not available on Windows. gvsbuild ships no WebKit, so WebkitGtkSharp and JavaScriptCoreSharp are Linux/macOS in practice. Guard any use of them with WebKit.Global.IsSupported.


The fastest start

dotnet new install GtkSharp4.Template.CSharp
dotnet new gtkapp -o HelloGtk
cd HelloGtk
dotnet run

You get a window with a label and a button that counts clicks, built from an embedded .ui file. There are also gtkwindow, gtkwidget and gtkdialog item templates, and F#/VB equivalents.

The gtkdialog template still contains Gtk 3 markup (GtkButtonBox, internal-child="vbox", use-stock), all of which Gtk 4 removed. Treat it as unverified until someone fixes it; the application template is the one that is known good.


A window from scratch

Without a template, the whole of a minimal application:

using System;
using Gtk;

class Program
{
    [STAThread]
    public static void Main(string[] args)
    {
        Application.Init();

        var app = new Application("org.example.HelloGtk", GLib.ApplicationFlags.None);
        app.Register(GLib.Cancellable.Current);

        var window = new Window { Title = "Hello", DefaultWidth = 480, DefaultHeight = 240 };
        window.Child = new Label("Hello, world");

        app.AddWindow(window);
        window.Present();

        Application.Run();
    }
}

Three things differ from Gtk 3 and each bit someone:

window.CloseRequest += (o, args) =>
{
    Application.Quit();
    args.RetVal = false;   // true would veto the close
};

Application.Run() runs a GLib.MainLoop. (gtk_main was removed in Gtk 4; this binding’s Application.Run is built on GLib.MainLoop instead.)


Laying out widgets

There is no Container. Every widget has a child list, and the container-ish widgets expose their own methods:

var box = new Box(Orientation.Vertical, 6);
box.Append(new Label("first"));
box.Append(new Label("second"));
box.Prepend(new Label("zeroth"));

var grid = new Grid();
grid.Attach(new Label("0,0"), 0, 0, 1, 1);
grid.Attach(new Label("spans two columns"), 0, 1, 2, 1);

Walk children with the linked list on Widget:

for (var child = box.FirstChild; child != null; child = child.NextSibling)
    Console.WriteLine(child.GetType().Name);

Sizing is Measure, which takes an orientation and a for-size — height depends on the width a widget is given:

label.Measure(Orientation.Vertical, forSize: 200,
              out var minimum, out var natural, out _, out _);

Use Hexpand/Vexpand and Halign/Valign to control how a widget takes up space, and MarginStart/MarginEnd/MarginTop/MarginBottom for spacing.


Signals, and one trap worth knowing first

Signals are C# events:

var button = new Button { Label = "Click me" };
button.Clicked += (o, e) => Console.WriteLine("clicked");

Read this before you write a handler that inspects state. += connects your handler after the widget’s own default handler. This binding only connects before when the handler is a named method carrying [GLib.ConnectBefore] — a lambda cannot, because the attribute is read off the delegate’s MethodInfo.

For most signals that is exactly what you want. For a signal whose default handler performs the operation, it means you see the world after the fact:

// WRONG if you meant to inspect the text before it is inserted.
buffer.InsertText += (o, args) => { /* the text is already in the buffer */ };

// Right:
[GLib.ConnectBefore]
void OnInsertText(object o, InsertTextArgs args) { /* not yet inserted */ }
buffer.InsertText += OnInsertText;

A DeleteRange handler attached with a lambda reads the empty string out of the range it was handed, because the iterators have already collapsed onto the deletion point. Nothing errors; you just get the wrong half of the transaction.

For a few signals it is worse than that: the lambda never runs at all. A signal whose accumulator stops the emission as soon as the class handler has answered never reaches an “after” handler. GtkDragSource::prepare and GtkDropTarget::accept — the two signals a drag-and-drop implementation is built on — are exactly those, so they cannot be handled with a lambda:

// Never invoked. No error, no warning: the emission is over before it gets here.
source.Prepare += (o, args) => args.RetVal = MakeContentProvider();

// Right:
[GLib.ConnectBefore]
void OnPrepare(object o, PrepareArgs args) { args.RetVal = MakeContentProvider(); }
source.Prepare += OnPrepare;

GtkRange::change-value is the contrast: its accumulator stops only for a handler that returns true, so an “after” lambda does run there. When a handler you connected appears to be ignored, this is the first thing to check.

An exception you do not catch ends the process

A handler runs on a native stack, called by GLib. An exception that escapes it cannot be propagated back through those frames, so the binding catches it and hands it to GLib.ExceptionManager. With nothing subscribed to ExceptionManager.UnhandledException, that prints the exception to stderr and calls Environment.Exit(1).

For a GUI application that means the window vanishes mid-click with no dialog and no visible stack trace — it reads as a native crash, and the first place people look is the binding. Catch inside the handler:

button.Clicked += (o, e) =>
{
    try { DoTheWork(); }
    catch (Exception ex) { ShowTheProblem(ex); }
};

Or install a process-wide policy. Subscribing at all is what keeps the application alive — the exit is what happens when nobody is listening:

GLib.ExceptionManager.UnhandledException += args =>
{
    Console.Error.WriteLine(args.ExceptionObject);
    // args.ExitApplication = true;  // opt back in to exiting, per exception
};

ExitApplication is one-way: the setter accepts true and ignores false, so a handler can decide to end the process but cannot un-decide it. Throwing from inside this handler exits, unconditionally.


Building UI from .ui files

Design in Cambalache or write the XML by hand, embed it, and load it with Builder. Fields marked [UI] are bound by name:

using UI = Gtk.Builder.ObjectAttribute;

class MainWindow : Window
{
    [UI] private Label _label1 = null;
    [UI] private Button _button1 = null;

    public MainWindow() : this(new Builder("MainWindow.ui")) { }

    private MainWindow(Builder builder) : base(builder.GetRawOwnedObject("MainWindow"))
    {
        builder.Autoconnect(this);          // binds [UI] fields
        _button1.Clicked += OnButtonClicked; // wire signals in C#
    }
}

Embed the file in your .csproj:

<ItemGroup>
  <None Remove="**\*.ui" />
  <EmbeddedResource Include="**\*.ui">
    <LogicalName>%(Filename)%(Extension)</LogicalName>
  </EmbeddedResource>
</ItemGroup>

Do not put <signal> elements in your .ui — such a document does not load at all, which is stronger than it sounds. Gtk 4 replaced gtk_builder_connect_signals_full with GtkBuilderScope, and the scope resolves each handler name while the document is being parsed. The default scope looks the name up as an exported C symbol, never finds your C# method, and fails the whole file:

NotSupportedException: This document declares a <signal> handler. Gtk 4 resolves
builder signal handlers through GtkBuilderScope at parse time ...
GtkBuilder reported: No function named `OnClicked`.

That inner sentence is what GtkBuilder says on its own, and it reads like a missing native symbol rather than an unsupported feature, so AddFromString, AddFromFile, AddFromResource and the Stream constructor all explain it instead. Builder.DeclaresSignals is set even when the load fails, so you can tell “my XML is wrong” from “this is not supported yet”.

The throw comes from the load call, not from Autoconnect. Parsing is when the handler is resolved, so the builder gives up before Autoconnect is ever reached — put the try around AddFromString/AddFromFile, not around the line you would expect to be responsible for signals.

Connect handlers in C#, after Autoconnect has bound the fields.

You can also build from a string, which is handy in tests:

var builder = new Builder();
builder.AddFromString("<interface><object class='GtkLabel' id='hi'/></interface>");
var label = (Label) builder.GetObject("hi");

Input: gestures and controllers

Gtk 4 routes input through event controllers you attach to a widget, not through widget signals:

var click = new GestureClick();
click.Pressed += (o, args) => Console.WriteLine($"press at {args.X},{args.Y}");
widget.AddController(click);

var keys = new EventControllerKey();
keys.KeyPressed += (o, args) =>
{
    if (args.Keyval == (uint) Gdk.Key.Escape)
        window.Close();
    args.RetVal = false;   // false lets the event continue
};
window.AddController(keys);

EventControllerMotion, EventControllerFocus, EventControllerScroll, DropTarget and DragSource follow the same shape. RemoveController detaches one, and controller.Widget tells you what it is attached to.

A controller that never fires is usually the propagation phase. A key event targets the focus widget, and PropagationPhase defaults to Bubble, which runs from that target outwards. Any widget along the way that handles the event and returns true ends the emission, and every controller further out is simply never reached.

Composite widgets make this the normal case rather than the exception. A Gtk 4 Entry is a shell around an internal GtkText, and it is the GtkText that holds the focus — so a controller on the Entry is already one step outwards, and GtkText hands the key to the input method, inserts the character and returns true. Nothing errors; the character appears and your handler never runs. The key controller in the sketch above has the same problem: the window is further out still, so Escape reaches it only while no text widget has focus.

Capture runs the other way, root down to the target, so an ancestor sees the event first:

var keys = new EventControllerKey();
keys.PropagationPhase = PropagationPhase.Capture;   // before GtkText swallows it
keys.KeyPressed += (o, args) => { …; args.RetVal = false; };
entry.AddController(keys);

Keep returning false: capture only moves when you see the event, and true still consumes it, so the character would never be typed.

Keyboard shortcuts go through a ShortcutController:

var shortcuts = new ShortcutController();
shortcuts.AddShortcut(new Shortcut(new ShortcutTrigger("<Control>s"),
                                   new ShortcutAction("action(win.save)")));
window.AddController(shortcuts);

Actions and menus

GAction replaced GtkAction, and GMenu replaced GtkUIManager. An action is a named, optionally stateful, optionally parameterised callback:

var save = new GLib.SimpleAction("save", null);
save.Activated += (o, args) => SaveDocument();

var group = new GLib.SimpleActionGroup();
group.AddAction(save);
window.InsertActionGroup("win", group);   // now addressable as "win.save"

Anything that can be activated refers to it by name:

var button = new Button { Label = "Save", ActionName = "win.save" };

Menus are built as a model, not as widgets:

var fileMenu = new GLib.Menu();
fileMenu.Append("Save", "win.save");
fileMenu.Append("Quit", "app.quit");

var menubar = new GLib.Menu();
menubar.AppendSubmenu("File", fileMenu);

Actions carrying a parameter take a VariantType and receive a Variant:

var open = new GLib.SimpleAction("open", GLib.VariantType.String);
open.Activated += (o, args) => Open((string) args.Parameter);
open.Activate(new GLib.Variant("/tmp/file.txt"));

Stateful actions have a trap. StateChanged is the change-state signal, not a notification after the fact — GLib’s default handler is what applies the new state, and connecting replaces it. A handler that only reads the value leaves the action on its old state:

toggle.StateChanged += (o, args) =>
{
    ApplyTheSetting((bool) args.Value);
    toggle.State = args.Value;    // you must do this yourself
};

Lists

GtkTreeView and its models still exist but are deprecated. New code uses a GListModel pipeline: a model, optional filter and sort stages, a selection model, and a view with a factory that recycles row widgets.

var source = new StringList(new[] { "delta", "alpha", "charlie", "bravo" });

var sorter = new StringSorter(new PropertyExpression(StringObject.GType, null, "string"));
var sorted = new SortListModel(source, sorter);

var filter = new CustomFilter(item =>
    ((StringObject) GLib.Object.GetObject(item)).String.Length == 5);
var filtered = new FilterListModel(sorted, filter);

var selection = new SingleSelection(filtered);
var view = new ListView(selection, MakeFactory());

Each stage is itself a model, so they stack. For your own row type, subclass GLib.Object and use a GLib.ListStore:

class Row : GLib.Object
{
    public Row() : base(IntPtr.Zero) { }
    public string Name { get; set; }
}

var store = new GLib.ListStore((GLib.GType) typeof(Row));
store.Append(new Row { Name = "first" }.Handle);

var view = new ColumnView(new SingleSelection(store));
view.AppendColumn(new ColumnViewColumn("Name", new SignalListItemFactory()));

A SignalListItemFactory has Setup (create the row widget) and Bind (fill it from the item) signals. selection.SelectedItem comes back as an IntPtr — GLib.Object.GetObject(...) turns it into your type.


Custom drawing with Cairo

Assign a DrawFunc to a DrawingArea; it is called with a Cairo context and the current size:

var area = new DrawingArea();
area.SetSizeRequest(200, 200);
area.DrawFunc = (da, cr, width, height) =>
{
    cr.SetSourceRGB(0.9, 0.9, 0.9);
    cr.Rectangle(0, 0, width, height);
    cr.Fill();

    cr.SetSourceRGB(0.8, 0, 0);
    cr.LineWidth = 5;
    cr.Translate(width / 2d, height / 2d);
    cr.Arc(0, 0, Math.Min(width, height) / 2 - 10, 0, 2 * Math.PI);
    cr.Stroke();
};

Call area.QueueDraw() when your state changes.

Dispose every Cairo object you create. Cairo.Path, ImageSurface, Context, Pattern and friends are IDisposable, and leaking one does not merely warn — the finalizer takes the process down, at whatever moment the GC next runs, so the crash lands nowhere near the cause. The context handed to a DrawFunc is not yours; anything you create inside it is.

For text, use Pango rather than Cairo’s toy text API:

using var layout = Pango.CairoHelper.CreateLayout(cr);
layout.FontDescription = Pango.FontDescription.FromString("Sans 12");
layout.SetText("measured and drawn");
Pango.CairoHelper.ShowLayout(cr, layout);

Styling with CSS

var css = new CssProvider();
css.ParsingError += (o, args) => Console.Error.WriteLine("bad CSS");
css.LoadFromString(@"
    .danger { color: white; background: #c01c28; }
    button:hover { opacity: 0.8; }
");

StyleContext.AddProviderForDisplay(Gdk.Display.Default, css,
                                   Gtk.StyleProviderPriority.Application);

button.AddCssClass("danger");

AddCssClass/RemoveCssClass/HasCssClass replace the Gtk 3 style-context juggling. Widget.GetCssName(Button.GType) tells you the element name a widget type uses as a CSS selector (button, label, window).


Text

var view = new TextView();
var buffer = view.Buffer;

buffer.Text = "Hello, world";

buffer.GetBounds(out var start, out var end);
Console.WriteLine(buffer.GetText(start, end, includeHiddenChars: false));

var bold = buffer.TagTable.Lookup("bold");
if (bold == null)
{
    // gtk_text_buffer_create_tag is variadic, so it is not bound. Build the tag
    // and register it: the two steps that call rolls into one.
    bold = new TextTag("bold") { Weight = Pango.Weight.Bold };
    buffer.TagTable.Add(bold);
}

buffer.ApplyTag(bold, buffer.GetIterAtOffset(0), buffer.GetIterAtOffset(5));

GetIterAtOffset returns the iterator rather than filling an out parameter — the TextIter-returning shape is the one this binding emits.

Three things surprise people:


Shipping your application

dotnet publish -c Release -r linux-x64 --self-contained

The Gtk runtime is not included: your users need it installed, or you ship it alongside. On Windows the gvsbuild tree that GtkSharp.targets downloads is what you would redistribute. The macOS/Linux on-demand install is a development convenience, not a deployment strategy — for a shipped app, bundle the Gtk 4 libraries or require them as a dependency rather than relying on a first-run download (set GTKSHARP_SKIP_RUNTIME_INSTALL=1 to be sure it never fires).

There is also a .NET workload, gtk (GtkSharp4.Sdk, GtkSharp4.Ref, GtkSharp4.Runtime), if you prefer <TargetFramework>net10.0-gtk4.22</TargetFramework> to a PackageReference; its application template is dotnet new gtk. Installing it mutates your SDK directory, so prefer the package unless you need the SDK integration.


Traps

Collected from defects actually caught, by the test suite or by walking through the GettingStarted tour. Each of these compiles cleanly.

Trap What happens
+= with a lambda connects after the default handler your handler sees the operation already done
DragSource.Prepare / DropTarget.Accept with a lambda never runs; the accumulator ends the emission first. Use a named [GLib.ConnectBefore] method
<signal> in a .ui file the document fails to load; NotSupportedException from AddFromString/AddFromFile, not from Autoconnect
An exception escaping a signal handler Environment.Exit(1); the window vanishes and the trace goes to stderr
An EventControllerKey on an Entry never fires: the internal GtkText is the target and consumes the key. Use PropagationPhase.Capture
Widget.Activate() on a button does not raise Clicked; Gtk 4 routes presses through a gesture
SimpleAction.StateChanged it is change-state; you must apply the state yourself
Leaking a Cairo.Path or surface the finalizer kills the process, far from the cause
new ValueArray(2), new Date(2) int converts to IntPtr implicitly, so these hit the raw-pointer constructor. Use 2u/2L
GLib.Bytes.Data on an empty Bytes returns null, not an empty array — foreach throws
ForwardWordEnd at end of buffer returns false; the last word is skipped
A left-gravity TextMark is the one that stays put

Where to look next