XMonad, tres pantallas y un debugger de Haskell que no existe


25 de febrero de 2026

Hay días en que te sientas a hacer "un cambio pequeño" y cuatro horas después estás leyendo el código fuente de XMonad, mirando fijamente cómo un String llamado "NSP" puede colapsar un programa entero en silencio. Este fue uno de esos días.

El problema: workspaces compartidos en multi-monitor

XMonad, por defecto, tiene un modelo de workspaces que puede sorprender si vienes de GNOME o KDE: los workspaces son globales. Si tienes tres pantallas y pulsas Mod+2, las tres pantallas siguen mostrando lo mismo porque workspace 2 existe una sola vez en todo el sistema.

Esto no es un bug, es una decisión de diseño. Pero cuando tienes un MacBook con pantalla Retina 2560x1600 conectado a un monitor ASUS de 27" y una tele Samsung de 1080p, quieres que cada pantalla tenga su propia vida. Que puedas trabajar en el editor en la pantalla principal mientras el navegador está en la secundaria sin que "viajar" al workspace 2 rompa tu layout.

La solución existe y se llama IndependentScreens. Está en el paquete XMonad.Layout.IndependentScreens y lo que hace es sencillo de entender: multiplica tus workspaces por el número de pantallas y añade un prefijo de pantalla. Si tienes 3 pantallas y 9 workspaces, en realidad tienes 27 workspaces internos con nombres como 0_1, 1_1, 2_1… pero tú sigues viendo solo el 1 al 9, cada uno independiente en su pantalla.

El cambio en la configuración es aparentemente inocente:

import XMonad.Layout.IndependentScreens

-- Antes
myWorkspaces = map show [1..9]

-- Después
myWorkspaces = withScreens 3 (map show [1..9])

Y luego ajustar los keybindings para que usen onCurrentScreen en lugar de los bindings directos:

