DEV Community

Cover image for Create a Full stack Rust desktop App with Tauri, Yew and Tailwind CSS
Max
Max

Posted on

Create a Full stack Rust desktop App with Tauri, Yew and Tailwind CSS

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"] }
Enter fullscreen mode Exit fullscreen mode

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
    },
    ...
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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");
}
Enter fullscreen mode Exit fullscreen mode

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
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:

/** @type {import('tailwindcss').Config} */
module.exports = {
  content: ["./src/**/*.rs"],
  theme: {
    extend: {},
  },
  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.

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
Enter fullscreen mode Exit fullscreen mode

Tailwind Watcher Service:

npx tailwindcss -i ./src/input.css -o ./output.css --watch
Enter fullscreen mode Exit fullscreen mode

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>
    }
}
Enter fullscreen mode Exit fullscreen mode

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(),
    ),
);
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

Include this helper in src/main.rs:

mod helpers;
Enter fullscreen mode Exit fullscreen mode

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>
    }
}
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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>
}
Enter fullscreen mode Exit fullscreen mode

Congratulations 🎉, we now have a really basic viewer for our time remaining:

Basic view of Pomodoro timer

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()
            }
        }
    }
};
Enter fullscreen mode Exit fullscreen mode

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
    })
};
Enter fullscreen mode Exit fullscreen mode

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>
}
Enter fullscreen mode Exit fullscreen mode

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.

Pomodoro image including increment decrement buttons


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;
}
Enter fullscreen mode Exit fullscreen mode

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));
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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;
});
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

Add this to your tauri::builder::default() under .on_system_tray_event:

.invoke_handler(tauri::generate_handler![set_title])
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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
    })
};
Enter fullscreen mode Exit fullscreen mode

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>
        )
    }
}
Enter fullscreen mode Exit fullscreen mode

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()} />
Enter fullscreen mode Exit fullscreen mode

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

Final image of pomodoro app


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)