Tutorial de Tauri – Capítulo 11: Menús, diálogos y bandejas del sistema

Una aplicación de escritorio profesional necesita más que una ventana con contenido web. Necesita diálogos nativos para confirmar acciones del usuario, menús con atajos de teclado para acceder rápidamente a las funciones principales y, opcionalmente, un icono en la bandeja del sistema para que la aplicación siga disponible en segundo plano. En este capítulo vamos a crear un nuevo proyecto donde integraremos estas tres funcionalidades nativas paso a paso.

Crear el proyecto

Si tienes la aplicación del capítulo anterior en ejecución, deténla con Ctrl+C en la terminal. Vamos a crear un proyecto nuevo desde cero:

npm create tauri-app@latest

Usa estos datos cuando te pregunte:

  • Project name: tauri-nativo
  • Identifier: com.tutorial.taurinativo
  • Frontend language: TypeScript / JavaScript
  • Package manager: npm
  • UI template: React
  • UI flavor: JavaScript

Entra en el directorio e instala las dependencias:

cd tauri-nativo && npm install

Instalar el plugin de diálogos

El plugin dialog nos permite mostrar mensajes, confirmaciones y selectores de archivo nativos del sistema operativo. Instálalo con el comando de Tauri:

npm run tauri add dialog

Este comando hace dos cosas: añade la dependencia de Rust en src-tauri/Cargo.toml y registra el plugin en src-tauri/src/lib.rs. Verifica que tu archivo src-tauri/Cargo.toml incluya tauri-plugin-dialog en las dependencias. Después, abre src-tauri/src/lib.rs y comprueba que la función run() tenga el plugin registrado. Debería verse así:

