Mi primer prototipo con gtk-rs (ahora sí)

En el stream de ayer hice la primera compilación del cURL gráfico que he empezado a desarrollar. Por ahora no quiero que sea muy sofisticado y vamos a empezar suavemente. La aplicación por ahora debería mostrar un campo de texto para poner la URL, un dropdown para elegir el verbo HTTP de la petición (por ejemplo, POST o GET), una tabla para introducir las cabeceras HTTP de la petición, un campo de texto para el cuerpo de la petición HTTP, un botón para tirar la petición HTTP y un campo de texto donde ver la respuesta de la petición HTTP.

Aunque acabará ocurriendo, el reto por ahora va a ser ver hasta cuánto puedo avanzar en el desarrollo sin instalar GNOME Builder ni crear un proyecto auténtico al estilo GNOME moderno, con su meson.build y su parafernalia. Por el momento he creado un proyecto a mano usando cargo new y luego he agregado gtk4 como dependencia usando cargo add gtk4.

Para meter el cuerpo de la petición, me interesa usar un GtkSourceView, porque quiero que se pueda colorear en caso de que se utilice XML o JSON, así que también lo meteré.

Mi Cargo.toml por ahora tendrá la siguiente forma:

[package]
name = "rest-client"
version = "0.1.0"
edition = "2021"

[dependencies]
glib = "0.19.3"
gtk4 = "0.8.1"
sourceview5 = "0.8.0"

Curiosamente, aunque vayamos por GTK+4, vamos por GtkSourceView 5. Igual que GTK+4 tiene breaking changes respecto a GTK+5, GtkSourceView 4 no tiene el mismo código. Cuidado con usar GtkSourceView 4 en vez de GtkSourceView 5. Empecé por accidente usando GtkSourceView 4 y hasta que no me lo señalaron en el chat del directo (por nadie menos que Bilal, por cierto) no lo rectifiqué.

Paso 1: el blueprint

Empecé fabricando un Blueprint. Sobre cómo funcionan los Blueprints ya he hablado en una entrada de blog separada, así que aquí no voy a hablar del formato de archivos ahora. Tampoco voy a pegar todo el código del Blueprint, ya lo subiré a internet cuando avance el prototipo. Mi idea es que tenga por ahora el siguiente aspecto.

Voy a usar la siguiente jerarquía de la componentes:

  • La ventana va a contener un Box vertical. El Box permite tener más de un hijo.
    • El primer hijo será una barra de herramientas, acomodada como otro Box horizontal para meter cada componente.
      • Primer componente: el campo URL para poner la dirección del endpoint que queremos atacar.
      • Segundo componente (no está en el blueprint): el dropdown para elegir el verbo. Ya lo haré.
      • Tercer componente: el botón enviar para lanzar la petición.
    • El segundo hijo principal será un Paned. El Paned es como un Box pero se centra en tener dos hijos más que un número variable de hijos. Además, se puede pasar el ratón por el espacio que hay entre ambos hijos para así cambiarlo de tamaño.
      • A la izquierda, por ahora, le voy a poner un GtkSourceView para poner el cuerpo de la petición HTTP donde tenga sentido. En el futuro, aquí habrá en su lugar un componente con pestañas para poder cambiar más cosas (entre ellas, las cabeceras HTTP de la petición que se manda).
      • A la derecha, vamos a ponerle por ahora un TextView para mostrar el cuerpo de la respuesta.

Esto podría tener un aspecto como el siguiente:

using Gtk 4.0;
using GtkSource 5;

ApplicationWindow win {
  Box {
    orientation: vertical;
    Box {
      Entry input { ... }
      Button send { ... }
    }
    Paned {
      GtkSource.View { ... }
      TextView { ... }
    }
  }
}

En fin, que el código completo ya lo subiré más adelante.

