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
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
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: [],
};
Next, create a CSS file named src/input.css
and insert the following Tailwind directives:
@tailwind base;
@tailwind components;
@tailwind utilities;
To integrate this stylesheet into your app, update the index.html
file in the root directory:
<link href="output.css" data-trunk rel="css" />
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": ""
}
]
}
}
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 thetemp_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);
}
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>
}
🐣 We now have the very basic beginnings of our calculator:
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(),
}
}
}
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
])
}
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>
)
}
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>
)
}
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;
Then, import this module in src/main.rs
for seamless integration:
mod buttons;
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>
}
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);
})
};
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);
}
})
};
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);
})
};
We will now have a pretty sleak, but somewhat useless calculator:
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>
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);
}
})
};
Our calculator will now look like this and be almost complete:
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>
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);
})
};
Done!
🥳 Congratulations! We've successfully built a fully functional calculator that mimics the Apple calculator's design and functionality.
I hope you found this tutorial insightful and engaging. For more content and updates, connect with me on Twitter and YouTube.
Top comments (0)