Programando GUIs nativos y accesibles en Windows con Rust

El problema.

Como usuario ciego, muchas veces quiero hacer una aplicación que tenga un GUI, y mis opciones no son muchas ni especialmente cómodas. Si además quiero programarlo en Rust por otros motivos, por ejemplo porque quiero la seguridad y eficiencia que Rust me pueden brindar, lo tengo todavía más difícil. Además, para mi la accesibilidad no es un añadido sino una absoluta necesidad.

Mucha gente se da por vencida y tira por interfaces web, pero eso no es un sistema nativo y no tiene las mismas prestaciones, agilidad y mínima latencia que yo busco en mis aplicaciones.

La solución: native-windows-gui.

Pues como esto no es un vídeo de YouTube no voy a darle más vueltas. La solución es native-windows-gui, o nwg en breve. nwg es un crate (así se llaman los paquetes de Rust) que nos permite acceder a prácticamente toda la API de GUIs win32: controladores, maquetado, eventos, e incluso personalizar y crear nuestros propios widgets con subclases, aunque obviamente no me voy a meter tan al fondo.

Voy a empezar desde el principio, pero os aviso que sobre las bases de Rust voy a pasar muy de puntillas porque doy por hecho que o lo conocéis, o podéis leer la documentación pertinente.

Va en plan tutorial, pero si no queréis copiar y pegar código y simplemente queréis los resultados, tenéis la fuente en zip con todos los archivos necesarios.

Y el binario compilado para windows.

Preparando el proyecto.

Primero, si no teneís rustup, necesitáis instalarlo de aquí: https://www.rust-lang.org/tools/install

Os bajáis el instalador de 64 bits y a vivir.

En cuanto a la cadena de herramientas necesarias, utilizaremos stable-msvc. Para ello necesitaréis determinadas partes de Visual Studio y bibliotecas redistribuibles. En fin, hay documentación al respecto.

Ahora que tenéis rustup instalado, vamos a crear nuestro proyecto. Id al directorio padre, por ejemplo yo tengo mis fuentes en c:/sources pero cada cual y lo suyo. Allí metemos los siguientes comandos:

cargo new gui
cd gui
rustup override set stable-msvc

Estos comandos hacen 3 cosas: 1) crear un proyecto en Rust con su estructura básica, 2) meternos en su directorio, 3) y fijar la cadena de herramientas como stable-msvc, que es la necesaria para poder compilar nwg.

Si dais un dir, veréis que ya tenéis una serie de archivos creados. Uno de ellos es Cargo.toml, que es donde viene la información del proyecto y sus dependencias.

Pues bien, necesitáis un archivo de esta forma:

[package]
name = "gui"
version = "0.1.0"
edition = "2021"
license = "GPL v3"
authors = ["@modulux@isonomia.net"]

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]
native-windows-derive = "1.0.5"
native-windows-gui = "1.0.13"
[build-dependencies]
embed-manifest = "1.3.1"

Os explico para que vale todo esto:

Name contiene el nombre del paquete. Version contiene la versión con las normas de semver (versionado semántico). License, la licencia. Authors, los autores. Edition indica la edición de Rust a utilizar, la última es 2021.

Debajo tenemos las dependencias: native-windows-gui (nwg) es la biblioteca principal, y native-windows-derive (nwd) contiene macros para simplificar y automatizar la creación de GUIs. En este tutorial voy a utilizarlas ambas. Es posible utilizar nwg sin nwd, pero es un coñazo.

La sección de build-dependencies tiene las dependencias necesarias para la fase de compilación (no ejecución). En este caso utilizamos embed-manifest para poder meter en el ejecutable unos datos de manifest que también podrían ir en xml. Es para poder evitar conflictos de versiones de COM y demás.

Precisamente hablando de la fase de compilación, necesitáis crear un archivo build.rs en ese mismo directorio con el siguiente contenido:

use embed_manifest::{embed_manifest, new_manifest};

fn main() {
    if cfg!(target_os = "windows") {
        let _ = embed_manifest(new_manifest("Contoso.Sample"));
    }

    println!("cargo:rerun-if-changed=build.rs");
}

Esto lo que hace es asegurarse de que el sistema objetivo sea Windows, y en su caso meter el manifest en el ejecutable. Además, creamos un directorio llamado .cargo, empezando por punto:

md .cargo

Y en él, crearemos un archivo config con el siguiente contenido:

[target.x86_64-pc-windows-msvc]
rustflags = ["-Ctarget-feature=+crt-static"]
[target.i686-pc-windows-msvc]
rustflags = ["-Ctarget-feature=+crt-static"]

Estas son instrucciones complementarias a Cargo, el gestor de compilado de Rust, para que meta lo que tiene que meter en el ejecutable estático.

A continuación, debemos escribir nuestra aplicación. En los proyectos en Rust, las fuentes están en el subdirectorio src. Si vamos a él veremos que ya hay un archivo creado, main.rs. Vamos a meterle nuestra aplicación.

Los comentarios en Rust se introducen con dos barras si se trata de una sola línea, o son como en C, si son multilínea. Este archivo va muy comentado para que pilléis de que va el rollo.

#![windows_subsystem = "windows"]
// La primera línea es una directiva para que Rust no deje el ejecutable corriendo en la consola.
// Si queréis hacer experimentos e imprimir variables con println podéis comentar esta línea y quedará la consola
// mientras esté abierto el programa.

extern crate native_windows_derive as nwd;
extern crate native_windows_gui as nwg;
use nwd::NwgUi;
use nwg::NativeUi;
// Estas líneas le están diciendo a Rust: vamos a usar nwg y nwd.

use std::cell::RefCell;
// RefCell es un tipo de datos que permite mutabilidad interior.

