Let’s write a little exchange rate viewer in Rust. It will have a GUI, fetch rates from the Internet, support changing the currencies without having to recompile, and all this in about 200 lines of code.

Screenshot of Sixty-Two
Screenshot of Sixty-Two

We’ll write it in Rust to show that not only is it possible, but it’s actually a good choice. Rust has a reputation for correct and low-level code, but also for being verbose and a bit arcane at times. It turns out that for tiny throw-away UIs, we mostly only get the benefits.

The full code for this post is available here (tag blogpost).

Architecture

Our goal is to write a little exchange rate (FX) viewer. These are the features we want:

  • The app lists several “symbols”, and their prices in a given “currency”. Both symbols and the currency might be fiat or crypto currencies. The only restriction here is multiple symbols/one currency.

  • The app lets us change the symbols and currency without having to recompile.

  • The app updates prices periodically and without user interaction.

  • The app should be quick to write. This is very much a throw-away codebase, and we’re not interested in maintainability. So, we have no tests, and we mush everything into one file, half of which is a single function. If we ever need to change the app, we’ll just rewrite it, and that shouldn’t be hard because it’s only 200 lines.

💭 As I write this, the Terra stablecoin crashed, and Tether seems to be wobbling. Although I’m not invested in cryptoland, spectating is fun, so I want a little display of the current exchange rates to pin to one of my desktops.

Graphically, we end up with something like this:

Architecture of Sixty-Two
Architecture of Sixty-Two

Next, we pick the libraries:

  • We need a GUI, so let’s use egui, an immediate mode UI library which claims to be easy to use. In addition to egui, which knows how to draw widgets, we also need something to create the window on Linux. We use egui’s own eframe since it seems to require the least amount of boilerplate.

  • We need to get the exchange rates from somewhere, and some StackExchange post claimed CryptoCompare’s API is the easiest to use, so we pick that.

  • We also need a library to do HTTP for us. We stay away from Rust’s async ecosystem to keep the code simple, so let’s use ureq. The ureq documentation describes it as “simple”, but I’ve done some fairly complicated things in the past with it. I suppose “simple” refers to the API, rather than to the capability of the library.

  • So, we query the CryptoCompare API with ureq, but how do we deserialize the JSON responses? We use Rust’s excellent serde and serde_json.

  • Since we have both a GUI and blocking network access, we need to use threads. While Rust’s standard library has excellent support for this, parking_lot’s Mutex is more pleasant to use, so we include that in our deps.

  • Finally, we have to deal with propagating errors. Since this is an app, we use anyhow which requires no setup.

Fetching exchange rates

Let’s get coding. We start with the FX rate fetching because it’s independent from the rest of the app. Practically, we’re going to be doing queries like this:

$ curl "https://min-api.cryptocompare.com/data/pricemulti?fsyms=BTC,UST&tsyms=USD"
{"BTC":{"USD":29388.45},"UST":{"USD":0.1689}}

The URL contains the symbols we want prices for (fsyms), and the currency we want the price in (tsyms). If there are multiple symbols or currencies, they are separated by commas (e.g. BTC,UST).

Here’s the Rust code:

fn get_prices(
    symbols: HashSet<String>,
    currency: String,
) -> OrError<HashMap<String, HashMap<String, f64>>> {
    let text = {
        ureq::get(&format!(
            "https://min-api.cryptocompare.com/data/pricemulti?fsyms={}&tsyms={}",
            symbols
                .into_iter()
                .collect::<Vec<String>>()
                .as_slice()
                .join(","),
            currency
        ))
        .call()?
        .into_string()?
    };
    Ok(serde_json::from_str(&text)?)
}

We query the URL with ureq to get a String, then we pass that through serde_json::from_str() to get a nested HashMap. The type information in the function’s signature is enough for serde_json to figure out how to deserialize the string.

The ugliest bit in the code above is the conversion from a HashSet<String> to a comma-separated string. The problem is that join is only defined for slices, not iterators, so we have to convert our HashSet first to a Vec, then to a slice.

