Lección 8: Dinámicos | Curso de UI Emacs Lisp

Lección 8: Dinámicos

Las interfaces que diseñamos, de momento, son estáticas. No podemos copiar, borrar o mover widgets en caliente. Lo cual es un problema si queremos diseñar interfaces vivas, dinámicas. Por ejemplo, tengo un buscador de productos. Estáticamente creo un campo de texto y un botón, que van a estar ahí desde el inicio. Ahora, cuando el usuario hace clic en el botón, quiero capturar el texto para hacer una busqueda y mostrar un listado de widgets con los resultados. ¿Cómo lo hago? ¿Cómo borro el campo de texto y el botón? ¿Cómo creo los widgets complejos con los resultados sin volver a iniciar la aplicación? ¿Cómo borro los resultados para hacer una nueva búsqueda? Estas y otras preguntas las responderemos en esta lección.

Para ilustrar el funcionamiento, haremos peticiones a una API REST ficticia (dummyjson) para hacer busquedas de productos.

En el siguiente vídeo puedes ver el resultado final.

Por lo tanto, en esta lección aprenderos a:

  • Crear widgets dinámicamente.
  • Borrar widgets presentes.
  • Navegar entre layouts.
  • Realizar peticiones HTTP a una API REST.
  • Crear atajos de teclado para navegar por los resultados.

¡A por ello!

1. Creando layout principal

Para crear la UI utilizaremos el paquete widget, como en otras ocasiones.

