Check WSLg is active:
echo $DISPLAY
Should return something like :0. If empty, GUI apps won't display.
sudo apt update sudo apt install mono-complete
Verify:
mono --version mcs --version
Note:
gtk-sharp2is not available on Ubuntu 24.04 (noble). Usegtk-sharp3instead.
sudo add-apt-repository universe sudo apt update sudo apt install gtk-sharp3
Verify the package is registered:
pkg-config --list-all | grep gtk-sharp
sudo apt install glade
Launch it:
glade
Design your UI visually, then save as yourfile.glade (XML format).
Only needed if your code uses System.Drawing.Bitmap, Graphics, etc. (not required for plain GTK#/Glade apps):
sudo apt install libgdiplus
mcs myprogram.cs mono myprogram.exe
mcs -pkg:gtk-sharp-3.0 myapp.cs mono myapp.exe
mcs -pkg:gtk-sharp-3.0 -pkg:glade-sharp-3.0 myapp.cs mono myapp.exe
Make sure the .glade file referenced in code sits in the same folder (or update the path in new Builder("...")).
using System;
class Hello {
static void Main() {
Console.WriteLine("Hello from Mono in WSL!");
}
}
mcs hello.cs mono hello.exe
using System;
using Gtk;
class GtkCheck {
static void Main() {
Application.Init();
var win = new Window("Hello GTK#3");
win.SetDefaultSize(300, 200);
win.DeleteEvent += (o, args) => Application.Quit();
var button = new Button("Click me");
button.Clicked += (o, args) => Console.WriteLine("Clicked!");
win.Add(button);
win.ShowAll();
Application.Run();
}
}
mcs -pkg:gtk-sharp-3.0 gtkcheck.cs mono gtkcheck.exe
using System;
using Gtk;
class Program {
static void Main() {
Application.Init();
var builder = new Builder("myui.glade");
builder.Autoconnect(new Program());
var win = (Window)builder.GetObject("window1");
win.DeleteEvent += (o, args) => Application.Quit();
win.ShowAll();
Application.Run();
}
}
mcs -pkg:gtk-sharp-3.0 -pkg:glade-sharp-3.0 program.cs mono program.exe
By default, mcs file.cs names the output after the source file (file.exe). You can control this with -out: and change what kind of binary is produced with -target:.
mcs -pkg:gtk-sharp-3.0 Program.cs -out:MyCustomApp.exe
Run it the same way:
mono MyCustomApp.exe
-target options| Target | Produces | Notes |
|---|---|---|
exe (default) | Console executable | Shows a console window when run on Windows |
winexe | Windows GUI executable | No console window — correct choice for GTK#/Glade GUI apps distributed on Windows |
library | .dll class library | No Main entry point required to run directly; used by other programs |
module | .netmodule | Rarely used; for combining into a single assembly later |
Your example, building a windowed GUI executable with a custom name:
mcs -target:winexe -pkg:gtk-sharp-3.0 Program.cs -out:programwin.exe
This produces programwin.exe, which on Windows will run without popping up a console window alongside your GTK window.
If you want to package reusable code (helper classes, business logic, etc.) as a library instead of a standalone app:
mcs -target:library Program.cs -out:MyLibrary.dll
Main() method is required in a library target (though it's fine if one class in your project has one — it just won't be used as an entry point for the DLL itself).MyLibrary.dll, a Mono/.NET assembly that other C# programs can reference.Suppose MyLibrary.dll contains:
// MyLibrary.cs
namespace MyLib {
public class Greeter {
public string Greet(string name) => $"Hello, {name}!";
}
}
Compiled with:
mcs -target:library MyLibrary.cs -out:MyLibrary.dll
Then in a separate consumer program:
// ConsumerApp.cs
using System;
using MyLib;
class ConsumerApp {
static void Main() {
var greeter = new Greeter();
Console.WriteLine(greeter.Greet("World"));
}
}
Compile it while referencing the DLL with -r::
mcs ConsumerApp.cs -r:MyLibrary.dll -out:ConsumerApp.exe
Run it (make sure MyLibrary.dll is in the same folder as ConsumerApp.exe, or on Mono's library search path):
mono ConsumerApp.exe
-target:library with GTK# packagesIf your DLL itself uses GTK# types (e.g., a shared custom widget), include the package flag when building the library too:
mcs -target:library -pkg:gtk-sharp-3.0 MyGtkWidgets.cs -out:MyGtkWidgets.dll
And reference both the package and the DLL when compiling the consumer:
mcs -pkg:gtk-sharp-3.0 ConsumerApp.cs -r:MyGtkWidgets.dll -out:ConsumerApp.exe
# Console exe, custom name mcs Program.cs -out:myapp.exe # Windowed (no console) exe, custom name mcs -target:winexe -pkg:gtk-sharp-3.0 Program.cs -out:programwin.exe # Class library (DLL) mcs -target:library Library.cs -out:MyLibrary.dll # Program referencing that DLL mcs Consumer.cs -r:MyLibrary.dll -out:Consumer.exe
Compiling in WSL produces a .exe that targets the .NET/Mono runtime — it is not a native Windows binary, and it will not run on Windows by itself. Two things are needed:
1. Install a runtime on the Windows machine
Pick one:
.exe files built with mcs can run via the Mono runtime. After install, you can either double-click the .exe (Mono associates itself with .exe files) or run it explicitly:mono programwin.exe
2. Copy the required DLLs alongside the .exe
Your compiled .exe references managed DLLs at runtime (not just at compile time). Anything you referenced with -r: or -pkg: must physically travel with the .exe:
MyLibrary.dll or MyGtkWidgets.dll.glade file, if the app loads one via Builder("myui.glade") — this isn't a DLL but is just as required; it must sit next to the .exe at the same relative path your code expectsMinimum folder layout to copy over to Windows: programwin.exe MyLibrary.dll (any custom DLLs you referenced with -r:) myui.glade (if using Glade)
Common pitfall: a program that runs fine in WSL but fails on Windows with FileNotFoundException or Could not load file or assembly almost always means a referenced DLL (or the .glade file) wasn't copied over, or Mono for Windows isn't installed.
| Error | Cause | Fix |
|---|---|---|
File does not contain a valid CIL image | Ran mono on .cs source instead of compiled .exe | Compile first: mcs file.cs, then mono file.exe |
CS0246: type or namespace 'Gtk' could not be found | Missing package reference during compile | Add -pkg:gtk-sharp-3.0 to mcs command |
CS0103: name 'Console' does not exist | Missing using System; | Add using System; at top of file |
Unable to locate package gtk-sharp2 | Package dropped in Ubuntu 24.04 | Use gtk-sharp3 instead |
DllNotFoundException: libgdiplus | Code uses System.Drawing without the lib installed | sudo apt install libgdiplus |
Could not load file or assembly 'MyLibrary.dll' | DLL not in same folder / not found | Place DLL next to the .exe, or set MONO_PATH |
FileNotFoundException / Could not load file or assembly (on Windows) | Runtime not installed, or referenced DLL/.glade not copied alongside the .exe | Install Mono for Windows; copy all -r: DLLs and .glade files into the same folder as the .exe |
GTK# is a legacy, lightly-maintained binding. For new projects, consider:
sudo apt install dotnet-sdk-8.0 dotnet new install Avalonia.Templates dotnet new avalonia.app -o MyApp
Use GTK#/Mono/Glade for legacy projects or learning; use Avalonia + dotnet for anything new and long-term.
Personal note:
Compile note: to compile the cs file mcs NAME_OF_THE_FILE.cs for GTK appilcation mcs -pkg:gtk-sharp-3.0 NAME_OF_THE_FILE.cs custom output name / windowed exe mcs -target:winexe -pkg:gtk-sharp-3.0 NAME_OF_THE_FILE.cs -out:CUSTOM_NAME.exe export as DLL mcs -target:library NAME_OF_THE_FILE.cs -out:CUSTOM_NAME.dll use a DLL in another program mcs Consumer.cs -r:CUSTOM_NAME.dll -out:Consumer.exe
to run note: same for all cases mono NAME_OF_THE_FILE.exe
To test the application/setup across different Linux distributions, I used Ventoy to create a multiboot USB drive. Ventoy lets you copy multiple ISO files onto a single USB stick and choose which one to boot at startup, without needing to reformat or re-flash the drive for each OS.
.iso files directly onto the Ventoy partition (no extraction needed).Notes covering the GTK# port of the auscultation trainer: dynamic UI generation from CSV, the notebook page structure, and the failure modes that cost the most time.
Source lives in program/, build output in program/bin/linux/.
Runtime assets must sit next to the .exe, not next to the source.
program/
├── Program.cs
├── MainWindow.cs root window + splash page
├── SettingWindow.cs settings page controller
├── SoundWindow.cs sound page controller
├── CaseDefinition.cs CSV row model + loader
├── WavePlayer.cs cross-platform wav playback
├── ArduinoConnection.cs
├── Language.cs
├── homepageallv3.glade
├── cases.csv
├── map/
├── sound/
└── bin/linux/ ← everything above (minus .cs) copied here
├── program.exe
├── homepageallv3.glade
├── cases.csv
├── map/
└── sound/
One GtkWindow (Root) holds one GtkNotebook (RootNoteBook) with
tabs hidden. Each "screen" is a notebook page; navigation is just
RootNoteBook.CurrentPage = N. No second window is ever created.
| Page | Index | Root widget | Controller |
|---|---|---|---|
| Splash | 0 | SplashGrid | MainWindow |
| Settings | 1 | SettingGird | SettingsWindow |
| Sound | 2 | SoundBox | SoundWindow |
SettingsWindow and SoundWindow are not Gtk.Window subclasses
despite the names — they're plain controller classes that receive the
shared Builder and bind their own widgets:
public class SoundWindow
{
[UI] private Label ConditionNameLabel = null;
[UI] private Grid SoundButtonGrid = null;
[UI] private Button SoundBackButton = null;
public event EventHandler BackRequested;
public SoundWindow(Builder builder)
{
builder.Autoconnect(this);
...
}
}
Wired up in MainWindow's private constructor:
_soundWindow = new SoundWindow(builder); _soundWindow.BackRequested += (s, e) => RootNoteBook.CurrentPage = PageSplash; Option1Button.Clicked += OnNormalSessionClicked;
Controllers never touch the notebook directly — they raise
BackRequested and let MainWindow decide. Keeps navigation in one place.
All three controllers share one Builder. Each Autoconnect call
binds only the ids matching that class's [UI] fields, which is exactly
why ids must be unique across the entire file (§13).
Autoconnect maps glade ids to field names by string match across the
whole builder, not per page. Two widgets sharing an id bind
unpredictably.
| Broken | Why | Fixed |
|---|---|---|
Condition Name | Space — never matches a field name | ConditionNameLabel |
BackButto | Typo | SoundBackButton |
BackButton on two pages | Duplicate across pages | SettingBackButton + SoundBackButton |
A mismatch fails silently — the field stays null and you get a
NullReferenceException later, often several clicks away from the cause.
See §19 for the guard that catches this at startup.
Verify before running:
grep -o 'id="[^"]*"' homepageallv3.glade | sort | uniq -d
Any output is a duplicate id.
The hatched empty cells Glade shows in a GtkGrid save as
<placeholder/> and are ignored at load time. A button placed at
left-attach=2 in a designer grid with two empty columns to its left
ends up at column 0 in the running app — this is the classic
"button jumps to the left" bug.
Never use placeholders for spacing or alignment. Use halign + hexpand.
Both properties are required:
<property name="halign">end</property> <property name="hexpand">True</property>
hexpand makes the cell consume the full row width; halign=end parks
the button at the right edge of that cell. halign alone does nothing
when the cell is only as wide as the button.
In Glade: Common tab → Horizontal Alignment = End,
Expand → Horizontal = checked.
GtkGrid doesn't implement GtkScrollable. Dropping one into a
GtkScrolledWindow requires an intermediate GtkViewport — Glade
inserts it automatically. Don't delete it.
SoundScroller GtkScrolledWindow hexpand + vexpand, packing expand=True └─ GtkViewport (auto-added, required) └─ SoundButtonGrid GtkGrid empty, column-homogeneous=True
GtkFlowBox is scrollable and wraps children automatically, but the
gtk-sharp3 binding on Ubuntu is 2.99.x and may not expose it. Check
before relying on it:
monop -r:/usr/lib/cli/gtk-sharp-3.0/gtk-sharp.dll Gtk.FlowBox
For a widget inside a GtkBox, Common → Expand sets the widget's own
hexpand/vexpand, while Packing → Expand sets the box child
property. They are different things and both usually need setting.
Leave the container empty in Glade and fill it at runtime. Three rules, all of which produce silent failures when broken:
private void BuildButtons(IEnumerable<string> labels, Action<string> onPick)
{
// 1. Remove AND destroy — Remove alone leaks the widget
foreach (var child in SoundButtonGrid.Children)
{
SoundButtonGrid.Remove(child);
child.Destroy();
}
int i = 0;
foreach (string text in labels)
{
// 2. Capture the loop variable — otherwise every handler
// sees the final value
string captured = text;
var btn = new Button(captured);
btn.Hexpand = true;
btn.Clicked += (s, e) => onPick(captured);
SoundButtonGrid.Attach(btn, i % Columns, i / Columns, 1, 1);
i++;
}
// 3. Widgets created in code start HIDDEN. Without this the grid
// stays blank with no error of any kind.
SoundButtonGrid.ShowAll();
}
ShowAll() on the container is the single most common cause of
"my buttons didn't appear" in GTK#.
All three modes (type → condition → play) use one grid and one render
method. State is a List<string> path; Back pops one level:
private readonly List<string> _path = new List<string>();
private void Render()
{
var matches = _allCases.Where(MatchesPath).ToList();
var options = matches
.Where(c => c.TreePath.Length > _path.Count)
.Select(c => c.TreePath[_path.Count])
.Distinct()
.ToList();
ConditionNameLabel.Text = _path.Count == 0
? "種別を選択"
: string.Join(" : ", _path);
BuildButtons(options, picked => { _path.Add(picked); /* leaf? play : Render(); */ });
}
private void OnBackClicked(object sender, EventArgs e)
{
if (_path.Count > 0) { _path.RemoveAt(_path.Count - 1); Render(); }
else { WavePlayer.Stop(); BackRequested(this, EventArgs.Empty); }
}
Tree depth varies per row (Tree_Level3 is often empty), so
CaseDefinition.TreePath returns only the non-empty levels and the
drill-down adapts automatically.
CaseDefinition.LoadCasesFromCsv(path) returns List<CaseDefinition>.
Column → property mapping:
| CSV column | Property |
|---|---|
Number | Number |
Type | Type (心音 / 呼吸音) — also drives IsHeart |
Category | CategoryJp |
Subcategory | SubcategoryJp |
Location | LocationJp |
Tree_Level1..3 | TreeLevel1..3 |
Image_File | MapFront |
Sound_File | SoundPath |
Image_Right / Image_Left / Image_Back | MapRight / MapLeft / MapBack |
Four things the loader must get right:
Encoding. The file contains Japanese. Read with Encoding.UTF8 and
BOM detection — the default ANSI codepage produces mojibake that only
shows up on the rendered buttons.
Delimiter. The file is TAB separated. Auto-detect so a re-export from Excel as comma-separated doesn't break it:
char sep = lines[0].Contains("\t") ? '\t' : ',';
Header mapping. Build name → index from row 0 rather than
hardcoding positions, so adding or reordering columns is safe.
InvariantCulture on every numeric parse. Same failure class as the
CSS scaling bug in §"scaling" — under ja_JP/de_DE the
culture-sensitive default silently misparses:
int.TryParse(get(f, "Number"), NumberStyles.Integer,
CultureInfo.InvariantCulture, out number)
The loader prints its result on every run:
Loaded 98 cases from /path/to/bin/linux/cases.csv
Loaded 0 cases points at the path or the delimiter, not the UI.
Never use bare relative paths. Path.GetFullPath("cases.csv")
resolves against the working directory, so the app works when launched
from program/ and silently loads nothing from anywhere else.
public static string AppFile(string relative)
{
string dir = Path.GetDirectoryName(
System.Reflection.Assembly.GetExecutingAssembly().Location);
return Path.Combine(dir, relative);
}
Use it for everything: the glade file, the CSV, maps, sounds.
builder.AddFromFile(SoundWindow.AppFile("homepageallv3.glade"));
Mixing the two conventions is how you end up editing
program/homepageallv3.glade while the app reads
bin/linux/homepageallv3.glade — every fix appears to do nothing.
Linux is case-sensitive. sound/snd200.wav ≠ sound/SND200.wav,
which was fine on Windows and isn't now. Audit the CSV against the disk:
cut -f10 cases.csv | tail -n +2 | while read f; do [ -n "$f" ] && [ -f "$f" ] || echo "MISSING: $f" done
XAudio2/SharpDX from the WinForms build does not load under Mono on
Linux. WavePlayer shells out instead:
| Platform | Mechanism |
|---|---|
| Linux / WSL | paplay, falling back to aplay |
| Windows | System.Media.SoundPlayer |
Install both helpers — WSLg routes through PulseAudio, so paplay is
the one that works:
sudo apt install pulseaudio-utils alsa-utils
Verify outside the app before blaming the C#:
paplay bin/linux/sound/SND200.wav echo $PULSE_SERVER # empty under WSLg → wsl --shutdown and retry
Format matters on Windows. SoundPlayer handles PCM WAV only:
file sound/SND200.wav # want: RIFF ... WAVE audio, Microsoft PCM
Stop() is a no-op on Windows — SoundPlayer.Play() returns no
handle. If stop-on-back is needed there, use PlaySync() on a
background thread or add NAudio.
Debug build (keeps the console so Console.WriteLine diagnostics show):
mcs -pkg:gtk-sharp-3.0 *.cs -out:./bin/linux/program.exe \ && cp homepageallv3.glade cases.csv ./bin/linux/ \ && cp -r map sound ./bin/linux/ \ && mono ./bin/linux/program.exe
The cp steps are not optional — stale assets in bin/linux/ are a
recurring source of phantom bugs (§16).
Re-copying every wav on each build gets slow. Once stable, symlink and
drop the cp -r:
ln -s ../../map bin/linux/map ln -s ../../sound bin/linux/sound
Release build (-target:winexe suppresses the console — don't use it
while debugging):
mcs -target:winexe -pkg:gtk-sharp-3.0 *.cs -out:./bin/windows/program.exe
An unbound [UI] field is null with no warning. Add an explicit guard
to every controller constructor so the failure names the widget at
startup:
builder.Autoconnect(this);
if (ConditionNameLabel == null || SoundButtonGrid == null || SoundBackButton == null)
throw new InvalidOperationException(
"Glade id mismatch — ConditionNameLabel=" + (ConditionNameLabel != null) +
" SoundButtonGrid=" + (SoundButtonGrid != null) +
" SoundBackButton=" + (SoundBackButton != null));
Output looks like:
System.InvalidOperationException: Glade id mismatch — ConditionNameLabel=False SoundButtonGrid=True SoundBackButton=True
For classes with many fields, loop a dictionary instead:
foreach (var pair in new Dictionary<string, object> {
{ "ComPortLabel", ComPortLabel }, { "ConnectButton", ConnectButton },
{ "SettingBackButton", SettingBackButton }, /* ... */ })
if (pair.Value == null)
throw new InvalidOperationException("Glade id not bound: " + pair.Key);
Exceptions inside signal handlers arrive wrapped:
System.Reflection.TargetInvocationException ---> System.NullReferenceException at SoundWindow.Render () [0x0007b]
Ignore the GLib.SignalClosure / MarshalCallback frames — they're
plumbing. The first frame naming your class is the real site, and
the [0x...] IL offset distinguishes lines within it.
| Symptom | Cause | Fix |
|---|---|---|
| NRE in controller ctor | [UI] field id mismatch | §19 guard, then fix the glade id |
| NRE on first click, no CSV log line | Controller never constructed | Assign it in the private MainWindow(Builder) ctor |
| Buttons don't appear, no error | Missing ShowAll() | Call it on the container after Attach |
| Every button does the same thing | Closure over loop variable | string captured = text; |
Loaded 0 cases | Wrong path or delimiter | Use AppFile(); check tab vs comma |
Button sits left despite halign=end | Relying on grid placeholders | Add hexpand=True |
| Fix appears to do nothing | Editing a different copy of the glade | Use AppFile() + cp on build |
| Japanese renders as garbage | Wrong encoding | File.ReadAllLines(path, Encoding.UTF8) |
| GTK warning about scrolling | Grid directly in ScrolledWindow | Keep the GtkViewport |
The C#, the glade file, and the CSV are identical. Four differences:
Stop() doesn't work.-target:winexe for no console window.ArduinoConnection enumerates COM* rather than
/dev/ttyUSB* / /dev/ttyACM*. Mono's Linux SerialPort.GetPortNames()
has historically missed devices; verify against ls /dev/tty*.Ship this folder:
program.exe homepageallv3.glade cases.csv map/ sound/
Not problems, for the record: forward slashes in CSV paths work fine on Windows, and Windows' case-insensitivity means anything working on Linux also works there — never the reverse. Develop on Linux and Windows comes free.