Published on

How to build a Calculator App using Rust and Iced GUI Library

Figure 1: Scenery from Banff, Alberta, Canada

Ever wondered how to create a calculator app using Rust and the Iced GUI library? Well look no further because we go over how to do in this blog post.

Before we start build this calculator application here are some GIFs of it in action:

Figure 1: Addition operation

Figure 1: Addition operation

Figure 2: Multiplication operation

Figure 2: Multiplication operation

Figure 3: Division operation

Figure 3: Division operation

Figure 4: Sin operation

Figure 4: Sin operation

Figure 5: Factorial operation

Figure 5: Factorial operation

Figure 6: DEL and CE operation

Figure 6: DEL and CE operation

Source Code

The source code for this project can be found in this GitHub repo

Feel free to clone it locally and follow along or make whatever additions to it you feel fits.

Development

To start off let's have a high level overview of the backend calculator logic.

Starting with the src/parser/ast.rs file we can see the following code:

extern crate std;
use std::collections::HashMap;
use std::f64;

pub trait Node {
    fn eval(&self, env: &mut HashMap<String, f64>) -> Option<f64>;
}

pub struct Num {
    pub num: f64
}

impl Node for Num {
    fn eval(&self, _env: &mut HashMap<String, f64>) -> Option<f64> {
        Some(self.num)
    }
}

pub struct Add {
    pub left: Box<dyn Node>,
    pub right: Box<dyn Node>,
}

impl Node for Add {
    fn eval(&self, env: &mut HashMap<String, f64>) -> Option<f64> {
        match self.left.eval(env) {
            Some(l) => {
                match self.right.eval(env) {
                    Some(r) => Some(l + r),
                    None => None
                }
            }
            None => None
        }
    }
}

...

Here is an overview of the above code:

  • pub struct Num: This is the most basic struct that is used extensively throughout our application. It represents a number using its field num, a f64 primitive.
  • pub trait Node: This is the most basic trait used in the application and it allows the structs that implement it to use the eval() function that it abstracts.
  • pub struct Add: This struct represents the addition operator. It implements the Node trait which allows us to use the eval() method. For this specific use-case the left f64 variable is added to the right f64 variable via the expression Some(r) => Some(l + r) and the resulting f64 variable is returned as the output

Similar to the Add struct here is the Mul struct that handles multiplication operations:

...
pub struct Mul {
    pub left: Box<dyn Node>,
    pub right: Box<dyn Node>,
}

impl Node for Mul {
    fn eval(&self, env: &mut HashMap<String, f64>) -> Option<f64> {
        match self.left.eval(env) {
            Some(l) => {
                match self.right.eval(env) {
                    Some(r) => Some(l * r),
                    None => None
                }
            }
            None => None
        }
    }
}
...

The Mul struct returns Some(l * r) in order to implement the multiplication functionality for our calculator.

Next, let's cover an operational struct, the Pow struct:

...
pub struct Pow {
    pub base: Box<dyn Node>,
    pub exponent: Box<dyn Node>
}

impl Node for Pow {
    fn eval(&self, env: &mut HashMap<String, f64>) -> Option<f64> {
        match self.base.eval(env) {
            Some(b) => {
                match self.exponent.eval(env) {
                    Some(e) => Some(b.powf(e)),
                    None => None
                }
            }
            None => None
        }
    }
}
...

Here we're using the powf() function to raise the base f64 number to the power specified exponent number.

Lastly let's see how a trigonometric operation is handled:

...
pub struct Cos {
    pub arg: Box<dyn Node>
}

impl Node for Cos {
    fn eval(&self, env: &mut HashMap<String, f64>) -> Option<f64> {
        match self.arg.eval(env) {
            Some(x) => Some(x.cos()),
            None => None
        }
    }
}
...

Here we're calculating the Cosine of the variable arg using the cos function

Next let's move on to the lexer.rs file and cover the most important parts:


pub struct Lexer {
    pub curr:  char,
    pub pos: usize,
    pub src: String,
    pub eof: bool
}

