The API uses Qt concepts with C# properties, events, value types and collections. See Hello World for a complete entry point.
Each Qt module has its own namespace. When modules contain types with the same name, qualify
the type or use an alias, for example using QTransform3D = QtSharp.Qt3DCore.QTransform;.
Q_PROPERTY becomes a C# property with the PascalCase name, and
the accessors keep their C++ spelling: edit.Text, edit.text(), edit.SetText("...");
timer.Interval, timer.interval(). A property whose name would collide with a nested enum, a
virtual or a field is skipped and the accessors stay.
Subscribe through C# events or use QSignal explicitly:
button.Clicked += handler; // C# event: -= really disconnects (QObject::disconnect)
button.ClickedSignal.Connect(handler);
button.ClickedSignal.Emit(false); // manual emit; only QSignal has itQ_SIGNALs with more than one overload get the 2,3,… suffix (Activated2Signal/Activated2).
A handler can ask who sent the signal: QObject.Sender and QObject.SenderSignal are PyQt's
sender()/senderSignalIndex(). Inside a handler they name the emitting object (the same
wrapper instance, resolved through the wrapper cache) and the moc signature the connection used:
button.Clicked += checkedState =>
{
if (button.Sender == someButton) { ... } // the wrapper itself, not a duplicate
Log(button.SenderSignal); // "clicked(bool)"
};Both are per thread and per callback: a nested emission sees the inner sender and restores the
outer one, and a queued delivery sees them on the thread that runs the slot. Outside a callback
both are null. A C#-declared [QtSignal] invoked directly runs its handlers after the emit
trampoline returned from Qt, so that path has no frame of its own; raise it with
EmitSignal(ValueChanged, value) to get one.
Qt values provide explicit conversions to BCL types:
var qdt = new QDateTime(dateTime); // Utc -> Qt::UTC, Local/Unspecified -> Qt::LocalTime
DateTime back = qdt.ToDateTime(); // qdt.ToDateTimeOffset() keeps the offset
var qd = new QDate(dateOnly); // qdate.ToDateOnly()
var qt = new QTime(timeOnly); // qtime.ToTimeOnly()
byte[] data = qba.ToArray(); // new QByteArray(data) is binary-safe
Point pt = (Point)qpoint; // QPoint/QSize/QRect <-> System.Drawing
Color c = (Color)qcolor; // QColor <-> System.Drawing.Color
Vector3 v = (Vector3)qvector; // QVector2D/3D/4D <-> System.Numerics
Quaternion q = (Quaternion)qquaternion; // QQuaternion <-> System.Numerics.Quaternion
Uri uri = (Uri)qurl; // QUrl <-> System.Uri
Guid id = Guid.CreateV5(ns, name); // QUuid is a Guid; v3/v5 are extensions on it
char upper = 'a'.ToUpper(); // QChar is a char; Unicode extras are extensions
object value = model.Data(index); // a QVariant signature is object: QColor, int, string, ...
model.SetData(index, value); // and back, without naming a variant typeHandle-wrapped types use identity equality. Convert explicitly when comparing them with BCL
values. Date/time conversions throw InvalidOperationException for invalid or out-of-range values.
One type goes the other way: QUuid is System.Guid - there is no QUuid type, the same way
there is no QString. It is 16 bytes of data, so every Qt signature that takes or returns one
surfaces as a Guid (value equality, no disposal), and Qt stores the same 16 bytes in a
QVariant's Uuid type. The Qt-only operations are C# 14 extensions on Guid
(Guid.CreateV3/Guid.CreateV5, Guid.FromRfc4122, id.Version(), id.Variant(),
id.ToRfc4122()), so a call site needs the .NET 10 compiler. A null QUuid is Guid.Empty;
parsing is Guid.Parse/Guid.TryParse; v4 and v7 ids are Guid.NewGuid/Guid.CreateVersion7;
Qt's string formats are Guid's "B"/"D"/"N".
QChar gets the same treatment: it is one UTF-16 code unit, so it is char and there is no QChar
type. The classification and case methods are extensions ('A'.IsLetter(), 'a'.ToUpper(),
'́'.CombiningClass()), Category() returns the BCL's System.Globalization.UnicodeCategory,
and the properties the BCL has no API for (script, bidi direction, joining type, decomposition,
mirroring, case folding, Unicode version) call Qt's own tables. The static code-point overloads
are extensions too: char.Category(0x41), char.Script(0x41). A QChar* buffer is a string
(QCollator.Compare, QRawFont.GlyphIndexesForChars).
The net types have the same conversions: new QHostAddress(ipAddress) / ToIPAddress(),
new QTimeZone(zoneInfo) / ToTimeZoneInfo(), new QProcessEnvironment(dictionary) /
ToDictionary() (a snapshot; InheritsFromParent has no .NET equivalent),
new QLocale(culture) / ToCultureInfo() (identity only - Qt uses CLDR, .NET uses ICU),
new QBitArray(bitArray) / ToBitArray() (bit order preserved), new QVersionNumber(version) /
ToVersion() (one to four segments; Build/Revision -1 means omitted), and
new QMatrix4x4(matrix4x4) / ToMatrix4x4() (column-vector vs row-vector transposes).
QCollator implements IComparer<string>, so it can be handed to any BCL sort.
The same rule covers every other trivially copyable Qt value: QModelIndex, QColor, QSizePolicy,
QTransform, QElapsedTimer, QDeadlineTimer, QOperatingSystemVersion, QTextLength, QRgba64,
QPixelFormat, QPointingDeviceUniqueId, QMetaMethod/QMetaEnum/QMetaClassInfo, QMetaType,
QCalendar, QMetaProperty, QTableWidgetSelectionRange, QTextLine, QTextInlineObject, QAudioFormat
and Qt3D's QNodeId/QKeyFrame are C# structs, not handles - no using, no finalizer, value
equality. A parameter Qt passes as const T& crosses as the value, a T& as ref/out, and a
method that mutates in place writes back. QCalendar() (Gregorian) and QKeyFrame() (Bezier)
keep their parameterless constructors because their zero values are not Qt's defaults.
Equality and hashing are Qt's: the generated struct calls operator==/qHash when Qt declares
them and falls back to the value's fields (or bytes) when it does not, so -0.0 vs 0.0,
QTextLength's fuzzy comparison and QColor's spec rules are exactly Qt's. The comparison
crosses the native boundary once per call.
QMatrix4x4 is the one deliberate hold-out: its non-const QMatrix4x4* out parameters need a
dedicated rule before it can stop being a handle. QMetaObject stays a handle too: Qt returns it
as a const QMetaObject* to static data, and the struct-pointer convention would surface that as
an IntPtr instead.
QJsonObject implements
IDictionary<string, QJsonValue> (a writable view over the Qt object; Insert returns the value
the C++ iterator points at) and QJsonArray implements IList<QJsonValue>. The LINQ-friendly
snapshots are ToObjectDictionary() and ToObjectList(), with values unwrapped the way a
variant's ToObject() unwraps them (nested objects as Dictionary<string, object>, arrays as
object[]). Object keys enumerate in Qt's sorted order.
StreamIODevice (in QtSharp.QtCore) implements
QIODevice's virtuals (readData/writeData/seek/size/...), so QImage.Load,
QXmlStreamReader, QDataStream and anything else taking a QIODevice* reads from or writes to
managed IO. The generator publishes QIODevice's virtuals through a small bridge registry
(gen/Bridges.cs), which is also where future bridges belong.
QImage's buffer constructors do not copy what they are given: the image reads the array until its
last copy is destroyed. The binding pins the array for that window and installs the cleanup that
releases the pin, then runs your cleanupFunction:
var pixels = new byte[width * height * 4]; // ARGB32, filled however you like
using var image = new QImage(pixels, width, height, QImage.Format.Format_ARGB32);
pixels[0] = 0xFF; // the image reads the same memory in placeThis is PySide's buffer-plus-decref design with a .NET pin, because the GC may move a byte[]:
the address is stable only while the pin lives, so the array is not copied and writes through it are
visible in the image. image.Copy() returns an image with pixels of its own, but the original
keeps reading the pinned array until it is destroyed. An image handed to Qt (a copy kept elsewhere)
delays the cleanup until the last copy dies, exactly as in Qt; until then the array stays pinned
and reachable.
The generator recognizes the shape - a uchar* buffer followed by a void(*)(void*) cleanup
callback - and only that shape is pinned; a bare uchar* parameter is borrowed for the length of
the call.
Create one application object: new QApplication(args),
app.Exec(), app.Quit(), app.Exit(code). From code that has no application reference (a menu
action), use QCoreApplication.Instance().Quit().
override the virtuals the model publishes — sizeHint (the SizeHint
property reads it, the same way Qt's Q_PROPERTY does), PaintEvent, MousePressEvent,
KeyPressEvent, model/delegate virtuals (RowCount, Data, Paint, CreateEditor),
Runnable.Run, FilterAcceptsRow, QGraphicsItem::boundingRect/paint/advance, … The
native side forwards to your override. Only virtuals exposed by the generator can be
overridden; private and final C++ virtuals cannot be overridden.
(PyQt's pyqtSignal/pyqtProperty): put the
attribute on the member, and the member is the declaration:
[QtSignal] public event Action<int> ValueChanged; // the Qt signal valueChanged(int)
[QtProperty] public int Value { get; set; } // readable and writable
[QtProperty("caption")] public string Text { get; set; } // an explicit Qt name
[QtProperty] public string Label => "read-only"; // get-only: a read-only property
void SetValue(int value)
{
Value = value;
ValueChanged?.Invoke(value); // the pyqtSignal emit: Qt sees it, then the handlers
}Raising the signal is invoking the event, the way PyQt's signal.emit() is: the runtime keeps
an emit trampoline in the event's handler field, so ?.Invoke calls QMetaObject::activate and
a native Qt connect sees it too. EmitSignal(ValueChanged, value) is the explicit spelling of
the same emit. The trampoline is always there, so the event is never null and ?.Invoke emits
even with no C# subscriber; ValueChanged == null is not a "has subscribers" test. Only
+=/-= the event: assigning it (ValueChanged = null) also drops the trampoline, and the
Invoke spellings stop reaching Qt.
The Qt name is the member's name with a lower-case first letter. C# events and native Qt
connections reach the same handlers, and Qt reaches a property through
QObject::setProperty/property, just like a moc-generated one.
[QtSignal] needs a field-like event (the compiler's handler list is what the runtime reads).
The rest of Q_PROPERTY maps to QtProperty's properties: Notify (the signal named by its
C# member or Qt name; <Property>Changed is used when omitted), Reset (a parameterless
method; Reset<Property> by convention), Constant, Final, Required, Designable,
Scriptable, Stored, User and Revision. BINDABLE is not exposed: it needs binding
support in the native runtime, not just a flag.
tests/MetaObjectApi.cs is the same declaration under test.
[QtSlot] (PyQt's pyqtSlot) puts a public instance method in the meta-object as a real slot, so
Qt reaches it by name:
[QtSlot] public int Add(int a, int b) => a + b; // the Qt slot add(int,int)
[QtSlot("multiply")] public int Times(int a, int b) => a * b; // an explicit Qt nameOverloads stay ordinary C# overloads and are told apart by their normalized signature
("add(int,int)"); the attribute's argument renames the slot. Every parameter and a non-void
return type must have a Qt type name, and a method that cannot be a slot (static, generic,
ref/out, params, an unmapped type) throws when the meta-object is built instead of being
silently omitted. The slot runs through reflection, so a throwing slot is reported through
ApiRuntime.UnhandledCallbackException rather than unwinding into Qt. QMetaObject.ConnectSlotsByName
finds managed slots with the usual on_<objectName>_<signal> naming.
A method can also be called by name, the script/plugin case:
QMetaObject.InvokeMethod(target, "setText", Qt.ConnectionType.DirectConnection,
new object[] { "hi" }, out object result);
QMetaMethod.Invoke(target, args, out result); // the resolved member, by indexThe arguments may be C# primitives, string, byte[], enums, bound Qt values and QObject
pointers; anything else crosses as a QVariant and is converted to the parameter's declared type.
A failure is false plus a reason, never an exception or a crash, and a queued call cannot carry
a result. The generated members carry [QtNativeSlot(signature, invokable)] so reflection can
tell a generated Q_SLOTS/Q_SLOT member from a Q_INVOKABLE one (Qt's QMetaMethod::Slot
vs QMetaMethod::Method).
The generated Qt classes are deliberately different: their properties and signals already exist
in moc's meta-object, so they do not carry these attributes. Their types are marked
[GeneratedCode("qtgen", …)], and the XML docs say what a member is — for example
The windowTitle property (Q_PROPERTY, QString, read/write) and
The windowTitleChanged(QString) signal as a C# event. [QtProperty]/[QtSignal] are only for
members you declare on a C# subclass. A generated member instead carries the read-side
[QtNativeProperty(name, type, writable)] or [QtNativeSignal(mocSignature)], so reflection
and tooling can tell the two apart without guessing from the type. Only the types the
hand-written runtime extends (the application classes, QObject, and the date/time/byte-array
types) are partial; every other
wrapper is a whole declaration, and unsafe sits on the few constructors that pass a function
pointer rather than on the class.
A wrapper is created as the object's actual bound class, the PyQt/PySide object model, so is/as
work on values Qt hands back as a base type:
foreach (var child in spinBox.Children()) // QObject::children() is QList<QObject*>
if (child is QLineEdit edit) { ... } // the editor is a QLineEdit wrapper, not a QObject one
QPushButton button = (QPushButton)widget.Window(); // the window was created as a QPushButtonThe type is resolved once, when the wrapper is made, from the native meta-object: an existing
compatible wrapper wins (reference identity, and a C# subclass keeps its own type), then the
meta-object chain gives the most-derived class the binding publishes, and an unpublished internal
class resolves to its nearest bound base (QComboBox.View() is a QListView). The declared type
is the fallback when nothing matches, for example a module whose assembly has not loaded yet.
QList<QObject*> elements are read through the identity cache, so repeated Children() calls
return the same wrapper instances.
The same resolution powers the two lookups Qt only offers as templates, rewritten as C# generics:
QPushButton button = window.FindChild<QPushButton>(); // first child of that type
QLineEdit edit = window.FindChild<QLineEdit>("edit"); // ... with a matching objectName
QWidget[] all = window.FindChildren<QWidget>(); // the whole subtree
QWidget[] direct = window.FindChildren<QWidget>(null, Qt.FindChildOptions.FindDirectChildrenOnly);The order is Qt's (findChildren tests a whole level before descending), null or an empty name
matches every object, a miss is null or an empty array, and a managed subclass is found by its
own type as well as by its base. tests/FindChildApi.cs is the same lookup under test.
For disposable wrappers, Dispose() releases native objects owned by the
wrapper. Objects transferred to a Qt parent, layout, scene or view follow that owner's
lifetime. Native destruction invalidates the managed wrapper; subsequent handle access
throws ObjectDisposedException. See the ownership tests and
known limitations for covered cases and remaining teardown issues.
A wrapper whose Qt object is gone compares equal to null the
way a destroyed UnityEngine.Object does, so if (w != null) w.Use(); is the guard to write, and
w.IsAlive asks the same question by name (PyQt's sip.isValid). Only the operator is
liveness-aware — w?.Use(), w ?? fallback and w is null test the reference itself, exactly as
they do for ordinary C# references.
The runtime catches exceptions from signal handlers and virtual
overrides at the native boundary, writes them to stderr and raises
ApiRuntime.UnhandledCallbackException. Subscribe to this event to handle or report them.
Qt's C function-pointer APIs take an Action (void return) or Func<..., R>, and the same
delegate comes back when Qt hands the pointer out again:
var curve = new QEasingCurve();
curve.SetCustomType(p => p * p); // double(*)(qreal)
Func<double, double> installed = curve.CustomType(); // the same delegate instance
var previous = Qt.QInstallMessageHandler(handler); // Action<QtMsgType, IntPtr, string>?The native side stores only the address, so the binding roots the thunk and the caller's delegate
in QtCallbackRegistry for the process lifetime; the registry is keyed by delegate instance, so
the same instance always gives the same address (qRemovePostRoutine needs that) and a callback
installed through the binding comes back as the very instance that was installed. A QString
callback argument is borrowed and copied with ApiRuntime.ToManagedTransient; a pointer argument
is an IntPtr. A callback parameter on a virtual or a signal is not published, and a variadic,
nested or class-returning callback stays out of the vocabulary.
QFunctionPointer is the deliberate exception: Qt promises no signature for QLibrary.Resolve,
QGuiApplication.PlatformFunction or QOpenGLContext.GetProcAddress, so it surfaces as the
one-field QtSharp.QtCore.QFunctionPointer struct rather than a callable:
QFunctionPointer address = lib.Resolve("qVersion"); // a carrier, not a Func
if (!address.IsNull && address.TryAs<Func<IntPtr>>(out var version))
Console.WriteLine(Marshal.PtrToStringAnsi(version()));As<TDelegate>/TryAs<TDelegate> are the explicit opt-in, the C# spelling of C++'s
reinterpret_cast: the caller vouches for the signature, and a wrong signature or a stale address
is a crash, not an exception. Identity is a property of this binding's registry; a pointer
installed by native code returns a wrapper over the same address. docs/model-vocabulary.md has
the full rule and the lifetime notes.
C# has await, so the binding does not mirror QFuture<T> — a template with nothing to bind.
Instead it installs a SynchronizationContext on the Qt thread, the same role PySide's
QtAsyncio plays for asyncio, and maps Qt's two threading primitives onto Task:
var sync = QtSynchronizationContext.Install(); // the GUI thread, after the QApplication
async void OnActivated()
{
var rows = await Task.Run(() => ReadRows()); // .NET worker thread
list.AddItems(rows); // back on the GUI thread
}
using var pool = new QThreadPool();
int count = await pool.RunAsync(() => CountRows()); // Qt's thread pool, result as a Task
await window.InvokeAsync(() => SetStatus("done")); // queued onto the object's threadQtSynchronizationContext.Post queues a continuation with QMetaObject::invokeMethod and
QueuedConnection, so the target thread must be pumping an event loop. Send runs inline on the
target thread and blocks from any other.
QFuture<T>, QFutureWatcher<T> and QtConcurrent are deliberately not part of the API: they are
templates, and C# already has the surface they model. Everything async is a Task, with
CancellationToken for cancellation and IProgress<T> for progress — no Qt handle to dispose and no
extra event loop:
var progress = new Progress<int>(value => bar.Value = value); // built on the GUI thread
int count = await pool.RunAsync(p =>
{
for (int i = 0; i < rows; i += 64) { p.Report(i); Scan(i); } // runs on a pool thread
return rows;
}, progress);
using var cts = new CancellationTokenSource();
var task = pool.RunAsync(token =>
{
foreach (var row in rows)
{
token.ThrowIfCancellationRequested(); // cooperative, like QFuture::isCanceled()
Decode(row);
}
return rows.Length;
}, cts.Token);
cts.Cancel(); // task ends cancelled, not faultedProgress<T> captures the SynchronizationContext it is constructed on, so reports made on the
worker thread reach the Qt thread through the context installed above. A token cancelled before a
worker picks the task up skips the work; a OperationCanceledException thrown after the token fires
cancels the task. A task whose queued invocation is dropped — the pool was cleared, or the context
object was destroyed before Qt dispatched it — comes back cancelled rather than pending. Exceptions
from the work fault the task; nothing unwinds into Qt's C++ frames. test-async-await covers the
thread hops, results, faults, cooperative cancellation, progress and the context-lifetime paths.
The other template-shaped thread APIs are bound the same way — hand-written around a managed token, with the delegate released when Qt drops it:
var worker = QThread.Create(() => DecodeAll()); // QThread::create: the caller owns the thread
worker.Start();
worker.Wait(5000);
worker.Dispose(); // after it has finished, as Qt requires
QTimer.singleShot(TimeSpan.FromMilliseconds(250), window, () => Refresh()); // duration + context
QTimer.singleShot(TimeSpan.FromSeconds(1), Qt.TimerType.PreciseTimer, () => Tick());A negative interval throws ArgumentOutOfRangeException; destroying the context before the timer
fires cancels the call and releases the delegate.
Qt event arguments expose methods such as e.Position(), e.Key(), e.Buttons() and
e.TimerId(). They are transient; do not retain them after the callback returns.