DEV Community

Rain9
Rain9

Posted on

2 1 2 2 2

Tauri (8) - Implementing global shortcut key function

Introduction

In modern desktop applications, shortcuts are an essential tool for enhancing user experience and productivity. Many applications allow users to perform specific actions through shortcuts. As a cross-platform desktop application framework, Tauri provides rich functionality to support global shortcuts.

This article introduces how to implement global shortcut functionality in Tauri, guiding you step-by-step to create a desktop application that supports global shortcuts.

Installing Dependencies

To get started, install the necessary dependencies:

pnpm tauri add global-shortcut

pnpm tauri add store
Enter fullscreen mode Exit fullscreen mode

After installation, you can use @tauri-apps/plugin-global-shortcut in the frontend as follows:

import { register } from '@tauri-apps/plugin-global-shortcut';
// When using `"withGlobalTauri": true`, you may use:
// const { register } = window.__TAURI__.globalShortcut;

await register('CommandOrControl+Shift+C', () => {
  console.log('Shortcut triggered');
});
Enter fullscreen mode Exit fullscreen mode

On the Rust side, the tauri-plugin-global-shortcut plugin will also be available for use:

pub fn run() {
    tauri::Builder::default()
        .setup(|app| {
            #[cfg(desktop)]
            {
                use tauri_plugin_global_shortcut::{Code, GlobalShortcutExt, Modifiers, Shortcut, ShortcutState};

                let ctrl_n_shortcut = Shortcut::new(Some(Modifiers::CONTROL), Code::KeyN);
                app.handle().plugin(
                    tauri_plugin_global_shortcut::Builder::new().with_handler(move |_app, shortcut, event| {
                        println!("{:?}", shortcut);
                        if shortcut == &ctrl_n_shortcut {
                            match event.state() {
                              ShortcutState::Pressed => {
                                println!("Ctrl-N Pressed!");
                              }
                              ShortcutState::Released => {
                                println!("Ctrl-N Released!");
                              }
                            }
                        }
                    })
                    .build(),
                )?;

                app.global_shortcut().register(ctrl_n_shortcut)?;
            }
            Ok(())
        })
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}
Enter fullscreen mode Exit fullscreen mode

Permission Configuration

In the src-tauri/capabilities/default.json file, add the following configuration:

{
  "$schema": "../gen/schemas/desktop-schema.json",
  "identifier": "main-capability",
  "description": "Capability for the main window",
  "windows": ["main"],
  "permissions": [
    "global-shortcut:allow-is-registered",
    "global-shortcut:allow-register",
    "global-shortcut:allow-unregister",
    "global-shortcut:allow-unregister-all"
  ]
}
Enter fullscreen mode Exit fullscreen mode

Implement Global Shortcuts

Create a shortcut.rs file in the src-tauri/src directory:

use tauri::App;
use tauri::AppHandle;
use tauri::Manager;
use tauri::Runtime;
use tauri_plugin_global_shortcut::GlobalShortcutExt;
use tauri_plugin_global_shortcut::Shortcut;
use tauri_plugin_global_shortcut::ShortcutState;
use tauri_plugin_store::JsonValue;
use tauri_plugin_store::StoreExt;

/// Name of the Tauri storage
const COCO_TAURI_STORE: &str = "coco_tauri_store";

/// Key for storing global shortcuts
const COCO_GLOBAL_SHORTCUT: &str = "coco_global_shortcut";

/// Default shortcut for macOS
#[cfg(target_os = "macos")]
const DEFAULT_SHORTCUT: &str = "command+shift+space";

/// Default shortcut for Windows and Linux
#[cfg(any(target_os = "windows", target_os = "linux"))]
const DEFAULT_SHORTCUT: &str = "ctrl+shift+space";