#[derive(Default, NwgUi)]
pub struct BasicApp {
    #[nwg_control(title: "Ejemplo básico", flags: "WINDOW|VISIBLE")]
    #[nwg_events( OnWindowClose: [BasicApp::say_goodbye] )]
    window: nwg::Window,
    // Esta es la ventana principal. Como veis tiene unos atributos como el título, visibilidad, etc.
    // También despacha un evento. Cuando se cierra, llama a la función say_goodbye.

    #[nwg_control(text: "Nombre", focus: true,)]
    name_edit: nwg::TextInput,
    // Este es un cuadro de edición precompletado con "nombre" y que adquiere el foco.

    #[nwg_control(text: "Di mi nombre.", )]
    #[nwg_events( OnButtonClick: [BasicApp::say_hello] )]
    hello_button: nwg::Button,
    // Aquí tenemos un botón, con sus atributos, y también se despacha un evento.
    // Al activarlo se llama a la función say_hello.

    #[nwg_control(text: "Bienvenido a la aplicación.", )]
    status_bar: nwg::StatusBar,
    // Barra de estado con un texto predefinido.

    clicks: RefCell<u32>,
    // Este es un RefCell, una célula que permite mutabilidad interior.
    // Contiene un elemento de tipo u32, que es un número sin signo de 32 bits.
    // Lo utilizaremos para contar el número de veces que se ha activado el botón.
    // En general, si necesitáis guardar datos lo mejor es que lo hagáis con RefCell.
}

// Ahora la implementación de las funciones de esta estructura:
impl BasicApp {
    fn say_hello(&self) {
        // La función toma &self, que es una referencia a la estructura para la que estamos implementando.

        nwg::simple_message("Hola", &format!("Hola {}", self.name_edit.text()));
        // Esto nos saca un cuadro de diálogo con un botón de aceptar, y el mensaje que sale ahí.
        // Fijaos en que se utiliza self-name_edit, que es el cuadro de edición, y su atributo text, para meter el nombre que hayamos puesto.

        self.name_edit.set_selection(0..self.name_edit.len());
        // Nos aseguramos que el texto en el cuadro de edición está seleccionado tras pulsar el botón.

        self.increment();
        // Llamamos a la función de la estructura para incrementar el número de clicks.

        self.status_bar.set_text(
            0,
            format!(
                "Ya has probado la aplicación {} veces.",
                self.clicks.borrow()
            )
            .as_str(),
        );
        // Ponemos el número de clicks (ya incrementado) en la barra de estado.

        self.hello_button.set_focus();
        // Por último, nos aseguramos que el foco permanece en el botón.
    }

    fn say_goodbye(&self) {
        nwg::simple_message("Adiós", &format!("Adiós, {}", self.name_edit.text()));
        // El mismo tipo de cuadro de diálogo.

        nwg::stop_thread_dispatch();
        // Esta es la forma de desmantelar el bucle principal del GUI para liberar recursos.
    }

    fn increment(&self) {
        // En esta función vamos a incrementar un número, clicks, que está en una RefCell.
        //  Por si sola no hace nada más, y la llama say-hello.

        let mut current = self.clicks.borrow_mut();
        // Aquí cogemos el valor de clicks con un préstamo mutable.

        *current += 1;
        // Y aquí lo incrementamos por referencia.
    }
}

fn main() {
    // Función principal.
    // Ten en cuenta que nwg corre en un hilo aparte su bucle principal. por eso aquí hay muy poca cosa.

    nwg::init().expect("Failed to init Native Windows GUI");
    // Esta es la inicialización del GUI. Si no tira es que el sistema está totalmente jodido y nos morimos.

    // Vamos a crear nuestro objeto de aplicación.
    let _app = BasicApp {
        clicks: 0.into(),
        // Inicializamos clicks a 0, utilizamos into porque es una RefCell.
        ..Default::default() // Y el resto a valores por defecto.
    };

    let _app = BasicApp::build_ui(Default::default()).expect("Failed to build UI");
    // Aquí utilizamos la maquinaria para construir el GUI a partir de nuestra estructura de datos.

    _app.name_edit.set_selection(0.._app.name_edit.len());
    // Nos aseguramos que el cuadro de edición tenga el texto seleccionado, porque me gusta más así.

    nwg::dispatch_thread_events();
    // Y aquí lanzamos el bucle principal de nwg a trabajar.
}

¿Como lo compilamos? Tres pasos esenciales que yo siempre ago:

cargo fmt
cargo check
cargo build

Cargo fmt es un comando que formatea los archivos de Rust utilizando la indentación y normas establecidas. Yo lo uso mucho porque muchas veces no indento o indento mal, y así no me tengo que preocupar.

Cargo check es una especie de fase de verificación que se asegura que el programa es sintácticamente correcto, que los tipos están bien puestos, y las reglas de préstamos y mutabilidad se cumplen. Es mucho más rápido que cargo build, aunque la primera vez que lo corráis tardará más.

Cargo build os compila el programa. Por defecto os saldrá una versión para depurado, en el directorio target/debug, pero si queréis compilar para distribuir, utilizad cargo build —release.

Unas últimas pinceladas.

Es importante recordar que esto es una mínima noción de como funciona Rust, nwg, y los GUI de win32. Sobra decir que se puede hacer mucho más, por ejemplo colocar los controles en lugares y con tamaños específicos, pero para mi una de las cosas interesantes de esta librería es que no nos lo exige. Supongo que queda feo de cojones, pero nada nos impide meter los controles que queramos sin fijranos en temas de maquetado, fuente, tamaño, posición, etc. Eso siempre se puede hacer después, colocándolo como parámetros en la estructura de la aplicación, con la pertinente ayuda vidente, o cada cual con su intuición.

En fin, espero que os haya sido interesante.

links

social