qt6.cr
Qt6 bindings for Crystal
Bindings for the Qt6 Framework using bindgen. This is the Qt 6 successor of qt5.cr.
Table of Contents
- Project Goals
- Installation
- User perspective
- Developer perspective
- Platform Support
- Contributing
- License
Project Goals
- Just Works™ - Drop the dependency into a project, and use it
- Providing an API that feels native to Crystal
- Focus on the GUI components (i.e.
QtCore,QtGui,QtWidgets)
Installation
Add the shard to your shard.yml. The generated bindings for the platforms CI builds (see Platform Support) are included, and compiled on first use, which needs a C++ compiler, make and the Qt development files. On other platforms, generate the binding for your system's Qt first, see Generating the bindings.
dependencies:
qt6:
github: crystallabs/qt6.cr
Your users will be required to have the Qt6 libraries on their system because this project defaults to binding to Qt libraries dynamically. (See more on this under License below.)
Additional development dependencies
Using your system's package manager (LLVM, Clang and CMake are only needed to generate a binding):
- ArchLinux
pacman -S qt6-base gc cmake llvm clang pcre2 - Debian/Ubuntu/Devuan
apt-get install qt6-base-dev qt6-base-dev-tools libgc-dev cmake llvm-dev clang libclang-dev libpcre2-dev libz-dev libstdc++-dev - macOS
brew install qt bdw-gc llvm cmake
The bindings need bindgen 0.11.1 or newer, which needs LLVM and Clang 14 or newer, CMake 3.12 or newer and Crystal 1.8 or newer. Qt 6.4 or newer is supported.
If linking Crystal programs fails with undefined references like __isoc23_strtol from libgc.a, your Crystal package bundles a libgc built for a newer glibc than your system has (e.g. recent Crystal on Debian 12). Link against the system's libgc instead:
export CRYSTAL_LIBRARY_PATH=/usr/lib/x86_64-linux-gnu
macOS
Homebrew's Qt is built as frameworks: its headers live in /opt/homebrew/lib/Qt*.framework/Headers, which the bindings find through qmake6 and pkg-config. Building the bindings needs the SDK and Homebrew's include and library directories:
export SDKROOT="$(xcrun --show-sdk-path)"
export CPPFLAGS=-I/opt/homebrew/include
export LIBRARY_PATH=/opt/homebrew/lib
export PKG_CONFIG_PATH=/opt/homebrew/opt/qt/lib/pkgconfig
Build bindgen's clang tool with Homebrew's LLVM:
cd lib/bindgen/clang && cmake -DLLVM_DIR=/opt/homebrew/opt/llvm/lib/cmake/llvm . && make
If Homebrew's qt@5 is installed as well, make sure qmake6 (not qt@5's qmake) is found, or set QMAKE=/opt/homebrew/opt/qt/bin/qmake6.
User perspective
Sample Crystal-Qt6 code
Have a look in samples/!
A short Hello World snippet looks like this:
require "qt6"
qApp = Qt::Application.new
label = Qt::Label.new "Hello from Crystal/Qt!"
label.show
Qt::Application.exec