;; -*- coding: utf-8 -*-
(require 'widget)

Definiremos las variables mínimas.

(defvar buffer-name "*Search products*")
(defvar separator "------------------")
(defvar input-name)
(defvar found-products)
(defvar list-products '())
  • buffer-name: Nombre del buffer donde se mostrará la UI. Lo usaremos para saber que buffer tenemos que matar cuando queramos cerrar la UI.
  • separator: Separador que utilizaremos para separar los productos.
  • input-name: Variable donde guardaremos el widget del input de nombre en el cual el usuario introducirá el nombre del producto a buscar.
  • found-products: Variable donde guardaremos el widget que mostrará el número de productos encontrados.
  • list-products: Lista donde guardaremos los widgets de los productos encontrados que mostraremos en la UI.

Crearemos la función main-layout que será la encargada de renderizar la UI.

(defun main-layout ()
  "Make widgets for the main layout."
  (interactive)
  ;; Clear variables
  (setq input-name nil)
  (setq found-products nil)
  (setq list-products '())
  ;; Create the buffer
  (switch-to-buffer buffer-name)
  ;; Clear the buffer
  (kill-all-local-variables)
  (let ((inhibit-read-only t))
    (erase-buffer))
  (remove-overlays)
  ;; Create the widgets
  ;; Title
  (widget-insert "- Search products -\n\n")
  ;; Input name
  (setq input-name (widget-create 'editable-field
                  :size 15
                  :tag "name"
                  :help-echo "Type a name"
                  :format "Name: %v"))
  ;; Separator
  (widget-insert " ")
    ;; Button search
  (widget-create 'push-button
         :notify #'search-products
         :help-echo "Search products"
         :highlight t
         :button-face '(:background "gray" :foreground "black")
         "Search")
  (widget-insert "\n")
  ;; End widgets
  ;; Display the buffer
  (use-local-map widget-keymap)
  (widget-setup)
  ;; Go to the first input field
  (widget-forward 1))

Lo más destacable es la parte que reescribimos ciertas variables a nil. Esto lo hacemos para que cuando llamemos a la función main-layout por segunda vez no se dupliquen los widgets, o mientras estamos desarrollando no tengamos que limpiar el buffer para volver a ejecutar la función.

Todo unido quedaría de tal forma.

;; -*- coding: utf-8 -*-
;; Imports
(require 'widget)

(eval-when-compile
  (require 'wid-edit))

;; Variables
(defvar buffer-name "*Search products*")
(defvar separator "------------------")
(defvar input-name)
(defvar found-products)
(defvar list-products '())

;; Functions

(defun main-layout ()
  "Make widgets for the main layout."
  (interactive)
  ;; Clear variables
  (setq input-name nil)
  (setq found-products nil)
  (setq list-products '())
  ;; Create the buffer
  (switch-to-buffer buffer-name)
  ;; Clear the buffer
  (kill-all-local-variables)
  (let ((inhibit-read-only t))
    (erase-buffer))
  (remove-overlays)
  ;; Create the widgets
  ;; Title
  (widget-insert "- Search products -\n\n")
  ;; Input name
  (setq input-name (widget-create 'editable-field
                  :size 15
                  :tag "name"
                  :help-echo "Type a name"
                  :format "Name: %v"))
  ;; Separator
  (widget-insert " ")
    ;; Button search
  (widget-create 'push-button
         :notify #'search-products
         :help-echo "Search products"
         :highlight t
         :button-face '(:background "gray" :foreground "black")
         "Search")
  (widget-insert "\n")
  ;; End widgets
  ;; Display the buffer
  (use-local-map widget-keymap)
  (widget-setup)
  ;; Go to the first input field
  (widget-forward 1))


;; Initialization
(main-layout)

2. Buscando productos

Para buscar productos utilizaremos la API REST de dummyJSON.com. Esta API nos permite obtener datos falsos en formato JSON. En nuestro caso, utilizaremos la ruta /products/search que nos brinda la posibilidad de filtrar productos por nombre.

La petición costa del endpoint /products/search y dos parámetros:

  • q: Nombre del producto a buscar.
  • limit: Número de productos a mostrar. Si ponemos 0 nos devolverá todos los productos.

El equivalente en curl sería:

curl -X GET "https://dummyjson.com/products/search?q=phone&limit=0" -H "accept: application/json"

Para realizar la petición en Elisp utilizaremos el paquete request. Este paquete nos permite realizar peticiones HTTP de forma sencilla. Si no lo tienes instalado, puedes instalarlo con M-x package-install RET request RET.

(request "https://dummyjson.com/products/search"
    :params `(("q" . ,(widget-value input-name))
          ("limit" . "0"))
    :parser 'json-read
    :sync t
    :success (cl-function
              (lambda (&key data &allow-other-keys)
              ;; Data
                )))

3. Insertar y borrar widgets dinámicamente

No podemos crear los widgets de los productos sin antes comprender que son elementos volátiles, que más adelante necesitaremos borrar cuando hagamos una nueva búsqueda. Cualquier elemento que insertemos dinámicamente, tendremos que guardar su referencia para poder eliminarlo más tarde.

Crearé una función para crear el widget de un producto.

(defun insert-product (item)
  "Render product."
  (add-to-list 'list-products (widget-create 'item
                         :format (format-product item)
                         :value (assoc-default 'id item))))

Cada producto nuevo, o item, será guardado en list-products para no perder la referencia de memoria. La función (format-product) se encarga de formatear el texto del producto, o el formato con los campos.

(defun format-product (item)
  "Format product."
  (format "\n%s\n%s\n%s\nPrice: %s€ Discount: %s%%"
      separator
      (assoc-default 'title item)
      (assoc-default 'description item)
      (assoc-default 'price item)
      (assoc-default 'discountPercentage item)))

También necesitaremos otra función para insertar el número de productos encontrados.

(defun insert-number-of-products ()
  "Insert number of products."
  (setq found-products (widget-create 'item
                      :format "\nFound %v products\n"
                      :value (length request-products))))

De igual modo, guardaremos en una variable su referencia.

Por último haremos uso de una función para limpiar, o borrar los widgets. Antes de insertar los nuevos resultados, debemos deshacernos de los anteriores.

(defun clear-results ()
  "Clear list-products."
  ;; Clear found-products
  (when (not (eq found-products nil)) (widget-delete found-products))
  ;; Clear list-products
  (dolist (list-item list-products)
    (widget-delete list-item))
  (setq list-products '()))

Si unimos todas las piezas ya dispondremos de una función encargada de conseguir de la API los productos, borrar los widgets anteriores y renderizar nuevos.

(defun search-products (widget &rest ignore)
  "Search products in dummyJSON.com."
  ;; Cursor to end of buffer
  (goto-char (point-max))
  ;; Show loading message
  (message "Searching products...")
  ;; Request data from dummyJSON.com
  (request "https://dummyjson.com/products/search"
    :params `(("q" . ,(widget-value input-name))
          ("limit" . "0"))
    :parser 'json-read
    :sync t
    :success (cl-function
              (lambda (&key data &allow-other-keys)
        (let ((request-products (assoc-default 'products data)))
          (clear-results)
          (insert-number-of-products)
          ;; Add products to list-products
          (cl-loop for item across request-products
               do (insert-product item)))
        ;; Focus to button search
        (widget-forward -1)))))

Ante la pregunta de porque se ha habilitado la petición síncrona en lugar de asíncrona, :sync t, se debe a que no queremos que el usuario pueda hacer una nueva búsqueda hasta que no se haya completado la anterior. Si no, podríamos tener problemas de concurrencia. Es sencillo de arreglar usando una variable de control o estado.

4. Navegar por los resultados con atajos de teclado

Para navegar por los resultados, o productos, utilizaremos atajos de teclado. En concreto la tecla n para ir al siguiente producto y la tecla p para ir al anterior producto.

(define-key widget-keymap (kbd "n") (lambda ()
                      (interactive)
                      (search-forward separator)
                      (forward-line 1)))
(define-key widget-keymap (kbd "p") (lambda ()
                        (interactive)
                        (search-backward separator)
                        (search-backward separator)
                        (forward-line 1)))

Ya que estamos, también añadiremos un atajo de teclado para cerrar la UI con la tecla q.

(define-key widget-keymap (kbd "q") (lambda ()
                      (interactive)
                      (kill-buffer buffer-name)))

5. Incluyendo imágenes

Necesitaremos una función capaz de descargar la imagen, a través de una URL válida, y posicionarla en el buffer con la posición y el tamaño que queramos. Podemos apoyarnos en la lección donde hablamos sobre las imágenes.

(defun put-image-from-url (url &optional width pos)
  "Put an image from an URL in the buffer at position."
  (unless pos (setq pos (1+ (count-lines 1 (point)))))
  (unless url (setq url (url-get-url-at-point)))
  (unless url
    (error "Couldn't find URL."))
  (let ((buffer (url-retrieve-synchronously url)))
    (unwind-protect
        (let ((data (with-current-buffer buffer
                      (goto-char (point-min))
                      (search-forward "\n\n")
                      (buffer-substring (point) (point-max)))))
	  (save-excursion
            (goto-char (point-min))
            (forward-line (1- pos)) ; Go to the beginning of the specified line
            (setq pos (line-beginning-position)))
	  (put-image (create-image data nil t :width width) pos))
      (kill-buffer buffer))))

A continuación llamamos la función a la hora de renderizar el producto en la función insert-product.

(defun insert-product (item)
  "Render product."
  ;; Add text
  (add-to-list 'list-products (widget-create 'item
					     :format (format-product item)
					     :value (assoc-default 'id item)))
  ;; Add image
  (goto-char (point-max))
  (when (search-backward separator nil t)
    (beginning-of-line))
  (forward-line 2)
  (put-image-from-url (assoc-default 'thumbnail item) 200) ;; Nuevo
  (goto-char (point-max)))

No tendremos un widget que nos ayude, pero podemos crear uno nosotros mismos el mecanismo.

6. Informando al usuario un layout de espera

Cuando hacemos una búsqueda, el usuario no sabe si la aplicación está trabajando o no. Por lo tanto, es buena idea mostrar un mensaje con un mínimo de retroalimentación. Para ello, crearemos un nuevo layout que se visualizará mientras se realiza la petición. Vamos a aprender a navegar entre layouts. La estrategia es sencilla: cambiar a un buffer nuevo con el nuevo layout. Cuando recibamos los resultados de la API, borraremos el buffer, volveremos al layout principal, limpiaremos viejos resultaods y renderizaremos los nuevos.

Primero incluimos algunas variables nuevas.

(defvar loading--name-buffer "*Loading*")
(defvar loading-text "Loading")
  • loading--name-buffer: Nombre del buffer donde se mostrará el mensaje de carga.
  • loading-text: Texto del mensaje de carga.

No disponemos de ningún medio para centra un texto horizontal y verticalmente en un buffer. No obstante, podemos calcular el padding horizontal y vertical necesario para ello. En otras palabras, calcularemos el número de espacios en blanco o saltos de línea que necesitamos para centrar el texto.

(defun loading--horizontal-padding ()
  "Calculate the horizontal padding for the loading text."
  (let* ((buffer-width (window-width))
	 (text-length (length loading-text))
         (horizontal-padding (/ (- buffer-width text-length) 2)))
    (make-string (max 0 horizontal-padding) ?\s)))

(defun loading--vertical-padding ()
  "Calculate the vertical padding for the loading text."
  (let* ((buffer-height (window-height))
	 (vertical-padding (/ (- buffer-height 1) 2))) ;; Subtract 1 for the mode line
    (make-string (max 0 vertical-padding) ?\n)))

Además incluiremos una función para formatear el texto con el padding calculado.

(defun loading--format-text ()
  "Format the loading text with padding."
  (format "%s%s%s" (loading--vertical-padding) (loading--horizontal-padding) loading-text))

Lo siguiente será crear el nuevo layout.

(defun loading-layout ()
  "Create the main layout for the loading screen."
  (switch-to-buffer loading--name-buffer)
  (read-only-mode 1)
  (kill-all-local-variables)
  (let ((inhibit-read-only t))
    (erase-buffer))
  (remove-overlays)
  (erase-buffer)
  (widget-create 'item :value (loading--format-text))
  (use-local-map widget-keymap)
  (widget-setup)
  (display-line-numbers-mode 0))

La única línea destacable es (widget-create 'item :value (loading--format-text)). Crea un widget de tipo item con el texto formateado.

Además hará falta funciones para mostrar u ocultar el mensaje de carga.

(defun loading--show ()
  "Show the loading screen."
  (loading-layout))

(defun loading--hide ()
    "Hide the loading screen."
    (kill-buffer loading--name-buffer))

Ya disponemos de todas la herramientas. Solo nos queda decidir cuando mostraremos el mensaje de carga y cuando lo ocultaremos. En este caso, lo mostraremos antes de hacer la petición y lo ocultaremos cuando recibamos los resultados.

(defun search-products (widget &rest ignore)
  "Search products in dummyJSON.com."
  (goto-char (point-max))
  (loading--show) ;; Mostramos
  (request "https://dummyjson.com/products/search"
    :params `(("q" . ,(widget-value input-name))
          ("limit" . "0"))
    :parser 'json-read
    :sync nil
    :success (cl-function
              (lambda (&key data &allow-other-keys)
        (let ((request-products (assoc-default 'products data)))
          (loading--hide) ;; Ocultaoms
          (clear-results)
          (insert-number-of-products)
          (cl-loop for item across request-products
               do (insert-product item)))
        (widget-forward -1)))))

Nuestro loading ya está listo.

7. Ejecutando la aplicación

Para ejecutar la aplicación solo tenemos que llamar a la función main-layout. Adicionalmente, podemos incluir la función get-separator para calcular el ancho del separador de forma dinámica.

(defun get-separator (&optional separator)
  (let* ((sep (or separator ?))
	 ;;(size (window-max-chars-per-line))
	 (size 30)
	 (line (make-string size sep)))
    line))

;; Initialization
(setq separator (get-separator))
(main-layout)

Ejemplo completo

El código fuente del ejemplo esta a continuación:

;; -*- coding: utf-8 -*-
;; Imports
(require 'widget)
(require 'cl-lib)
(require 'url)

(eval-when-compile
  (require 'wid-edit))

;; Variables
(defvar buffer-name "*Search products*")
(defvar loading--name-buffer "*Loading*")
(defvar loading-text "Loading")
(defvar separator "")
(defvar input-name)
(defvar input-is-discount)
(defvar found-products)
(defvar list-products '())

;; Functions

(defun get-separator (&optional separator)
  (let* ((sep (or separator ?))
	 ;;(size (window-max-chars-per-line))
	 (size 30)
	 (line (make-string size sep)))
    line))

(defun loading--horizontal-padding ()
  "Calculate the horizontal padding for the loading text."
  (let* ((buffer-width (window-width))
	 (text-length (length loading-text))
         (horizontal-padding (/ (- buffer-width text-length) 2)))
    (make-string (max 0 horizontal-padding) ?\s)))

(defun loading--vertical-padding ()
  "Calculate the vertical padding for the loading text."
  (let* ((buffer-height (window-height))
	 (vertical-padding (/ (- buffer-height 1) 2))) ;; Subtract 1 for the mode line
    (make-string (max 0 vertical-padding) ?\n)))

(defun loading--format-text ()
  "Format the loading text with padding."
  (format "%s%s%s" (loading--vertical-padding) (loading--horizontal-padding) loading-text))

(defun loading--show ()
  "Show the loading screen."
  (loading-layout))

(defun loading--hide ()
  "Hide the loading screen."
  (kill-buffer loading--name-buffer))


(defun put-image-from-url (url &optional width pos)
  "Put an image from an URL in the buffer at position."
  (unless pos (setq pos (1+ (count-lines 1 (point)))))
  (unless url (setq url (url-get-url-at-point)))
  (unless url
    (error "Couldn't find URL."))
  (let ((buffer (url-retrieve-synchronously url)))
    (unwind-protect
        (let ((data (with-current-buffer buffer
                      (goto-char (point-min))
                      (search-forward "\n\n")
                      (buffer-substring (point) (point-max)))))
	  (save-excursion
            (goto-char (point-min))
            (forward-line (1- pos)) ; Go to the beginning of the specified line
            (setq pos (line-beginning-position)))
	  (put-image (create-image data nil t :width width) pos))
      (kill-buffer buffer))))

(defun insert-number-of-products ()
  "Insert number of products."
  (setq found-products (widget-create 'item
				      :format "\nFound %v products\n"
				      :value (length request-products))))

(defun clear-results ()
  "Clear list-products."
  ;; Clear all images
  (remove-images (point-min) (point-max))
  ;; Clear found-products
  (when (not (eq found-products nil)) (widget-delete found-products))
  ;; Clear list-products
  (dolist (list-item list-products)
    (widget-delete list-item))
  (setq list-products '()))

(defun format-product (item)
  "Format product."
  (format "\n%s\n\n\n\n🔸 %s 🔸\n📖 %s\n💰: %s€\n🏷️: %s%%"
      separator
      (assoc-default 'title item)
      (assoc-default 'description item)
      (assoc-default 'price item)
      (assoc-default 'discountPercentage item)))

(defun insert-product (item)
  "Render product."
  ;; Add text
  (add-to-list 'list-products (widget-create 'item
					     :format (format-product item)
					     :value (assoc-default 'id item)))
  ;; Add image
  (goto-char (point-max))  ; Empieza desde el principio del buffer
  (when (search-backward separator nil t) ; Buscar la cadena "important"
    (beginning-of-line))
  (forward-line 2)
  (put-image-from-url (assoc-default 'thumbnail item) 200)
  (goto-char (point-max)))

;;https://dummyjson.com/products/search?q=text&limit=0
(defun search-products (widget &rest ignore)
  "Search products in dummyJSON.com."
  ;; Cursor to end of buffer
  (goto-char (point-max))
  ;; Show loading
  (loading--show)
  ;; Request data from dummyJSON.com
  (request "https://dummyjson.com/products/search"
    :params `(("q" . ,(widget-value input-name))
          ("limit" . "0"))
    :parser 'json-read
    :sync nil
    :success (cl-function
              (lambda (&key data &allow-other-keys)
        (let ((request-products (assoc-default 'products data)))
	  (loading--hide)
          (clear-results)
          (insert-number-of-products)
          ;; Add products to list-products
          (cl-loop for item across request-products
               do (insert-product item)))
        ;; Focus to button search
        (widget-forward -1)))))

(defun main-layout ()
  "Make widgets for the main layout."
  (interactive)
  ;; Clear variables
  (setq input-name nil)
  (setq input-is-discount nil)
  (setq found-products nil)
  (setq list-products '())
  ;; Create the buffer
  (switch-to-buffer buffer-name)
  ;; Clear the buffer
  (kill-all-local-variables)
  (let ((inhibit-read-only t))
    (erase-buffer))
  (remove-overlays)
  ;; Create the widgets
  ;; Title
  (widget-insert "\n Search products 🔎\n\n")
  ;; Input name
  (setq input-name (widget-create 'editable-field
                  :size 15
                  :tag "name"
                  :help-echo "Type a name"
                  :format "Name: %v"))
  ;; Separator
  (widget-insert " ")
    ;; Button search
  (widget-create 'push-button
         :notify #'search-products
         :help-echo "Search products"
         :highlight t
         :button-face '(:background "gray" :foreground "black")
         "Search")
  (widget-insert "\n")
  ;; End widgets
  ;; Display the buffer
  (use-local-map widget-keymap)
  (widget-setup)
  (display-line-numbers-mode 0)
  ;; Go to the first input field
  (widget-forward 1))

(defun loading-layout ()
  "Create the main layout for the loading screen."
  (switch-to-buffer loading--name-buffer)
  (read-only-mode 1)
  (kill-all-local-variables)
  (let ((inhibit-read-only t))
    (erase-buffer))
  (remove-overlays)
  (erase-buffer)
  (widget-create 'item :value (loading--format-text))
  (use-local-map widget-keymap)
  (widget-setup)
  (display-line-numbers-mode 0))


;; Controls
;; n - Next item
;; p - Previous item
;; q - Quit
(define-key widget-keymap (kbd "n") (lambda ()
				      (interactive)
				      (search-forward separator)
				      (forward-line 1)))
(define-key widget-keymap (kbd "p") (lambda ()
                      (interactive)
                      (search-backward separator)
                      (search-backward separator)
                      (forward-line 1)))
(define-key widget-keymap (kbd "q") (lambda ()
                      (interactive)
                      (kill-buffer buffer-name)))

;; Initialization
(setq separator (get-separator))
(main-layout)

Posibles mejoras que puedes implementar

Es un ejemplo muy sencillo, existe cabida para muchos controles y ayudas en la interfaz. Algunas de ellas son:

  • Paginar los resultados, donde limitamos el número de productos y dispongamos de botones para viajar entre las páginas.
  • Sustituir el tipo de los productos por link-url para poder navegar a la página del producto.
  • Filtros de búsqueda más avanzados (precio, descuento, valoraciones, etc.).

Todo ello serán pequeños retos que te ayudarán a mejorar tus habilidades en Emacs Lisp.

Actividad 1

Crea una calculadora para obtener el índice de masa corporal (IMC) . Los campos estarán repartidos en pasos (steps), cada paso es un buffer. O dicho de otro modo, en lugar de mostrar un formulario con todos los campos, se nos irá preguntando cada dato en un buffer separado. En el último buffer se visualizará el resultado.

Para calcular el IMC necesitamos los siguientes datos:

  • Altura (cm)
  • Peso (kg)

Busca en la red la fórmula para calcular el IMC.

Incluye botones para avanzar o retroceder entre los pasos.

Actividad 2

Programa un visualizador de perfiles de Mastodon.

El usuario únicamente introducirá el nombre de la cuenta.

Investiga el endpoint de la API de Mastodon para obtener los datos de un usuario y sus publicaciones.

Incluye imágenes, enlaces y botones para navegar entre su historial de publicaciones.

Esta obra está bajo una Licencia Creative Commons Atribución-NoComercial-SinDerivadas 4.0 Internacional.

Atribución/Reconocimiento-NoComercial-SinDerivados 4.0 Internacional

¿Me ayudas?

Comprame un café
Pulsa sobre la imagen

No te sientas obligado a realizar una donación, pero cada aportación mantiene el sitio en activo logrando que continúe existiendo y sea accesible para otras personas. Además me motiva a crear nuevo contenido.

Comentarios

{{ comments.length }} comentarios

Nuevo comentario

Nueva replica  {{ formatEllipsisAuthor(replyComment.author) }}

Acepto la política de Protección de Datos.

Escribe el primer comentario