DEV Community

Cover image for Rust GUI and GTK calc
Antonov Mike
Antonov Mike

Posted on • Edited on

Rust GUI and GTK calc

Introduction

I’ve started to learn rust GUI and GTK probably in june 2022. All this time I tried to get a job (and still trying) and was doing some tests tasks which took some time. And september was something like no-code month for reasons.
I found GTK documentation for Rust is pretty weird sometimes. I just had to google instead of reading docs. Some answers I found on the old forums, some I got by myself. Anyway I like the fact I can write at least something simple. It was interesting and I’ll try to do something else.
You wont find nothing special in this code, no difficult algorithms or original ideas. It is just an example for «advanced juniors» or something like that created by self educated person with no IT background.

Example

Technical details

The whole code is here. It may be changed later, but this article includes all the code you need to reproduce this app.

Project’s structure:

src
|– constants.rs
|– functions.rs
|– gui.rs
|– lib.rs
|– main.rs
tests
|– integration_test.rs
Enter fullscreen mode Exit fullscreen mode

File Cargo.toml

[dependencies]
glib = "0.15.12"
rand = "0.8.5"
gdk = "0.15.4"
chrono = "0.4.19"
gtk = "0.15.5"
glib-macros = "0.15.11"
Enter fullscreen mode Exit fullscreen mode

Before starting this project you need to install gtk-3-dev on your machine
For Linux machines:

sudo apt install libgtk-3-dev build-essential
Enter fullscreen mode Exit fullscreen mode

or

sudo apt-get install libgtk-3-dev
Enter fullscreen mode Exit fullscreen mode

For Windows and Mac check official docs.

GTK does not work with nightly, so check rustc version:

rustc --version
rustup default stable
Enter fullscreen mode Exit fullscreen mode

Ok, let's hit the ground running.

Application files

main.rs

Main file is pretty common and obvious:

use gtk::prelude::*;

mod constants;
mod functions;
mod gui;

fn main() {
    let application = gtk::Application::new(
        Some("com.github.gtk-rs.calculator_gtk"),
        Default::default(),
    );
    application.connect_activate(gui::build_ui);
    application.run();
}
Enter fullscreen mode Exit fullscreen mode

Application ID is just your application name like «com.github.gtk-rs.application _name».

constants.rs

Why I used constants instead of using &str ‘s directly? 1. Such kind of signs are easier to spot in a huge code. 2. You may need to change the value in the process. Then you simply change the value of the constant. Otherwise you have to change the value wherever it was applied.

pub const ADD: &str = " + ";
pub const SUBTRACT: &str = " - ";
pub const MULTIPLY: &str = " \u{00D7} "; // " × "
pub const DIVIDE: &str = " \u{00F7} "; // " ÷ "
pub const EQUALS: &str = " = ";
Enter fullscreen mode Exit fullscreen mode

The reason I use additional "-" sign is because I split String by spaces and I just need to be sure the negative numbers have no spaces like «-1 * -2»

pub const NEGATIVE: &str = "-";
Enter fullscreen mode Exit fullscreen mode

I used this constant because it was more convenient for me. It isn’t mandatory.

pub const NONE: u8 = 0;
Enter fullscreen mode Exit fullscreen mode

gui.rs

Import

Most interesting part if you are learning GTK and closures. First of all import this

use gtk::Entry;
use std::cell::Cell;
use std::rc::Rc;
use gtk::prelude::*;
use glib_macros::clone;
Enter fullscreen mode Exit fullscreen mode

Connect constants and functions

use crate::functions::entry_parser;
use crate::constants::*;
Enter fullscreen mode Exit fullscreen mode

Using GTK Entry

This is how we get text from entry

entry.text()
Enter fullscreen mode Exit fullscreen mode

Each time you click numeric button, method text() gives you the whole entry content, for example:

Click Button 0 -> 0
Click Button . -> 0.
Click Button 2 -> 0.2
Click Button + -> 0.2 +
Enter fullscreen mode Exit fullscreen mode

etc.

First part of gui.rs

should look like this. Nothing special