pub fn run() {
    tauri::Builder::default()
        .plugin(tauri_plugin_dialog::init())
        .plugin(tauri_plugin_opener::init())
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

Ahora configura los permisos. Abre el archivo src-tauri/capabilities/default.json y comprueba que dialog:default esté en la lista de permisos. Si el comando anterior lo añadió automáticamente, ya lo tendrás. Si no, añádelo manualmente:

{
  "identifier": "default",
  "windows": ["main"],
  "permissions": [
    "core:default",
    "opener:default",
    "dialog:default"
  ]
}

Diálogos nativos

Vamos a crear un componente que demuestre todos los tipos de diálogo disponibles. Reemplaza todo el contenido de src/App.jsx con lo siguiente:

import { useState } from "react";
import { message, ask, confirm } from "@tauri-apps/plugin-dialog";

function App() {
  const [resultado, setResultado] = useState("");

  async function mostrarInfo() {
    await message("La operación se completó correctamente", {
      title: "Información",
      kind: "info"
    });
    setResultado("Mensaje informativo mostrado");
  }

  async function mostrarError() {
    await message("No se pudo conectar con el servidor", {
      title: "Error",
      kind: "error"
    });
    setResultado("Mensaje de error mostrado");
  }

  async function pedirConfirmacion() {
    const ok = await confirm(
      "¿Estás seguro de que quieres eliminar este archivo?",
      { title: "Confirmar eliminación", kind: "warning" }
    );
    setResultado(ok ? "Confirmado: archivo eliminado" : "Cancelado");
  }

  async function preguntarGuardar() {
    const guardar = await ask(
      "Hay cambios sin guardar. ¿Qué deseas hacer?",
      {
        title: "Cambios sin guardar",
        kind: "warning",
        okLabel: "Guardar",
        cancelLabel: "Descartar"
      }
    );
    setResultado(guardar ? "Cambios guardados" : "Cambios descartados");
  }

  return (
    <div style={{ maxWidth: "500px", margin: "0 auto", padding: "2rem" }}>
      <h1>Diálogos Nativos</h1>
      <div style={{ display: "flex", flexDirection: "column", gap: "1rem", margin: "2rem 0" }}>
        <button onClick={mostrarInfo}>Mensaje informativo</button>
        <button onClick={mostrarError}>Mensaje de error</button>
        <button onClick={pedirConfirmacion}>Pedir confirmación</button>
        <button onClick={preguntarGuardar}>Preguntar antes de salir</button>
      </div>
      {resultado && (
        <p style={{ padding: "1rem", background: "#2a2a2a", borderRadius: "8px", marginTop: "1rem", color: "#e0e0e0" }}>
          {resultado}
        </p>
      )}
    </div>
  );
}

export default App;

Ejecuta la aplicación para probar los diálogos:

npm run tauri dev

Al hacer clic en cada botón verás un diálogo nativo del sistema operativo. Vamos a explicar qué hace cada función.

Explicación del código de diálogos

  • message(texto, opciones): muestra un mensaje nativo del sistema operativo. La opción kind puede ser "info", "error" o "warning", y determina el icono que se muestra. La función es asíncrona y espera hasta que el usuario cierra el diálogo.
  • confirm(texto, opciones): muestra un diálogo con botones Sí/No. Devuelve true si el usuario pulsa Sí y false si pulsa No.
  • ask(texto, opciones): funciona igual que confirm, pero permite personalizar el texto de los botones con okLabel y cancelLabel. También devuelve true o false.
  • Todos estos diálogos son ventanas nativas del sistema operativo, no elementos HTML. Esto significa que tienen el aspecto y comportamiento del sistema donde se ejecuta la aplicación: macOS, Windows o Linux.

Diálogos de archivo (referencia)

Además de mensajes y confirmaciones, el plugin de diálogos ofrece funciones para abrir y guardar archivos. No las añadiremos al componente actual, pero es importante conocerlas porque son muy útiles combinadas con el plugin de sistema de archivos que vimos en el Capítulo 9.

import { open, save } from "@tauri-apps/plugin-dialog";

// Abrir un selector de archivo
const ruta = await open({
  multiple: false,
  filters: [{ name: "Texto", extensions: ["txt", "md"] }]
});
if (ruta) {
  console.log("Archivo seleccionado:", ruta);
}

// Abrir un diálogo de guardar como
const rutaGuardar = await save({
  filters: [{ name: "Texto", extensions: ["txt"] }]
});

open() abre un selector de archivos nativo y devuelve la ruta del archivo seleccionado o null si el usuario cancela. La opción filters limita los tipos de archivo visibles. save() abre un diálogo de «guardar como» y devuelve la ruta elegida o null. Ambas funciones son útiles para leer y escribir archivos usando el plugin del sistema de archivos del Capítulo 9.

Menús de aplicación

Los menús de una aplicación de escritorio (Archivo, Editar, Ayuda…) son elementos nativos del sistema operativo, no HTML. Por eso se configuran en Rust, no en JavaScript. Nuestro menú tendrá un submenú «Archivo» con tres opciones: Nuevo, Guardar y Salir, cada una con su atajo de teclado.

Reemplaza todo el contenido de src-tauri/src/lib.rs con lo siguiente:

use tauri::{
    menu::{Menu, MenuItem, Submenu},
    Emitter,
};

pub fn run() {
    tauri::Builder::default()
        .plugin(tauri_plugin_dialog::init())
        .plugin(tauri_plugin_opener::init())
        .menu(|app| {
            let about = MenuItem::with_id(app, "about", "Acerca de tauri-nativo", true, None::<&str>)?;
            let app_menu = Submenu::with_items(app, "tauri-nativo", true, &[&about])?;

            let nuevo = MenuItem::with_id(app, "nuevo", "Nuevo", true, Some("CmdOrCtrl+N"))?;
            let guardar = MenuItem::with_id(app, "guardar", "Guardar", true, Some("CmdOrCtrl+S"))?;
            let salir = MenuItem::with_id(app, "salir", "Salir", true, Some("CmdOrCtrl+Q"))?;
            let archivo = Submenu::with_items(app, "Archivo", true, &[&nuevo, &guardar, &salir])?;

            Menu::with_items(app, &[&app_menu, &archivo])
        })
        .on_menu_event(|app, event| {
            match event.id().as_ref() {
                "nuevo" => app.emit("menu-nuevo", ()).unwrap(),
                "guardar" => app.emit("menu-guardar", ()).unwrap(),
                "salir" => std::process::exit(0),
                _ => {}
            }
        })
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

Explicación del código de menús línea por línea

Este código de Rust puede parecer largo, pero cada parte tiene un propósito claro. Veamos qué hace cada línea:

  • use tauri::menu::{Menu, MenuItem, Submenu}: importa los tres tipos necesarios para crear menús. MenuItem es un elemento individual, Submenu es un grupo desplegable y Menu es la barra completa.
  • use tauri::Emitter: importa el trait que permite enviar eventos a JavaScript con emit.
  • .menu(|app| { ... }): este closure define el menú de la aplicación. Se ejecuta una sola vez al arrancar. Recibe el handle de la app, que necesitamos para crear los elementos del menú.
  • MenuItem::with_id(app, "id", "Label", enabled, shortcut): crea un elemento de menú. Los parámetros son: el handle de la app, un identificador único (lo usaremos luego para saber cuál se pulsó), el texto visible, si está habilitado y un atajo de teclado opcional.
  • Some("CmdOrCtrl+N"): define el atajo de teclado. CmdOrCtrl significa Cmd en macOS y Ctrl en Windows/Linux. Tauri se encarga automáticamente de usar la tecla correcta según el sistema.
  • Submenu::with_items(app, "Archivo", true, &[&nuevo, &guardar, &salir]): crea un submenú llamado «Archivo» que contiene los tres elementos. La sintaxis &[&nuevo, ...] es un array de referencias en Rust.
  • Menu::with_items(app, &[&app_menu, &archivo]): crea la barra de menú con dos submenús: primero el menú de la app y después Archivo.
  • MenuItem::with_id(app, "about", ...): en macOS, el primer submenú se convierte en el menú de la aplicación. Por eso creamos primero un menú con el nombre de la app y después el menú «Archivo» con nuestras opciones.
  • None::<&str>: indica que ese elemento no tiene atajo de teclado. El ::<&str> es necesario para que Rust sepa el tipo del None.
  • .on_menu_event(|app, event| { ... }): registra un handler que se ejecuta cada vez que el usuario hace clic en un elemento del menú.
  • match event.id().as_ref(): comprueba qué elemento del menú se pulsó usando su identificador. Recuerda el match de Rust que vimos en el Capítulo 6.
  • app.emit("menu-nuevo", ()): envía un evento a JavaScript, igual que los eventos del Capítulo 8. El frontend puede escucharlo con listen.
  • std::process::exit(0): cierra la aplicación inmediatamente. El 0 indica que la salida fue normal, sin errores.

Escuchar eventos del menú en JavaScript

Los menús envían eventos desde Rust, y JavaScript los recibe con listen, igual que en el Capítulo 8. Vamos a actualizar nuestro src/App.jsx para integrar los diálogos con los eventos del menú. Reemplaza todo el contenido con este código final:

import { useState, useEffect } from "react";
import { message, ask, confirm } from "@tauri-apps/plugin-dialog";
import { listen } from "@tauri-apps/api/event";

function App() {
  const [resultado, setResultado] = useState("");

  useEffect(() => {
    let cancelado = false;

    const unNuevo = listen("menu-nuevo", () => {
      if (!cancelado) setResultado("Menú: Nuevo seleccionado");
    });
    const unGuardar = listen("menu-guardar", () => {
      if (!cancelado) setResultado("Menú: Guardar seleccionado");
    });

    return () => {
      cancelado = true;
      unNuevo.then(fn => fn());
      unGuardar.then(fn => fn());
    };
  }, []);

  async function mostrarInfo() {
    await message("La operación se completó correctamente", {
      title: "Información",
      kind: "info"
    });
    setResultado("Mensaje informativo mostrado");
  }

  async function mostrarError() {
    await message("No se pudo conectar con el servidor", {
      title: "Error",
      kind: "error"
    });
    setResultado("Mensaje de error mostrado");
  }

  async function pedirConfirmacion() {
    const ok = await confirm(
      "¿Estás seguro de que quieres eliminar este archivo?",
      { title: "Confirmar eliminación", kind: "warning" }
    );
    setResultado(ok ? "Confirmado: archivo eliminado" : "Cancelado");
  }

  async function preguntarGuardar() {
    const guardar = await ask(
      "Hay cambios sin guardar. ¿Qué deseas hacer?",
      {
        title: "Cambios sin guardar",
        kind: "warning",
        okLabel: "Guardar",
        cancelLabel: "Descartar"
      }
    );
    setResultado(guardar ? "Cambios guardados" : "Cambios descartados");
  }

  return (
    <div style={{ maxWidth: "500px", margin: "0 auto", padding: "2rem" }}>
      <h1>Diálogos y Menús Nativos</h1>
      <div style={{ display: "flex", flexDirection: "column", gap: "1rem", margin: "2rem 0" }}>
        <button onClick={mostrarInfo}>Mensaje informativo</button>
        <button onClick={mostrarError}>Mensaje de error</button>
        <button onClick={pedirConfirmacion}>Pedir confirmación</button>
        <button onClick={preguntarGuardar}>Preguntar antes de salir</button>
      </div>
      {resultado && (
        <p style={{ padding: "1rem", background: "#2a2a2a", borderRadius: "8px", marginTop: "1rem", color: "#e0e0e0" }}>
          {resultado}
        </p>
      )}
    </div>
  );
}

export default App;

Ahora, cuando pulses Ctrl+N (o Cmd+N en macOS) o hagas clic en Archivo → Nuevo, verás el mensaje «Menú: Nuevo seleccionado» en la interfaz. Lo mismo con Ctrl+S para Guardar. Y Ctrl+Q cerrará la aplicación directamente.

Bandeja del sistema (referencia)

Esta sección es una referencia para que sepas cómo funciona. No la añadiremos al proyecto actual, pero podrás usarla en cualquier proyecto futuro.

El icono en la bandeja del sistema (system tray) permite que la aplicación siga disponible incluso cuando el usuario cierra la ventana principal. Es la funcionalidad que usan aplicaciones como Slack, Discord o Dropbox: cierras la ventana pero el icono sigue en la barra de tareas.

Para añadir un icono de bandeja básico, añadirías este código dentro del closure .setup(), después del código del menú:

use tauri::tray::TrayIconBuilder;

// Dentro de .setup(), después del código del menú:
TrayIconBuilder::new()
    .icon(app.default_window_icon().unwrap().clone())
    .tooltip("Mi App Tauri")
    .build(app)?;

Este código crea un icono en la bandeja del sistema usando el icono por defecto de la aplicación. TrayIconBuilder::new() inicia el constructor. .icon() establece la imagen del icono: usamos app.default_window_icon() para obtener el icono que Tauri ya incluye en el proyecto. .tooltip() define el texto que aparece al pasar el ratón por encima. .build(app)? construye y registra el icono.

El comportamiento de la bandeja varía según el sistema operativo: en macOS aparece en la barra de menú superior, en Windows aparece en la bandeja del sistema junto al reloj, y en Linux depende del entorno de escritorio. Esta funcionalidad es opcional y puedes añadirla a cualquier proyecto más adelante cuando la necesites.

Control de ventanas desde JavaScript (referencia)

Otra funcionalidad útil como referencia: Tauri permite controlar la ventana de la aplicación desde JavaScript. Con la función getCurrentWindow() puedes acceder a métodos para manipular la ventana principal:

import { getCurrentWindow } from "@tauri-apps/api/window";

const ventana = getCurrentWindow();

await ventana.minimize();              // Minimizar la ventana
await ventana.maximize();              // Maximizar la ventana
await ventana.setTitle("Nuevo título"); // Cambiar el título
await ventana.center();                // Centrar en la pantalla
await ventana.setFullscreen(true);     // Pantalla completa

Estas funciones son útiles para crear barras de título personalizadas o controlar el comportamiento de la ventana según el contexto de tu aplicación.

Resumen

En este capítulo hemos creado el proyecto tauri-nativo y hemos aprendido a integrar funcionalidades nativas en una aplicación Tauri:

  • Diálogos nativos: message para mostrar información, confirm para pedir una confirmación Sí/No y ask para personalizar los botones del diálogo.
  • Diálogos de archivo: open y save para seleccionar archivos, combinables con el plugin del sistema de archivos.
  • Menús de aplicación: creados en Rust con MenuItem, Submenu y Menu, incluyendo atajos de teclado con CmdOrCtrl.
  • Eventos de menú: los menús envían eventos desde Rust con emit y JavaScript los escucha con listen.
  • Bandeja del sistema: TrayIconBuilder para mantener la aplicación en segundo plano.
  • Control de ventanas: getCurrentWindow() para minimizar, maximizar, centrar y más.

En el próximo capítulo pondremos todo en práctica construyendo una aplicación de notas completa.

Nos vemos en el Capítulo 12.


¿Te está gustando este tutorial?

Este tutorial forma parte del libro Tauri 2.0: Aplicaciones de Escritorio con React y Rust, disponible en Amazon en formato papel y ebook. El libro incluye todos los capítulos, tres proyectos completos paso a paso y contenido exclusivo que no encontrarás en el blog.

Un saludo, y si aún no lo has hecho no olvides suscribirte a mi blog para no perderte los próximos posts  :-),

También puedes seguirme en Twitter en ‎@revi_apps y no olvides que me ayudas mucho si compartes este post en las redes sociales.

Deja un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

Este sitio usa Akismet para reducir el spam. Aprende cómo se procesan los datos de tus comentarios.

Scroll al inicio