The problem.
As a blind user, I often want to make an app that has an GUI, and my options are neither many nor especially comfortable. If I also want to write it in Rust for other reasons, for example because I want the security and efficiency Rust can give me, it gets even more difficult. Moreover, accessibility is not a feature but an absolute necessity for me.
Many people give up and settle for web interfaces, but that’s not native and doesn’t have the same features, responsiveness and minimal latency that I look for in my apps.
The solution: native-windows-gui.
Well, since this isn’t a YouTube video, I’m not going to keep you in suspense any further. The solution is native-windows-gui, or nwg in short. nwg is a crate (that’s how Rust packages are called) that allows us to access virtually the entire win32 GUI API: controllers, layouts, events, and even customising and creating our own widgets with subclassing, although obviously I’m not going to get that deep.
I’m going to start right from zero, but I’ll warn you that I’m going to go very quickly and lightly regarding Rust basics, because I take it for granted that you either know about it, or you can read the relevant documentation.
This is written in tutorial style, but if you don’t want to copy and paste code and just want the results, you have the source in a zip with all the necessary files.
And the binary compiled for windows.
Preparing the project.
First, if you don’t have rustup, you need to install it from here: https://www.rust-lang.org/tools/install
Get the 64-bit installer and you’re done.
As for the necessary toolchain, we will use stable-msvc. To do this you will need certain parts of Visual Studio and redistributable libraries. Anyway, there is documentation on this.
Now that you have rustup installed, we’re going to create our project. Go to the parent directory, for example I have my sources in c:/sources but to each their own. There we run in the following commands:
cargo new gui
cd gui
rustup override set stable-msvc
These commands do 3 things: 1) create a project in Rust with its basic structure, 2) get us into its directory, 3) and set the tool chain to stable-msvc, which is necessary to compile nwg.
If you run dir, you’ll see that you already have some files created. One of them is Cargo.toml, which is where the project information and its dependencies are stored.
You need something like this:
[package]
name = "gui"
version = "0.1.0"
edition = "2021"
license = "GPL v3"
authors = ["@modulux@isonomia.net"]
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
native-windows-derive = "1.0.5"
native-windows-gui = "1.0.13"
[build-dependencies]
embed-manifest = "1.3.1"
I’ll explain what all that does:
Name contains the name of the package. Version contains the version following semver standards (semantic versioning). License is the license. Authors, the authors. Edition indicates the Rust edition to use, and the latest is 2021.
Below we have the dependencies: native-windows-gui (nwg) is the main library, and native-windows-derive (nwd) contains macros to simplify and automate the creation of GUIs. In this tutorial I’m going to use both of them. It is possible to use nwg without nwd, but it is a drag.
The build-dependencies section has the necessary crates for the compilation phase (not execution). In this case we use embed-manifest to be able to put manifest data in the executable, though it could also be placed in an xml file. It’s there in order to avoid conflicts with COM versions and so on.
Now w’re talking about the build phase, you need to create a build.rs file in the same directory with the following content:
use embed_manifest::{embed_manifest, new_manifest};
fn main() {
if cfg!(target_os = "windows") {
let _ = embed_manifest(new_manifest("Contoso.Sample"));
}
println!("cargo:rerun-if-changed=build.rs");
}
This makes sure the target system for the build is Windows, and in that case it embeds the manifest file with the executable. In addition, we created a directory called .cargo, starting with a dot:
md .cargo
And inside it, we create a file called config with the following content:
[target.x86_64-pc-windows-msvc]
rustflags = ["-Ctarget-feature=+crt-static"]
[target.i686-pc-windows-msvc]
rustflags = ["-Ctarget-feature=+crt-static"]
These are additional instructions to Cargo, the Rust compiler manager, to put what it needs to into the static executable.
Then we must write our application. In a Rust project, the sources are in the src subdirectory. If we go into it we’ll see that there’s already a file there, called main.rs. Let’s get our app written.
Comments in Rust are introduced with two slashes, if it is a single line, or like in C, if they are multiline. This file is heavily commented so you get what’s going on.
#![windows_subsystem = "windows"]
// The first line is a directive so rust won't leave a console open while the program runs.
// If you want to experiment and use println to write out variables you can comment this line
// and a console will be open while the program is running.
extern crate native_windows_derive as nwd;
extern crate native_windows_gui as nwg;
use nwd::NwgUi;
use nwg::NativeUi;
// These lines are telling Rust: we're going to use nwd and nwg.
use std::cell::RefCell;
// RefCell is a data type that permits interior mutability.
#[derive(Default, NwgUi)]
pub struct BasicApp {
#[nwg_control(title: "Basic example", flags: "WINDOW|VISIBLE")]
#[nwg_events( OnWindowClose: [BasicApp::say_goodbye] )]
window: nwg::Window,
// This is the main window. As you can see it has some attributes such as a title and visibility.
// There's also an event hook. When it's closed, the say_goodbye function is called.
#[nwg_control(text: "Name", focus: true,)]
name_edit: nwg::TextInput,
// This is an edit field prefilled with "name" and it takes focus.
#[nwg_control(text: "Say my name.", )]
#[nwg_events( OnButtonClick: [BasicApp::say_hello] )]
hello_button: nwg::Button,
// Here we have a button with its attributes, and also an event hook.
// On activation, the say_hello function is called.
#[nwg_control(text: "Welcome to the application.", )]
status_bar: nwg::StatusBar,
// Status bar with a preset text.
clicks: RefCell<u32>,
// This is a RefCell, a cell that allows for interior mutability.
// It contains a u32 element, which is an unsigned 32-bit number.
// We'll use it to count the number of times the button is pressed.
// Generally, if you need to keep data, the best is to use RefCell.
}
// Now we implement the methods bound to this structure:
impl BasicApp {
fn say_hello(&self) {
// The method takes &self, which is a reference to the struct on which it's implemented.
nwg::simple_message("Hello", &format!("Hello, {}", self.name_edit.text()));
// This will bring up a dialog box with the message written there.
// Notice that it uses name_edit, the edit field, and its attribute text, to retrieve the name that has been written in there.
self.name_edit.set_selection(0..self.name_edit.len());
// We make sure the text in the edit field is set to selected once the button is pressed.
self.increment();
// We call the struct method to increment the number of clicks.
self.status_bar.set_text(
0,
&format!(
"You have already tried the application {} times.",
self.clicks.borrow()
));
// We write the number of clicks (already incremented) into the status bar.
self.hello_button.set_focus();
// Finally, we make sure the button remains focused.
}
fn say_goodbye(&self) {
nwg::simple_message("Goodbye", &format!("Goodbye, {}", self.name_edit.text()));
// The same kind of dialog box.
nwg::stop_thread_dispatch();
// This is the way to tear down the GUI to free the resources.
}
fn increment(&self) {
// This method increments clicks, a number contained in a RefCell.
// It doesn't do anything else by itself, and it's called by say_hello.
let mut current = self.clicks.borrow_mut();
// Here we take the value of clicks with a mutable borrow.
*current += 1;
// And here we increment it by reference.
}
}
fn main() {
// Main function.
// Bear in mind nwg runs its own event loop in a thread, so there's not much going on here.
nwg::init().expect("Failed to init Native Windows GUI");
// This is the GUI initialisation. If it fails the system is completely broken and we die here.
// We create our application object.
let _app = BasicApp {
clicks: 0.into(),
// We initialise clicks to 0, using into because it's a RefCell.
..Default::default() // And the rest to default values.
};
let _app = BasicApp::build_ui(Default::default()).expect("Failed to build UI");
// Here we use the machinery to build the GUI from our data structure.
_app.name_edit.set_selection(0.._app.name_edit.len());
// We make sure the edit field has its text selected, because I prefer it that way.
nwg::dispatch_thread_events();
// And here we launch nwg's main loop and set it to work.
}
How do we compile it? Three essential steps that I always follow:
cargo fmt
cargo check
cargo build
Cargo fmt is a command that formats Rust files using the established standards. I use it a lot because many times I don’t remember to indent or do so badly, and so I don’t have to worry.
Cargo check is a kind of verification phase that ensures that the program is syntacticly correct, that types are right, and the borrowing and mutability rules are met. It’s much faster than building, although the first time you run it will take longer.
Cargo build builds the program. By default it produces a debug version, in the target/debug directory, but if you want to compile to distribute, use build —release.
Some final brushstrokes.
It’s important to remember that this is a minimal notion of how Rust, nwg and win32 GUIs work. Needless to say, much more can be done, for example placing the controls in specific positions and with specific sizes, but for me one of the interesting things about this library is that it does not require it. I suppose it’s ugly as hell, but nothing prevents us from placing the controls we want without fixating on issues of layup, font, size, position, etc. That can always be done later, using parameters in the structure of the application, with relevant sighted help, or with one’s own intuition.
Anyway, I hope it’s been interesting.