Recreating the Apple Calculator in Rust using Tauri, Yew and Tailwind

maxtaylor

Max

Posted on November 30, 2023

Recreating the Apple Calculator in Rust using Tauri, Yew and Tailwind

Introduction

In this tutorial, we will be rebuilding the Apple calculator using Rust. This project is designed to be a stimulating challenge, providing a hands-on experience with several key technologies:

  • Tauri: An innovative framework for building lightweight desktop applications.
  • Yew: A modern Rust framework for creating frontend web apps.
  • TailwindCSS: A utility-first CSS framework for rapid UI development.

At a high-level the Apple calculator can be broken down into some key areas of functionality:

  • Numeric Buttons (0-9)
  • Operational Buttons (Add, Subtract, Multiply, Divide, Equals)
  • Special Function Buttons: Percentage, Invert Sign, Decimal Point, Clear

We will be working through these step-by-step in this tutorial, creating reusable components where possible. Here is the source code

Additionally, to complement this guide, I have prepared YouTube video demonstrating the entire build process. You can watch it here:

And if you want to see more of my work, feel free to explore these links: Twitter YouTube

Let's get started!

Setup

Run the following command in your console:

yarn create tauri-app
Enter fullscreen mode Exit fullscreen mode

Answer the prompts as follows:

  • Project name: calculator-app
  • Frontend language: Rust (cargo)
  • UI template: Yew - (https://yew.rs/)

Setup tailwind

Integrating Tailwind CSS into our project will give us a powerful yet simple way to style our components. First, ensure you have npx installed, a tool that allows executing npm package binaries. It's a part of npm, and you can learn more about it here.

To create a Tailwind configuration file, run:

npx tailwindcss init
Enter fullscreen mode Exit fullscreen mode

This command generates a tailwind.config.js file in your project's root directory. Modify this file to include our Rust files and also add the colors we need for our button backgrounds:

/** @type {import('tailwindcss').Config} */
module.exports = {
  content: ["./src/**/*.rs"],
  theme: {
    extend: {
      colors: {
        background: "#172425",
        darkGrey: "#2d3131",
        lightGrey: "#4d5250",
        orange: "#fd8d18",
      },
    },
  },
  plugins: [],
};
Enter fullscreen mode Exit fullscreen mode

Next, create a CSS file named src/input.css and insert the following Tailwind directives:

@tailwind base;
@tailwind components;
@tailwind utilities;
Enter fullscreen mode Exit fullscreen mode

To integrate this stylesheet into your app, update the index.html file in the root directory:

<link href="output.css" data-trunk rel="css" />
Enter fullscreen mode Exit fullscreen mode

This ensures that the generated CSS is included in the app.

Title bar style:

The Apple calculator doesn't have the title bar normal programs do, let's make some modifications to our Tauri config to fit that style:

{
.  ...
  "tauri": {
    ...

    "windows": [
      {
        "titleBarStyle": "Transparent",
        "title": ""
      }
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode

Setting up the calculator

Let's first setup the states we need for our calculator, these are the main states we will need:

  • temp_value: Tracks the current input from the user.
  • memory: Stores the ongoing calculation or result.
  • action: Holds the current action (like Add, Subtract).
  • trailing_dot: If the decimal point was the last input with no subsequent digits.
  • has_dot_pressed: If the temp_value has a decimal point

Modify src/app.rs to include the states:

#[function_component(App)]
pub fn app() -> Html {
    let temp_value: UseStateHandle<Option<f32>> = use_state(|| None);
    let memory: UseStateHandle<f32> = use_state(|| 0.0);
    let action: UseStateHandle<Option<Action>> = use_state(|| None);
    let trailing_dot: UseStateHandle<bool> = use_state(|| false);
    let has_dot_pressed: UseStateHandle<bool> = use_state(|| false);
}
Enter fullscreen mode Exit fullscreen mode

Let's also add a display for the current calculator value, showing temp_value if it exists, otherwise memory:

let display_value: f32 = {
    if let Some(i) = *temp_value {
        i
    } else {
        *memory
    }
};

html! {
    <div class={classes!("h-screen", "w-screen", "bg-background", "flex-col", "flex")}>
        <div class={classes!("flex", "justify-end", "text-5xl", "pr-2", "py-1")}>
            {display_value}{match *trailing_dot {
              true => ".",
              false => "",
            }}
        </div>
    </div>
}
Enter fullscreen mode Exit fullscreen mode

🐣 We now have the very basic beginnings of our calculator:

Calculator beginnings

Let's continue giving it life!

Defining Actions and Operations

Within src/app.rs let's create an enum to represent calculator operations, this enum will be used for basic arithmetic. Let's call this enum Action and add a way to caste it to a string, so that we can easily display it in our UI later on:

#[derive(Clone, Copy, PartialEq)]
pub enum Action {
    Add,
    Subtract,
    Multiply,
    Divide,
    Equals,
}

impl Action {
    pub fn to_string(&self) -> String {
        match self {
            Action::Add => "+".to_string(),
            Action::Subtract => "-".to_string(),
            Action::Multiply => "x".to_string(),
            Action::Divide => "÷".to_string(),
            Action::Equals => "=".to_string(),
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Button Components

For a unified aesthetic across our calculator, create src/buttons/button_styles.rs. This file will contain the common styles for our buttons, ensuring consistency and ease of maintenance:

use yew::{classes, Classes};

pub fn get_button_styles(extra_classes: Classes) -> Classes {
    classes!(vec![
        classes!(
            "flex",
            "flex-1",
            "items-center",
            "justify-center",
            "rounded-none",
            "border-[0.25px]",
            "border-black",
            "text-xl",
            "font-bold",
            "hover:border-black" // override base styles
        ),
        extra_classes
    ])
}
Enter fullscreen mode Exit fullscreen mode

Now, let's develop two primary button components: NumberButton for digits and ActionButton for operations.

Number button:

This component handles digit inputs (0-9). It takes a floating-point value, displays it, and invokes the specified callback function upon interaction:

use yew::{classes, function_component, html, Callback, Html, Properties};

use crate::buttons::button_styles::get_button_styles;

#[derive(Clone, PartialEq, Properties)]
pub struct Props {
    pub value: f32,
    pub onclick: Callback<f32>,
}

#[function_component]
pub fn NumberButton(Props { value, onclick }: &Props) -> Html {
    let value = *value;

    html!(
      <button class={get_button_styles(classes!("bg-lightGrey", "active:bg-[rgb(180,180,180)]"))} onclick={onclick.reform(move |_| value)}>
        {value}
      </button>
    )
}
Enter fullscreen mode Exit fullscreen mode

Action button:

This component, slightly more complex, handles operational inputs like Add, Subtract, etc. It displays the provided action and communicates it back when the button is pressed. Additionally, it changes its style based on the whether this action button is currently the selected action:

use yew::{classes, function_component, html, Callback, Html, Properties};

use crate::{app::Action, buttons::button_styles::get_button_styles};

#[derive(Clone, PartialEq, Properties)]
pub struct Props {
    pub action: Action,
    pub selected_action: Option<Action>,
    pub onclick: Callback<Action>,
}

#[function_component]
pub fn ActionButton(
    Props {
        action,
        selected_action,
        onclick,
    }: &Props,
) -> Html {
    let action = *action;

    let is_selected = {
        if let Some(selected_action) = selected_action {
            *selected_action == action
        } else {
            false
        }
    };

    let classes = {
        if is_selected {
            classes!(
                "border-[1.2px]",
                "border-black",
                "h-full",
                "w-full",
                "flex",
                "items-center",
                "justify-center"
            )
        } else {
            classes!("")
        }
    };

    html!(
      <button class={get_button_styles(classes!("bg-orange", "active:bg-[rgb(184,115,51)]"))} onclick={onclick.reform(move |_| action)}>
          <div class={classes}>{action.to_string()}</div>
      </button>
    )
}
Enter fullscreen mode Exit fullscreen mode

To make these button components accessible in our main app, we create src/buttons.rs:

pub mod action_button;
pub mod button_styles;
pub mod number_button;
Enter fullscreen mode Exit fullscreen mode

Then, import this module in src/main.rs for seamless integration:

mod buttons;
Enter fullscreen mode Exit fullscreen mode

Calculator top row

First, let's define the top row of our calculator's interface, update your html in src/app.rs to be:

html! {
  <div class={classes!("h-screen", "w-screen", "bg-background", "flex-col", "flex")}>
    <div class={classes!("flex", "justify-end", "text-5xl", "pr-2", "py-1")}>
      {display_value}{match *trailing_dot {
        true => ".",
        false => "",
      }}
    </div>
    <div class={classes!("flex", "flex-1", "flex-row")}>
      <button onclick={clear_state} class={get_button_styles(classes!("bg-darkGrey","active:bg-lightGrey"))}>
        {match clear_all {
          true => "AC",
          false => "C",
        }}
      </button>
      <button onclick={invert_sign} class={get_button_styles(classes!("bg-darkGrey","active:bg-lightGrey"))}>
        {"±"}
      </button>
      <button onclick={apply_percentage} class={get_button_styles(classes!("bg-darkGrey","active:bg-lightGrey"))}>
        {"%"}
      </button>
      <ActionButton action={Action::Divide} onclick={handle_action_button.clone()} selected_action={*action}  />
    </div>
  </div>
}
Enter fullscreen mode Exit fullscreen mode

We have some missing functionality required to support these new buttons:

  • clear_state - Clearing
  • invert_sign - Inverting the sign (- to +)
  • apply_percentage - Applying a percentage to the value
  • handle_action_button - Handling actions

Let's walk through each of these

Clearing:

There are 2 distinct types of state clearing; CE (Clear everything) or C (Clear), this is how most calculators function and you'll notice on the Apple calculator that the clear button text changes depending on this state. When we clear everything, the major difference is that memory is also cleared, we only want to do this if; there is no temporary value, current action or a trailing dot. Let's implement this:

let clear_all = {
    if (*temp_value).is_some() || (*action).is_some() || *trailing_dot {
        false
    } else {
        true
    }
};

let clear_state = {
    let temp_value = temp_value.clone();
    let action = action.clone();
    let memory = memory.clone();
    let trailing_dot = trailing_dot.clone();
    let has_dot_pressed = has_dot_pressed.clone();

    Callback::from(move |_| {
        if clear_all {
            memory.set(0.0);
            temp_value.set(None);
            action.set(None);
        } else {
            action.set(None);
            temp_value.set(None);
        }

        trailing_dot.set(false);
        has_dot_pressed.set(false);
    })
};
Enter fullscreen mode Exit fullscreen mode

Inverting the sign & applying the percentage:

The methods invert_sign and apply_percentage are similar; they either modify temp_value if it exists, otherwise they modify memory:

let invert_sign = {
    let temp_value = temp_value.clone();
    let memory = memory.clone();

    Callback::from(move |_| {
        if let Some(i) = *temp_value {
            temp_value.set(Some(i * -1.0));
        } else {
            memory.set(*memory * -1.0);
        }
    })
};

let apply_percentage = {
    let temp_value = temp_value.clone();
    let memory = memory.clone();

    Callback::from(move |_| {
        if let Some(i) = *temp_value {
            temp_value.set(Some(i / 100.0));
        } else {
            memory.set(*memory / 100.0);
        }
    })
};
Enter fullscreen mode Exit fullscreen mode

Handle the action button:

This is a core piece of our functionality; handling actions. This essentially works by applying actions to our temporary value and memory to get our new result.

You'll notice this method applies the previous action that was pressed, not the new action. This is because when you press an action button on the calculator it doesn't immediately apply it, you then need to type in a temporary value to operate against. This action is processed when the next action button is pressed, typically this is equals but it also natively supports chaining calculations:

let handle_action_button = {
    let temp_value = temp_value.clone();
    let memory = memory.clone();
    let action = action.clone();
    let trailing_dot = trailing_dot.clone();
    let has_dot_pressed = has_dot_pressed.clone();

    Callback::from(move |passed_action: Action| {
        // If there is an action already pressed and a temporary_value, then we want to apply that previous action
        if let (Some(existing_action), Some(temp_value)) = (*action, *temp_value) {
            match existing_action {
                Action::Add => {
                    memory.set(*memory + temp_value);
                }
                Action::Subtract => {
                    memory.set(*memory - temp_value);
                }
                Action::Multiply => {
                    memory.set(*memory * temp_value);
                }
                Action::Divide => {
                    memory.set(*memory / temp_value);
                }
                Action::Equals => {
                    memory.set(temp_value);
                }
            }
        } else {
            let memory_update = match *temp_value {
                Some(i) => i,
                None => *memory,
            };

            memory.set(memory_update);
        }
        if passed_action != Action::Equals {
            action.set(Some(passed_action));
        } else {
            action.set(None);
        }

        // Reset our temporary states
        temp_value.set(None);
        trailing_dot.set(false);
        has_dot_pressed.set(false);
    })
};
Enter fullscreen mode Exit fullscreen mode

We will now have a pretty sleak, but somewhat useless calculator:

Calculator with first row

Major button rows

Next, we'll add 3 additional rows, which will support 1-9 number buttons and 3 additional actions: Multiply, Subtract and Add. Insert the following code into the HTML macro in src/app.rs, positioning it below the previously created top row:

  <div class={classes!("flex", "flex-row", "flex-1")}>
    <NumberButton value={7.0}
      onclick={handle_number_button_press.clone()} />
    <NumberButton value={8.0}
      onclick={handle_number_button_press.clone()} />
    <NumberButton value={9.0}
      onclick={handle_number_button_press.clone()} />
    <ActionButton action={Action::Multiply}
      onclick={handle_action_button_press.clone()} selected_action={*action} />
  </div>
  <div class={classes!("flex", "flex-row", "flex-1")}>
    <NumberButton value={4.0}
      onclick={handle_number_button_press.clone()} />
    <NumberButton value={5.0}
      onclick={handle_number_button_press.clone()} />
    <NumberButton value={6.0}
      onclick={handle_number_button_press.clone()} />
    <ActionButton action={Action::Subtract}
      onclick={handle_action_button_press.clone()} selected_action={*action} />
  </div>
  <div class={classes!("flex", "flex-row", "flex-1")}>
    <NumberButton value={1.0}
      onclick={handle_number_button_press.clone()} />
    <NumberButton value={2.0}
      onclick={handle_number_button_press.clone()} />
    <NumberButton value={3.0}
      onclick={handle_number_button_press.clone()} />
    <ActionButton action={Action::Add}
      onclick={handle_action_button_press.clone()} selected_action={*action} />
  </div>
Enter fullscreen mode Exit fullscreen mode

We have most of the functionality already to support this, the only missing part is the functionality required to support number buttons. To do this, we need to handle appending numbers to temp_value, and also managing decimal points:

let handle_number_button = {
    let temp_value = temp_value.clone();
    let trailing_dot = trailing_dot.clone();
    let has_dot_pressed = has_dot_pressed.clone();

    Callback::from(move |value: f32| {
        if let Some(i) = *temp_value {
            let trailing_dot_string = match *trailing_dot {
                true => ".",
                false => "",
            };

            let new_value = format!("{}{}{}", i, trailing_dot_string, value);

            temp_value.set(Some(new_value.parse::<f32>().unwrap()));
        } else {
            if *trailing_dot {
                let value = format!("0.{}", value).parse::<f32>().unwrap();
                temp_value.set(Some(value));
            } else {
                temp_value.set(Some(value));
            }
        }

        if *trailing_dot {
            trailing_dot.set(false);
            has_dot_pressed.set(true);
        }
    })
};
Enter fullscreen mode Exit fullscreen mode

Our calculator will now look like this and be almost complete:

Calculator almost complete, missing last row

Final row

Finally, we add the bottom row to our calculator, completing the UI:

<div class={classes!("flex", "flex-1", "flex-row")}>
    <div class={classes!("flex", "flex-1")}>
        <NumberButton value={0.0} onclick={handle_number_button.clone()} />
    </div>
    <div class={classes!("flex", "flex-1")}>
        <button onclick={handle_dot_press} class={get_button_styles(classes!("bg-lightGrey"))}>
            {"."}
        </button>
        <ActionButton action={Action::Equals} onclick={handle_action_button.clone()} selected_action={*action}  />
    </div>
</div>
Enter fullscreen mode Exit fullscreen mode

Great, the only missing functionality for this is handle_dot_press, this function ensures that only one decimal point can be included in a number, ensuring correctness:

let handle_dot_press = {
    let trailing_dot = trailing_dot.clone();
    let has_dot_pressed = has_dot_pressed.clone();

    Callback::from(move |_| {
        if *has_dot_pressed {
            return;
        }

        trailing_dot.set(true);
    })
};
Enter fullscreen mode Exit fullscreen mode

Done!

🥳 Congratulations! We've successfully built a fully functional calculator that mimics the Apple calculator's design and functionality.

Calculator complete

I hope you found this tutorial insightful and engaging. For more content and updates, connect with me on Twitter and YouTube.

💖 💪 🙅 🚩
maxtaylor
Max

Posted on November 30, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related