/// Set shortcut during application startup
pub fn enable_shortcut(app: &App) {
    let store = app
        .store(COCO_TAURI_STORE)
        .expect("Creating the store should not fail");

    // Use stored shortcut if it exists
    if let Some(stored_shortcut) = store.get(COCO_GLOBAL_SHORTCUT) {
        let stored_shortcut_str = match stored_shortcut {
            JsonValue::String(str) => str,
            unexpected_type => panic!(
                "COCO shortcuts should be stored as strings, found type: {} ",
                unexpected_type
            ),
        };
        let stored_shortcut = stored_shortcut_str
            .parse::<Shortcut>()
            .expect("Stored shortcut string should be valid");
        _register_shortcut_upon_start(app, stored_shortcut); // Register stored shortcut
    } else {
        // Use default shortcut if none is stored
        store.set(
            COCO_GLOBAL_SHORTCUT,
            JsonValue::String(DEFAULT_SHORTCUT.to_string()),
        );
        let default_shortcut = DEFAULT_SHORTCUT
            .parse::<Shortcut>()
            .expect("Default shortcut should be valid");
        _register_shortcut_upon_start(app, default_shortcut); // Register default shortcut
    }
}

/// Get the current stored shortcut as a string
#[tauri::command]
pub fn get_current_shortcut<R: Runtime>(app: AppHandle<R>) -> Result<String, String> {
    let shortcut = _get_shortcut(&app);
    Ok(shortcut)
}

/// Unregister the current shortcut in Tauri
#[tauri::command]
pub fn unregister_shortcut<R: Runtime>(app: AppHandle<R>) {
    let shortcut_str = _get_shortcut(&app);
    let shortcut = shortcut_str
        .parse::<Shortcut>()
        .expect("Stored shortcut string should be valid");

    // Unregister the shortcut
    app.global_shortcut()
        .unregister(shortcut)
        .expect("Failed to unregister shortcut")
}

/// Change the global shortcut
#[tauri::command]
pub fn change_shortcut<R: Runtime>(
    app: AppHandle<R>,
    _window: tauri::Window<R>,
    key: String,
) -> Result<(), String> {
    println!("Key: {}", key);
    let shortcut = match key.parse::<Shortcut>() {
        Ok(shortcut) => shortcut,
        Err(_) => return Err(format!("Invalid shortcut {}", key)),
    };

    // Store the new shortcut
    let store = app
        .get_store(COCO_TAURI_STORE)
        .expect("Store should already be loaded or created");
    store.set(COCO_GLOBAL_SHORTCUT, JsonValue::String(key));

    // Register the new shortcut
    _register_shortcut(&app, shortcut);

    Ok(())
}

/// Helper function to register a shortcut, primarily for updating shortcuts
fn _register_shortcut<R: Runtime>(app: &AppHandle<R>, shortcut: Shortcut) {
    let main_window = app.get_webview_window("main").unwrap();
    // Register global shortcut and define its behavior
    app.global_shortcut()
        .on_shortcut(shortcut, move |_app, scut, event| {
            if scut == &shortcut {
                if let ShortcutState::Pressed = event.state() {
                    // Toggle window visibility
                    if main_window.is_visible().unwrap() {
                        main_window.hide().unwrap(); // Hide window
                    } else {
                        main_window.show().unwrap(); // Show window
                        main_window.set_focus().unwrap(); // Focus window
                    }
                }
            }
        })
        .map_err(|err| format!("Failed to register new shortcut '{}'", err))
        .unwrap();
}

/// Helper function to register shortcuts during application startup
fn _register_shortcut_upon_start(app: &App, shortcut: Shortcut) {
    let window = app.get_webview_window("main").unwrap();
    // Initialize global shortcut and set its handler
    app.handle()
        .plugin(
            tauri_plugin_global_shortcut::Builder::new()
                .with_handler(move |_app, scut, event| {
                    if scut == &shortcut {
                        if let ShortcutState::Pressed = event.state() {
                            // Toggle window visibility
                            if window.is_visible().unwrap() {
                                window.hide().unwrap(); // Hide window
                            } else {
                                window.show().unwrap(); // Show window
                                window.set_focus().unwrap(); // Focus window
                            }
                        }
                    }
                })
                .build(),
        )
        .unwrap();
    app.global_shortcut().register(shortcut).unwrap(); // Register global shortcut
}