pub fn build_ui(application: &gtk::Application) {
    let window = gtk::ApplicationWindow::new(application);
    window.set_title("GTK calc");
    window.set_default_size(200, 120);

    let margin = 5;
    let grid = gtk::Grid::builder()
        .margin_start(margin)
        .margin_end(margin)
        .margin_top(margin)
        .margin_bottom(margin)
        .row_spacing(margin)
        .column_spacing(margin)
        .build();

    window.set_child(Some(&grid));

    // Operational data
    let value: Rc<Cell<u8>> = Rc::new(Cell::new(NONE));
    let operand: Rc<Cell<bool>> = Rc::new(Cell::new(false));

    // Text entry and display
    let entry = Entry::builder()
        .margin_start(margin)
        .margin_top(margin)
        .margin_end(margin)
        .margin_bottom(margin)
        .build();
    grid.attach(&entry, 0, 0, 3 ,1);
Enter fullscreen mode Exit fullscreen mode

The second part.

Here is the for each loop. This is how we can create all the 0-9 num buttons with very few lines of code

    for iterator in 0..=9 {
        let button = gtk::Button::with_label(&iterator.to_string());
        let mut column = 0;
        let mut raw = 1;

        button.connect_clicked(clone!( @strong entry, @strong value => move |_| {
            if value.take() == 0 { value.set(1) } 
            else { value.set(2) }
            entry.insert_text(&iterator.to_string(), &mut -1);
        }));

        if iterator == 1 || iterator == 4 || iterator == 7 { column = 0 }
        if iterator == 2 || iterator == 5 || iterator == 8 { column = 1 }
        if iterator == 3 || iterator == 6 || iterator == 9 { column = 2 }
        if iterator == 0 { column = 1 }

        if (1..=3).contains(&iterator) { raw = 1 }
        else if (4..=6).contains(&iterator) { raw = 2 }
        else if (7..=9).contains(&iterator) { raw = 3 }
        else if iterator == 0 { raw = 4 }

        grid.attach(&button, column, raw, 1, 1);
    }
Enter fullscreen mode Exit fullscreen mode

Part three.

Making other buttons

    let plus_button  = gtk::Button::with_label(ADD);
    let minus_button = gtk::Button::with_label(SUBTRACT);
    let mult_button  = gtk::Button::with_label(MULTIPLY);
    let div_button   = gtk::Button::with_label(DIVIDE);
    let equals_bttn  = gtk::Button::with_label(EQUALS);

    let dot_button   = gtk::Button::with_label(".");
    let clear_button = gtk::Button::with_label("C");
Enter fullscreen mode Exit fullscreen mode

Part four.

Connecting functions. We have to clone some of variables like this @strong entry to use them inside closures.
Negative numbers work only if there is no space between - and a number, so we can not use SUBTRACT “ - “ constant and use NEGATIVE “-” constant instead. And we need to know which time use NEGATIVE or SUBTRACT, so we can use value and operand like markers. For example: if value == 0 and "Button -" is pressed we’ll get NEGATIVE; if value == 1 we’ll get a SUBSTRACT because we are already have the first number.
Button Clear resets value and entry to default.
Button Equals calls for entry_parser() function. See next chapter.
Other buttons are using entry.insert_text() to append new characters to entry.

    plus_button.connect_clicked(clone!(@strong entry, @strong operand => move |_| {
            operand.set(true);
            entry.insert_text(ADD, &mut -1);
        }));
    minus_button.connect_clicked(clone!(@strong entry, @strong value, @strong operand => move |_| {
        if value.take() == 1 && !operand.take() {
            operand.set(true);
            entry.insert_text(SUBTRACT, &mut -1)
        } else {
            entry.insert_text(NEGATIVE, &mut -1)
        }
        }));

    mult_button.connect_clicked(clone!(@strong entry, @strong operand => move |_| {
            operand.set(true);
            entry.insert_text(MULTIPLY, &mut -1);
        }));

    div_button.connect_clicked(clone!(@strong entry, @strong operand => move |_| {
            operand.set(true);
            entry.insert_text(DIVIDE, &mut -1);
        }));

    equals_bttn.connect_clicked(clone!(@strong entry, @strong operand => move |_| {
            let get_entry = entry.text();
            let entry_data: String = format!("{}", get_entry);
            let result = entry_parser(entry_data.clone());
            let entry_vew = format!("{} = {}", entry_data, result);

            operand.set(false);
            entry.set_text(&entry_vew);
        }));

    dot_button.connect_clicked(clone!(@strong entry, => move |_| {
            entry.insert_text(".", &mut -1);
        }));

    clear_button.connect_clicked(clone!(@strong entry => move |_| {
            value.set(NONE);
            entry.set_text("");
        }));
Enter fullscreen mode Exit fullscreen mode

The last part.

Just attach operators and extra buttons to grid. Arguments:
0 – Button
1 – Position from left to the right
2 – Position from top to the bottom
3 – Button’s width
4 – Button’s height

    grid.attach(&plus_button,  3, 1, 1, 1);
    grid.attach(&minus_button, 3, 2, 1, 1);
    grid.attach(&mult_button,  3, 3, 1, 1);
    grid.attach(&div_button,   3, 4, 1, 1);
    grid.attach(&equals_bttn,  2, 4, 1, 1);
    grid.attach(&dot_button,   0, 4, 1, 1);
    grid.attach(&clear_button, 3, 0, 1, 1);

    window.show_all();
}
Enter fullscreen mode Exit fullscreen mode

functions.rs

Import constants and make logic now war

use crate::constants::*;
Enter fullscreen mode Exit fullscreen mode

This function performs simple arithmetic operations: addition, subtraction, multiplication, and division. Then the result would be formatted in different ways for addition / subtraction and multiplication / division.

pub fn entry_parser(entry_data: String) -> String {
    if char_check(&entry_data) {
        let v: Vec<&str> = entry_data.split(' ').collect();
        let operand = v[1].parse::<char>().unwrap();

        match operand {
            '+' => (v[0].parse::<f64>().unwrap() + v[2].parse::<f64>().unwrap()).to_string(),
            '-' => (v[0].parse::<f64>().unwrap() - v[2].parse::<f64>().unwrap()).to_string(),
            '×' => format!("{:.6}", extra_zeroes_remover(v[0].parse::<f64>().unwrap() * v[2].parse::<f64>().unwrap())),
            '÷' => format!("{:.6}", extra_zeroes_remover(v[0].parse::<f64>().unwrap() / v[2].parse::<f64>().unwrap())),
            _ => "0".to_string(),
        }
    } else {
        "0".to_string()
    }
}
Enter fullscreen mode Exit fullscreen mode

