Introducción

Hace un par de años, en 2022, escribí una librería para crear interfaces de usuario gráficas en pantallas OLED monocromáticas, particularmente en sistemas embebidos. Fue un proyecto chico que eventualmente abandoné, pero nunca se me fueron las ganas de reescribirlo desde 0, tomando consideraciones adicionales en la fase de diseño y adaptándolo a algunos nuevos requerimientos que tengo. Entonces, luego de todo este tiempo, he decidido volverlo realidad.

En esta serie de publicaciones voy a documentar el proceso completo de desarrollo de esta nueva librería, desde la fase de diseño hasta las últimas pruebas que haga en circuitos reales.

La librería original se llamaba GIF (Graphical Interface Framework), así que decidí ponerle eGIF (Embedded Graphical Interface Framework) a este nuevo proyecto. El nombre me suena a electronic GIF (‘e’ como en eInk) o extended GIF y sigue conservando el significado coherente de la sigla. Parece una tontera, pero no soy fan de cambiar nombres a mitad de camino.

Inspiración

Como mencioné antes, eGIF es un framework, por lo que pretende ser mucho más que una librería para construir componentes gráficos en una pantalla. Mi inspiración inicial fue el Flipper Zero, un pequeño dispositivo que permite trabajar con múltiples tecnlogías y protocolos.

Flipper Zero

Más allá de lo lindo que es, lo que llamó mi atención en ese momento fue su interfaz gráfica: Tiene aplicaciones, menús, widgets, animaciones, etc. y originalmente estaba intentando replicar algo similar.

Menú Flipper Zero

Si bien quiero diseñar algo desde 0, mi objetivo es que que eGIF permita hacer interfaces parecidas a las que tiene el Flipper Zero, con el mismo nivel de modularidad y el mismo tipo de jerarquización entre sus componentes.

Características de la Librería

Hay algunas cosas básicas que sí o sí deben cumplirse. Todas estas aplicaban a mi proyecto original, así que sé que no son tan descabelladas:

  • Debe estar escrita en C: Es para sistemas embebidos, es lo más natural.
  • Debe usar FreeRTOS: Como el nombre dice, eGIF es un framework, no solo una librería. No pretendo que se use solamente para crear componentes gráficos o widgets en una pantalla, sino que sea un ecosistema completo que permita gestionar aplicaciones, inputs, eventos asíncronos, widgets, etc. La forma más fácil de hacer eso es desarrollando todo en base a algún RTOS y diseñándolo de forma que los proyectos se construyan sobre eGIF y no al revés. Además, FreeRTOS es el único RTOS con el que he trabajado :).
  • El diseño debe ser modular: La librería debe permitir integrar nuevas funcionalidades de forma natural y coherente, así como ofrecer una API que facilite la creación de componentes nativos. e.g. Creación de nuevos Widgets, eventos personalizados, servicios nuevos, wrappers para otros tipos de pantallas, etc.

GUIs, OOP y C

Por diseño, una interfaz gráfica exige algún grado de orientación a objetos. En particular, los widgets -y su naturaleza modular- no podrían existir si no fuera por el polimorfismo, y la creación (intuitiva) de componentes sería difícil de implementar sin algún tipo de herencia. Si bien implementar estas cosas en C no es ninguna novedad, es muy fácil perderse con este nuevo nivel de abstracción, combinando programación procedural con un diseño orientado a objetos.

Herencia

La modularidad que busco para la librería está directamente relacionada con el concepto de herencia (en el contexto de OOP). Por ejemplo, sería ideal tener una estructura base Widget que sea heredada por otras estructuras derivadas para crear nuevos componentes. Algo así:

typedef struct
{
    // Atributos de un Widget
    int prop_w1;
    int prop_w2;
} Widget;

typedef struct
{
    Widget widget;

    // Propiedades de NuevoComponente
    int prop_n1;
} NuevoComponente;

En este caso, la estructura derivada NuevoComponente “hereda” la estructura base Widget. De hecho, si esta última es la primera variable declarada en la estructura derivada, se pueden hacer algunos truquitos interesantes.

Como recordatorio, los miembros de una estructura son almacenados en memoria en el mismo órden en el que son declarados. En el ejemplo anterior, si creáramos estas dos variables:

Widget widget;
NuevoComponente nuevo_componente;

Se verían algo así en memoria, respectivamente:

            .---------.     .---------.                      
&widget + 0 | prop_w1 |     | prop_w1 | &nuevo_componente + 0
            :---------:     :---------:                      
