Tauri (8) – Implementing global shortcut key function

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

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');
});

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");
}

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"
  ]
}

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
        ),
    }
}

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");
}

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>
  );
}

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>
  );
}

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
  };
}

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!

Total
0
Shares
Leave a Reply

Your email address will not be published. Required fields are marked *

Previous Post
building-hounty:-baby-steps

Building Hounty: Baby steps

Next Post
responde-como-un-experto:-gestion-de-incidentes-y-seguridad-con-aws

Responde como un experto: Gestión de incidentes y seguridad con AWS

Related Posts
12章13

12章13

このJavaコードは、switchステートメントを使って文字列strの値に基づいて異なる操作をすることを意図しています。しかし、strが初期化されていないため、その値はnullです。switchステートメントはnull値を扱うことができないため、実行時にNullPointerExceptionが発生します。それを踏まえて、コードにコメントを追加して説明します。コメントを追加するには、行に対して直接説明を書きます。以下がその例です: public class Sample { // strはクラスレベルの変数で初期値はnull static String str; public static void main(String[] args) { // switch文にはstrが渡されるが、strはnullなのでNullPointerExceptionが投げられる…
Read More