-- Para cambiar de workspace: solo la pantalla actual
[((m .|. modMask, k), windows $ onCurrentScreen f i)
    | (i, k) <- zip (workspaces' conf) [xK_1..xK_9]
    , (f, m) <- [(W.greedyView, 0), (W.shift, shiftMask)]]

Hasta aquí, la teoría. La práctica fue otra cosa.

Primer bug: readProcess dentro del restart

El número de pantallas no es un valor estático en una config declarativa. Hoy tienes tres monitores, mañana enchufas el portátil solo. Así que lo lógico es detectar las pantallas al inicio.

La primera aproximación fue usar readProcess para ejecutar xrandr y contar las líneas con "connected". Funciona perfectamente al arrancar XMonad. Pero al hacer mod+q (restart de XMonad), el proceso se colgaba.

El motivo tiene que ver con cómo los sistemas Unix manejan señales entre procesos padre e hijo. XMonad, durante el restart, sobreescribe el handler de SIGCHLD. Cuando readProcess lanza un subprocess, espera esa señal para saber que terminó. Si el handler está modificado, la espera se convierte en un bucle infinito.

La solución correcta es no salir del proceso X11 para contar pantallas. XMonad ya tiene una función que lo hace nativo:

import XMonad.Layout.IndependentScreens (countScreens)

-- Correcto: usa X11 directamente, sin subprocess
nScreens <- countScreens
let myWorkspaces = withScreens nScreens (map show [1..9])

countScreens consulta directamente al servidor X sin ejecutar nada externo. Funciona en cualquier contexto, incluido el restart.

Segundo bug: el crash silencioso de marshallPP

Este fue el más entretenido de depurar.

XMonad tiene un logHook que comunica el estado de los workspaces a xmobar (la barra de estado). Con IndependentScreens, los workspaces tienen nombres internos como 0_1, 1_2… pero xmobar debe mostrar solo el número, sin el prefijo de pantalla. Para eso existe marshallPP: convierte la PP (pretty-printer) de xmobar para que filtre y transforme los nombres según la pantalla.

El setup era correcto. Los workspaces se mostraban bien. Pero había un scratchpad configurado con XMonad.Util.NamedScratchpad, y ese scratchpad tiene un workspace especial llamado… NSP.

El problema: marshallPP asume que todos los workspaces tienen el formato N_nombre. Cuando encuentra el workspace NSP e intenta procesarlo con unmarshallS (que extrae el prefijo de pantalla), hace un read sobre la cadena "NSP". Y read :: Int sobre "NSP" lanza una excepción en tiempo de ejecución.

Excepción silenciosa. Sin mensaje de error visible. Solo xmobar dejaba de mostrar información.

La solución requiere que el filtro del workspace NSP se aplique antes de que marshallPP lo vea:

myLogHook xmproc0 xmproc1 = do
  -- filterOutWsPP ANTES de marshallPP. El orden importa.
  let pp screen handle = marshallPP screen $ filterOutWsPP [scratchpadWorkspaceTag] def
        { ppOutput = hPutStrLn handle
        , ppCurrent = xmobarColor "#98be65" "" . wrap "[" "]"
        , ppVisible = xmobarColor "#98be65" ""
        , ppHidden  = xmobarColor "#c678dd" "" . noScratchPad
        , -- ...
        }
  multiPP (pp 0 xmproc0) (pp 1 xmproc1)

Tercer bug: prevWS' y nextWS' con workspaces no válidos

Mismo problema, diferente lugar. Las funciones de navegación prevWS' y nextWS' (que saltan entre workspaces ignorando los vacíos) también llaman internamente a unmarshallS. Si en la lista de workspaces aparece NSP antes de ser filtrado, mismo crash.

La solución aquí es filtrar antes de llamar a esas funciones, usando isMarshalled para verificar que un workspace tiene el formato correcto (es decir, que contiene el separador '_'):

import XMonad.Layout.IndependentScreens (isMarshalled)

-- Solo navegar entre workspaces con formato válido (N_nombre)
prevWS' (isMarshalled . W.tag)
nextWS' (isMarshalled . W.tag)

isMarshalled simplemente comprueba si el nombre contiene un '_'. Trivial. Pero sin ese filtro, cualquier workspace con nombre arbitrario explota en tiempo de ejecución.

autorandr declarativo: el punto final

Con los workspaces funcionando, quedaba el problema del DPI. La pantalla Retina del MacBook necesita escala 2x (DPI 192). El monitor externo va a DPI 96. Cuando conectas o desconectas monitores, el DPI debe cambiar.

La solución habitual es tener scripts sueltos de xrandr por ahí. La solución NixOS es usar autorandr con perfiles declarativos en home-manager:

programs.autorandr = {
  enable = true;
  profiles = {
    "laptop-solo" = {
      fingerprints = {
        eDP-1 = "hash-del-panel-interno";
      };
      config = {
        eDP-1 = {
          enable = true;
          mode = "2560x1600";
          rate = "60.00";
          dpi = 192;
        };
      };
    };
    "dual-asus" = {
      fingerprints = {
        eDP-1 = "hash-del-panel";
        HDMI-1 = "hash-del-asus";
      };
      config = {
        eDP-1 = { enable = true; dpi = 96; position = "0x0"; };
        HDMI-1 = { enable = true; mode = "2560x1440"; position = "2560x0"; };
      };
    };
  };
};

Cuando conectas un monitor, autorandr detecta qué perfil corresponde por la huella de los monitores conectados y aplica la configuración automáticamente. El DPI se ajusta solo.

Una nota sobre la compatibilidad con una pantalla

Antes de terminar, esto importa: con una sola pantalla, IndependentScreens se comporta exactamente igual que la configuración original. Los workspaces internos son 0_1 a 0_9, pero tú ves 1 a 9 como siempre. No hay diferencia perceptible.

Esto significa que la migración es segura en servidores o máquinas con un solo monitor. Cambias la config, reinicias XMonad, y nada cambia desde el punto de vista del usuario.

Conclusión

IndependentScreens es la forma correcta de gestionar múltiples monitores en XMonad si quieres comportamiento similar a GNOME o KDE. El módulo existe, está maduro, y funciona bien. Los bugs que encontramos no son del módulo en sí sino de interacciones con NamedScratchpad (el workspace NSP) y con cómo inicializas el conteo de pantallas.

Las lecciones resumidas:

El código completo vive en el repositorio de dotfiles. Y la configuración funciona en producción desde el mismo día que se escribió.

Comparte este post:

Es tu post

Estas seguro? Esto no se puede deshacer.

Comentarios (0)

Sin comentarios todavia. Se el primero!

Deja un comentario