Remove extra zeroes. I suppose there are few other ways to implement precision

fn extra_zeroes_remover(f: f64) -> String {
    let string_0 = format!("{}", f);
    let mut vec: Vec<char> = string_0.chars().collect();
    for _i in 0..vec.len() {
        let last_element = vec[vec.len() - 1];
        if last_element == '0' {
            vec.remove(vec.len() - 1);
        }
    }

    vec.iter().collect::<String>()
}
Enter fullscreen mode Exit fullscreen mode

Check for wrong input. For example an empty string, or not digits, or extra operator signs etc.

fn char_check(entry_data: &String) -> bool {
    if 
    entry_data.contains(ADD) || entry_data.contains(SUBTRACT) ||
    entry_data.contains(MULTIPLY) || entry_data.contains(DIVIDE)
    { 
        if entry_data.len() > 4 
        && !entry_data.chars().all(|x| x.is_alphanumeric())
        && entry_data.chars().last().unwrap().is_numeric() {
            true 
        } else { false }        
    } else { false }
}
Enter fullscreen mode Exit fullscreen mode

Tests

Ok… I don't feel like I'm doing well with tests. It's more likely that I don't have a lot of experience. And I don't know how to write tests for the GUI.
I found this video pretty useful: Testing in Rust. If you can help me with better understanding this topic feel free to post some hints in the comments below.

lib.rs

Unit tests are small and focused on separate modules.

Don’t forget to import

pub mod functions;
pub mod constants;
Enter fullscreen mode Exit fullscreen mode

First of all we have to check wrong user input like single number or empty string:

#[test]
fn wrong_input() {
    let entry_data = "-".to_string();
    assert_eq!("0", functions::entry_parser(entry_data));
    let entry_data = "1 -".to_string();
    assert_eq!("0", functions::entry_parser(entry_data));
    let entry_data = "1".to_string();
    assert_eq!("0", functions::entry_parser(entry_data));
    let entry_data = ".".to_string();
    assert_eq!("0", functions::entry_parser(entry_data));
    let entry_data = "stop_war".to_string();
    assert_eq!("0", functions::entry_parser(entry_data));
    let entry_data = "abcd1234".to_string();
    assert_eq!("0", functions::entry_parser(entry_data))
}
Enter fullscreen mode Exit fullscreen mode

Then we test simple arithmetic:

#[test]
fn add() {
    let entry_data = "1.022 + 3.009".to_string();
    assert_eq!("4.031", &functions::entry_parser(entry_data));
    let entry_data = "-1 + -2".to_string();
    assert_eq!("-3", &functions::entry_parser(entry_data))
}
#[test]
fn div() {
    let entry_data = "1.022 ÷ 3.009".to_string();
    assert_eq!("0.3396", &functions::entry_parser(entry_data));
    let entry_data = "6 ÷ -2".to_string();
    assert_eq!("-3", &functions::entry_parser(entry_data))
}
Enter fullscreen mode Exit fullscreen mode

Et cetera
Remember about precision issue for multiplication and division. I am still not sure what is the better way to solve this issue.

integration_test.rs

Integration tests are external and live in a special directory /tests. Cargo knows that files in this directory are integration tests. Every file in /tests directory is gonna be a new crate. Each file would be a different section in terminal output.
We have to bring an adder library use adder; or our project and testing function like use calculator_gtk::functions; into scope.
To run only integration tests you have to

cargo test –test integration_test
Enter fullscreen mode Exit fullscreen mode

If I understand correctly, there should be tests of the graphical part of the application. But I'm not sure.
The full content of integration_test.rs file is:

use calculator_gtk::functions;

#[test]
fn empty_input() {
    let entry_data = "".to_string();
    assert_eq!("0", functions::entry_parser(entry_data))
}
Enter fullscreen mode Exit fullscreen mode

Outro

I know this is not the best way an application should be written. There are few different styles of writing a code in different companies and in different segment of development you should follow to be effective I guess. I need to add new functions, such as calculating percentages. In addition, I believe that algorithms can be made more efficient and I should have to figure out how to improve them.

What I have to do:
– Listen for keyboard events
– Scrollable Entry
– Set rounding precision (1.9869999999999999 -> 1,987)
– More math operations
– Documentation

Hope this article was useful

Good luck
Take care

Post scriptum

I built this app on my laptop running Linux Mint 20.3 Cinnamon:

cargo build --release
Enter fullscreen mode Exit fullscreen mode

And found the file here

/target/release
./calculator_gtk
Enter fullscreen mode Exit fullscreen mode

Everything works just fine.
But any time I tried cross-compile from Linux to Windows or Mac I received few errors. I tried different ways, googled and still have no solution.

Top comments (0)