impl Lexer {
    pub fn new(src: &str) -> Lexer {
        let mut l = Lexer {
            curr: '\0',
            pos: 0,
            src: src.to_string(),
            eof: false
        };
        if l.pos >= src.len() {
            l.eof = true;
        } else {
            l.curr = src.chars().nth(0).unwrap();
        }
        l
    }
    pub fn next_token(&mut self) -> Result<token::Token, String> {
        if self.eof {
            return Ok(EOF);
        }
        self.consume_whitespace();
        match self.curr {

            '(' => {self.bump(); Ok(LPAREN)}
            ')' => {self.bump(); Ok(RPAREN)}
            c if c.is_digit(10) => {
                let start = self.pos;
                let mut end = start + 1;
                self.bump();
                while (self.curr.is_digit(10) || self.curr == '.') && !self.eof{
                    self.bump();
                    end += 1;
                }
                Ok(NUMBER(self.src[start..end].parse::<f64>().unwrap()))
            }

            c if c.is_alphabetic() => {
                let start = self.pos;
                let mut end = start + 1;
                self.bump();
                while self.curr.is_alphabetic() && !self.eof {
                    self.bump();
                    end += 1;
                }
                Ok(SYMBOL(self.src[start..end].to_string()))
            }
            '+' => {self.bump(); Ok(ADD)}
            '-' => {self.bump(); Ok(SUB)}
            '*' => {self.bump(); Ok(MUL)}
            '/' => {self.bump(); Ok(DIV)}
            '^' => {self.bump(); Ok(CARET)}
            '=' => {self.bump(); Ok(EQUALS)}
            '%' => {self.bump(); Ok(MOD)}
            c => { Err(format!("unexpected token {} at position {}", c, self.pos)) }
        }
    }
    pub fn bump(&mut self) {
        self.pos += 1;
        if self.pos >= self.src.len() {
            self.eof = true;
            return;
        }
        self.curr = self.src.chars().nth(self.pos).unwrap();
    }

    pub fn consume_whitespace(&mut self) {
        while is_whitespace(self.curr) {
            self.bump();
        }
    }
}

In case you didn't know, a lexer is a software program that is responsible for extracting individual words from a stream of sentences.

In our case, this lexer struct is designed to extract the numbers and operation symbols from the input string provided from the GUI so that the necessary calculation can be performed. Here is a brief overview of the methods in the Lexer struct:

  • new(): This constructor is used to create a new instance of the Lexer struct with some default values for the fields curr, pos, src and eof. These fields will hold information about the input string provided, such as the current character being processed, its position, and whether or not the end of the string has been reached.
  • next_token(): This method is responsible for moving to the new token in the list of tokens created by deconstructing the input string. The next token is categorized into whether it is a number, math operation, parentheses or unexpected character. The category is then returned in the Ok() method
  • bump(): This method increments the current position in the input string being read and assigns the new value to the field self.curr
  • consume_whitespace(): This method just skips any whitespace in the input string and moves on to the next position

Next, let's move on to the src/parser/token.rs file and cover the most important sections:

...
pub enum Token {
    LPAREN,
    RPAREN,
    ADD,
    SUB,
    MUL,
    DIV,
    MOD,
    CARET,
    EQUALS,
    NUMBER(f64),
    SYMBOL(String),
    EOF
}

impl Token {
    /* returns (prec, associativity) where 0 is left and 1 is right*/
    pub fn info(&self) -> Option<(usize, usize)> {
        match *self {
            ADD | SUB => Some((10, 0)),
            MUL | DIV | MOD => Some((20, 0)),
            CARET => Some((30, 1)),
            _ => { None}
        }
    }

        pub fn to_char(&self) -> char {
        match *self {
            LPAREN => '(',
            RPAREN => ')',
            ADD => '+',
            SUB => '-',
            MUL => '*',
            DIV => '/',
            CARET => '^',
            MOD => '%',
            EQUALS => '=',
            EOF => 'E',
            NUMBER(_) => 'N',
            SYMBOL(_) => 'S',
        }
    }
}
...

Here is a quick overview of this file:

  • pub enum Token: This enum contains all the different types of characters that can be considered tokens for this application. Notice that SYMBOL(String) and NUMBER(f64) are enum variants. SYMBOL(String) can take values like Sin(), Tan(), fact() and NUMBER(f64) takes on any number inputted into the calculator
  • fn info(&self): This method sets the order of operations for the calculation. For example, if we have the expression 4 + 5 / 6 + 4^4, then caret operation will be handled first followed by the division operation and concluded with the addition operations.
  • fn to_char(&self): This method converts a Token instance to its character representation. For example: if the Token::Add is passed into it as an argument then the character '+' will be returned as output

Now, let's move onto the src/parser/mod.rs files and cover the most important sections:


pub struct Parser {
    pub current: token::Token,
    pub lexer: lexer::Lexer,
    pub peeked: Option<token::Token>,
}

impl Parser {
    pub fn new(input: &str) -> Parser {
        let l = lexer::Lexer::new(input);
        let p = Parser {
            current: EOF,
            peeked: None,
            lexer: l
        };
        p
    }
    ...
}

In the above code we can see that the Parser struct contains the following fields:

  • current: This field is for representing the current token from the input string
  • lexer: This is the Lexer instance that extracts tokens from the input string
  • peeked: This is an Option field that may or may not contain a token. It is used to check whether there is a next token after the current token being processed by the application.

Then in the implementation block we declare a new() with a set of default values for the aforementioned fields.

