- Published on
How to build a Calculator App using Rust and Iced GUI Library
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 2: Multiplication operation
Figure 3: Division operation
Figure 4: Sin operation
Figure 5: Factorial 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 fieldnum
, af64
primitive.pub trait Node
: This is the most basic trait used in the application and it allows the structs that implement it to use theeval()
function that it abstracts.pub struct Add
: This struct represents the addition operator. It implements theNode
trait which allows us to use theeval()
method. For this specific use-case the leftf64
variable is added to the rightf64
variable via the expressionSome(r) => Some(l + r)
and the resultingf64
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 theLexer
struct with some default values for the fieldscurr
,pos
,src
andeof
. 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 theOk()
methodbump()
: This method increments the current position in the input string being read and assigns the new value to the fieldself.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 thatSYMBOL(String)
andNUMBER(f64)
are enum variants.SYMBOL(String)
can take values likeSin()
,Tan()
,fact()
andNUMBER(f64)
takes on any number inputted into the calculatorfn info(&self)
: This method sets the order of operations for the calculation. For example, if we have the expression4 + 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 aToken
instance to its character representation. For example: if theToken::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 stringlexer
: This is theLexer
instance that extracts tokens from the input stringpeeked
: This is anOption
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 GUIfn 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 thelhs
variable, right-hand side value is saved in therhs
variable and the operation to be performed is saved incurr
. Finally, those variables are passed to theop()
method for evaluation and the output is returned as aResult<Box<dyn ast::Node>, String>
data type. That is, if the evaluation was successful aBox<dyn ast::Node>
is returned and if not then an errorString
is returned.fn atom()
: This method used to check the next token in the input string using thepeek_token()
method and the return aBox<dyn ast::Node>
type instance that matches the type of the token that was peeked. An errorString
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 theop
argument that is passed into it. The evaluation is done using theast
module's operation structs shown above.fn function()
: This method is quite similar toop()
except that it handles single number operations such assin
,sqrt
,fact
andtan
.
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. Thenext_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 notfn 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 lexerfn 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 thesrc/parser
folder. It contains theinput_string
field which contains the inputted string obtained from the GUI and theoutput_string
string will be assigned the resulting value derived from the logic in thesrc/parser
package.pub async fn calculate()
: This is the method that will call theevaluate_expr()
method in order to start the calculation process. The resulting value will be assigned to theresult_output
variable which is the output for this methodpub async fn evaluate_expr()
: This method will trim uneccesary whitespace from the input string before providing the input string to theevaluate()
methodpub async fn evaluate()
: This method calls theparse()
method in thesrc/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 stringself.display_text
- Operation type messages(Ex:
Message::Multiply
) adds the corresponding operation symbol to the input string - Calculation type messages(Ex:
Message::Equals
) calls theCalculator::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 2: Multiplication operation
Figure 3: Division operation
Figure 4: Sin operation
Figure 5: Factorial 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.