App skeleton

Next, let’s have a look at the shape of the program. We start by defining an alias for results with anyhow errors, and we hardcode the price updating frequency. We could make this configurable, but it doesn’t strike me as something people will want to change at runtime.

type OrError<T> = Result<T, anyhow::Error>;
const PRICE_REFRESH_RATE: Duration = Duration::from_secs(10);

The main program just creates our App, and then passes it to eframe’s run function. The latter displays the window, and gets the event loop going.

fn main() -> OrError<()> {
    let native_options = eframe::NativeOptions {
        initial_window_size: Some((400.0, 200.0).into()),
        ..eframe::NativeOptions::default()
    };
    let app = App::new();
    eframe::run_native(Box::new(app), native_options);
}

We split our app state into the bit that isn’t shared across threads (App), and the bit that is (App::state). The latter is protected by an Arc<Mutex<_>>. The Mutex ensures that only one thread is accessing the state at a time, and the Arc lets us have references to the mutex in both threads.

struct App {
    state: Arc<Mutex<State>>,
    new_symbol: String,
    new_currency: String,
}

struct State {
    last_updated: Instant,
    prices: HashMap<String, HashMap<String, f64>>,
    symbols: HashSet<String>,
    currency: String,
    frame: Option<epi::Frame>,
    error: Option<String>,
    refresh_requested: bool,
}

We made a design decision here to shove the entire State into one mutex. The alternative is to put each of the state’s fields into their own mutex. The upside to that is that it allows for more fine-grained access, but the downside is riskier code. The problem with mutexes is that there’s nothing to stop us from causing a deadlock. This is especially true for Rust mutexes which are locked manually, but unlock automatically when their lock goes out of scope.

In our program, we have to be careful with patterns of code like this:

let something = mutex.lock();
...
mutex.lock(); // Deadlock because we're already holding the lock.
              // It might actually panic instead since it can see
              // that the current thread has the lock, but that's
              // not any better for us.
{
    let something = mutex.lock();
}
...
mutex.lock(); // Ok because the lock expired when it went out of scope.

The trickiness comes into play when holding references to locked data (but not to the lock itself). For instance, this also deadlocks, even though it looks like the lock should expire at the end of the assignment:

let symbols: Vec<&String> = prices_mutex.lock().keys().collect();
prices_mutex.lock() = new_prices; // Deadlock

So long as we have mutexes, we have to be very careful when we lock them, and when the lock expires. It’s easiest to keep their number to a minimum, so we put the whole State into a single mutex.

Next, the App constructor makes a state, and starts the fetcher thread:

impl App {
    fn new() -> Self {
        let state = {
            // create empty state
        };
        thread::spawn({
            let state = Arc::clone(&state);
            move || {
                // fetcher thread
            }
        });
        Self {
            state,
            new_symbol: String::new(),
            new_currency: String::new(),
        }
    }
}

After every update, we need to instruct eframe to redraw the screen, so we write a helper for that. The trickiness here is that we need a reference to the frame (i.e. the window) in order to issue the repaint command, but we won’t have the reference until the window is shown. So, the frame reference has to be an Option<_> that is empty when the App is created, and is filled in by App:setup when the window is shown.

impl State {
    fn repaint(&self) {
        if let Some(frame) = self.frame.as_ref() {
            frame.request_repaint();
        }
    }
}

Next, we have some eframe boilerplate, then we store a reference to the window in the State:

impl epi::App for App {
    fn name(&self) -> &str {
        "Sixty-Two"
    }

    fn setup(
        &mut self,
        _ctx: &egui::Context,
        frame: &epi::Frame,
        _storage: Option<&dyn epi::Storage>,
    ) {
        self.state.lock().frame = Some(frame.clone());
    }

Finally, we have the function that is called to draw the screen—update. We lock the state at the beginning, so that we can easily access the fields.