/// Retrieve the stored global shortcut as a string
pub fn _get_shortcut<R: Runtime>(app: &AppHandle<R>) -> String {
    let store = app
        .get_store(COCO_TAURI_STORE)
        .expect("Store should already be loaded or created");

    match store
        .get(COCO_GLOBAL_SHORTCUT)
        .expect("Shortcut should already be stored")
    {
        JsonValue::String(str) => str,
        unexpected_type => panic!(
            "COCO shortcuts should be stored as strings, found type: {} ",
            unexpected_type
        ),
    }
}
Enter fullscreen mode Exit fullscreen mode

In the src-tauri/src/lib.rs file, import and register:

mod shortcut;

pub fn run() {
    let mut ctx = tauri::generate_context!();

    tauri::Builder::default()
        .plugin(tauri_plugin_store::Builder::default().build())
        .invoke_handler(tauri::generate_handler![
            shortcut::change_shortcut,
            shortcut::unregister_shortcut,
            shortcut::get_current_shortcut,
        ])
        .setup(|app| {
            init(app.app_handle());

            shortcut::enable_shortcut(app);
            enable_autostart(app);

            Ok(())
        })
        .run(ctx)
        .expect("error while running tauri application");
}
Enter fullscreen mode Exit fullscreen mode

At this point, the app has implemented the functionality to toggle its visibility using a global shortcut.

  • The default shortcut for macOS is command+shift+space.
  • The default shortcut for Windows and Linux is ctrl+shift+space.

If the default shortcut conflicts with another application or the user has personal preferences, they can modify it.

Modify shortcut keys

Then you need to create a front-end interface to allow users to operate on the front-end interface.

import { useState, useEffect } from "react";
import { isTauri, invoke } from "@tauri-apps/api/core"; 

