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.
.ui filesnetstandard2.0 and
nothing else, so a project on an older runtime — .NET Framework 4.x, Mono —
resolves the same assembly a .NET 10 project does.| 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.
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
gtkdialogtemplate 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.
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:
Present(), not ShowAll(). Widgets are visible by default in Gtk 4;
ShowAll is gone.window.Child = …, not window.Add(…). GtkContainer no longer exists.
A Window holds exactly one child; use a Box or Grid to hold more.delete-event is gone. Handle CloseRequest and set
args.RetVal = false to let the close proceed: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.)
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 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.
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.
.ui filesDesign 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");
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);
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
};
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.
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);
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).
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:
TextMark is the one that does not move when text is
inserted at its position; the right-gravity mark is pushed along. The name
describes which side of the insertion the mark ends up on, not where it goes.ForwardWordEnd returns false at the end of the buffer, even though the
last word ends there — so while (iter.ForwardWordEnd()) silently drops the
final word.Weight is Pango.Weight, not an int. The generated gint property is
hidden in favour of a hand-written typed one, so tag.Weight =
Pango.Weight.Bold is the whole of it.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.
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 |
SampleApps/GettingStarted — this document as a running application.
One page per section above, including the traps, demonstrated by doing rather
than describing: the two InsertText handlers side by side, the stateful action
that does and does not apply its own state, the list pipeline reporting what
each stage holds, Activate() failing to raise Clicked. It is a standalone
solution consuming the NuGet packages, so it also exercises the packaging:
dotnet cake build.cake --BuildTarget=PackageNuGet --Configuration=Release
cd SampleApps && dotnet run --project GettingStarted
Source/Samples — a browsable application with a section per widget. Run it
with dotnet cake build.cake --BuildTarget=RunSamples. It is the widest worked
example in the repository.Source/Tests/GtkSharp.Tests — several hundred behavioural tests that
double as short, verified examples of nearly every API mentioned above.gtk_widget_set_visible → widget.Visible,
gtk_box_append → box.Append.