    fn update(&mut self, ctx: &egui::Context, _frame: &epi::Frame) {
        let mut state = self.state.lock();
        // draw UI here
    }
}

There’s no point in trying to reduce the time the state stays locked because this function completes quickly. Additionally, the only thing it could block is the fetcher thread, which is usually either sleeping or is blocked by IO. More generally, we’re more worried about other things blocking the UI thread’s update function, than the other way around.

Fetcher thread

Let’s fill out the fetcher thread code in App::new. First, we clone the Arc reference to the state. Note that this is outside the closure that will be the thread.

thread::spawn({
    let state = Arc::clone(&state);

Our thread is a loop at the top-level. It starts by locking the state, cloning the fields we need to fetch prices, and unlocking the state. We’re careful not to hold the lock while doing network IO. If we did hold onto the lock, that would prevent the update function from drawing the screen.

    move || loop {
        let now = Instant::now();
        let (symbols, currency, last_updated, refresh_requested) = {
            // We release this lock before doing the network
            // request in `get_prices`.
            let mut state = state.lock();
            (
                state.symbols.clone(),
                state.currency.clone(),
                state.last_updated,
                mem::replace(&mut state.refresh_requested, false),
            )
        };

If a refresh was requested by the UI, or if enough time has passed since we last updated, we call get_prices. Once we have the result, we lock the state, update the relevant fields, and unlock.

        if refresh_requested
            || now.saturating_duration_since(last_updated) > PRICE_REFRESH_RATE
        {
            match get_prices(symbols, currency) {
                Ok(new_prices) => {
                    let mut state = state.lock();
                    state.last_updated = now;
                    state.prices = new_prices;
                    state.error = None;
                }
                Err(err) => state.lock().error = Some(err.to_string()),
            }
        }

Lastly, we trigger a repaint so that the seconds label updates, then we sleep for one second. We again have to be careful with the state because we don’t want to be holding the lock while waiting.

        {
            // Trigger a repaint every second.
            state.lock().repaint();
        }
        thread::sleep(Duration::from_secs(1));
    }
});

UI

Finally, we have the core of the program—the update function which draws the screen. The way drawing works in egui is that we build a hierarchy of widgets on every update. At any point, we have a ui reference, which is the parent we’re drawing into. For example, ui.label() draws a label inside ui. Some widgets like layouts have children. In this case, we pass a closure with a |ui| parameter to the layout’s constructor. For example, ui.horizontal(|ui| { ... }) creates a horizontal layout in ui, and we can use the inner ui in the closure to add widgets to the layout.

Our UI is a CentralPanel so that it covers the whole window, then a ScrollArea so that we get scroll bars if we overflow the window, then a vertical_centered layout for all the contents.