import { ShortcutItem } from "./ShortcutItem"; 
import { Shortcut } from "./shortcut"; 
import { useShortcutEditor } from "@/hooks/useShortcutEditor"; 
export default function GeneralSettings() {
  const [shortcut, setShortcut] = useState<Shortcut>([]);

  async function getCurrentShortcut() {
    try {
      const res: string = await invoke("get_current_shortcut"); 
      console.log("DBG: ", res); 
      setShortcut(res?.split("+")); 
    } catch (err) {
      console.error("Failed to fetch shortcut:", err); 
    }
  }


  useEffect(() => {
    getCurrentShortcut(); 
  }, []);


  const changeShortcut = (key: Shortcut) => {
    setShortcut(key); 
    if (key.length === 0) return; 
    invoke("change_shortcut", { key: key?.join("+") }).catch((err) => {
      console.error("Failed to save hotkey:", err); 
    });
  };

  const { isEditing, currentKeys, startEditing, saveShortcut, cancelEditing } =
    useShortcutEditor(shortcut, changeShortcut);


  const onEditShortcut = async () => {
    startEditing(); 
    invoke("unregister_shortcut").catch((err) => {
      console.error("Failed to save hotkey:", err); 
    });
  };

  const onCancelShortcut = async () => {
    cancelEditing(); 
    invoke("change_shortcut", { key: shortcut?.join("+") }).catch((err) => {
      console.error("Failed to save hotkey:", err); 
    });
  };


  const onSaveShortcut = async () => {
    saveShortcut(); 
  };


  return (
    <div className="space-y-8">
      <div>
        <h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
          General Settings
        </h2>
        <div className="space-y-6">
          <ShortcutItem
            shortcut={shortcut} 
            isEditing={isEditing} 
            currentKeys={currentKeys} 
            onEdit={onEditShortcut} 
            onSave={onSaveShortcut}
            onCancel={onCancelShortcut} 
          />
        </div>
      </div>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

ShortcutItem.tsx file:

import { formatKey, sortKeys } from "@/utils/keyboardUtils";
import { X } from "lucide-react";
interface ShortcutItemProps {
  shortcut: string[];
  isEditing: boolean;
  currentKeys: string[];
  onEdit: () => void;
  onSave: () => void;
  onCancel: () => void;
}

export function ShortcutItem({
  shortcut,
  isEditing,
  currentKeys,
  onEdit,
  onSave,
  onCancel,
}: ShortcutItemProps) {
  const renderKeys = (keys: string[]) => {
    const sortedKeys = sortKeys(keys);
    return sortedKeys.map((key, index) => (
      <kbd
        key={index}
        className={`px-2 py-1 text-sm font-semibold rounded shadow-sm bg-gray-100 border-gray-300 text-gray-900 dark:bg-gray-700 dark:border-gray-600 dark:text-gray-200`}
      >
        {formatKey(key)}
      </kbd>
    ));
  };

  return (
    <div
      className={`flex items-center justify-between p-4 rounded-lg bg-gray-50 dark:bg-gray-700`}
    >
      <div className="flex items-center gap-4">
        {isEditing ? (
          <>
            <div className="flex gap-1 min-w-[120px] justify-end">
              {currentKeys.length > 0 ? (
                renderKeys(currentKeys)
              ) : (
                <span className={`italic text-gray-500 dark:text-gray-400`}>
                  Press keys...
                </span>
              )}
            </div>
            <div className="flex gap-2">
              <button
                onClick={onSave}
                disabled={currentKeys.length < 2}
                className={`px-3 py-1 text-sm rounded bg-blue-500 text-white hover:bg-blue-600 dark:bg-blue-600 dark:text-white dark:hover:bg-blue-700
                   disabled:opacity-50 disabled:cursor-not-allowed`}
              >
                Save
              </button>
              <button
                onClick={onCancel}
                className={`p-1 rounded text-gray-500 hover:text-gray-700 hover:bg-gray-200 dark:text-gray-400 dark:hover:text-gray-200 dark:hover:bg-gray-600`}
              >
                <X className="w-4 h-4" />
              </button>
            </div>
          </>
        ) : (
          <>
            <div className="flex gap-1">{renderKeys(shortcut)}</div>
            <button
              onClick={onEdit}
              className={`px-3 py-1 text-sm rounded bg-gray-200 text-gray-700 hover:bg-gray-300 dark:bg-gray-600 dark:text-gray-200 dark:hover:bg-gray-500`}
            >
              Edit
            </button>
          </>
        )}
      </div>
    </div>
  );
}

Enter fullscreen mode Exit fullscreen mode

hooks/useShortcutEditor.ts file:

import { useState, useCallback, useEffect } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';

import { Shortcut } from '@/components/Settings/shortcut';
import { normalizeKey, isModifierKey, sortKeys } from '@/utils/keyboardUtils';

const RESERVED_SHORTCUTS = [
  ["Command", "C"],
  ["Command", "V"],
  ["Command", "X"],
  ["Command", "A"],
  ["Command", "Z"],
  ["Command", "Q"],
  // Windows/Linux
  ["Control", "C"],
  ["Control", "V"],
  ["Control", "X"],
  ["Control", "A"],
  ["Control", "Z"],
  // Coco
  ["Command", "I"],
  ["Command", "T"],
  ["Command", "N"],
  ["Command", "G"],
  ["Command", "O"],
  ["Command", "U"],
  ["Command", "M"],
  ["Command", "Enter"],
  ["Command", "ArrowLeft"],
  ["Command", "ArrowRight"],
  ["Command", "ArrowUp"],
  ["Command", "ArrowDown"],
  ["Command", "0"],
  ["Command", "1"],
  ["Command", "2"],
  ["Command", "3"],
  ["Command", "4"],
  ["Command", "5"],
  ["Command", "6"],
  ["Command", "7"],
  ["Command", "8"],
  ["Command", "9"],
];

export function useShortcutEditor(shortcut: Shortcut, onChange: (shortcut: Shortcut) => void) {
  console.log("shortcut", shortcut)

  const [isEditing, setIsEditing] = useState(false);
  const [currentKeys, setCurrentKeys] = useState<string[]>([]);
  const [pressedKeys] = useState(new Set<string>());

  const startEditing = useCallback(() => {
    setIsEditing(true);
    setCurrentKeys([]);
  }, []);

  const saveShortcut = async () => {
    if (!isEditing || currentKeys.length < 2) return;

    const hasModifier = currentKeys.some(isModifierKey);
    const hasNonModifier = currentKeys.some(key => !isModifierKey(key));

    if (!hasModifier || !hasNonModifier) return;

    console.log(111111, currentKeys)

    const isReserved = RESERVED_SHORTCUTS.some(reserved =>
      reserved.length === currentKeys.length &&
      reserved.every((key, index) => key.toLowerCase() === currentKeys[index].toLowerCase())
    );

    console.log(22222, isReserved)


    if (isReserved) {
      console.error("This is a system reserved shortcut");
      return;
    }

    // Sort keys to ensure consistent order (modifiers first)
    const sortedKeys = sortKeys(currentKeys);


    onChange(sortedKeys);
    setIsEditing(false);
    setCurrentKeys([]);
  };

  const cancelEditing = useCallback(() => {
    setIsEditing(false);
    setCurrentKeys([]);
  }, []);

  // Register key capture for editing state
  useHotkeys(
    '*',
    (e) => {
      if (!isEditing) return;

      e.preventDefault();
      e.stopPropagation();

      const key = normalizeKey(e.code);

      // Update pressed keys
      pressedKeys.add(key);

      setCurrentKeys(() => {
        const keys = Array.from(pressedKeys);
        let modifiers = keys.filter(isModifierKey);
        let nonModifiers = keys.filter(k => !isModifierKey(k));

        if (modifiers.length > 2) {
          modifiers = modifiers.slice(0, 2)
        }

        if (nonModifiers.length > 2) {
          nonModifiers = nonModifiers.slice(0, 2)
        }

        // Combine modifiers and non-modifiers
        return [...modifiers, ...nonModifiers];
      });
    },
    {
      enabled: isEditing,
      keydown: true,
      enableOnContentEditable: true
    },
    [isEditing, pressedKeys]
  );

  // Handle key up events
  useHotkeys(
    '*',
    (e) => {
      if (!isEditing) return;
      const key = normalizeKey(e.code);
      pressedKeys.delete(key);
    },
    {
      enabled: isEditing,
      keyup: true,
      enableOnContentEditable: true
    },
    [isEditing, pressedKeys]
  );

  // Clean up editing state when component unmounts
  useEffect(() => {
    return () => {
      if (isEditing) {
        cancelEditing();
      }
    };
  }, [isEditing, cancelEditing]);

  return {
    isEditing,
    currentKeys,
    startEditing,
    saveShortcut,
    cancelEditing
  };
}
Enter fullscreen mode Exit fullscreen mode

Summary

Through the introduction of this article, you can integrate global shortcuts into your Tauri application to provide users with a smoother operation experience. If you have not used Tauri yet, I hope you can have a deeper understanding of it through this article and start trying this feature in your own projects!

Open Source

Recently, I’ve been working on a project based on Tauri called Coco. It’s open source and under continuous improvement. I’d love your support—please give the project a free star 🌟!

This is my first Tauri project, and I’ve been learning while exploring. I look forward to connecting with like-minded individuals to share experiences and grow together!

Thank you for your support and attention!

Do your career a big favor. Join DEV. (The website you're on right now)

It takes one minute, it's free, and is worth it for your career.

Get started

Community matters

Top comments (0)

AWS Security LIVE!

Tune in for AWS Security LIVE!

Join AWS Security LIVE! for expert insights and actionable tips to protect your organization and keep security teams prepared.

Learn More

👋 Kindness is contagious

Discover a treasure trove of wisdom within this insightful piece, highly respected in the nurturing DEV Community enviroment. Developers, whether novice or expert, are encouraged to participate and add to our shared knowledge basin.

A simple "thank you" can illuminate someone's day. Express your appreciation in the comments section!

On DEV, sharing ideas smoothens our journey and strengthens our community ties. Learn something useful? Offering a quick thanks to the author is deeply appreciated.

Okay