A 2025 Survey of Rust GUI Libraries (13 Apr 2025)
I did this in 2020 and then again in 2021, but I’m in the mood to look around again. Let’s look through Are We GUI Yet? and see what’s up these days.
The task today is to have a text label and an input field that can change the text in the label. In React, for example, this is basically free:
const Demo = () => {
let [state, setState] = useState("Hello, world!");
return (
<div>
<p>{state}</p>
<input type="text" value={state} onInput={evt => setState(evt.target.value)} />
</div>
);
}
Choosing a task this simple means I can actually have a shot at completing this at a reasonable pace (although it took me two weeks), but it also means frameworks that prioritize scaling over initial setup will be at a disadvantage here. If you’ve found this post looking for specific guidance for a project that’s going to be substantially more complicated than this, don’t assume my conclusions are valid in your context.
A few other reasons my context may not match yours:
- I’m developing this, like nearly all my personal projects, on Windows. I’m in good company there — per the 2024 Stack Overflow developer survey, “Windows is the most popular operating system for developers, across both personal and professional use” — but for a handful of reasons Windows is an afterthought in a lot of open source development. I find some of those reasons more compelling than others, but for GUI libraries in particular I think avoiding Windows is avoiding success, and if Windows support is lower on your roadmap than trend chasing AI bullshit, you are not serious.
- I am checking that the text label can be read out from Windows Narrator. Screen reader accessibility is another frequent afterthought, and it’s not load-bearing for me personally but it’s a lot more important as a matter of principle (and potentially a matter of law, depending on the project).
- New in the 2025 version of this exercise: I will be using the Windows Japanese IME to type in the kanji for Tokyo,
東京
(which on my US-layout-emulating keyboard I do withtoukyou<Tab><Return>
). I don’t speak Japanese (although there are more obscure IMEs that I’m more interested in), and there are a lot of internationalization pieces that a minimal GUI library can reasonably decide to ignore, but if you’ve implemented text fields from scratch and you’re just appending keystrokes to a buffer then you have rejected compatibility with a lot of languages that are more complicated than that.
I’m feeling very slightly more patient this time around, so I’m not going to give up instantly if something takes very slightly more setup than just cargo add
, but I’ve got a lot of things to check, so that’s gotta be enough preamble.
I’m writing this in linear order in parallel with my development, so it’s more of a journal than a reference and it probably reads best top to bottom, but if you want a TL;DR or you’re coming back for reference you can skip right to the conclusion or the table.
Azul
Azul is the first beneficiary of my newfound patience: you have to manually download the prebuilt .dll
(via a link that doesn’t quite work, or directly off the GitHub release), and the last time I was here I balked at that request.
It’s not a great sign that the getting started guide has samples for C++ and Python but not Rust, but there are examples in the repo that aren’t too hard to follow.
However, the hello world sample doesn’t actually work if I copy and paste it into my main.rs
- it looks like the API has changed somewhat since the latest release.
There’s a bit of a theme of release versioning issues with Azul - following the guide appears to give me version 1.0.0-alpha4
, but the only Git tag is 1.0.0-alpha1
and it’s not clear what may have changed between alpha1 and alpha4.
The broader issue, though, is that even if I download the 1.0.0-alpha1
examples off the GitHub release and try to run the same code myself, I am beset with error LNK2019: unresolved external symbol __imp_AzCallbackInfo_getNodeIdOfRootDataset
and 47 other unresolved symbols.
I made an honest attempt to get Azul working, but it still doesn’t work.
I’ll see you in a couple years, Azul.
cacao
Cocoa is some subset of the macOS API; it has Rust bindings named Cacao. I could have sworn that the cocoa/cacao wordplay had been done forever ago in the macOS space, but maybe I just couldn’t fucking read, because the only things I’m able to find are this crate. Unsurprisingly, this does not work on Windows.
core-foundation
Core Foundation is a different subset of the macOS API; it has Rust bindings. Also not useful from Windows.
Crux
Crux is new, and I find it really intriguing. The idea of writing a shared library with business logic and then writing an ideally minimal native UI shell around it is also how Kotlin Multiplatform works (if you adopted it before Compose Multiplatform on iOS was out of alpha, at least), and I’ve been using that at my day job for a year and a half with only a handful of complaints. The initial project setup accurately describes itself as a sharp edge that needs better tooling, but it’s not miserable.
Manually defining the entire shared library interface feels like it would get old fast, but this task is faster. Unfortunately, though, I’m just now processing that Crux doesn’t actually support desktop GUI development, only mobile and web! It’s very interesting that this exists, though, and if my aim today was actually mobile development I’d be very curious if actual Swift bindings solve my Kotlin Multiplatform woes (I suspect that they might).
Hang on, though, this isn’t even a GUI library, the whole point is that you still use the native GUI library directly from each platform, and in fact several of the web examples use libraries that will be coming back later in this very list. I’m not quite certain I agree with listing it on Are We GUI Yet? in the first place, although I guess the current design doesn’t have space for a separate section for non-GUI frameworks that would be useful for GUI applications.
Cushy
Cushy is another new entrant - apparently there’s been a lot of movement in the space in the last four years. The README code snippet has a funny but inconsequential mistake:
// Create a dynamic usize.
let count = Dynamic::new(0_isize);
Maybe one day clippy will be able to detect comments that don’t actually match the code.
The README example actually works (it’s a Christmas miracle!) but it spams stderr with a ton of eyebrow-raising Vulkan/DirectX 12 errors:
2025-04-03T04:20:03.810927Z ERROR wgpu_hal::auxil::dxgi::exception: ID3D12CommandQueue::ExecuteCommandLists: Using ClearRenderTargetView on Command List (0x0000026C511CF250:'Unnamed ID3D12GraphicsCommandList Object'): Resource state (0x0: D3D12_RESOURCE_STATE_[COMMON|PRESENT]) of resource (0x0000026C510E4EB0:'Unnamed ID3D12Resource Object') (subresource: 0) is invalid for use as a render target. Expected State Bits (all): 0x4: D3D12_RESOURCE_STATE_RENDER_TARGET, Actual State: 0x0: D3D12_RESOURCE_STATE_[COMMON|PRESENT], Missing State: 0x4: D3D12_RESOURCE_STATE_RENDER_TARGET. [ EXECUTION ERROR #538: INVALID_SUBRESOURCE_STATE]
2025-04-03T04:20:03.812586Z ERROR wgpu_hal::auxil::dxgi::exception: ID3D12CommandQueue::ExecuteCommandLists: Using IDXGISwapChain::Present on Command List (0x0000026C51057910:'Internal DXGI CommandList'): Resource state (0x4: D3D12_RESOURCE_STATE_RENDER_TARGET) of resource (0x0000026C510E4EB0:'Unnamed ID3D12Resource Object') (subresource: 0) is invalid for use as a PRESENT_SOURCE. Expected State Bits (all): 0x0: D3D12_RESOURCE_STATE_[COMMON|PRESENT], Actual State: 0x4: D3D12_RESOURCE_STATE_RENDER_TARGET, Missing State: 0x0: D3D12_RESOURCE_STATE_[COMMON|PRESENT]. [ EXECUTION ERROR #538: INVALID_SUBRESOURCE_STATE]
It’s probably fine, though. The actual application is about as simple as you’d hope it’d be:
let text = Dynamic::new("Hello, world!".to_string());
let label = text.map_each(|text| text.clone());
let text_input = text.into_input();
label.and(text_input).into_rows().run()
Unfortunately, Windows Narrator has no idea what’s inside this window.
Kanji input with the IME sorta works, though; I don’t get to see the とうきょう
that my toukyou
becomes as I type it, but when I press Return I do in fact get the kanji I was expecting.
In IME jargon, turning toukyou
into とうきょう
is the job of the composer and turning とうきょう
into 東京
is the job of the converter, so the composer step is hidden but the converter step works fine.
I’m not sure whether Cushy has done anything in particular to accept IME results in its text input widgets or if the IME just dispatches the selected kanji as though they were typed directly, but I suspect it’s the latter. I know Windows is pretty flexible with how it treats keyboard input — every curly quote in this blog post was hand-curled with WinCompose despite my Markdown renderer almost certainly doing smart quotes automatically — so maybe the IME result is just dispatched as though it’s a direct input of U+6771 CJK Unified Ideograph and then U+4EAC CJK Unified Ideograph. (I had forgotten about Han unification; I’m curious how if at all using the Japanese IME causes the Japanese kanji to be displayed instead of the theoretically-equivalent hanzi/hanja, but I suspect the answer is that it doesn’t, and that scares me.)
CXX-Qt
CXX-Qt is a framework for using the well-established Qt C++ GUI library from Rust. I have been avoiding Qt for years, but it seems like it’s time to stop.
It’s annoying that I have to make an account to install Qt, and it’s very annoying that they want my name and location before they’ll let me download it. I was right to hate this the whole time.
Their sample code will not run due to 1058 linker errors:
Creating library C:\Users\Melody\Projects\misc\2025\rust-gui-survey\target\debug\deps\cxx_qt_demo.lib and object C:\Users\Melody\Projects\misc\2025\rust-gui-survey\target\debug\deps\cxx_qt_demo.exp␍
0245cd17b2ba3548-com_kdab_cxx_qt_demo_plugin_init.o : error LNK2019: unresolved external symbol "__declspec(dllimport) void __cdecl qRegisterStaticPluginFunction(struct QStaticPlugin)" (__imp_?qRegisterStaticPluginFunction@@YAXUQStaticPlugin@@@Z) referenced in function "public: __cdecl Staticcom_kdab_cxx_qt_demo_pluginPluginInstance::Staticcom_kdab_cxx_qt_demo_pluginPluginInstance(void)" (??0Staticcom_kdab_cxx_qt_demo_pluginPluginInstance@@QEAA@XZ)␍
libcxx_qt_lib-c91f193d907e83ae.rlib(badec7f11aadc5df-qcoreapplication.o) : error LNK2001: unresolved external symbol "__declspec(dllimport) void __cdecl qt_assert(char const *,char const *,int)" (__imp_?qt_assert@@YAXPEBD0H@Z)␍
<...>
libcxx_qt-464ae71fe424547a.rlib(0602fb52cb66f316-connection.o) : error LNK2019: unresolved external symbol "__declspec(dllimport) public: __cdecl QMetaObject::Connection::Connection(void)" (__imp_??0Connection@QMetaObject@@QEAA@XZ) referenced in function "class QMetaObject::Connection __cdecl rust::cxxqt1::qmetaobjectconnectionDefault(void)" (?qmetaobjectconnectionDefault@cxxqt1@rust@@YA?AVConnection@QMetaObject@@XZ)␍
libcxx_qt-464ae71fe424547a.rlib(0602fb52cb66f316-connection.o) : error LNK2019: unresolved external symbol "__declspec(dllimport) public: static bool __cdecl QObject::disconnect(class QMetaObject::Connection const &)" (__imp_?disconnect@QObject@@SA_NAEBVConnection@QMetaObject@@@Z) referenced in function "bool __cdecl rust::cxxqt1::qmetaobjectconnectionDisconnect(class QMetaObject::Connection const &)" (?qmetaobjectconnectionDisconnect@cxxqt1@rust@@YA_NAEBVConnection@QMetaObject@@@Z)␍
C:\Users\Melody\Projects\misc\2025\rust-gui-survey\target\debug\deps\cxx_qt_demo.exe : fatal error LNK1120: 1058 unresolved externals␍
I suspect it’s the entire Qt standard library that’s missing.
The linker args all look reasonable, though — it’s looking for C:/Qt/6.9.0/mingw_64/lib\libQt6Qml.a
, which aside from the mixed slashes is a real path that exists — so I’m not sure what the problem is.
This is a complete bust. There’s a section in the docs about building projects with CMake instead of cargo, but it says it’s optional, and it’s not like I’m desperate for more opportunities to use CMake, so I’m not going to try it.
Dioxus
I think I remember Dioxus as being one of the Rust frontend web dev frameworks; apparently they’ve branched out.
Their tutorial involves building “HotDog - basically Tinder, but for dogs!”, and by that they mean the app lets you swipe through a pile of dog photos and then view the ones you’ve swiped whichever direction is good on. That is not what I would expect “Tinder, but for dogs” to be, but maybe I don’t know what Tinder is.
Apparently the way Dioxus supports desktop development is through WebView2/WebKitGTK, so they haven’t branched very far out. I’m a little bit skeptical that Diet Electron is really the future, but given that Electron is the present, maybe I need to take what I can get. I also have some concerns about leaning this hard into cloning React — React hooks are a fascinating hack to almost build algebraic effects in JS, and it’s not like Rust really has algebraic effects, either, but maybe the real compilation step means they can do a little more magic and make it actually work reasonably. At this scale, though, it’s hard to argue with the results:
let mut text = use_signal(|| "Hello, world!".to_string());
rsx! {
p { "{text}" }
input {
type: "text",
oninput: move |event| text.set(event.value()),
value: "{text}"
}
}
Windows Narrator can even see what the text is, although it feels a little clumsy (it’s saying “Web content region” on its way into the body of the frame). The IME works perfectly, too. I guess there are benefits to letting Chrome-via-Edge-via-WebView2 be responsible for all the UI machinery.
Dioxus did not invent the Diet Electron approach (that was Tauri, whose WebView2/WebKitGTK/macOS things library Dioxus builds its desktop support on), but the Rust-all-the-way-through approach feels like a way better idea than how Tauri works, which I’ll get into once I make it that far through my list. If Diet Electron is really the best thing out there, I may be a little bit sad, but it’s probably possible to use Dioxus for real work without constantly being miserable, and that’s a new high water mark for this blog series.
Dominator
Dominator is a Web-only UI crate, and unlike Dioxus it does not also offer a blessed desktop stack.
egui
egui, and its framework eframe
, have been around for a while.
The setup process has always been pretty straightforward, which is nice.
It’s pretty simple to use, too:
egui::CentralPanel::default().show(ctx, |ui| {
ui.label(&self.label);
ui.text_edit_singleline(&mut self.label);
});
Windows Narrator can even see this text! It feels a little janky trying to get Narrator into the text field, though, but maybe that’s just the ceiling of how well Windows Narrator can work, or maybe I’m just holding it wrong and there’s a way to get behavior that feels more intuitive that I just can’t think of.
The default font doesn’t have hiragana or kanji coverage, though, and if I manually load a system font (which requires loading the bytes directly instead of just specifying a system font name, which is suboptimal but probably common), my Tab press to select 東京
as the kanji for とうきょう
gets eaten by egui and I’m stuck with the hiragana forever.
I prefer this default appearance to Cushy’s or Dioxus’s, although it’s probably possible to make anything look like anything if you try hard and believe in yourself. I’m not sure I love immediate mode on principle, although at this scale it extremely doesn’t matter.
Digression: “Immediate mode” and “retained mode”
The fact that it’s possible to render your UI yourself and still have real accessibility support is definitely a good thing, and if it weren’t for the weird IME issues this would be perfect. If you don’t need IME support and you want better styles out of the box or an immediate mode library you can plug into your existing game engine, egui seems like a perfectly reasonable choice, and that IME issue will probably get fixed eventually.
Floem
Floem is the UI framework developed for Lapce, the cooler VSCode-but-in-Rust IDE. I haven’t used Lapce in a while — several years ago when I last checked, its support for non-Rust languages was pretty weak, and I don’t actually prefer VSCode-style lightweight IDEs anyway (I pay for the JetBrains suite despite not doing enough personal development to justify that expense) — but everything that exists and is good enough for me once existed and was not. It’s cool that it exists; let’s see if their UI framework is any good.
Getting from zero to today’s sample is pretty straightforward:
let label = create_rw_signal("Hello, world!".to_owned());
(
dyn_view(move || label),
text_input(label),
).style(|s| s.flex_col())
Windows Narrator can’t see any of this text, and the IME won’t even start, I’m stuck with “toukyou” forever.
Building layouts out of tuples feels a little bit weird — you can only have up to 16 widgets directly within a container at once, due to tuple generics in Rust being still obviously incomplete after 11 years and counting — but it’s probably better than Cushy’s .and()
.
The complete lack of accessibility or IME support is the real issue, though.
Maybe one day they’ll fix it, but for now, this is no good.
fltk
FLTK is a C++ library with Rust bindings.
Conveniently, the Rust bindings offer a bundled
feature so I don’t have to figure out how to build FLTK from source on Windows.
Unfortunately, FLTK doesn’t appear to have an idea of widgets having an inherent size, and its whole layout subsystem leaves something to be desired:
let app = App::default();
let mut wind = Window::new(100, 100, 400, 300, "Hello from rust");
let mut pack = Pack::default_fill().with_type(PackType::Vertical);
let mut label = Frame::default();
label.set_label("Hello, world!");
let mut input = Input::default();
input.set_value("Hello, world!");
input.set_callback(move |input| label.set_label(&input.value()));
input.set_trigger(CallbackTrigger::Changed);
pack.end();
pack.auto_layout();
wind.end();
wind.show();
app.run().unwrap();
Windows Narrator has no idea what’s going on in here, but I did notice a two-year-stale fltk-accesskit repo under the fltk-rs
org, and that works but it requires its own ugly setup (you have to pass it a redundant list of all your widgets, which is tough because I moved one of them into the callback for the other).
The IME works perfectly, which I wasn’t expecting.
The main issue here is the layout subsystem, which appears to have no concept of widgets having intrinsic sizes.
You can manually position and size everything, or you can use one of the clumsy automatic layouts, but neither of those is particularly satisfying.
The fact that adding widgets to containers happens in implicit global state and you have to .end()
a container to stop adding items to it is a little bit horrifying, I can’t lie.
I don’t like this API design one bit.
flutter_rust_bridge
Flutter is a Google framework for cross-platform UI development, but you use it from Dart.
Dart has switch
statements and switch
expressions with completely different syntax.
Dart sucks.
Maybe using Flutter from Rust doesn’t suck?
It’s very funny to me that Flutter for Windows claims you absolutely need a 1366x768 display; as an act of spite I will be disabling my primary display and only using my 1024x768 secondary display for the remainder of this section.
Oh god this is cramped, I regret this already.
Flutter hates my MSVC toolchain for some reason, and the Visual Studio installer does not want to be this narrow, but if I tab offscreen or move the window to the side I can still add the right components. Apparently “MSVC v143 - VS 2022 C++ x64/x86 build tools” and “Windows 11 SDK” aren’t good enough, and Flutter absolutely insists on having specifically “MSVC v142 - VS 2019 C++ x64/x86 build tools” and “Windows 10 SDK”.
If I install those, though, flutter doctor
still doesn’t think I have them for some reason.
It seems like my issue, which flutter doctor
failed to detect for some reason, was that I didn’t have the “Desktop development with C++” workload installed in Visual Studio.
Getting flutter_rust_bridge set up is easy once Flutter itself is working, although having to run flutter_rust_bridge_codegen generate
explicitly is no good; it can’t hook into the Flutter/Dart build process because the only piece that anything can hook into runs completely outside the Flutter/Dart build process.
However, what you actually get with flutter_rust_bridge is the opportunity to write your business logic in Rust and your UI in Dart still.
You can write your UI state in Rust if you want, but you still have to define your widgets in Dart:
#[frb(ui_state)]
pub struct RustState {
pub label: String,
}
impl RustState {
pub fn new() -> Self {
Self {
label: "Hello, world!".to_owned(),
base_state: Default::default(),
}
}
#[frb(ui_mutation)]
pub fn set_label(&mut self, label: String) {
self.label = label;
}
}
void main() => runRustApp(body: body, state: RustState.new);
Widget body(RustState state) {
return Column(
children: [
Text(state.label),
TextField(
controller: TextEditingController(text: state.label),
onChanged: (text) => state.setLabel(label: text),
),
],
);
}
This doesn’t even actually work, though: typing happens in reverse in the input field, because the TextEditingController
contains the caret position but keeps getting reset.
In pure Flutter, you’d probably solve this by moving the controller into the widget state, but it’s not obvious how to do that here since our widget state is being defined in Rust instead.
Windows Narrator appears to be able to see this text, at least, although it feels janky trying to move between the label and the input.
The IME sort of works, but for presumably the same TextEditingController
reasons, the intermediate states aren’t being cleared as I type in the IME, so the actual value that I’ve entered with toukyou<Tab><Return>
is 東京東京東京東京とうきょうとうきょうとうきょとうきょとうkyとうkyとうkとうkとうとうととt
.
I’d get slightly better functionality if I moved this widget state to Flutter, I’m sure, but then there’d be no Rust code at all. If I wanted to write my UIs in Flutter, I’d just do that. If you want to write your UIs in Flutter and just some business logic in Rust, this might work alright for you, but that is not what I want.
Freya
Per the README, Freya is “a cross-platform GUI library for Rust powered by 🧬 Dioxus and 🎨 Skia.” Evidently, it takes the logic and structure of Dioxus and but renders everything itself instead of using Diet Electron. I did grumble about the Diet Electron-hood of Dioxus, so maybe this is the exact thing I was hoping for all along.
Freya’s latest stable release depends on the prior minor version of Dioxus, which may or may not have a slightly different rsx!
macro (Dioxus 0.6 uses rsx! {}
but Freya’s Dioxus 0.5 uses rsx!()
, and I forget if that’s actually different or not), but this still looks a lot like our Dioxus code:
let mut text = use_signal(|| "Hello, world!".to_string());
rsx!(
label { "{text}" }
Input {
value: text.read().clone(),
onchange: move |value| text.set(value),
}
)
Narrator appears to almost understand the structure of this window — it’s seeing that there’s a text edit, at least — but it can’t actually figure out what any of the text actually is. The IME activates, but in the wrong place on screen, and neither the provisional kana nor the final kanji actually shows up in the text input. Even my WinCompose shortcuts don’t work.
There are some drawbacks to rendering things yourself instead of letting Chrome-via-Edge-via-WebView2 do it for you, it seems. Regardless, it’s extremely cool that Dioxus is set up in a way that makes this possible, and it’s extremely cool that someone’s trying to do it. It is not, however, at a point where I would recommend using it.
fui
FUI does not have a lot of high-level documentation I can use to figure out what to put here.
The example in the README has drifted from the examples in the code, which I suppose is natural but which is rarely auspicious.
Even less auspicious is that I can’t build fui_system
:
qmake.stderr: Project ERROR: Cannot run compiler 'g++'. Output:
===================
===================
Maybe you forgot to setup the environment?
I can find no documentation about how to set up my environment, and if I really need g++
then that’s not great.
GemGui
The last commit to GemGui was two years ago; that’s rarely a good sign.
GemGui leans even further into Diet Electron by actually running your frontend on an HTTP server and by default just opening it in your regular browser. There’s a setting to run it in its own application frame instead, but that appears to have a load-bearing dependency on Python being installed in a way that I currently don’t have it installed. It also looks like you’re only really intended to define the UI elements in HTML and wire up the business logic in Rust.
If I give this a venv and then manually ensure python3
will do the right thing, that isn’t enough, because the Python dependency doesn’t actually work or something.
“Embed an HTTP server and then use a Web framework and open your server in the system default browser” is a very boring way to technically claim that you’re doing GUI development.
This repo has four stars on GitHub. Why is it even listed in Are We GUI Yet?
GPUI
GPUI is the UI framework developed for Zed, the other VSCode-but-in-Rust IDE. Are We GUI Yet? links to someone squatting it on crates.io, which is silly.
Remember this piece from the intro?
if Windows support is lower on your roadmap than trend chasing AI bullshit, you are not serious.
Well, that’s Zed. It’s a heavily-LLM-focused IDE with no Windows support. GPUI appears to work alright on Windows, though.
It looks like GPUI doesn’t have a basic text input widget, though; their text input example is over 700 lines of code. It’s possible to shuffle that example around to at least get something that’ll meet the task I’m working on, though.
// this isn’t even the bad part!
div()
.flex()
.key_context("TextInput")
.track_focus(&self.focus_handle(cx))
.cursor(CursorStyle::IBeam)
.on_action(cx.listener(Self::backspace))
.on_action(cx.listener(Self::delete))
.on_action(cx.listener(Self::left))
.on_action(cx.listener(Self::right))
.on_action(cx.listener(Self::select_left))
.on_action(cx.listener(Self::select_right))
.on_action(cx.listener(Self::select_all))
.on_action(cx.listener(Self::home))
.on_action(cx.listener(Self::end))
.on_action(cx.listener(Self::show_character_palette))
.on_action(cx.listener(Self::paste))
.on_action(cx.listener(Self::cut))
.on_action(cx.listener(Self::copy))
.on_mouse_down(MouseButton::Left, cx.listener(Self::on_mouse_down))
.on_mouse_up(MouseButton::Left, cx.listener(Self::on_mouse_up))
.on_mouse_up_out(MouseButton::Left, cx.listener(Self::on_mouse_up))
.on_mouse_move(cx.listener(Self::on_mouse_move))
.bg(rgb(0xeeeeee))
.line_height(px(30.))
.text_size(px(24.))
.child(
div()
.h(px(30. + 4. * 2.))
.w_full()
.p(px(4.))
.bg(white())
.child(TextElement {
input: cx.entity().clone(),
}),
)
Narrator has no idea what’s going on inside this window (and it’s in good company there). The IME works fine, though.
I’m not sure you’re actually supposed to use GPUI at this stage; the documentation is spotty, the installation is janky, and the standard library is woefully inadequate. But at least you can generate bad code way faster, and clearly that’s enough to get your Series A in. Was inflicting Electron on us all by way of Atom not enough?
GTK 3
UNMAINTAINED Rust bindings for the GTK+ 3 library (use gtk4 instead).
OK then.
GTK 4
GTK is the GNOME toolkit (although apparently that’s not actually what it stands for); it’s got Rust bindings. Conveniently, there are specific installation instructions for Windows. It’s not clear whether or not just downloading the prebuilt binaries would work, and it’d certainly save a lot of time if they would, but I’m going to assume building the binaries myself will be more likely to work. That only took five minutes, astonishingly.
I find it a little bit counterintuitive that the single-line text widget is named Entry
, but it does kinda rule that GTK’s property bindings mean I don’t have to keep any state at all:
let label = Label::builder()
.label("Hello, world!")
.build();
let entry = Entry::builder()
.text("Hello, world!")
.build();
entry
.bind_property("text", &label, "label")
.build();
let r#box = Box::builder()
.orientation(Orientation::Vertical)
.build();
r#box.append(&label);
r#box.append(&entry);
let window = ApplicationWindow::builder()
.application(app)
.title("My GTK App")
.child(&r#box)
.build();
window.present();
Not only can Narrator not see this text, it can’t even see the minimize/maximize/close buttons, and that’s not a failure state I had realized was possible. The IME works fine.
You may have noticed that this is not the idiomatic Windows window decoration; these minimize/maximize/close buttons are very GNOMEy, which makes sense but isn’t really what I want.
Maybe Adwaita will solve this?
For some reason, gvsbuild build libadwaita librsvg
(as recommended by the gtk-rs
book) is building fucking libsass; not the actually maintained stop-trying-to-make-Dart-happen rewrite dart-sass, but the old and busted last-commit-two-years-ago libsass.
rsvg also appears to depend on some yanked crate versions, which is concerning but not my problem today.
Well, Adwaita certainly makes it look different:
I’ve got way more drop shadow or something that’s extending the ShareX window capture way beyond the actual boundary of the window, and I’ve got dark mode and a purple emphasis color (which I’m not sure if they’re reading from somewhere or if the default was just picked out by someone with good taste). It still doesn’t look like a Windows window, though, and Adwaita has not magically fixed the accessibility.
Using GTK4 from Rust on Windows works, I guess, but I would have to lower my standards a lot to call this good enough.
Iced
Iced says it’s inspired by Elm, and that’s cool. Elm was your favorite programming language’s favorite programming language. I miss it sometimes.
struct State {
label: String,
}
impl Default for State {
fn default() -> Self {
Self { label: "Hello, world!".to_owned() }
}
}
impl State {
fn update(&mut self, message: Message) {
match message {
Message::SetLabel(label) => self.label = label
}
}
fn view(&self) -> Column<Message> {
column![
text(&self.label),
text_input("", &self.label)
.on_input(Message::SetLabel),
]
}
}
#[derive(Clone, Debug)]
enum Message {
SetLabel(String),
}
Windows Narrator can’t see into this window, and the IME won’t even switch into active mode when I try to switch it into active mode, which may actually be a better failure state than having it just not work.
Apparently System76 is all in on Iced for Pop!_OS’s COSMIC shell, so for their users’ sake I hope accessibility and IME support are actually happening at some point.
imgui
Dear ImGui is a minimalist C++ GUI library with Rust bindings.
I will always think of it as being called dear imgui,
, even though it hasn’t been canonically spelled that way since 2018; the trailing comma is delightful in an e e cummings sort of way, and they should bring it back.
Unfortunately, since Dear ImGui is designed to be plugged into an existing game engine, starting with it from scratch is a little bit annoying.
There’s a downright Linux-desktop-environment number of different ways to use imgui-rs, but apparently “The most tested platform/renderer combination is imgui-glow-renderer
+ imgui-winit-support
+ winit
”, so that’s what I’ll use.
Or at least it would be what I’d use if the examples didn’t all use imgui-glium-renderer
instead; that one’s deprecated and it doesn’t appear to work with the latest version of glium
but I can’t figure out how to get glow
working instead.
Open source!
// eliding the 160 lines of glue i copied and pasted without understanding
let mut label = "Hello, world!".to_string();
support::simple_init(file!(), move |_, ui| {
ui.window("Hello world")
.size([300.0, 110.0], Condition::FirstUseEver)
.build(|| {
ui.text_wrapped(&label);
ui.input_text("Text", &mut label)
.build();
});
});
Windows Narrator can’t see this text, and the IME refuses to activate.
The tiny window within the huge window is hilarious, and it makes sense if you’re actually doing game dev and there’s a game in the rest of the window, but I am not, and the massive white void is not something I would tolerate even in an application I was building solely for myself. If I had a graphics stack picked out already because I was doing game dev, I might not mind the flexibility of supporting what feels like hundreds of different renderers and backends, but since that is not my current situation I very much do mind the flexibility. This is what Sartre meant by being “condemned to freedom”: you have innumerable options available to you, but nobody can rescue you from the responsibility of deciding between them.
digression: the irony you may have noticed
KAS
KAS, the toolKit Abstraction System, is written from scratch in Rust. The tutorials are a bit out of date — some things appear to have been moved around between when the tutorials were written and the most recent stable release — but the examples in the actual repo appear to work. I’m not sure I quite understand how the state management is designed, but after a bit of fumbling I can at least complete the task:
let tree = column![
format_value!("{}"),
EditBox::instant_parser(|x: &String| x.clone(), SetLabel),
];
Adapt::new(tree, "Hello, world!".to_string())
.on_message(|_, label, SetLabel(text)| *label = text)
Narrator can’t see this text, and the IME won’t activate.
The part that confuses me the most is why the EditBox
needs to be explicitly told that it’s a String
that’s being edited; that seems like it shouldn’t require an explicit declaration.
Maybe if the tutorial were up to date it’d be easier to understand.
Regardless, it seems like this isn’t really ready for prime time yet.
kittest
kittest is an AccessKit-driven testing library that only supports egui. This is cool, but it’s not in the same category as the sort of thing I’m actually looking for. If I maintained Are We GUI Yet? I’d probably split the list up into separate categories like Are We Web Yet? has.
Leptos
Leptos is a Web frontend framework that is for some reason on the Are We GUI Yet? list. The README has an FAQ about native GUIs that says it’d be possible to build native GUIs with Leptos but it’s not actually supported because it sent the whole codebase into generics hell when they tried it.
lvgl
LVGL, the Lightweight and Versatile Graphics Library, is a C GUI library designed for embedded use; it has Rust bindings that are #![no_std]
compatible by default, which is neat if you need that.
Unfortunately, after copying around the C header files that define the configuration, I’m getting C compiler errors:
C:\Users\Melody\.cargo\registry\src\index.crates.io-1949cf8c6b5b557f\lvgl-sys-0.6.2\vendor\lv_drivers\display\fbdev.c(13): fatal error C1083: Cannot open include file: 'unistd.h': No such file or directory
It seems like the LVGL configuration in the Rust binding samples is not designed to work on Windows, and if I figure out the flag to disable the framebuffer driver I get similar errors about not finding SDL, and if I disable SDL I get bindgen not being able to find libclang, and if I come back after fixing bindgen for a later library I get errors about the linker not finding SDL even though I already turned off SDL. I wouldn’t be surprised if doing desktop development with LVGL is missing the point, though, and I’m holding it wrong by not cross compiling to some slightly cursed embedded Linux target.
Makepad
Makepad is another novel Rust GUI framework. They’re publishing versions to crates.io but not creating Git tags to match those versions, so it’s hard to find the examples that are supposed to work with the published crates, and it’s easier to just point the dependency right at the Git repo so the examples from their main branch will work. They’ve got a macro DSL with no documentation I can find, but a bit of persistence is all it takes to turn the simplest example into something that works:
live_design! {
import makepad_widgets::base::*;
import makepad_widgets::theme_desktop_dark::*;
App = {
ui: <Root> {
main_window = <Window> {
body = <ScrollXYView> {
flow: Down,
spacing: 10,
align: { x: 0.5, y: 0.5 },
label1 = <Label> {
text: "Hello, world!",
}
input1 = <TextInput> {
text: "Hello, world!",
}
}
}
}
}
}
impl MatchEvent for App {
fn handle_actions(&mut self, cx: &mut Cx, actions: &Actions) {
let input1 = self.ui.text_input(id!(input1));
if let Some(new_text) = input1.changed(&actions) {
let label1 = self.ui.label(id!(label1));
label1.set_text(&new_text);
label1.redraw(cx);
}
}
}
Narrator can’t even see the minimize/maximize/close buttons, much less the content of the window. The IME opens in the corner of the display and has its own wrapper to show the kana while I’m typing, but when I press Enter the kanji get correctly entered into the text field.
I kinda like the Blender aesthetic they’ve got for their stock widgets, but the lack of accessibility support is a downer, and the lack of documentation around the load-bearing DSL may be worse. Maybe the documentation exists and I just can’t find it (they’ve got a Discord server that I’m not joining), but I suspect that Makepad is built for the Makepad team right now, and any utility anyone else can get out of it is coincidental. A lot of projects start in that stage, and most of mine never leave, so that’s not a bad thing, but I do kinda wish Are We GUI Yet? was doing a little more pruning. I’ve been working on this post for almost a week already, and I’m only 58% of the way through the list.
masonry
Masonry is a pure-Rust GUI library that’s the successor to the discontinued Druid framework that I found really impressive early on. It primarily serves as the foundation for Xilem, a kinda Elm-ish kinda SwiftUI-ish framework I’m looking forward to trying once I get all the way down the list.
The last release of Masonry was 11 months ago, and it looks like a lot has changed since then, so I’m going to just point to the main
branch directly.
fn on_action(&mut self, ctx: &mut DriverCtx<'_>, _widget_id: WidgetId, action: Action) {
match action {
Action::TextChanged(new_text) => {
ctx.render_root().edit_widget(self.label_id, |mut label| {
let mut label = label.downcast::<Label>();
Label::set_text(&mut label, new_text);
});
}
_ => {}
}
}
let main_widget = Portal::new(
Flex::column()
.with_child(Label::new("Hello, world!").with_id(label_id))
.with_child(Textbox::new("Hello, world!"))
.with_spacer(VERTICAL_WIDGET_SPACING),
);
Confusingly, Narrator can tell that there’s a text field in here, but it’s wrong about where in the window it is.
Kanji input via the IME works, but the default font appears to not support the fullwidth Latin characters that show up in provisional states before the hiragana replace them, so there’s a bit of tofu in the intermediate states ☐
, とう☐
, and とう☐☐
(after the t
, k
, and y
in toukyou
).
I think it’s fair to say that you aren’t really supposed to use Masonry directly, the intent is to build an architecture on top of Masonry. The API ergonomics feel like they match that: you probably could build a nontrivial application directly in Masonry, but I wouldn’t recommend it.
Maycoon
Maycoon is another new from scratch pure Rust framework. It looks like it’s still pretty new. The quick start guide is out of date, but the in-repo examples are still good. (It’s very nice that Cargo defaults to compiling examples as part of running tests.)
Maycoon doesn’t appear to have a text input widget. GPUI didn’t, either, but at least they had an example that involved building one from scratch that I could just copy. Maycoon is too new to be usable for this task.
Pax
Pax is “a revolutionary new canvas for building apps & websites with AI.” 🤮🤮🤮 Shipping a real visual editor for your GUI DSL is a good idea, though, although I think I broke the fancy editor somehow because it stopped responding to anything and started logging a bunch of index out of bounds errors.
Alas, the only desktop target Pax supports is macOS, which is useless to me today. Maybe one day an AI bro who pays for Twitter will contribute something positive to society, but this is not that day.
qmetaobject
QMetaObject is a different approach to Qt bindings than CXX-Qt that appears to try to put even more code into Rust.
Unfortunately, it appears to not work nicely with the windows-msvc
target, and I don’t appear to have a gcc toolchain installed in a place that it likes.
relm
relm is named after Elm, which is neat. Unfortunately, relm is built on the unmaintained GTK 3 bindings, and even if I did install GTK 3 I’m not certain it would’ve worked, because some load-bearing components are making incorrect UNIX-centric assumptions about how lists of paths work:
pkg-config exited with status code 1
> PKG_CONFIG_PATH=C:\Users\Melody\Projects\_resources\gtk-build\gtk\x64\release\lib\pkgconfig PKG_CONFIG_ALLOW_SYSTEM_CFLAGS=1 pkg-config --libs --cflags gdk-3.0 'gdk-3.0 >= 3.22'
The system library `gdk-3.0` required by crate `gdk-sys` was not found.
The file `gdk-3.0.pc` needs to be installed and the PKG_CONFIG_PATH environment variable must contain its parent directory.
PKG_CONFIG_PATH contains the following:
- C
- \Users\Melody\Projects\_resources\gtk-build\gtk\x64\release\lib\pkgconfig
I wonder if there’s a library that’s like relm but built on GTK 4?
Relm4
Relm4 is like relm but built on GTK 4.
view! {
gtk::Window {
set_title: Some("Simple app"),
set_default_size: (300, 100),
gtk::Box {
set_orientation: gtk::Orientation::Vertical,
set_spacing: 5,
set_margin_all: 5,
gtk::Label {
#[watch]
set_label: &model.label,
set_margin_all: 5,
},
gtk::Entry {
set_text: &model.label,
connect_changed[sender] => move |x| sender.input(Msg::SetLabel(x.text().into())),
},
}
}
}
As was the case with the GTK 4 test, Narrator can’t even see the window chrome, and the IME works fine.
This architecture is probably easier to work with at scale than throwing GTK 4 widgets around directly would be, but it also inherits all of the problems of GTK 4 on Windows, of which there are many.
Also, that view!
macro is (like all Rust macros) really annoying to try to debug if your input is slightly incorrect, which mine was while I tried to figure out how to get connect_changed
working.
If you love the widgets of GTK 4 but want a nicer architecture, you might like Relm4, but if you are not in that situation, this library has nothing to offer you.
Ribir
Ribir is a bespoke Rust GUI framework that markets itself as being “non-intrusive”, meaning that your GUI components read and write your model objects directly, with no intermediate state layers or constraints on model objects. I’m not sure that this is actually a problem with other frameworks, and I feel like there might be a clearer way to pitch that, but it’s in principle good to be trying to stand out in what I had not quite realized was this crowded of a landscape.
The website docs only have options for 0.2.x and main despite 0.3.0 coming out last August.
Well, I can’t get the pile of macro magic to work correctly, and I’m not sure why. This code looks like it should work, but the text I type into the input field doesn’t actually appear in the label:
App::run(fn_widget! {
let label = State::value("Hello, world!".to_string());
let input = @Input { auto_focus: true };
$input.write().set_text("Hello, world!");
@Column {
@Text { text: pipe!($label.to_string()) }
@ $input {
on_key_up: move |_| {
label.set($input.text().to_string());
},
}
}
});
Windows Narrator can’t see any of the text inside this window. The IME opens in a random corner of my monitor, the composer state is hidden, and the default font doesn’t have kanji, but there are two missing-glyph symbols after I confirm from the IME converter, so it seems like the IME technically a little bit works.
If the pitch was that you don’t need to think about the mechanisms of state going into and out of the UI, then the pitch was overstated, because I have an app that looks like it should work but doesn’t because that glue has gone wrong.
Rinf
Rinf is another framework for using Rust for business logic and Flutter for UIs. I want that even less now than I did a few days ago when I wrote the flutter_rust_bridge section.
rui
rui is a Rust GUI library “inspired by SwiftUI”; as someone who’s worked with SwiftUI a lot at my day job, I’m reading that as meaning that this will be almost good but its state management will be clumsy and annoying to work with.
It’s pretty alright at scales this small, though, so that may not be fair:
rui(state(
|| "Hello, world!".to_string(),
|label, cx| {
vstack((
text(&cx[label]).padding(Auto),
text_editor(label).padding(Auto),
))
},
));
Narrator can’t see this text, and the IME won’t even activate.
I think I kinda like the aesthetic — a strong default font goes a long way towards giving a GUI toolkit a cohesive look — and I also like the lack of load-bearing macros, but I’m of course less wild about the lack of accessibility or IME support.
Slint
Slint is like if Qt had been invented 30 years later. Like Qt, it’s got its own bespoke DSL, and like Qt, the business model is to be GPL and sell exceptions, but unlike Qt, desktop and web exceptions are free and it’s only embedded development that they want you to pay for. There’s no inherent guarantee that that’ll always be the case, but also, doing this stuff right is hard and I am in favor of people getting paid for the work that they do.
This DSL kinda rules, I can’t lie:
export component AppWindow inherits Window {
property <string> label: "Hello, world!";
VerticalBox {
Text {
text: root.label;
}
LineEdit {
text <=> root.label;
}
}
}
Narrator works perfectly.
The IME renders all its provisional states as just missing-character glyphs ☐
, which makes me think the default font just doesn’t have fullwidth Latin or hiragana or kanji, but once I accept the kanji from the converter I get the actual kanji displaying correctly, so that may actually be a bug and not just a font selection issue.
Using <=>
as the operator to create a two-way data binding is really clever, I hope they’re proud of that.
Also, since this DSL is a standalone language and not just a pile of Rust macros, the ceiling on error message quality is way higher.
I don’t think they’re actually hitting that ceiling yet, though: before I stumbled into the two-way data binding operator I had a callback with a syntax error and the Slint compiler wasn’t a ton of help.
They’ve even got C++, JS, and Python bindings, and it’s cool that you can write a library once in Rust and then use it from that many different languages.
I suggested towards the end of my previous excursion here that Slint (then still called SixtyFPS) would merit a more thorough look once it had better accessibility support, and it certainly does. It’s not perfect, but it’s come a long way in the last four years, and I’m curious what the next couple years will look like for Slint.
Tauri
Do you like Electron but wish it was Rust? Tauri is that. To their credit, they’ve also swapped out the bundled Chromium for just binding to the system’s inherent web browser, whether that’s WebView2 on Windows, WebKit on macOS, or WebKitGTK on Linux, so Tauri applications don’t fill your hard drive with a dozen copies of the same web browser. Unfortunately, they have not touched the architecture; you still have a host process running outside of the browser and an independent frontend running inside the browser.
Building that frontend is not something Tauri is concerned with; you have to decide for yourself what you want your stack to look like. I want to write Rust, so in the new project wizard I pick Rust as my frontend language (rather than JavaScript/TypeScript or, for all three diehard Blazor fans, C#). I’m then asked which Rust frontend framework I want, and I don’t really want to have to pick between Dioxus and Leptos and Sycamore and Yew right now, so I pick “Vanilla” assuming that it’ll give me bare web-sys to make the same DOM API calls as vanilla JS but in Rust; I’ve done this before, and it’s not very good, but at this scale it’d be completely tolerable. Instead, though, “Vanilla” means vanilla JS even if I selected Rust, and since I selected Rust I don’t even get an option of vanilla TS instead. This was reported a year and a half ago and ignored.
If I’m stuck making a choice, I need an excuse to ignore three of the four provided options. I already looked at Dioxus, so that’d be boring to use again. Yew’s 0.22 release was announced in October 2024, added to the changelog in December 2024, and released on crates.io literally never (as of April 2025); that’s not great. The Leptos book says that it’s “most similar to frameworks like Solid and Sycamore”; maybe that’s a sign that it doesn’t matter, or maybe it’s a sign that I should try both.
// leptos
#[component]
pub fn App() -> impl IntoView {
let (label, set_label) = signal("Hello, world!".to_string());
let update_label = move |ev| {
let v = event_target_value(&ev);
set_label.set(v);
};
view! {
<p>{ move || label.get() }</p>
<input type="text" value={ move || label.get() } on:input=update_label />
}
}
// sycamore
#[component]
pub fn App() -> View {
let label = create_signal("Hello, world!".to_string());
view! {
p { (label) }
input(r#type="text", bind:value=label)
}
}
Narrator can see all this text, and the IME provisional states are drawn in the corner of the screen rather than inline but they work correctly and the final kanji look fine.
I guess the main difference between these two Web frameworks that we can see from here is that Sycamore has a bind:
modifier for attributes (very good!) but can’t quite handle input type=
because type
is a Rust keyword and requires r#type
instead (very bad!).
The largest issue, though, is something I haven’t captured here, because today’s task is so trivial it can be done entirely within the frontend, and I let that count for GTK so I have to let it count here too.
If I add the requirement that the new value of the label be printed to standard output when the label changes (as a stand-in for, say, performing some file I/O), in most frameworks it suffices to either add a println!
to the existing event handler or subscribe to a two-way-bound state with a println!
.
Even Dioxus, built on the same WebView2/WebKitGTK library as Tauri, will do the right thing if I println!
from an event handler.
In Tauri, though, a println!
from the frontend will be completely ignored, and if we want to be able to println!
we need inter-process communication between the frontend and the host process.
In the host, this is nice and easy due to the magic of proc macros:
#[tauri::command]
fn print(text: &str) {
println!("{}", text);
}
In the frontend, however, there is a lot more boilerplate:
#[wasm_bindgen]
extern "C" {
#[wasm_bindgen(js_namespace = ["window", "__TAURI__", "core"])]
async fn invoke(cmd: &str, args: JsValue) -> JsValue;
}
#[derive(Serialize, Deserialize)]
struct PrintArgs<'a> {
text: &'a str,
}
#[component]
pub fn App() -> View {
// ...
create_effect(move || {
let label = label.get_clone();
spawn_local_scoped(async move {
let args = serde_wasm_bindgen::to_value(&PrintArgs { text: &label }).unwrap();
invoke("print", args).await;
})
});
// ...
}
This makes me sad for two reasons.
The first is a question of principle: this arbitrary boundary drawn through the middle of my application means that if I discover I need a new piece of functionality I may need to move a substantial chunk of code from the frontend to the backend, and if it’s something load-bearing within the frontend I’m going to have a real motherfucker of a time pivoting my architecture on short notice for no reason.
The second is a question of type safety: as you may have noticed, the IPC interface in the frontend takes an &str
for the command name and a JsValue
for the command arguments, meaning frontend IPC calls have no type checking.
Indeed, if I rename the argument in the host from text
to text_to_print
and don’t update the PrintArgs
in the frontend, I get not even a warning at compile time, and at runtime I get
panicked at src\app.rs:6:1:
unexpected exception: JsValue("invalid args `textToPrint` for command `print`: command print missing required key textToPrint")
Uncaught RuntimeError: unreachable
If I then change the label, I get
panicked at C:\Users\Melody\.cargo\registry\src\index.crates.io-1949cf8c6b5b557f\wasm-bindgen-futures-0.4.50\src\task\singlethread.rs:103:37:
already borrowed: BorrowMutError
and this is all just misery upon misery.
Half the point of Rust is the sheer quantity of bugs that it can catch at compile time, and if your IPC is just tossing strings around and praying at runtime, you may as well be just writing vanilla JavaScript.
(I checked, and even if your frontend is TypeScript, the invoke
IPC boundary just takes a string
rather than a union of the actual legal values.)
Hilariously, the Tauri docs claim that the command mechanism is “for reaching Rust functions with type safety”, as distinct from their event system, which is even less type safe. With events, you’re tossing strings and arbitrary JavaScript payloads around in both the frontend and the host, so technically it’s not false to claim that only doing that on one end is more type safe, but type checking only at one end is like putting a lock on your bike but not running it through the bike rack: you aren’t tying two things together, you’re just tying one thing to itself and praying. Even to the limited extent that half of type safety could be useful, though, they’ve picked the wrong half: there’s inherently only one implementation of the command, but there can be many calls to it, so it’d be far more valuable to have type safety at the call sites than at the implementation site. Commands only go from the frontend to the host, so type checking in the host and YOLOing in the frontend is being picky at the receive end and sloppy at the send end, which is the exact opposite of Postel’s law.
digression: Postel’s law
If there was an unnecessary IPC boundary but it was type safe, or if there was bad stringly typed nonsense somewhere but no unnecessary IPC boundary, I might find it within myself to forgive that, but the combination of the entirely unnecessary split-brain architecture with the absolute lack of type safety at the boundary means that I think I genuinely hate Tauri.
tinyfiledialogs
tinyfiledialogs is a C library with Rust bindings providing a handful of basic prompts, including message boxes, text input a la JS’s window.prompt
, and, as the name implies, file dialogs.
I can’t actually complete today’s task with those.
Tk
Tk is the GUI framework for Tcl, a fascinating late-80s programming language that I would describe as weird in a Lua way, weird in a LISP way, and weird in its own way all at once. Evidently it has Rust bindings.
The docs for the Rust bindings recommend ActiveState’s ActiveTcl, but I can’t find an actual way to download ActiveTcl, and ActiveState’s pricing page has an FAQ entry that doesn’t inspire confidence:
Can I still get ActivePerl, ActivePython, or ActiveTcl?
If you still need access to our legacy releases, please get in touch with us via our Contact us page.
I’m installing Magicsplat Tcl instead. It doesn’t appear to have been enough, though:
thread 'main' panicked at C:\Users\Melody\.cargo\registry\src\index.crates.io-1949cf8c6b5b557f\bindgen-0.64.0\lib.rs:2393:31:
Unable to find libclang: "couldn't find any valid shared libraries matching: ['clang.dll', 'libclang.dll'], set the `LIBCLANG_PATH` environment variable to a path where one of these files can be found (invalid: [])"
Apparently this can be fixed with just winget install LLVM.LLVM
.
let tk = make_tk!()?;
let root = tk.root();
let c = root
.add_ttk_frame("c" -padding((3, 3, 12, 12)))?
.grid(-column(0) -row(0) -sticky("nwes"))?;
root.grid_columnconfigure(0, -weight(1))?;
root.grid_rowconfigure(0, -weight(1))?;
let label = c
.add_ttk_label("label" -text("Hello, world!"))?
.grid(-column(1) -row(1))?;
let entry = c
.add_ttk_entry("entry" -textvariable("label"))?
.grid(-column(1) -row(2))?;
entry.insert(0, "Hello, world!")?;
entry.bind(
event::any_key_release(),
tclosure!(tk, || label.configure(-text(entry.get()?))),
)?;
Windows Narrator can’t see any of this text, and the IME works fine.
I guess translating ttk::label "label" -text "Hello, world!"
(which is how Tcl works, I’m fairly certain) to add_ttk_label("label" -text("Hello, world!"))
makes sense for existing Tcl users, but it feels really weird, and cargo fmt
is understandably confused by it.
It’s also hard to tell which options are actually supported on which widgets, because of all the trait magic that’s going on; it took me a while to discover that .insert()
is the way you set the initial text of the text field.
There’s also a bit of jank in the example — apparently attaching a callback to a button requires unsafe
.
Plus, after I uninstalled Tcl/Tk, the binary stopped working, so you can only ship a binary to people who also manually install Tcl/Tk.
If you already know and love Tcl/Tk and just wish (heh) you could use it from Rust, maybe you’d find this useful, but I am not in that situation and I do not.
Vizia
Vizia is another novel Rust GUI library. The book says to depend on the Git repo rather than the latest release, which I usually try to avoid, but sure. Conveniently, the counter example is pretty easy to adapt for what I need here, and there’s no list of widgets in the book (at least that I could find) but there is a list of widgets in the docs so I don’t have to spend fifteen minutes guessing what they call their text field.
#[derive(Lens)]
pub struct AppData {
label: String,
}
pub enum AppEvent {
SetText(String),
}
impl Model for AppData {
fn event(&mut self, _: &mut EventContext, event: &mut Event) {
event.map(|app_event, _| match app_event {
AppEvent::SetText(text) => {
self.label = text.clone();
}
});
}
}
fn main() -> Result<(), ApplicationError> {
Application::new(|cx| {
AppData {
label: "Hello, world!".to_string(),
}
.build(cx);
VStack::new(cx, |cx| {
Label::new(cx, AppData::label);
Textbox::new(cx, AppData::label).on_edit(|cx, text| cx.emit(AppEvent::SetText(text)));
})
.alignment(Alignment::Center);
})
.title("Counter") // Configure window properties
.inner_size((400, 100))
.run()
}
Something weird happens to the internal padding in the text field when I focus it, though:
Narrator can see that there’s a text label and a text input within this window, but it doesn’t appear to be able to see what the text actually is in either widget, which is a new one. The IME converter dropdown appears in the corner of the screen, but the provisional states draw correctly in the text field, and the final kanji go in correctly.
The structure that’s present here seems promising, but Vizia doesn’t seem to be quite ready for serious use yet.
WebRender
WebRender is part of the guts of Servo, the Rust-based web browser engine that Mozilla founded and then abandoned (an event which prompted my biggest hit on this blog). It’s been picked back up and is some amount of back, although quite how back its new team wants it to be is still up in the air.
The in-repo examples are so old they’re still using the 2018 language edition, and the latest version published on crates.io is from 2020, so I guess I’m depending on the Git repo again.
There’s a main
branch last updated four months ago and an upstream
branch updated last week; presumably the upstream
branch is the right one to use, but it’d be nice if that was actually explained somewhere.
Looking a bit at the examples, WebRender does not have widgets, it just has shapes; this is a low-level graphics crate and not a high-level GUI crate, and it’s not clear why it’s on the Are We GUI Yet? list.
windows
Windows is an operating system created by Microsoft in the mid-1980s; its APIs have Rust bindings.
I do not know how to do GUI development with bare Win32 API calls, though, so doing them from Rust is not exciting.
XAML, which is to my understanding the shiny new way to do desktop GUI development in specifically Windows, is explicitly not included in the windows
crate because “Xaml is also focused and tailored for C# app development so this API isn't applicable to Rust developers.”
This is confusing, because the XAML-based WinUI 3 is also supported on C++, not just C#.
Ah, Microsoft.
WinSafe
WinSafe is apparently a set of Rust wrappers around the Win32 GUI API.
#[derive(Clone)]
pub struct MyWindow {
wnd: gui::WindowMain,
label: gui::Label,
field: gui::Edit,
}
impl MyWindow {
pub fn new() -> Self {
let wnd = gui::WindowMain::new(
gui::WindowMainOpts {
title: "My window title".to_owned(),
size: (300, 150),
..Default::default()
},
);
let label = gui::Label::new(
&wnd,
gui::LabelOpts {
text: "Hello, world!".to_string(),
position: (20, 20),
..Default::default()
},
);
let field = gui::Edit::new(
&wnd,
gui::EditOpts {
text: "Hello, world!".to_string(),
position: (20, 50),
..Default::default()
},
);
let new_self = Self { wnd, label, field };
new_self.events(); // attach our events
new_self
}
pub fn run(&self) -> AnyResult<i32> {
self.wnd.run_main(None) // simply let the window manager do the hard work
}
fn events(&self) {
let ready = Arc::new(AtomicBool::new(false));
let ready2 = ready.clone();
self.wnd.on().wm_create(move |_| {
ready2.store(true, Ordering::SeqCst);
Ok(0)
});
let self2 = self.clone();
self.field.on().en_change(move || {
if ready.load(Ordering::SeqCst) {
self2.label.set_text(&self2.field.text());
}
Ok(())
});
}
}
Unsurprisingly, Narrator and the IME both work.
Manual positioning is no good, and the alternatives involve Win32 .res
file editing and other Pandora’s boxen I don’t want to open.
Also, I had to add that ready
tracking myself, because the callback was firing for the first time before the window had been created, which was causing crashes.
This isn’t great, and of course it’s only useful on Windows.
Xilem
Xilem is another novel pure-Rust framework, built on top of the previously discussed masonry, the successor to Druid, which I thought was really promising when I first got started with this series. It hasn’t had a numbered release in almost a year, so I’m pointing at the Git repo again.
Conveniently, they’ve got a todo list example that I can sculpt into what I need today mostly by deleting.
struct State {
label: String,
}
fn app_logic(state: &mut State) -> impl WidgetView<State> + use<> {
flex((
label(state.label.clone()),
textbox(state.label.clone(), |state: &mut State, new_value| {
state.label = new_value;
}),
))
}
fn run(event_loop: EventLoopBuilder) -> Result<(), EventLoopError> {
let data = State {
label: "Hello, world!".to_string(),
};
let app = Xilem::new(data, app_logic);
app.run_windowed(event_loop, "First Example".into())
}
fn main() -> Result<(), EventLoopError> {
run(EventLoop::with_user_event())
}
As was the case when using Masonry directly, Narrator sees the text but is wrong about its position, and some IME provisional states have missing glyphs but the IME behavior works fine.
This architecture seems pretty neat, although of course this is not sufficient to know how well it works at any reasonable scale. Honestly, aside from the screen reader jank, my only gripe is the lack of versioning, and presumably that’ll be coming as Xilem matures.
Conclusion
What a list, huh? If I had counted how many of these there were before I got started, I might’ve never started, because 43 is a lot. It’s good that there are this many different people working in this space, but the more options there are, the more important it is for people to be sifting through them to distinguish the ones that aren’t ready to use from the ones that are.
Let’s pick some winners. If you’d rather take the quirks of CSS layout over the quirks of some other layout engine, Dioxus seems like a pretty reasonable choice; Diet Electron is definitely better than regular Electron, and that may be good enough for you, but it feels not-better-enough to me (although my sense of not-better-enough is non-standard). If you like DSL-driven UIs that are putting serious effort into developer tooling, Slint might be for you. If you want to avoid DSLs and macros and write only regular Rust, egui offers that. If you’re looking for something to invest in early, Xilem is basically usable now if you don’t mind pointing at the Git repo and putting up with some jank. There are open issues for improving accessibility in Floem, Freya, and iced, so it may be worth at least keeping an eye on those issues, although the iced issue has been open for 4½ years now.
I would not describe any of these as a super easy slam dunk obviously correct choice, but there are a lot of reasonable options available, and that’s better than 2021, where I was longing for wxPython by the end of the post. Maybe better things are possible after all.
Now if you’ll excuse me, I’ve got some cargo clean
s to run:
The Table
library | works at all? | screen reader accessible? | IME works? |
---|---|---|---|
Azul | linker hell | ||
cacao | macOS-specific | ||
core-foundation | macOS-specific | ||
Crux | no desktop targets | ||
Cushy | yes! | nope | composer hidden, converter works |
CXX-Qt | linker hell | ||
Dioxus | yes! | yes! | yes! |
Dominator | web-specific | ||
egui | yes! | yes! | composer works, Tab press stolen from converter |
Floem | yes! | nope | nope |
fltk | yes! | with extra crate | yes! |
flutter_rust_bridge | kinda, but state hell | yes! | kinda, but state hell |
Freya | yes! | nope | nope |
fui | qmake hell | ||
GemGui | technically | ||
GPUI | yes! | nope | yes! |
GTK 3 | unmaintained | ||
GTK 4 | yes! | nope | yes! |
Iced | yes! | nope | nope |
imgui | yes! | nope | nope |
KAS | yes! | nope | nope |
kittest | only for testing | ||
Leptos | web-specific | ||
lvgl | C dependency hell | ||
Makepad | yes! | nope | composer outside window, converter works |
masonry | yes! | content but not position | yes! but some temporary tofu |
Maycoon | no text input widget | ||
Pax | no Windows support | ||
qmetaobject | no windows-msvc | ||
relm | uses unmaintained GTK 3 | ||
Relm4 | yes! | nope | yes! |
Ribir | kinda, but state hell | nope | composer hidden, converter works |
Rinf | does not use Rust for GUI | ||
rui | yes! | nope | nope |
Slint | yes! | yes! | missing glyphs in provisional states but logic works and final kanji displayed correctly |
Tauri | yes! | yes! | composer outside window, converter works |
tinyfiledialogs | not general-purpose | ||
Tk | yes! | nope | yes! |
Vizia | yes! | structure but not content | converter outside window, everything works |
WebRender | too low-level | ||
windows | i don’t know Win32 | ||
WinSafe | yes! | yes! | yes! |
Xilem | yes! | content but not position | yes! but some temporary tofu |