fn update(&mut self, ctx: &egui::Context, _frame: &epi::Frame) {
    let mut state = self.state.lock();
    egui::CentralPanel::default().show(ctx, |ui| {
        ScrollArea::both().show(ui, |ui| {
            ui.vertical_centered(|ui| {

At the very top, we have the line with the price selector:

This is a horizontal layout with a label, a text edit, and an optional button. The button is the most interesting widget: it gets created by the ui.button() call in the if’s condition. However, it’s after an &&, so its construction gets short circuited if nothing was written into the text box.

                ui.horizontal(|ui| {
                    ui.label("Prices in ");
                    let currency_resp = ui.add(
                        TextEdit::singleline(&mut self.new_currency)
                            .hint_text("USD")
                            .desired_width(200.0),
                    );
                    if !self.new_currency.is_empty()
                        && (ui.button("Change").clicked()
                            || (currency_resp.lost_focus()
                                && ui.input().key_pressed(egui::Key::Enter)))
                    {
                        state.currency = mem::take(&mut self.new_currency);
                        state.refresh_requested = true;
                    }
                });

If the button is clicked or the user presses Enter in the text box, then we take the temporary value from new_currency, store it in the state, then set refresh_requested so that we re-query prices on the next second. This illustrates how communication between threads works: the UI thread sets values in the state, and the fetcher thread polls them every second.

Next, we have a line showing how long ago the prices were updated.

                ui.label(format!(
                    "Updated {:.2}s ago",
                    state.last_updated.elapsed().as_secs()
                ));

Next is the error display. It isn’t very informative, but there are only two text inputs, so the user can probably guess what went wrong.

An error
An error
                if let Some(error) = state.error.as_ref() {
                    ui.label(
                        egui::RichText::new(error)
                            .size(32.0)
                            .background_color(Color32::RED)
                            .color(Color32::WHITE),
                    );
                }

Next, we display the prices in a table. Because we store them in an unordered HashMap, we have to extract them into a Vec and sort it before displaying.

                let mut prices: Vec<(String, String, f64)> = state
                    .prices
                    .iter()
                    .flat_map(|(symbol, prices)| {
                        prices
                            .iter()
                            .map(|(currency, price)| {
                                (symbol.to_string(), currency.to_string(), *price)
                            })
                            .collect::<Vec<_>>()
                    })
                    .collect();
                prices.sort_by(|(c1, s1, _), (c2, s2, _)| (c1, s1).cmp(&(c2, s2)));
                for (symbol, currency, price) in prices {
                    ui.horizontal(|ui| {
                        ui.add(
                            Label::new(
                                egui::RichText::new(format!(
                                    "1 {:>4} = {:8.2} {}",
                                    symbol, price, currency
                                ))
                                .size(32.0)
                                .monospace(),
                            )
                            .wrap(false),
                        );
                        if ui.button("X").clicked() {
                            state.symbols.remove(&symbol);
                            state.refresh_requested = true;
                        }
                    });
                }

We also have a little “X” button on the right of each row to let users remove symbols. In case you’re wondering why the price is aligned so far to the right (the format string {:8.2} specifies 8 digits), it’s to account for BTC which has a price of around 30,000.00 USD.

Finally, we have a row for adding new symbols. Like the top row, it has a label, a text edit, and a button that is only visible when something is entered into the text box.

                ui.horizontal(|ui| {
                    ui.label("Add symbol ");
                    let symbol_resp = ui.add(
                        TextEdit::singleline(&mut self.new_symbol)
                            .desired_width(100.0)
                            .hint_text("BTC"),
                    );
                    if !self.new_symbol.is_empty()
                        && (ui.button("Add").clicked()
                            || (symbol_resp.lost_focus()
                                && ui.input().key_pressed(egui::Key::Enter)))
                    {
                        state.symbols.insert(mem::take(&mut self.new_symbol));
                        state.refresh_requested = true;
                    }
                });
            });
        });
    });
}

That’s almost all the code. We skipped the use statements at the top of the file, which you can see in the repo (tag blogpost).

Wrapping up

We wrote a little FX viewer in Rust in about 200 lines of code. Nothing we did was particularly hard, and none of the code was obnoxiously verbose.

The good here was that the code was fast to write, even without prior experience. The result isn’t amazing—we’re not winning any design or UX awards with this—but it is good enough for one-off apps, and it works. One thing that surprised me was that egui isn’t at all wasteful with CPU. I was expecting it to be one of those frameworks that just draws as often as possible, and spins one CPU at 100%. In fact, it only redraws when the window changes or something passes over it.

The bad here was that the layout was a pain to get working. Or rather, the code above was easy, but I wanted the top and bottom rows to be centred, so I spent a couple of hours trying to do this, and ultimately failed. The egui/eframe stack also uses OpenGL, and getting the right dependencies and environment variables was hard on NixOS. On the upside, I now have a flake.nix which encodes all the requirements, so other people won’t have to redo this work.

All in all, this went far better than expected. I had a mental image of GUI programming being annoying, and requiring lots of out-of-code things like XML files, resource lists, and custom build systems. I wish I had know earlier that things got better in the last decade or so.