Here are some important methods in src/parser/mod.rs:

    pub fn parse(&mut self) -> Result<Box<dyn ast::Node>, String> {
        self.expr(1)
    }

    pub fn expr(&mut self, prec: usize) -> Result<Box<dyn ast::Node>, String> {
        let mut lhs = self.atom()?;
        let mut rhs;

        loop {
            let curr = self.peek_token()?;
            if token::is_eof(&curr) {
                //println!("breaking out of expr loop");
                break;
            }
            ...
        Ok(lhs)
    }

    pub fn atom(&mut self) -> Result<Box<dyn ast::Node>, String> {
        match self.peek_token()? {
            EOF => { Ok(Box::new( ast::Num {num: 0f64})) }
            LPAREN => {
                self.expect('(')?;
                let e = self.expr(1)?;
                self.expect(')')?;
                Ok(e)
            }
            ...
    }

    pub fn op (&self, op: token::Token, lhs: Box<dyn ast::Node>, rhs: Box<dyn ast::Node>)
            -> Box<dyn ast::Node> {
        match op {
            ADD => {
                Box::new( ast::Add {
                    left: lhs,
                    right: rhs
                })
            }
            ...
        }
    }

    pub fn function<'a>(&'a self, op: String, arg: Box<dyn ast::Node>) -> Box<dyn ast::Node> {
        match &op[..] {
            "sin" | "sine" => {
                Box::new( ast::Sin {
                    arg: arg
                })
            }
            ...
    }

And here is a brief overview of the above methods:

  • fn parser(): This method is used to start the parsing process once the input string to be calculated has been received from the Calculator GUI
  • fn expr(): This recursive method uses a while loop to identify the left-hand side and right-hand side numbers along with the operation symbols in the middle of them. After identification, the left-hand side value is saved in the lhs variable, right-hand side value is saved in the rhs variable and the operation to be performed is saved in curr. Finally, those variables are passed to the op() method for evaluation and the output is returned as a Result<Box<dyn ast::Node>, String> data type. That is, if the evaluation was successful a Box<dyn ast::Node> is returned and if not then an error String is returned.
  • fn atom(): This method used to check the next token in the input string using the peek_token() method and the return a Box<dyn ast::Node> type instance that matches the type of the token that was peeked. An error String is returned if the end of the input string has been reached.
  • fn op(): This method evaluates the left-hand side and right-hand side values on the basis of the op argument that is passed into it. The evaluation is done using the ast module's operation structs shown above.
  • fn function(): This method is quite similar to op() except that it handles single number operations such as sin, sqrt, fact and tan.

And now here are the remaining methods in the Parser struct:

    pub fn expect(&mut self, tok: char) -> Result<(), String> {
        self.next_token()?;
        if self.current.to_char() != tok {
            return Err(format!("expected {:?} but found {}", tok, self.current.to_char()));
        }
        Ok(())
    }
    pub fn peek_token(&mut self) -> Result<token::Token, String> {
        if self.peeked.is_none() {
            self.peeked = Some(self.lexer.next_token()?);
        }
        Ok(self.peeked.clone().unwrap())
    }
    pub fn next_token(&mut self) -> Result<(), String> {
        match self.peeked {
            Some(ref mut pk) => {
                self.current = pk.clone();
            }
            None => {
                self.current = self.lexer.next_token()?;
            }
        }
        self.peeked = None;
        Ok(())
    }

Along with their explanations:

  • fn expect(): This method is used to predict the next token that comes after the current token. The next_token() method is called to increment the parser to the next token and then the expected value is compared to the actual new token value. Finally, it returns nothing if the expectation is fulfilled or a error string if it is not
  • fn peek_token(): This method is used to find out the next token in the input string without actually making it the new current token. Doing so allows the parser to construct mathematical expressions out of the list of tokens generated by the lexer
  • fn next_token(): This method is used to obtain the next token in the list of tokens generated by the lexer

Now let's cover the final file to be covered, which is the src/main.rs file:

...
pub struct Calculator {
    pub input_string: String,
    pub output_string: String,
}

impl Calculator {
    pub async fn calculate(
        input_string: String,
    ) -> String {
        let result_output = Self::evaluate_expr(&input_string).await;
        match result_output {
            Ok(result) => result.to_string(),
            Err(result) => result
        }
    }

    async fn evaluate(input: &str, env: &mut HashMap<String, f64>) -> Result<f64, String> {
        let mut p = parser::Parser::new(input);
        let ast = p.parse()?;
        match ast.eval(env) {
            Some(result) => Ok(result),
            None => Err("No value for that expression!".to_string())
        }
    }

    async fn evaluate_expr(input_string: &str) -> Result<f64, String> {
        use std::f64;
        let mut env = HashMap::new();
        env.insert("wow".to_string(), 35.0f64);
        env.insert("pi".to_string(), f64::consts::PI);

        let mut input = input_string;

        let expression_text = input.trim_right();

        let result = Self::evaluate(expression_text, &mut env);
        match result.await {
            Ok(value) => {
                Ok(value)
            }
            Err(s) => {
                Err(s)
            }
        }
    }

}

Here is an explanation for the above code:

  • pub struct Calculator: This is the struct that will start the process of calculation using the files we've covered in the src/parser folder. It contains the input_string field which contains the inputted string obtained from the GUI and the output_string string will be assigned the resulting value derived from the logic in the src/parser package.
  • pub async fn calculate(): This is the method that will call the evaluate_expr() method in order to start the calculation process. The resulting value will be assigned to the result_output variable which is the output for this method
  • pub async fn evaluate_expr(): This method will trim uneccesary whitespace from the input string before providing the input string to the evaluate() method
  • pub async fn evaluate(): This method calls the parse() method in the src/parser/mod.rs file to start the parsing process

And now here is the constructor of the CalculatorGUI struct:

struct CalculatorGUI {
    display_text: String,
    done_calculation: bool,
}

impl Application for CalculatorGUI {
    type Message = Message;
    type Theme = Theme;
    type Executor = executor::Default;
    type Flags = ();

    fn new(_flags: ()) -> (CalculatorGUI, Command<Message>) {
        (
            CalculatorGUI {
                display_text: "".to_string(),
                done_calculation: true,
            },
            Command::perform(Calculator::calculate("".to_string()), Message::DoneCalculating),
        )
    }

    ...

}

The constructor sets the default text to be displayed in the display area to an empty string and the done_calculation variable to true. This variable is used to indicate when the provided input string's calculation has been completed. The Command::perform() call is used to create a Command instance that performs the action of the given future, which is the Calculator::calculate() method in this case. Note that is not using the Command in the std::process library but the one provided by Iced which you can see here

Next let's cover the update() method:

fn update(&mut self, message: Message) -> Command<Message> {
        match message {
            Message::One => {
                if self.done_calculation {
                    self.display_text = "1".to_string();
                    self.done_calculation = false;
                } else {
                    self.display_text += "1";
                }

                Command::none()
            },
            ...
        }
    ...
}

This method used to handle a Message instance and update the state of the Application. For the sake of brevity here are the most important updates:

  • Number type messages(Ex: Message::Three) adds the corresponding number character to the input string self.display_text
  • Operation type messages(Ex: Message::Multiply) adds the corresponding operation symbol to the input string
  • Calculation type messages(Ex: Message::Equals) calls the Calculator::calculate() method in order to start the calculation process
  • Message::DoneCalculating is the message used to indicate that the result of the calculation has been completed and then the resulting value is shown in the display text area

Up next is the view() method which determines the widgets to be displayed in the Application. As this is a calculator-themed application all the widgets inside this method correspond to the various number buttons, operation buttons, CE, DEL and the display text area(which is represented by the widget display_text)

The final code snippet to explain is the following:

mod theme {
    use iced::widget::{button, container, text, row};
    use iced::{application, color, Color};

    #[derive(Debug, Clone, Copy, Default)]
    pub struct Theme;

    impl application::StyleSheet for Theme {
        type Style = ();

        fn appearance(&self, _style: &Self::Style) -> application::Appearance {
            application::Appearance {
                background_color: color!(0x28, 0x28, 0x28),
                text_color: color!(0xeb, 0xdb, 0xb2),
            }
        }
    }

    impl text::StyleSheet for Theme {
        type Style = ();

        fn appearance(&self, _style: Self::Style) -> text::Appearance {
            text::Appearance {
                color: color!(0xeb, 0xdb, 0xb2).into(),
            }
        }
    }
    ...

}

This theme module is a custom theme developed for this Application. It allows us to add a light blue border for the main container widget and the display text area widget, which is a button widget.

Well that's it for the development section!

Demonstration:

In order to build and run the application run the following command in the application root directory:

cargo run --package basic-calculator

Now here are some GIFs of the finished calculator application in action:

Figure 1: Addition operation

Figure 1: Addition operation

Figure 2: Multiplication operation

Figure 2: Multiplication operation

Figure 3: Division operation

Figure 3: Division operation

Figure 4: Sin operation

Figure 4: Sin operation

Figure 5: Factorial operation

Figure 5: Factorial operation

Figure 6: DEL and CE operation

Figure 6: DEL and CE operation

Conclusion

Thanks for reading this blog post!

If you have any questions or concerns please feel free to post a comment in this post and I will get back to you if I find the time.

If you found this article helpful please share it and make sure to follow me on Twitter and GitHub, connect with me on LinkedIn and subscribe to my YouTube channel.