The web platform is the delivery mechanism of choice for a ton of software these days, either through the web browser itself or through Electron, but that doesn’t mean there isn’t a place for a good old fashioned straight-up desktop application in the picture.
Fortunately, it’s easier than ever to write a usable, pretty, and performant desktop app, using my language of choice (Rust) and the wildly successful cross-platform GUI framework GTK. In this blog post, we’ll walk through the source code of gDiceRoller. In future posts, I’ll explain how I packaged it for different systems.
What App?
gDiceRoller is a truly simple app. It leverages the excellent rfyl dice notation library to allow users to roll dice of arbitrary numbers of sides and perform arithmetic on the results.
Dice notation is the de facto standard notation used by tabletop gaming systems such as Dungeons and Dragons to express how many and which kind of dice a player should roll to determine what happens in a game. A roll is expressed as NdM, where N is the number of dice to be rolled, and M is the number of sides each die should have.
Dungeons and Dragons is known for using an icosahedron, or d20, for many of its rolls. When determining the effects of attacks in combat, it’s common to roll 2d8 or more, and in older editions of the game it was possible to build up truly complex sets of rolls, like 1d20 + 1d6 - (1d4 / 2) for a skill check or 12d6 + 2 for some damage rolls.
The most common dice are those that correspond to the platonic solids; tetrahedrons (d4), cubes (d6), octahedrons (d8), decahedrons (d10), dodecahedrons (d12), and icosahedrons (d20). People also like to roll two d10s and use the first as the first digit and the second as the second digit to make a makeshift “d100”, for percentages.
This application will allow its users to generate pseudorandom numbers based on parameters specified in dice notation, or by clicking on any of the several common dice provided, as well as permitting common operations like halving results with specific rounding behavior.
That’s it - it’s quite simple!
UI Prototyping
Glade, from the GNOME project, is easy to use and very powerful.
Given the simple interface for this program, it would be fairly easy to build the entire interface from within Rust code, but such an approach becomes unwieldy for larger projects. Fortunately, the GNOME project provides a best-in-class UI design program called Glade (available from your distribution or at glade.gnome.org).
Glade is pretty intuitive; I suggest picking up the UI definition for this project and pulling it up yourself. This is an XML-based file that defines the tree structure of the user interface, much like HTML does for web applications. (If you want to deep-dive on Glade, check out the dedicated tutorial series from the GNOME project.)
Glade’s killer feature, in my opinion, is its “preview snapshot” functionality, which opens the current UI definition in an actual native window. This lets you play with any standard UI functionality like notebooks (tabs), scroll bars, and tree views without writing any code at all.
Because it’s a tree structure, you can prototype fragments of the UI, like complex list entries or modals, in their proper place and then simply drag-and-drop them into their own tree to be used in the program once they look good.
Once the UI looks great, just save it for later. Integration into gtk-rs, the Rust GTK3 bindings, is very easy.
Dependencies
Note: from this point on, anywhere you see codes.nora.gDiceRoller or gDiceRoller, you will need to use your own project’s name. I would have called the crate codes.nora.gDiceRoller, in standard reverse domain notation used in many packaging systems, but that’s not permitted, so I simply called it gDiceRoller.
All you need to start writing this code is a working cargo installation and the GTK development libraries. On Ubuntu, that’s libgtk-3-dev.
Within the Cargo.toml, the dependencies are actually pretty simple; we need GTK, GLib, and RFYL (which handles the dice rolling).
With GTK, we have to specify a feature corresponding to the minimum toolkit version the program will support, through the use of cargo feature tags. This project deliberately uses an old version for maximum compatibility, but you’re free to choose whatever you want.
[package]
name = "gdiceroller"
version = "0.1.0"
authors = ["Leonora Tindall <nora@nora.codes>"]
edition = "2018"
[dependencies]
rfyl = "0.3.1"
glib = "0.7"
[dependencies.gtk]
version = "0.6"
features = ["v3_16"]
Factorization
Thanks to @federicomena for help refactoring this program to be easier to read.
gDiceRoller is an example of an easy-to-use factorization template for any GTK Rust application. It keeps its application logic, state management, and GUI management in seperate modules, and uses the main() function to tie them together.
Rolling Dice
The most important thing this program does is to roll dice - that is, take a string of dice notation and generate a number from it. All of that logic is provided by rfyl, and it’s only really necessary to have a single function that handles the whole process.
That’s roll_expression; it lives in src/rolls.rs and takes a string, passes it to rfyl::roll, and returns either the result or an error. Putting this logic in its own file also gives us an obvious place to do some basic tests.
State Management
Then there’s the application’s state to think about. For gDiceRoller, that’s simple. It stores the last rolled value and, if there was a problem with the user-entered dice notation, what caused that error.
Whatever your application’s global state is, it can go in its own module, state. In gDiceRoller, that’s a single file src/state.rs, and it contains the actual state as a State struct with functions to operate on it. These functions are what the rest of the application will use to change the state, and they take an &mut self. In gDiceRoller, there’s just one; the application does sometimes directly change the value inside, but the only complex behavior is encapsulated in update_from_roll_result, which is tested in the same file.
GUI Handling
Now comes the actual “front-end” portion; the GUI. In gDiceRoller, all the handles into the UI are contained in a single struct, MainWindow, in `src/main_window.rs’
In the new() function for this struct, the include_str!() macro is used to load the Glade template file. I just saved it in the src/ folder and did include_str!("mainwindow.glade").
Then, each element of the struct is initialized with builder.get_object("objectName").unwrap(). This takes these objects from the stringly-typed world of the XML file to their exact types in the MainWindow struct.
The exception to this rule is the buttons. There are a lot of buttons, and they all do the same thing with a minor variation, so a for loop is used to run through a slice of all the names and add them to a HashMap<String, gtk::Button>. It doesn’t provide quite the same compile-time guarantees, but at least get() on that HashMap returns a gtk::Button rather than an arbitrary GObject.
The MainWindow struct also has a separate run-time function, start(), which sets the application’s name, its wmclass, and its delete event, and shows it and its children.
Finally, MainWindow has a function update_from which takes an &State and changes all the visible fields to their appropriate values. In this case, that’s just showing the error popover if there’s an error, and updating the number displayed at the top.
This is all the data structures in use; the rest of the work is done in main.rs.
Functionality
main.rs functions as something of a plugboard, wiring together the actual computation with the State and MainWindow structs, and starting the whole thing rolling.
The program first tries to initialize GTK. Then, the GUI state and the application state are initialized and placed in Arc<> and Arc<RefCell<>>, respectively. State can now be safely modified from within callbacks, thanks to RefCell’s interior mutability (see Section 15.05 in The Rust Programming Language for more information on this pattern).
RefCell and RwLock are both good candidates for use as the interior-mutability abstraction here. RefCell is used in this case because there is no need to share between threads, and RefCell has less complexity than RwLock. Mutex is also a viable candidate, or a custom data structure using std::sync::atomics.
The use of Arc instead of Rc is unnecessary here, since the program is single-threaded. This is an error on my part; the data structure should be Rc<RefCell<T>> or Arc<Mutex<T>>/Arc<RwLock<T>>. This will be corrected soon.
After that, main just needs to add all the callbacks for the various buttons. All the standard dice can be done with a loop. Those for clearResult, halveDownResult, et cetera are done on their own.
There’s a pattern to these callbacks. Firstly, each callback is created in its own local scope, so as to make lifetime juggling easier. Then, the appropriate control is fetched, and Arc::clone() is called on &gui and &state to get a local reference to the global state. Finally, the actual code is written in a move closure, so these references are captured.
For example, the clearResult callback is written thus:
{
let button = gui.button("clearResult");
let gui = Arc::clone(&gui);
let state = Arc::clone(&state);
button.connect_clicked(move |_| {
let mut state = state.borrow_mut();
state.value = 0;
gui.update_from(&state);
});
}
Finally, main calls gui.start() and gtk::main() and the program runs!
You can run the tests with cargo tests, or actually run the program with cargo run.
Putting the “Desktop” in “Desktop App”
Just a pretty GUI and speedy functionality aren’t enough, though; a good desktop app needs integration. The most basic integration, on the Linux desktop, is provided through a .desktop file and an application icon. In addition, applications can provide an appdata.xml file describing their features and history. All of these files are held in the data directory, in the case of gDiceRoller.
[Desktop Entry]
Name=Dice Roller
GenericName=Dice Roller
Comment=Roll dice of many different shapes and sizes in all possible combinations.
Categories=Game;GTK
Icon=codes.nora.gDiceRoller
Exec=codes.nora.gDiceRoller
Terminal=false
Type=Application
This format is well documented, and supports a lot of nice features like translations. It should be placed in /usr/share/applications/ or somewhere similar (depending on distribution).
You’ll notice that the Icon field uses the same name as the application’s full ID. That is because the icon can also be installed on the system. gDiceRoller provides an SVG icon which can be scaled to any size, as well as raster icons at 64x64 and 128x128 resolutions. These are placed in /usr/share/icons/hicolor/<size>/applications. These icon names can then be used to set the window’s icon in the Glade file as well.
GTK and desktop environments automatically find icons by name, but it’s sometimes necessary to refresh their cache before they can actually find new icons.
All of this is getting kind of complex. Time to introduce: the build system!
The Build System
The build system, in this case using make, lets us easily install and uninstall the program from a system.
So, why did I use make? Well, it’s really simple and pretty much just does what I want, and gets out of the way. In addition, using a make-based build makes it easier for other distributions to package the project in the future. Here’s the whole Makefile I used during early development, which will act as the template for the rest of the project.
# Just tell make that clean, install, and uninstall doesn't generate files
.PHONY: clean clean-all install uninstall
# Build the application
target/release/gDiceRoller : src
cargo build --release
# Install onto the system
install : target/release/gDiceRoller
# Install the binary
cp target/release/gDiceRoller /usr/bin/codes.nora.gDiceRoller
cp data/codes.nora.gDiceRoller.desktop /usr/share/applications/codes.nora.gDiceRoller.desktop
cp data/codes.nora.gDiceRoller.svg /usr/share/icons/hicolor/scalable/applications/codes.nora.gDiceRoller.svg
# Remove from the system
uninstall :
rm -f /usr/bin/codes.nora.gDiceRoller
rm -f /usr/share/applications/codes.nora.gDiceRoller.desktop
rm -f /usr/share/icons/hicolor/scalable/applications/codes.nora.gDiceRoller.svg
# Rebuild from scratch
clean-all : clean
cargo clean
# Does nothing, yet
clean :
true
With this Makefile, it’s possible to simply:
make
sudo make install
and have a totally working installation. To fix a few issues with the system not picking up on the new icon, adding touch /usr/share/icon/hicolor to the install target after installing the icon does the trick.
Then, if you want to remove the program:
sudo make uninstall
NOTE: This Makefile is incomplete; please check out the one used by the actual project as a better template here and/or wait for the next post to explain all the aspects of that Makefile.
Conclusion and Recap
gDiceRoller isn’t a groundbreaking application, but I hope it demonstrates how easy it is to make useful, non-web-based software. I only showed how to install the software on a dev machine, but in the next few posts, I’ll look at Flatpak, Snap, .deb packages, and even Windows packaging. GTK is a cross-platform toolkit, after all!
Speedy Desktop Apps With GTK and Rust | Nora Codes Skip to main content
Nora Codes
home blog tutorials projects rss
Speedy Desktop Apps With GTK and Rust
Leonora Tindall 2019/07/05
The web platform is the delivery mechanism of choice for a ton of software these days, either through the web browser itself or through Electron, but that doesn’t mean there isn’t a place for a good old fashioned straight-up desktop application in the picture.
Fortunately, it’s easier than ever to write a usable, pretty, and performant desktop app, using my language of choice (Rust) and the wildly successful cross-platform GUI framework GTK. In this blog post, we’ll walk through the source code of gDiceRoller. In future posts, I’ll explain how I packaged it for different systems.
What App?
gDiceRoller is a truly simple app. It leverages the excellent
rfyl
dice notation library to allow users to roll dice of arbitrary numbers of sides and perform arithmetic on the results.This application will allow its users to generate pseudorandom numbers based on parameters specified in dice notation, or by clicking on any of the several common dice provided, as well as permitting common operations like halving results with specific rounding behavior.
That’s it - it’s quite simple!
UI Prototyping
Given the simple interface for this program, it would be fairly easy to build the entire interface from within Rust code, but such an approach becomes unwieldy for larger projects. Fortunately, the GNOME project provides a best-in-class UI design program called Glade (available from your distribution or at glade.gnome.org).
Glade is pretty intuitive; I suggest picking up the UI definition for this project and pulling it up yourself. This is an XML-based file that defines the tree structure of the user interface, much like HTML does for web applications. (If you want to deep-dive on Glade, check out the dedicated tutorial series from the GNOME project.)
Glade’s killer feature, in my opinion, is its “preview snapshot” functionality, which opens the current UI definition in an actual native window. This lets you play with any standard UI functionality like notebooks (tabs), scroll bars, and tree views without writing any code at all.
Because it’s a tree structure, you can prototype fragments of the UI, like complex list entries or modals, in their proper place and then simply drag-and-drop them into their own tree to be used in the program once they look good.
Once the UI looks great, just save it for later. Integration into
gtk-rs
, the Rust GTK3 bindings, is very easy.Dependencies
All you need to start writing this code is a working
cargo
installation and the GTK development libraries. On Ubuntu, that’slibgtk-3-dev
.Within the Cargo.toml, the dependencies are actually pretty simple; we need GTK, GLib, and RFYL (which handles the dice rolling).
With GTK, we have to specify a feature corresponding to the minimum toolkit version the program will support, through the use of
cargo
feature tags. This project deliberately uses an old version for maximum compatibility, but you’re free to choose whatever you want.Factorization
gDiceRoller is an example of an easy-to-use factorization template for any GTK Rust application. It keeps its application logic, state management, and GUI management in seperate modules, and uses the
main()
function to tie them together.Rolling Dice
The most important thing this program does is to roll dice - that is, take a string of dice notation and generate a number from it. All of that logic is provided by
rfyl
, and it’s only really necessary to have a single function that handles the whole process.That’s
roll_expression
; it lives insrc/rolls.rs
and takes a string, passes it torfyl::roll
, and returns either the result or an error. Putting this logic in its own file also gives us an obvious place to do some basic tests.State Management
Then there’s the application’s state to think about. For gDiceRoller, that’s simple. It stores the last rolled value and, if there was a problem with the user-entered dice notation, what caused that error.
Whatever your application’s global state is, it can go in its own module,
state
. In gDiceRoller, that’s a single filesrc/state.rs
, and it contains the actual state as aState
struct with functions to operate on it. These functions are what the rest of the application will use to change the state, and they take an&mut self
. In gDiceRoller, there’s just one; the application does sometimes directly change the value inside, but the only complex behavior is encapsulated inupdate_from_roll_result
, which is tested in the same file.GUI Handling
Now comes the actual “front-end” portion; the GUI. In gDiceRoller, all the handles into the UI are contained in a single struct,
MainWindow
, in `src/main_window.rs’In the
new()
function for this struct, theinclude_str!()
macro is used to load the Glade template file. I just saved it in thesrc/
folder and didinclude_str!("mainwindow.glade")
.Then, each element of the struct is initialized with
builder.get_object("objectName").unwrap()
. This takes these objects from the stringly-typed world of the XML file to their exact types in theMainWindow
struct.The exception to this rule is the buttons. There are a lot of buttons, and they all do the same thing with a minor variation, so a
for
loop is used to run through a slice of all the names and add them to aHashMap<String, gtk::Button>
. It doesn’t provide quite the same compile-time guarantees, but at leastget()
on thatHashMap
returns agtk::Button
rather than an arbitraryGObject
.The
MainWindow
struct also has a separate run-time function,start()
, which sets the application’s name, itswmclass
, and its delete event, and shows it and its children.Finally,
MainWindow
has a functionupdate_from
which takes an&State
and changes all the visible fields to their appropriate values. In this case, that’s just showing the error popover if there’s an error, and updating the number displayed at the top.This is all the data structures in use; the rest of the work is done in
main.rs
.Functionality
main.rs
functions as something of a plugboard, wiring together the actual computation with theState
andMainWindow
structs, and starting the whole thing rolling.The program first tries to initialize GTK. Then, the GUI state and the application state are initialized and placed in
Arc<>
andArc<RefCell<>>
, respectively.State
can now be safely modified from within callbacks, thanks toRefCell
’s interior mutability (see Section 15.05 in The Rust Programming Language for more information on this pattern).After that,
main
just needs to add all the callbacks for the various buttons. All the standard dice can be done with a loop. Those forclearResult
,halveDownResult
, et cetera are done on their own.There’s a pattern to these callbacks. Firstly, each callback is created in its own local scope, so as to make lifetime juggling easier. Then, the appropriate control is fetched, and
Arc::clone()
is called on&gui
and&state
to get a local reference to the global state. Finally, the actual code is written in amove
closure, so these references are captured.For example, the
clearResult
callback is written thus:Finally,
main
callsgui.start()
andgtk::main()
and the program runs!You can run the tests with
cargo tests
, or actually run the program withcargo run
.Putting the “Desktop” in “Desktop App”
Just a pretty GUI and speedy functionality aren’t enough, though; a good desktop app needs integration. The most basic integration, on the Linux desktop, is provided through a
.desktop
file and an application icon. In addition, applications can provide anappdata.xml
file describing their features and history. All of these files are held in thedata
directory, in the case of gDiceRoller.For
gDiceRoller
, the.desktop
filecodes.nora.gDiceRoller.desktop
looks like this:This format is well documented, and supports a lot of nice features like translations. It should be placed in
/usr/share/applications/
or somewhere similar (depending on distribution).You’ll notice that the
Icon
field uses the same name as the application’s full ID. That is because the icon can also be installed on the system.gDiceRoller
provides an SVG icon which can be scaled to any size, as well as raster icons at 64x64 and 128x128 resolutions. These are placed in/usr/share/icons/hicolor/<size>/applications
. These icon names can then be used to set the window’s icon in the Glade file as well.GTK and desktop environments automatically find icons by name, but it’s sometimes necessary to refresh their cache before they can actually find new icons.
All of this is getting kind of complex. Time to introduce: the build system!
The Build System
The build system, in this case using
make
, lets us easily install and uninstall the program from a system.So, why did I use
make
? Well, it’s really simple and pretty much just does what I want, and gets out of the way. In addition, using amake
-based build makes it easier for other distributions to package the project in the future. Here’s the wholeMakefile
I used during early development, which will act as the template for the rest of the project.With this
Makefile
, it’s possible to simply:and have a totally working installation. To fix a few issues with the system not picking up on the new icon, adding
touch /usr/share/icon/hicolor
to theinstall
target after installing the icon does the trick.Then, if you want to remove the program:
Conclusion and Recap
gDiceRoller isn’t a groundbreaking application, but I hope it demonstrates how easy it is to make useful, non-web-based software. I only showed how to install the software on a dev machine, but in the next few posts, I’ll look at Flatpak, Snap, .deb packages, and even Windows packaging. GTK is a cross-platform toolkit, after all!
Last updated May 05 2020
All text CC-BY-SA & code AGPL by Leonora Tindall unless otherwise noted. Bitter ht, OFL |
| No AMP
https://nora.codes/tutorial/speedy-desktop-apps-with-gtk-and-rust/