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
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
orgDiceRoller
, you will need to use your own project’s name. I would have called the cratecodes.nora.gDiceRoller
, in standard reverse domain notation used in many packaging systems, but that’s not permitted, so I simply called itgDiceRoller
.
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’
pub struct MainWindow {
window: gtk::Window,
result: gtk::Label,
popover: gtk::Popover,
error_label: gtk::Label,
user_spec_entry: gtk::Entry,
buttons: HashMap<String, gtk::Button>,
}
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
andRwLock
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, andRefCell
has less complexity thanRwLock
.Mutex
is also a viable candidate, or a custom data structure usingstd::sync::atomic
s.
The use of
Arc
instead ofRc
is unnecessary here, since the program is single-threaded. This is an error on my part; the data structure should beRc<RefCell<T>>
orArc<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.
For gDiceRoller
, the .desktop
file codes.nora.gDiceRoller.desktop
looks like this:
[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!