Una nota más: en realidad, poner tal cual los GtkSource.View y TextView como descendientes de Paned es una idea terrible, porque se romperá el scroll. Esto es porque en muchas ocasiones los TextView tienen ancho mínimo (el del contenedor al que pertenecen), pero no máximo, así que si metes más líneas o columnas de las que caben en la ventana, se empezará a hacer cada vez más grande el contenedor.

La solución está en envolver la caja de texto en un ScrolledWindow para que aparezcan barras de desplazamiento y así asegurarse de que jamás ocupe más ancho y alto del espacio que se le ha asignado al widget:

Paned {
  vexpand: true;
  hexpand: true;
  wide-handle: true;

  ScrolledWindow {
    hexpand: true;
    vexpand: true;

    GtkSource.View {
      auto-indent: true;
      indent-width: 2;
      show-line-numbers: true;
      ...
    }
  }

  ScrolledWindow {
    hexpand: true;
    vexpand: true;

    TextView { ... }
  }
}

Paso 2: pasar el blueprint a XML

Aunque Blueprint sea moderno, cuando luego queramos cargar esta interfaz desde el código fuente, será necesario antes pasarlo a XML, que es lo que acepta GtkBuilder. En condiciones normales, meson.build se ocuparía de transformar bien esto a XML. Pero soy un cabezón y por ahora no quiero.

Así que lo voy a hacer a mano con blueprint-compiler. El comando blueprint-compiler compile acepta un parámetro más con la ruta al archivo Blueprint y escupe sobre stdout el XML traducido. Lo voy a guardar en data/prototype.ui:

blueprint-compiler compile data/prototype.blp > data/prototype.ui

El XML es a todos los efectos esa traducción:

<?xml version="1.0" encoding="UTF-8"?>
<interface>
  <requires lib="gtk" version="4.0"/>
  <object class="GtkApplicationWindow" id="win">
    <child>
      <object class="GtkBox" id="with_toolbar">
        <property name="orientation">1</property>
        <child>
          <object class="GtkBox" id="toolbar">
            <property name="spacing">10</property>
            <property name="margin-start">10</property>
            <property name="margin-end">10</property>
            <property name="margin-top">10</property>
            <property name="margin-bottom">10</property>
            <child>
            ...

Paso 3: cargar esto desde Rust

En el main, primero creamos una aplicación (GtkApplication). Organizar todo el código alrededor de una aplicación es beneficioso porque a largo plazo permite establecer un ID de aplicación, da un nombre de app que se usará en el launcher, en notificaciones, en otros menús… Actualizamos todo para que sea una aplicación:

fn main() -> glib::ExitCode {
  let app = Application::builder()
    .application_id("es.danirod.RestClient")
    .build();
  app.run()
}

Esto compila, pero no hace nada. Vamos a agregarle el handler de la señal activate, para que cuando esté lista haga algo.

fn main() -> glib::ExitCode {
  let app = Application::builder()
    .application_id("es.danirod.RestClient")
    .build();
  app.connect_activate(|_| {
    println!("Activada");
  });
  app.run()
}

Dentro del handler voy a cargar el contenido del XML usando la macro include_str!, que se trae el contenido de un archivo a un &str, y lo cargo en un Builder. Con este Builder ya puedo sacar la referencia a la ventana principal, para interactuar con ella como si la hubiese declarado en código.

Por el camino, es importante llamar a sourceview5::init() para que el GtkSourceView se pinte bien.

app.connect_activate(|app| {
    sourceview5::init();

    let ui = include_str!("../data/prototype.ui");
    let builder = Builder::from_string(ui);

    let window = builder.object::<ApplicationWindow>("win").unwrap();
    window.set_application(Some(app));
    window.present();
});

Con todo esto, cargo run muestra la ventana tal cual la he fabricado. ¡Prometedor!

En el próximo capítulo, continuaré diseñando la interfaz de usuario y empezaré a conectar los elementos de la interfaz, aunque sólo sea para poner println!().