Locale
By default Qt sets the locale to the system locale when an application class (like Qt::Application) is instantiated. However, this causes stdlib Crystal functions like to_f to behave unexpectedly, e.g., using a different character as decimal separator (also see the discussion of this PR). Therefore, in contrast to the behaviour of the C++ classes of Qt, qt6.cr resets the numeric locale to "C" after creating an application class instance (this follows an advice from Qt itself).
String lists
QStringList is a QList<QString>, wrapped as Qt::StringList: methods taking one accept any Enumerable(String), and methods returning one return a Qt::StringList, which is an Indexable(String):
combo_box.add_items(["one", "two"])
names = Qt::Dir.new(".").entry_list(["*.cr"], Qt::Dir::Filters::Files)
names.sort(Qt::CaseSensitivity::CaseInsensitive) # In place
names.filter(".cr").join(", ")
Qt's own QStringList methods are bound: #sort(cs = CaseSensitive), #remove_duplicates, #filter, #replace_in_strings, #contains(str, cs), #index_of and #last_index_of (also taking a Qt::RegularExpression), and #join. Enumerable's methods come before Qt's in Crystal's lookup, so #join is Enumerable#join (with the same result), while #sort is Qt's in-place one, as Enumerable has no #sort: use #to_a.sort for a sorted Array.
Qt::Polygon and Qt::PolygonF are lists of Qt::Point and Qt::PointF the same way: polygon << Qt::Point.new(1, 2), polygon.to_a.
Object lifetime
Objects created from Crystal are collected by the GC once Crystal no longer refers to them. Qt's references, like a layout's to its widgets, are invisible to the GC. So the methods by which Qt takes ownership of an object or keeps a pointer to it, like Qt::Layout#add_widget, Qt::ListWidget#add_item, Qt::MainWindow#central_widget=, Qt::AbstractItemView#model= or Qt::PushButton#menu=, keep their arguments alive:
layout.add_widget Qt::Label.new("Stays alive")
view.model = Qt::StandardItemModel.new # Stays alive too
button.menu = Qt::Menu.new # And so does this
They're kept alive until passed to Qt::BindgenHelper.release, e.g. after taking a widget out again:
layout.remove_widget(label)
Qt::BindgenHelper.release(label)
The methods are configured in config/keep_alive.yml. Giving an object a parent by its constructor or #parent= doesn't keep it alive: keep a reference to it, or add it by one of these methods.
Threads
Qt::Thread, Qt::ThreadPool and the locks are bound, but a Qt thread must not call into Crystal: the Crystal runtime and its GC only know the threads they start, so a Crystal method run from a Qt thread corrupts the program. A thread started from Crystal runs Qt code only, like the event loop QThread::run starts by default, and the queued signals it emits are delivered in the main thread:
thread = Qt::Thread.new
thread.on_finished { puts "done" } # Called from the main event loop
thread.start
thread.quit
thread.wait
QThread and QRunnable are the one place a Crystal override of a virtual method is unsupported: they get no C++ subclass, so a Crystal run is never called (and a Crystal Qt::Runnable subclass can't be instantiated). Use Crystal's own Thread or fibers for Crystal work. The locks exclude Qt threads from Qt data: mutex.lock, semaphore.acquire(2), condition.wait(mutex, Qt::DeadlineTimer.new(100)).
Null pointers
A method returning a pointer to a Qt object returns nil when the pointer is null, so its result is a union with Nil: Qt::Widget#parent_widget is a Qt::Widget?, Qt::ListWidget#item a Qt::ListWidgetItem?, and so are Qt::AbstractItemView#model, Qt::StandardItem#child or Qt::AnimationGroup#take_animation. Check the result, or unwrap it with not_nil! where it can't be null, like the menu bar a Qt::MainWindow creates on demand and the menu added to it:
if item = list.item(row)
puts item.text
end
menu = window.menu_bar.not_nil!.add_menu("File").not_nil!
Only method results are nilable. A method returning a reference, like Qt::Widget#font, always returns an object, as does a cast like Qt::Widget#as_paint_device. The elements of a returned list, like Qt::StandardItemModel#take_row, or the members of a tuple, like those of Qt::Gradient#stops, are the objects themselves, and so is the argument a signal or an overridden virtual method receives: a block of Qt::StandardItemModel#on_item_changed gets a Qt::StandardItem, and an override of Qt::Widget#mouse_press_event takes a Qt::MouseEvent, as declared (see samples/virtual_override.cr). This is bindgen's nilable_results option, set in qt.yml.
Downcasts
A wrapper is of the class a method declares, so Qt::Layout#item_at returns a Qt::LayoutItem and Qt::Object#children holds Qt::Objects. #to? casts to a subclass, returning nil when the object isn't one:
if button = widget.to?(Qt::PushButton)
button.flat = true
end
The cast is C++'s dynamic_cast, so the binding is built with RTTI (ext/Makefile), as Qt itself is.
Differences to qt5.cr
The Crystal API follows Qt 6's C++ API, so code written for qt5.cr may need these changes:
- Sizes and indices are
qsizetype, which isInt64on 64-bit platforms, where Qt 5 hadInt32: e.g.Qt::Dir#countorQt::ListWidget#countof aQList-backed widget. QVectoris an alias ofQList, so there's only one container type.Qt::DesktopWidgetandQt::DeferredDeleteEventare gone, like in Qt 6.Qt::MouseEventinheritsQt::SinglePointEventandQt::PointerEvent, which Qt 6 added.Qt::MouseEvent#posand#buttonswork like before.- Signal overloads Qt 6 removed, like
QComboBox::currentIndexChanged(QString), are gone with it. Qt::Locale::TerritoryreplacesQt::Locale::Country.
Developer perspective
Generating the bindings
If you want to work on qt6.cr itself or you want to generate bindings for new versions, run the generation yourself as described below.
Important: For this you'll also have to meet the dependencies of bindgen. See lib/bindgen/README.md and lib/bindgen/clang/README.md.
The generated src/qt6/binding/binding_*.cr and ext/qt_binding_*.cpp of the platforms CI builds are committed. CI regenerates them on every run, warns when the committed ones are out of date, and uploads the fresh ones as the binding-<platform> workflow artifact: download it and commit its two files.
Deprecated Qt API
The bindings don't wrap deprecated Qt API. The headers are parsed and the C++ wrappers built as C++17 with QT_DISABLE_DEPRECATED_BEFORE=0x060800 (see qt.yml and ext/Makefile), which hides the declarations Qt deprecated up to 6.8. Qt 6.5 and later call this macro QT_DISABLE_DEPRECATED_UP_TO, but still read the old name, which Qt 6.0 to 6.4 only know. What Qt deprecated later, and the deprecated virtual functions it keeps visible, are declared [[deprecated]], which bindgen's ignore_deprecated: true drops overload by overload, so a non-deprecated overload like QSpinBox::valueChanged(int) stays.
Building the bindings should thus print no -Wdeprecated-declarations warnings, and CI fails if it does.
Naming scheme
As qt6.cr supports different versions of Qt on different platforms, generated bindings follow a naming scheme. The scheme contains 4 components as follows:
KERNEL - LIB_C - ARCH - qtVERSION, e.g. linux-gnu-x86_64-qt6.8
Where:
KERNELis the OS kernel, e.g.linux,darwin,windowsLIB_Cis the lib C name, e.g.gnu,musl,win32, orunknownon macOSARCHis the architecture, e.g.x86_64,aarch64VERSIONis the Qt version, e.g.6.4,6.8,6.11
The naming scheme is not strictly enforced. However, it should always end with -qtVERSION!
(Re)generating the binding for the system's Qt6
To generate the binding for the Qt6 installed on your system, run from the qt6.cr directory:
crystal support/generate_bindings.cr -- --system
It asks qmake (qmake6, qmake-qt6 or qmake, or $QMAKE if set) where Qt is installed and which version it is, and names the binding after the host, e.g. linux-gnu-x86_64-qt6.8 or darwin-unknown-aarch64-qt6.11.
Then verify with crystal spec, and build the samples.
support/generate_bindings.cr without --system downloads and configures Qt sources to build bindings for other versions. This mode is untested with Qt 6.
(Re)generating a specific Qt6 binding version
- Decide which version of Qt to use, and build the scheme (See above)
- Export the binding scheme:
export BINDING_PLATFORM=linux-gnu-x86_64-qt6.8 - If you're not using your system's Qt:
export QMAKE=/path/to/qmake6 - Run bindgen as usual:
lib/bindgen/tool.sh qt.yml --stats - Verify with
crystal spec
Type rules for Qt 6
config/types.yml holds the rules Qt 6 needs beyond bindgen's built-in types, see the comments there:
- Class-local typedefs like
QPoint::Representation(Qt 6.10+, aQCheckedInt<int>) orQPalette::ResolveMaskgetalias_forrules, so the structures can be copied and the methods wrapped. char32_t, which theQLocale::toStringoverloads take, andchar16_t, whichQTextStream#<<takes, map toCharlikeQChar.std::chrono::millisecondsandstd::chrono::secondsmap to::Time::Span(qualified, asQt::TimeshadowsTimeinside the module):timer.start(2.seconds),lock_file.try_lock(10.milliseconds).QStringListandQVariantListare aliases ofQList<QString>andQList<QVariant>, so methods take anyEnumerable(String):combo_box.add_items(["a", "b"]).QList<QString>andQListSpecialMethods<QString>, which holds theQStringListmethods, are listed as classes, so the wrapperQt::StringListhas those methods. The overloads takingQStringView,QLatin1StringViewor a string matcher are dropped, as the CrystalStringmaps toQString.QVariantmaps toQt::Variant, which exchanges the types Qt knows in their native form, so Qt can display, sort and store them:Nil,Bool,Int32,UInt32,Int64,UInt64,Float32,Float64,Char,String,Bytes,Array(String)(QStringList),Point,PointF,Size,SizeF,RectandRectF. Any other Crystal value is wrapped as is and comes back unchanged; aQVariantof another Qt type unpacks as unknown (#to?returnsnil). SeeQt::Variant.QByteArraymaps toBytes, copied in both directions likeQString:String.new(process.read_all_standard_output),mime_data.set_data(type, bytes).QList<QByteArray>is anEnumerable(Bytes).QByteArrayViewmaps toBytesas well, likeQAnyStringViewtoString:CryptographicHash.hash(bytes, algorithm).- bindgen instantiates the
QLists the wrapped methods use, looking through Qt's typedefs of them:QFileInfoListis anEnumerable(FileInfo),QModelIndexListanEnumerable(ModelIndex).QItemSelectionderives fromQList<QItemSelectionRange>, soQt::ItemSelectionis that list with the selection methods on top. - Qt 6's
QPairisstd::pair, a container oftype: Pair, exchanged as aTuple:UrlQuery#query_itemsis anEnumerable({String, String}),Gradient#stops(QGradientStops) anEnumerable({Float64, Color}),VariantAnimation#key_valuesanEnumerable({Float64, Variant}), and the setters take an array of tuples:gradient.stops = [{0.0, red}, {1.0, blue}]. - A model written in Crystal implements the pure virtual methods only, like
row_count(parent)anddata(index, role)of anAbstractListModel: bindgen makes the no-argument overloads a default argument splits off concrete, and wraps the methodsQAbstractListModeloverrides privately (parent,columnCount,hasChildren) throughQAbstractItemModel.StringListModelcan be subclassed too. Seespec/qt/models_spec.cr. QAbstractTextDocumentLayout::PaintContextandSelection,QTextLayout::FormatRange, bare structs of Qt classes, are wrapped as classes with property methods:Qt::TextDocumentLayoutPaintContext,Qt::TextLayoutFormatRange.QTextFrame::iteratorandQTextBlock::iteratorare CrystalIterators (src/qt6/text_iterators.cr):frame.begin.each { |frame_or_block| },block.begin.map(&.text).- A Crystal
@[Flags]enum definesNoneandAllitself, so bindgen drops Qt's when they agree and renames the othersCppAll:QTreeWidgetItemIterator::IteratorFlags::All(Qt's0, no filter) isIteratorFlags::CppAll, likeStyle::SubControls::CppAll(SC_All, all bits set) andInputDevice::Capabilities::CppAll.Qt::TreeWidgetItemIteratoris anIterator. QHeaderView's protectedinitialize()is#cpp_initialize, as Crystal reserves the name for constructors.QValidator::validateandfixuptake the input by non-const reference, which bindgen can't pass aString(aQStringby value) by, soext/validator_helper.hppcalls them on a copy:validator.validate("42")returns theValidator::State,#fixupthe fixed string. Validators can't be written in Crystal: as the pure virtualvalidateis dropped, bindgen skips the C++ subclass (a warning, shown bytool.sh --debug).- The
QStyleenum constants drop their prefixes:Style::PixelMetric::ButtonMargin. QGraphicsWidgetderives fromQGraphicsObjectandQGraphicsLayoutItem: the wrapper has the layout item methods, and#as_graphics_layout_itemcasts it forGraphicsLinearLayout#add_itemand friends.QMapandQHashare associative containers, exchanged as aHash-likeEnumerable({K, V})with#[],#[]?,#has_key?and#keys:StandardItemModel#item_data(index)is one ofInt32toVariant,AbstractItemModel#role_namesofInt32toBytes,CalendarWidget#date_text_formatofDatetoTextCharFormat, and the setters take aHash:model.set_item_data(index, {role => Qt.variant(x)}).std::optional<QFont>(FontComboBox#display_font) has no mapping yet and is ignored.QVector2D,QVector3D,QVector4D,QQuaternion,QRgba64,QPainterPath::Element,QPageRanges::RangeandQColorSpace::PrimaryPointsare copied structures passed by value, likeQPoint, with property methods for their public fields:element.x,range.from.ext/matrix_helper.hppdefinesVector3D * Matrix4x4and its mirror, which Qt declares as friends but whose definitions are deprecated and hidden by the cutoff, so the binding wouldn't link without them.QMatrix3x3(aQGenericMatrixinstantiation) has no mapping, which dropsMatrix4x4#normal_matrixandQuaternion#to_rotation_matrix.- The QXmlStream classes return names, values and text as
QStringView, which has no mapping (a view of a temporaryQStringwould dangle inRegularExpression#match), sosrc/qt6/xml_stream.crwraps the helpers ofext/xml_helper.hpp:reader.name,attribute.value,reader.attributes.value("id").QXmlStreamAttributesis aQList<QXmlStreamAttribute>, soreader.attributes.map(&.name)works. QDataStreamstreamsQStringandQByteArraythrough free operators, which bindgen doesn't wrap as methods:src/qt6/data_stream.craddsstream << "text",stream.read(String)andstream.read(Int32)over the wrapped>>(Int32*). The>>of a copied structure (QPoint &) is dropped likeQValidator::validateabove.std::chrono::nanosecondsmaps to::Time::Spanlike milliseconds, and so do Qt's typedefs of it likeQElapsedTimer::Duration:ChronoTimer.new(5.milliseconds),ElapsedTimer#duration_elapsed.QKeyCombinationandQPointingDeviceUniqueIdare copied structures:KeyCombination.new(KeyboardModifiers::ControlModifier, Key::A).QCalendar::YearMonthDaytoo.Qt::RegularExpressionMatchIterator(RegularExpression#global_match) is anIterator(RegularExpressionMatch).QJsonObject#[]returns aJsonValuecopy;QJsonArray#[]aJsonValueRef, whose#assign!writes through.QJsonObject#insertreturns aJsonObjectIterator. LikewiseCborMap#insertreturns aCborMapIteratorwith#keyand#value(aCborValueRef); the pair of references the iterator dereferences to isn't default constructible, which a pair container needs, so it is ignored.QCborTagandQCborNegativeInteger,enum classes without enumerators, areUInt64:CborValue.new(Qt::CborKnownTags::Url, value).tagis32.CborValue.new(Qt::CborValue::Type::Null)replaces thenullptrconstructor.QUtf8StringViewmaps toStringlikeQAnyStringView.QStyleOptionHeaderV2andQStyleOptionMenuItemV2keep their fields in bit-fields, which the property methods read and write:option.text_elide_mode = Qt::TextElideMode::Middle,menu_item.mouse_down.StyleHintReturnMask#regionandStyleHintReturnVariant#variantare properties too.- A Crystal
Qt::IconEnginesubclass implementspaintandclone(seespec/qt/providers_spec.cr).cloneis declaredIconEngine?, as every pointer result is; an override returningnilhands C++ a null pointer. - A Crystal editor creator derives from the abstract
Qt::ItemEditorCreatorBase, implementingcreate_widget(parent)andvalue_property_name(Bytes), and registers withItemEditorFactory#register_editor(Qt::MetaType::Type::Int.to_i, creator).QItemEditorCreator<T>is a template and isn't bound.QRangeModel(Qt 6.10+) only has template constructors over a C++ range and isn't bound. QReadWriteLockderives fromQBasicReadWriteLocksince Qt 6.11, likeQMutexfromQBasicMutex: the bases are listed so the methods are inherited. The std-styletry_lock()andtry_acquire()aliases oftryLock()andtryAcquire(n = 1)would be the same Crystal methods, so bindgen drops them.try_lock_forand friends are templates.QMutexLockeris a template in Qt 6 and isn't bound.QException, which only serves throwing across QtConcurrent threads, isn't bound: it would shadow::Exceptioninside the module.- The meta-object system exchanges names as
const char *, anUInt8*:String.new(meta_object.class_name),meta_object.index_of_property("text".to_unsafe).Qt::Widget. static_meta_objectis a copy of the class'sQMetaObject, which the pointer comparison ofMetaObject#inheritsdoesn't match: pass an instance'smeta_object.QMetaObject::invokeMethodandQMetaMethod::invoketakeQGenericArguments (type name andvoid *pairs) or are templates, so they are dropped;Qt::MetaObject.invoke_method(object, "click")(ext/meta_helper.hpp) invokes a method without arguments.MetaType::Typekeeps Qt's constant names,MetaType::Type::QString. - Types without a mapping yet, like
QFont::Tagor theQEventconstructor tags, are ignored, which drops the methods using them. Abool *ok = nullptrargument isok : Bool*? = nil:bits.to_u_int32(endian), ortransform.inverted(pointerof(invertible))to learn the outcome.
Future things to do
- Forwarding
qHash()of wrapped types (toObject#hash) - Integration with LibEvent: Right now, Qt blocks the whole thread.
- The rest of the billion Qt classes of interest
- Integration for the
Qt DesignerUI designer - Integration for
Qt Linguist- Localization/Translation for your applications!
- UI test library, with adapter for
spec - Automated copy (and adaption) of the Qt documentation, for easy Crystal-specific docs
- The Qt Docs license should allow this if done correctly
Platform Support
| System | Status | Qt version | Notes |
|---|---|---|---|
| Ubuntu 24.04 | Works | 6.4 | CI, with LLVM 14 and 18 |
| macOS (Homebrew, arm64) | Works | 6.11 | CI |
| ArchLinux | Should work | 6.x | Help wanted |
| Windows | Help wanted | ? |
Contributing
- Open a new issue on the project to discuss what you're going to do and possibly receive comments
- Read bindgen's
STYLEGUIDE.mdfor some tips - Then do the rest, PR and all. You know the drill :)
Contributors
- Papierkorb Stefan Merettig - creator of qt5.cr
- docelic Davor Ocelic - qt6.cr
License
The Qt bindings, including the generated and manually-written parts, are subject to the MPL-2 license. You can find a copy attached of the full license text in the LICENSE file.
This project claims no copyright on the Qt framework or of any of its trademarks, source, or any other assets.
A note on Qt's license
A common misconception is that you have to pay for Qt to use it in closed-source applications.
This project assumes you'll link to Qt dynamically. In this case, you can use Qt free of charge including for closed-source, commercial applications under the terms of the LGPL.
You can build closed-source applications using Crystal, this shard, and Qt for free.
Note: This section is to combat this misconception, the authors of qt6.cr are in no way responsible to check if the same terms apply in your jurisdiction.
qt6.cr
- 0
- 0
- 0
- 0
- 1
- about 2 hours ago
- October 18, 2026
Mozilla Public License 2.0
Mon, 19 Oct 2026 11:25:20 GMT