&widget + 4 | prop_w2 |     | prop_w2 | &nuevo_componente + 4
            '---------'     :---------:                      
                            | prop_n1 | &nuevo_componente + 8
                            '---------'                      

Esto significa que se puede castear una variable de tipo NuevoComponente* en un Widget*, porque el estándar de C nos asegura que un puntero a NuevoComponente también es un puntero válido a su Widget. En la práctica, esto se vería así:

// Función que recibe un puntero a un Widget y retorna la suma de sus dos miembros
int process_widget(Widget *widget);

Widget widget;
Widget *widget_ptr = &widget;
process_widget(widget_ptr);

NuevoComponente nuevo_componente;
NuevoComponente *nuevo_componente_ptr = &nuevo_componente;

process_widget((Widget *)(nuevo_componente_ptr));

Nótense la última línea, en la que accedemos al widget de nuevo_componente simplemente casteando el puntero. Bajo nuestras limitaciones, esto sirve solamente con herencia simple, pero en mi experiencia eso debería ser más que suficiente.

Polimorfismo

Ahora que está resuelto el problema de la herencia, podemos empezar a explorar el polimorfismo. En su esencia, todo lo que necesitamos es que la estructura base tenga declarados punteros a funciones, de forma que apunten a implementaciones hechas por ella misma o por alguna estructura derivada. Gráficamente, algo así:

                    .-------------------------.                    
                    |          Parser         |                    
                    :-------------------------:                    
                    | - name: const char*     |                    
                    :-------------------------:                    
                    | + parse(const char *)   |                    
                    '-------------------------'                    
                                 ^                                 
                                 |                                 
                .----------------+----------------.                
                |                                 |                
.---------------^---------------. .---------------^---------------.
|          JSON Parser          | |           XML Parser          |
:-------------------------------: :-------------------------------:
| + parse(const char *)         | | + parse(const char *)         |
'-------------------------------' '-------------------------------'

Para simplificarlo más, podemos poner todos los punteros a las funciones en su propia estructura. Este constructo ya tiene un nombre, Virtual Method Table, y curiosamente es exactamente lo que usan algunos lenguajes OOP internamente. En la práctica se vería algo así:

typedef struct
{
    void (*parse)(const char *);
} Parser_vtable;

typedef struct
{
    const char *name;
    Parser_vtable *vtable_;
} Parser;

void parse_file(Parser *parser, const char *filename)
{
    return parser->vtable_->parse(filename);
}

void JSON_parse(const char *filename)
{
    // Parse 'filename' JSON file
}

void XML_parse(const char *filename)
{
    // Parse 'filename' JSON file
}

void main()
{
    Parser JSONParser = {"JSON Parser", {JSON_parse}};
    Parser XMLParser = {"XML Parser", {XML_parse}};

    parse_file(&JSONParser, "file.json");
    parse_file(&XMLParser, "file.xml");
}

Ideas Preliminares

Existen 2 elementos esenciales en eGIF: servicios y aplicaciones.

Esta de más decir que todo esto podría cambiar en el futuro, dependiendo de problemas que vayan surgiendo o nuevas ideas que vaya teniendo.

Servicios

La estructura más básica en eGIF son los servicios. En jerga de FreeRTOS, los servicios son los Tasks con mayor prioridad en la aplicación. Por defecto, solo existe el servicio GUI que se encarga de configurar la pantalla y dibujar los elementos correspondientes. Además, cuenta con una Queue para que otros servicios puedan comunicar eventos e interacturar con el contenido de la pantalla. Por ejemplo, se podría crear un servicio que traduzca el input de botones físicos en eventos de tecleo. También se podría generar un servicio que envíe eventos de “timeout” cada cierto tiempo para apagar la pantalla si no ha habido actividad.

   .-----------. .-----------.                       
   | Servicio1 | | Servicio2 |                       
   '-----.-----' '-----.-----'                       
         |             |                             
         |           .-'---------.                   
  .------|-----------|-----------|------------------.
  | .----v----. .----v----. .----v----.             |
.-| | evento1 | | evento2 | | evento3 |             |
| | '---------' '---------' '---------'             |
| '-------------------------------------------------'
|                  Cola de Eventos                   
|  .--------------.                                  
'->| Servicio GUI |                                  
   '--------------'                                  

Aplicaciones

Las aplicaciones en eGIF son Tasks de menor prioridad que consumen uno o más servicios. El ejemplo más simple sería una aplicación que usa el servicio GUI para dibujar una interfaz gráfica en la pantalla. Además, puede obtener información de otros servicios a través de la cola de eventos, como input del usuario de un botón físico.