In this tutorial, we're going to be building a desktop Pomodoro timer app using Tauri and Yew. I especially like this build because it uses rust for every single aspect (except CSS of course). If you're unfamiliar with the the Pomodoro technique, it is a time management method shown to help maintain productivity throughout your day, which is especially important in fields like software development.
We'll be using some community packages; gloo-timers
for timer functionality and yew-feather
for icons. Additionally, we'll style our components using Tailwind, and I'll guide you through its setup in this tutorial.
Along with this build I've put together a YouTube video demonstrating the build from start to finish, can see that here:
Also you can find the source code here
Project setup
Let's start by setting up the project, for this we will use the Tauri CLI. This command requires yarn
, which can be installed following the instructions here.
Run the following command in your console:
yarn create tauri-app
Answer the prompts as follows:
-
Project name:
pomodoro-app
- Frontend language: Rust (cargo)
- UI template: Yew - (https://yew.rs/)
After completing these steps, you'll have a pomodoro-app
directory with the necessary boilerplate to kickstart our Tauri app. Note: If any dependencies are missing, the CLI will guide you through the installation.
Install Dependencies:
Let's install our additional dependencies now:
cargo add yew-feather gloo-timers
Enable System Tray:
To display our timer's status in the system tray, we need to enable Tauri's system-tray functionality. Begin by updating the Tauri dependency in src-tauri/cargo.toml
to include the system-tray
feature:
tauri = { version = "1.5", features = ["shell-open", "system-tray"] }
Next, configure an icon for the system tray. Modify the tauri
field in src-tauri/tauri.conf.json
to specify an icon path:
{
...
"tauri": {
"systemTray": {
"iconPath": "icons/icon.png",
"iconAsTemplate": true
},
...
}
}
Ensure to keep the existing settings intact while adding the systemTray
object.
After these changes, reinstall the Tauri app packages:
cargo install --path ./src-tauri
Improve Development Experience:
Enhancing the development workflow is crucial for efficiency. Currently, closing the window terminates the server, and there's no way to reopen the window once closed. Let's refine our src-tauri/main.rs
file to address these issues:
fn main() {
// Create the system tray object
let tray = SystemTray::new();
tauri::Builder::default()
.on_window_event(|event| {
match event.event() {
// Prevents the dev server from exiting when we close the window
tauri::WindowEvent::CloseRequested { api, .. } => {
event.window().hide().unwrap();
api.prevent_close();
}
_ => {}
}
})
// Include the system tray and open the window when the icon is pressed
.system_tray(tray)
.on_system_tray_event(|app, event| match event {
SystemTrayEvent::LeftClick {
position: _,
size: _,
..
} => {
let window = app.get_window("main").unwrap();
window.show().unwrap();
}
_ => (),
})
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
This update ensures that closing the window hides it instead of shutting down the server, and clicking on the system tray icon will reopen the window.
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:
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ["./src/**/*.rs"],
theme: {
extend: {},
},
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.
Start Services:
With the setup complete, it's time to start the development server and the Tailwind watcher service. Open two separate terminals and execute the following commands:
Tauri Dev Server:
cargo tauri dev
Tailwind Watcher Service:
npx tailwindcss -i ./src/input.css -o ./output.css --watch
These commands will start the Tauri development server and initiate Tailwind's process to watch for any changes in your CSS file, ensuring real-time updates.
Timer
State hooks:
To manage our timer, we'll use three state hooks in src/app.rs
. These hooks are crucial for handling different aspects of our timer's functionality:
-
session_length
: Determines the duration of a session, allowing users to adjust it with buttons. -
timer_duration
: Tracks the elapsed time in a work or break session. -
timer_state
: Manages three distinct states of the timer - Paused, Running, and Break.
Update src/app.rs
with the following code to define these states:
use yew::prelude::*;
#[derive(PartialEq, Copy, Clone, Debug)]
pub enum TimerState {
Paused,
Running,
Break,
}
#[function_component(App)]
pub fn app() -> Html {
let session_length = use_state(|| 25 * 60); // Default 25 minutes
let timer_duration = use_state(|| 0);
let timer_state = use_state(|| TimerState::Paused);
html! {
<div class={classes!("flex", "items-center", "justify-center", "flex-col", "h-screen")}>
{"Pomodoro Timer"}
</div>
}
}
Timer Logic:
Next, we'll implement the logic to update timer_duration
every second. We'll use a use_effect_with_deps
to create a timer that updates our state:
use_effect_with_deps(
move |props| {
let (timer_duration, timer_state, _) = props.clone();
let timeout = Timeout::new(1_000, move || {
if *timer_state != TimerState::Paused {
timer_duration.set(*timer_duration + 1);
}
});
move || {
timeout.cancel();
}
},
(
timer_duration.clone(),
timer_state.clone(),
session_length.clone(),
),
);
This sets up a one-second timeout updating the timer_duration
, unless the timer is paused. When the component unmounts, the timeout is canceled. We also need to clone our states multiple times, so we can move the ownership into the use_effect_with_deps
closure and then the Timeout::new
closure.
Timer UI:
For the timer display, we'll be formatting the time into MM:SS format, create src/helpers.rs
and add the following function for time formatting:
pub fn format_time(seconds: u32) -> String {
let minutes = seconds / 60;
let seconds = seconds % 60;
format!("{:02}:{:02}", minutes, seconds)
}
Include this helper in src/main.rs
:
mod helpers;
Now let's create a directory to hold our UI components: src/components
and create the file src/components/timer_display.rs
for the timer UI. This component will display the timer and the buttons required to increase our session length. Let's first define the imports, props and integrate the MM::SS formatting:
use yew::prelude::*;
use crate::{app::TimerState, helpers::format_time};
// Define the component's properties
#[derive(Clone, PartialEq, Properties)]
pub struct Props {
pub timer_state: UseStateHandle<TimerState>,
pub timer_duration: UseStateHandle<u32>,
pub session_length: UseStateHandle<u32>,
}
#[function_component]
pub fn TimerDisplay(props: &Props) -> Html {
let is_expired = *props.timer_duration > *props.session_length;
let get_session_display: String = {
let Props {
timer_duration,
session_length,
..
} = props.clone();
if is_expired {
format_time(*timer_duration)
} else {
format_time(*session_length - *timer_duration)
}
};
html! {
<div class={classes!("flex", "flex-col", "space-y-2", "items-center", "mb-3")}>
<p class={classes!("text-5xl", "min-w-[50px]", "text-right")}>
{get_session_length}
</p>
</div>
}
}
We need to allow our app to have access to this component, do this by creating the file src/components.rs
and exposing the timer_display
module:
pub mod timer_display;
Finally, integrate the TimerDisplay
component in src/app.rs
:
html! {
<div class={classes!("flex", "items-center", "justify-center", "flex-col", "h-screen")}>
<TimerDisplay timer_state={timer_state.clone()} timer_duration={timer_duration.clone()} session_length={session_length.clone()} />
</div>
}
Congratulations π, we now have a really basic viewer for our time remaining:
Session indicator:
Currently it's a bit confusing what is going on with the timer, let's resolve this by creating a simple indicator to display the timer state. This requires an additional method in timer_display.rs
:
let session_state_display = {
let timer_state = props.timer_state.clone();
match *timer_state {
TimerState::Paused => "Paused".to_string(),
TimerState::Break => {
if isExpired {
"Finished break".to_string()
} else {
"On break".to_string()
}
}
TimerState::Running => {
if isExpired {
"Finished session".to_string()
} else {
"In session".to_string()
}
}
}
};
We will integrate this method into our UI later on in the tutorial.
Adjusting Session Length:
For dynamic control over the session length, we will use Callback closures. These methods will be triggered by buttons to increase and decrease the session length, let's define them:
let increase_session_length = {
let session_length = props.session_length.clone();
Callback::from(move |_: ()| {
session_length.set(*session_length + 60 * 5); // Increase by 5 minutes
})
};
let decrease_session_length = {
let session_length = props.session_length.clone();
Callback::from(move |_: ()| {
session_length.set(*session_length - 60 * 5); // Decrease by 5 minutes
})
};
Finally, we integrate all the logic we've developed into our UI with proper styling:
html! {
<div class={classes!("flex", "flex-col", "space-y-2", "items-center")}>
<div class={classes!("flex", "flex-row", "space-x-3")}>
<button onclick={move |_| {
decrease_session_length.emit(());
}} class={classes!("p-2", "border-2", "border-red-500")}>
{ "- 5" }
</button>
<p class={classes!("text-5xl")}>
{get_session_length}
</p>
<button onclick={move |_| {
increase_session_length.emit(());
}} class={classes!("p-2", "border-2", "border-green-500")}>
{ "+ 5" }
</button>
</div>
{session_state_display}
</div>
}
Our application will now have improved controls, allowing us to increase and decrease the timer state and it will also display more information, making the UI easier to understand.
System Tray Updating:
To manage and display the timer's status in the system tray, we need to define the necessary data structures and functions. Start by declaring a struct for setting the tray title and the required wasm method:
#[derive(Serialize, Deserialize)]
struct SetTitleArgs<'a> {
title: &'a str,
}
// Defines an async Rust function to call Tauri, used for updating the system tray state.
#[wasm_bindgen]
extern "C" {
#[wasm_bindgen(js_namespace = ["window", "__TAURI__", "tauri"])]
async fn invoke(cmd: &str, args: JsValue) -> JsValue;
}
Then, create a function that determines the appropriate tray title based on the current state:
pub fn get_tray_title(timer_state: TimerState, timer_duration: u32, session_length: u32) -> String {
match timer_state {
TimerState::Paused => String::from("Paused"),
TimerState::Running => {
if timer_duration >= session_length {
return format!("Finished session: {}", format_time(timer_duration));
}
return format!(
"In session: {}",
format_time(session_length - timer_duration)
);
}
TimerState::Break => {
if timer_duration >= session_length {
return format!("Finished break: {}", format_time(timer_duration));
}
return format!("Break: {}", format_time(session_length - timer_duration));
}
}
}
Inside src/app.rs
, update the use_effect_with_deps
to handle tray title updates, add the below snippet under the let timeout = ...
declaration:
let (timer_duration, timer_state, session_length) = props.clone();
// Spawn a thread so that it can await the async call
spawn_local(async move {
let args = to_value(&SetTitleArgs {
title: get_tray_title(*timer_state, *timer_duration, *session_length).as_str(),
})
.unwrap();
invoke("set_title", args).await;
});
For the Tauri application in src-tauri/src/main.rs
, implement the set_title
command to update the system tray:
#[tauri::command]
async fn set_title(app_handle: tauri::AppHandle, title: String) {
if let Err(e) = app_handle.tray_handle().set_title(&title) {
eprintln!("error updating timer: {}", e);
}
}
Add this to your tauri::builder::default()
under .on_system_tray_event
:
.invoke_handler(tauri::generate_handler![set_title])
Timer Controls:
We will now implement the core timer controls, allowing us to start, pause, reset the timer, and initiate or finish a break. Create the file src/components/timer_controls.rs
and let's first define our imports, required props and TimerControl declaration:
use yew::prelude::*;
use yew_feather::{Coffee, Pause, Play, RefreshCcw};
use crate::app::TimerState;
#[derive(Clone, PartialEq, Properties)]
pub struct Props {
pub timer_state: UseStateHandle<TimerState>,
pub timer_duration: UseStateHandle<u32>,
pub session_length: UseStateHandle<u32>,
}
#[function_component]
pub fn TimerControls(props: &Props) -> Html {
let Props {
timer_state,
timer_duration,
session_length,
} = props;
}
Callbacks:
To handle the various user interactions, we will need to declare a variety of Callback<()>
instances, each altering the state in a unique way:
let start_timer: Callback<()> = {
let timer_state = timer_state.clone();
Callback::from(move |_| {
timer_state.set(TimerState::Running);
})
};
let pause_timer: Callback<()> = {
let timer_state = timer_state.clone();
Callback::from(move |_| {
timer_state.set(TimerState::Paused);
})
};
let reset_timer: Callback<()> = {
let timer_state = timer_state.clone();
let timer_duration = timer_duration.clone();
let session_length = session_length.clone();
Callback::from(move |_| {
timer_state.set(TimerState::Paused);
timer_duration.set(0);
session_length.set(25 * 60); // Reset to 25 minute session time
})
};
let take_break: Callback<()> = {
let timer_state = timer_state.clone();
let timer_duration = timer_duration.clone();
let session_length = session_length.clone();
Callback::from(move |_| {
timer_state.set(TimerState::Break);
timer_duration.set(0);
session_length.set(5 * 60); // 5 minute break time
})
};
let finish_break: Callback<()> = {
let timer_state = timer_state.clone();
let timer_duration = timer_duration.clone();
let session_length = session_length.clone();
Callback::from(move |_| {
timer_state.set(TimerState::Running);
timer_duration.set(0);
session_length.set(25 * 60); // Reset state to 25 minutes
})
};
Rendering the buttons:
The returned HTML depends on the timer_state
, we want to return varying controls to the user depending on whether the timer is; Running, Paused or Break. Let's use a match block to handle this:
match **timer_state {
TimerState::Running => {
html!(
<div class={classes!("flex", "flex-row", "space-x-2")}>
<button class={classes!("p-3")} onclick={move |_| {
take_break.emit(());
}}>
<Coffee />
</button>
<button class={classes!("p-3")} onclick={move |_| {
pause_timer.emit(());
}}>
<Pause />
</button>
<button class={classes!("p-3")} onclick={move |_| {
reset_timer.emit(());
}}>
<RefreshCcw />
</button>
</div>
)
}
TimerState::Paused => {
html!(
<div class={classes!("flex", "flex-row", "space-x-2")}>
<button class={classes!("p-3")} onclick={move |_| {
take_break.emit(());
}}>
<Coffee />
</button>
<button class={classes!("p-3")} onclick={move |_| {
start_timer.emit(());
}}>
<Play />
</button>
<button class={classes!("p-3")} onclick={move |_| {
reset_timer.emit(());
}}>
<RefreshCcw />
</button>
</div>
)
}
TimerState::Break => {
html!(
<div class={classes!("flex", "flex-row", "space-x-2")}>
<button class={classes!("p-3")} onclick={move |_| {
finish_break.emit(());
}}>
<Play />
</button>
</div>
)
}
}
And finally let's integrate this component into our src/app.rs
by adding the following line after our <TimerDisplay />
component:
<TimerControls session_length={session_length.clone()} timer_state={timer_state.clone()} timer_duration={timer_duration.clone()} />
Great work! You will now have a fully functional UI and a useful tool that you can continue to iterate on over time assisting you in your software development
Conclusion:
Congratulations! π You've just built a full-stack Rust desktop app using Tauri and Yew, complete with a functional Pomodoro timer. This app not only serves as a practical tool for managing work and break periods but also demonstrates the power and flexibility of Rust in creating desktop applications.
I hope this tutorial has been insightful and enjoyable. Rust's growing ecosystem offers a unique approach to desktop application development, combining performance, safety, and modern tooling.
If you enjoyed the tutorial and want to see more of my other work checkout the links:
Twitter
YouTube
Top comments (0)