Mis Dotfiles de Hyprland en Lua: Migración, Scripts y Lo Que Aprendí

Mis Dotfiles de Hyprland en Lua: Migración, Scripts y Lo Que Aprendí

Cuando Hyprland 0.55 anunció que deprecaba hyprlang en favor de Lua como lenguaje de configuración, lo vi venir. Llevaba meses con mi setup fino, 32 temas, scripts bash por todos lados, plugins… y ahora tocaba migrar todo.

No fue un simple find-and-replace. Fue re-entender cómo mi escritorio funcionaba realmente.

Este post es la documentación que me hubiera gustado tener el día que empecé.


Por qué Lua

Hyprland creció rápido. La sintaxis de hyprlang —aunque simple— se quedaba corta para lo que la comunidad quería hacer: funciones, condiciones, lógica real en la configuración. Lua resuelve eso de golpe.

Ventajas clave que encontré:

  • Módulos reales con require(), no source
  • Lógica condicional nativa — nada de hacks con exec-once
  • API tipadahl.config(), hl.bind(), hl.animation(), hl.window_rule()
  • Hookshl.on("hyprland.start", ...), hl.on("config.reloaded", ...), hl.on("window.update_rules", ...)
  • Un solo ecosistema — misma sintaxis para config, binds, reglas y hooks

La migración no es opcional. Hyprland 0.55+ tira warning con archivos .conf. Eventualmente dejarán de funcionar. Así que me puse manos a la obra.


Estructura de archivos

Así quedó mi ~/.config/hypr/ después de la migración:

~/.config/hypr/
├── hyprland.lua          ← punto de entrada (solo requires)
├── modules/
│   ├── monitors.lua      ← pantallas, resoluciones, escalado
│   ├── env.lua           ← variables de entorno
│   ├── autostart.lua     ← apps y servicios al iniciar
│   ├── appearance.lua    ← gaps, bordes, blur, sombras, opacidad
│   ├── animations.lua    ← curvas bézier y animaciones
│   ├── input.lua         ← teclado, mouse, touchpad, layout
│   ├── plugins.lua       ← hyprbars, hyprexpo, borders++, hyprfocus
│   ├── binds.lua         ← todos los atajos de teclado
│   ├── rules.lua         ← reglas de ventana y capa
│   └── hooks.lua         ← lógica condicional en respuesta a eventos
├── scripts/
│   ├── minimize.sh
│   ├── minimize-all.sh
│   ├── unminimize.sh
│   ├── unminimize-all.sh
│   ├── toggle-float.sh
│   ├── bar-switch.sh
│   ├── gtk.sh
│   ├── install-updates.sh
│   ├── wallpaper.sh
│   ├── hotcorner.sh
│   ├── inject-super-tab.py
│   └── add-hyprbars-buttons.sh
├── keybind_profiles/
└── rule_profiles/

El archivo hyprland.lua es mínimo — solo requiere los módulos en orden. Cada módulo es autocontenido y responsable de una capa del sistema.


El archivo principal: hyprland.lua

require("modules/monitors")
require("modules/env")
require("modules/autostart")
require("modules/appearance")
require("modules/animations")
require("modules/input")
require("modules/plugins")
require("modules/binds")
require("modules/rules")
require("modules/hooks")

Así de simple. El orden importa: los monitores van primero porque otras configs pueden depender de ellos. Los hooks van al final porque reaccionan a eventos que los otros módulos ya configuraron.


Módulo por módulo

monitors.lua

Configura mis dos pantallas — una principal 1440p @ 144Hz y una secundaria 1080p @ 100Hz en vertical:

hl.monitor({ output = "DP-1",     mode = "[email protected]", position = "0x0",    scale = 1 })
hl.monitor({ output = "HDMI-A-1", mode = "[email protected]", position = "2560x0", scale = 1 })

En hyprlang esto era un monitor= por línea. En Lua es una llamada a función con tabla — mucho más expresivo y menos propenso a errores de sintaxis.

env.lua

Variables de entorno que necesito para Wayland, Qt, GTK y el cursor:

hl.env("XCURSOR_SIZE", "24")
hl.env("QT_QPA_PLATFORM", "wayland")
hl.env("QT_STYLE_OVERRIDE", "kvantum")
hl.env("MOZ_ENABLE_WAYLAND", "1")

Antes eran env = XCURSOR_SIZE,24 en hyprlang. La API de Lua es más clara: hl.env(clave, valor).

autostart.lua

Lo que arranca con Hyprland. Esto migró de varios exec-once a un solo hook:

hl.on("hyprland.start", function()
    hl.exec_cmd("waybar")
    hl.exec_cmd("hyprpaper")
    hl.exec_cmd("swaync")
    hl.exec_cmd("hyprpm reload -n")
    hl.exec_cmd("/home/vhs/.config/eww/scripts/launch-sidebar.sh")
    hl.exec_cmd("/home/vhs/.config/hypr/scripts/gtk.sh")
    hl.exec_cmd("/home/vhs/.config/hypr/wallpaper.sh")
    hl.exec_cmd("/home/vhs/.config/hypr/hotcorner.sh")
    hl.exec_cmd("handy --start-hidden")
end)

Tener todo el autostart en un solo bloque con hl.on("hyprland.start", ...) me permite ver de un vistazo qué arranca y en qué orden. En hyprlang era una lista plana de exec-once dispersa en el archivo.

appearance.lua

Aquí vive la estética: gaps, bordes con gradiente, blur, sombras, opacidad, layout dwindle:

hl.config({
    general = {
        gaps_in = 5, gaps_out = 7,
        border_size = 2,
        col = {
            active_border = {colors = {"rgba(cba6f7ff)", "rgba(62a0eaff)"}, angle = 0},
            inactive_border = "rgba(9a9996ff)",
        },
        layout = "dwindle",
    },
    decoration = {
        rounding = 2,
        blur = { enabled = true, size = 6, passes = 2, vibrancy = 0.9 },
        shadow = { enabled = true, range = 5, render_power = 3, color = "rgba(5e5c64ff)" },
    },
})

Los bordes activos usan un gradiente de dos colores gracias a la sintaxis de tabla de Lua — algo que en hyprlang requería una sintaxis especial con espacios.

animations.lua

Curvas bézier personalizadas y animaciones para cada tipo de elemento:

hl.curve("bounce",   { type = "bezier", points = {{0.05, 0.9}, {0.1, 1.35}} })
hl.curve("overshot", { type = "bezier", points = {{0.13, 0.99}, {0.29, 1.2}} })

hl.animation({ leaf = "windows",    speed = 6, bezier = "bounce", style = "slide" })
hl.animation({ leaf = "workspaces", speed = 7, bezier = "overshot", style = "slide" })
hl.animation({ leaf = "borderangle", speed = 22, bezier = "linear", style = "loop" })

Definir curvas con nombre y reusarlas en múltiples animaciones es de las cosas que simplemente no existían en hyprlang.

input.lua

Teclado latinoamericano, repeat rate rápido, touchpad con tap-to-click:

hl.config({
    input = {
        kb_layout = "latam",
        repeat_rate = 25, repeat_delay = 600,
        touchpad = { tap_and_drag = true, disable_while_typing = true },
    },
    dwindle = { preserve_split = true, smart_resizing = true },
    misc = { disable_hyprland_logo = true, animate_manual_resizes = false },
})

plugins.lua

Esta fue la parte más delicada de la migración. Los plugins se cargan con hyprpm y su configuración va dentro de hl.on("config.reloaded", ...) porque necesitan que el plugin ya esté cargado antes de configurarse:

hl.on("config.reloaded", function()
    hl.config({
        plugin = {
            hyprexpo = {
                columns = 3, gap_size = 5,
                bg_col = "rgb(111111)",
                workspace_method = "center current",
            },
            ["borders-plus-plus"] = {
                add_borders = 2, natural_rounding = true,
                ["col.border_1"] = "rgba(cba6f7ff)",
            },
            hyprbars = {
                bar_height = 25, bar_color = "rgba(1e1e2eff)",
                bar_title_enabled = true, bar_part_of_window = true,
            },
            hyprfocus = { mode = "slide", slide_height = 8, fade_opacity = 0.85 },
        },
    })

    -- Botones de la barra de título
    hl.plugin.hyprbars.add_button({
        bg_color = "rgba(f38ba8ff)", size = 18, icon = "",
        action = "hyprctl dispatch \"hl.dsp.window.close()\"",
    })
    hl.plugin.hyprbars.add_button({
        bg_color = "rgba(f9e2afff)", size = 18, icon = "",
        action = "hyprctl dispatch \"hl.dsp.window.fullscreen({mode = 1})\"",
    })
    hl.plugin.hyprbars.add_button({
        bg_color = "rgba(a6e3a1ff)", size = 18, icon = "",
        action = "/home/vhs/.config/hypr/scripts/minimize.sh",
    })
end)

Los botones de hyprbars se agregan en Lua llamando hl.plugin.hyprbars.add_button() con una tabla de configuración. Cada botón tiene su color, ícono y acción. En hyprlang esto se hacía con plugin:hyprbars:add_button(...).

binds.lua

Aquí está el corazón del uso diario. Todos los atajos migrados a la API hl.bind():

hl.bind("SUPER + Return", hl.dsp.exec_cmd("kitty"))
hl.bind("SUPER + Space", hl.dsp.exec_cmd("rofi -show drun -theme ~/.config/rofi/launchpad.rasi"))
hl.bind("SUPER + A", hl.dsp.exec_cmd("rofi -show drun -theme ~/.config/rofi/spotlight.rasi"))
hl.bind("SUPER + Q", hl.dsp.window.close())
hl.bind("SUPER + F", hl.dsp.window.fullscreen())
hl.bind("SUPER + SHIFT + Space", hl.dsp.window.float({ action = "toggle" }))
hl.bind("SUPER + left", hl.dsp.focus({ direction = "left" }))
hl.bind("SUPER + SHIFT + left", hl.dsp.window.move({ direction = "left" }))
hl.bind("SUPER + CTRL + left", hl.dsp.exec_raw("resizeactive -50 0"), { repeating = true })
hl.bind("SUPER + 1", hl.dsp.focus({ workspace = 1 }))
hl.bind("SUPER + SHIFT + 1", hl.dsp.window.move({ workspace = 1 }))

El cambio más grande aquí fue semántico. En hyprlang un bind era bind = SUPER, Q, killactive. En Lua es hl.bind("SUPER + Q", hl.dsp.window.close()). La API de dispatchers es orientada a objetos: hl.dsp.window.close(), hl.dsp.focus({direction = "left"}), hl.dsp.window.move({workspace = 3}).

Algunos comandos que no tienen equivalente directo en la API de Lua se ejecutan con hl.dsp.exec_raw() — como el resize activo con repetición.

Media keys y brillo también viven aquí:

hl.bind("XF86AudioRaiseVolume", hl.dsp.exec_cmd("pamixer -i 5"), { locked = true, repeating = true })
hl.bind("XF86AudioLowerVolume", hl.dsp.exec_cmd("pamixer -d 5"), { locked = true, repeating = true })
hl.bind("XF86AudioMute", hl.dsp.exec_cmd("pamixer -t"), { locked = true })
hl.bind("XF86AudioPlay", hl.dsp.exec_cmd("playerctl play-pause"), { locked = true })
hl.bind("XF86MonBrightnessUp", hl.dsp.exec_cmd("brightnessctl set +5%"), { locked = true, repeating = true })

Los binds con {locked = true} funcionan incluso con la sesión bloqueada — esencial para volumen y brillo. El {repeating = true} permite mantener la tecla presionada.

rules.lua

Reglas para ventanas que deben flotar, centrarse o tener tamaño específico:

hl.window_rule({
    name = "float-pavucontrol",
    match = { class = "pavucontrol" },
    float = true, center = true,
})

hl.window_rule({
    name = "float-settings",
    match = { title = "Preferencias" },
    float = true, center = true,
})

hl.window_rule({
    name = "vsfetch",
    match = { class = "vsfetch" },
    float = true, center = true, size = "700 700",
})

hl.window_rule({
    name = "ai-webapps",
    match = { class = "webapp-.*" },
    float = true, center = true, size = "1000 700",
})

Cada regla tiene un nombre descriptivo, un patrón de match (por clase o título), y propiedades específicas. La regla ai-webapps usa un regex para capturar cualquier ventana de webapp.

La regla suppress-maximize evita que las aplicaciones GTK intenten maximizarse a pantalla completa, algo que descubrí necesario después de la migración:

hl.window_rule({
    name = "suppress-maximize",
    match = { class = ".*" },
    suppress_event = "maximize",
})

hooks.lua

Este módulo no lo genera vsHyprland Manager — lo escribí y mantengo a mano. Contiene lógica condicional que reacciona a eventos:

local _skip_classes = {
    "pavucontrol", "nm-connection-editor", "blueman-manager",
    "flameshot", "vsfetch",
}

local function _is_auto_float(window)
    local cls = (window and window.class) or ""
    for _, c in ipairs(_skip_classes) do
        if cls:find(c, 1, true) then return true end
    end
    if cls:find("webapp%-", 1, true) then return true end
    return false
end

hl.on("window.update_rules", function(window)
    if not window then return end
    local floating = window.floating
    local prev = _prev_float[window.address]
    _prev_float[window.address] = floating

    if floating and not prev and not _is_auto_float(window) then
        hl.dispatch(hl.dsp.window.resize({ exact = true, x = 900, y = 600 }))
        hl.dispatch(hl.dsp.window.center())
    end
end)

Lo que hace: cuando una ventana que NO está en la lista de auto-float transiciona de tiled a flotante, la redimensiona a 900×600 y la centra automáticamente. Si la ventana ya es flotante por regla (como pavucontrol), no la toca. Esto evita que ventanas que flotamos manualmente con Super + Shift + Space queden con tamaño raro.

Este tipo de lógica condicional simplemente no era posible en hyprlang. Es la razón principal por la que Lua es superior.


Scripts: los que sobrevivieron y los que cambiaron

Mi setup depende de varios scripts bash. Con la migración a Lua, varios requirieron ajustes porque los nombres de los dispatchers cambiaron.

minimize.sh y unminimize.sh

Hyprland no tiene “minimizar” nativo. Mi solución usa un workspace especial llamado special:minimized:

# minimize.sh — envía la ventana activa a special:minimized
ADDR=$(hyprctl activewindow -j | jq -r '.address')
hyprctl dispatch "hl.dsp.window.move({workspace = \"special:minimized\", window = \"address:${ADDR}\"})"
hyprctl dispatch "hl.dsp.workspace.toggle_special(\"minimized\")"
# unminimize.sh — restaura la última ventana minimizada
LAST=$(hyprctl clients -j | jq -r '[.[] | select(.workspace.name == "special:minimized")] | last | .address')
WORKSPACE=$(hyprctl activeworkspace -j | jq -r '.id')
hyprctl dispatch "hl.dsp.window.move({workspace = \"${WORKSPACE}\", window = \"address:${LAST}\"})"

Antes de Lua, el dispatcher era movetoworkspace special:minimized. Ahora es hl.dsp.window.move({workspace = "special:minimized"}). Misma semántica, sintaxis completamente distinta.

Las versiones -all aplican la misma lógica a todas las ventanas del workspace activo usando hyprctl clients -j para listar direcciones.

toggle-float.sh

hyprctl dispatch togglefloating
sleep 0.05
STATE=$(hyprctl activewindow -j | jq -r '.floating')
if [ "$STATE" = "true" ]; then
    hyprctl dispatch resizeactive exact 900 600
    hyprctl dispatch centerwindow
fi

Este script no necesitó cambios en la migración porque togglefloating, resizeactive y centerwindow son comandos legacy de hyprctl dispatch que Hyprland mantiene por compatibilidad. Pero eventualmente habrá que migrarlos a la API nueva.

bar-switch.sh

Alterna entre Waybar e Ironbar con un solo comando:

if pgrep -x waybar > /dev/null; then
    killall waybar && ironbar &
else
    killall ironbar 2>/dev/null && waybar &
fi

Simple pero útil cuando quiero probar barras alternativas sin cambiar la configuración.

hotcorner.sh

Este fue el script que más me costó. Las esquinas activas dejaron de funcionar con la migración a Lua y tuve que reescribirlo desde cero:

COOLDOWN=1
THRESHOLD=15

load_monitors() {
    eval "$(hyprctl monitors -j | python3 -c "
import json, sys
for m in json.load(sys.stdin):
    n = m['name'].replace('-','_')
    print(f'{n}_X={m[\"x\"]}')
    print(f'{n}_W={m[\"width\"]}')
    print(f'{n}_Y={m[\"y\"]}')
    print(f'{n}_H={m[\"height\"]}')
")"
}

while true; do
    pos=$(hyprctl cursorpos)
    x=$(echo "$pos" | awk -F',' '{print int($1)}')
    y=$(echo "$pos" | awk -F',' '{print int($2)}')

    if [[ $x -le $((DP_1_X + THRESHOLD)) && $y -le $((DP_1_Y + THRESHOLD)) ]]; then
        hyprctl dispatch "hl.plugin.hyprexpo.expo('toggle')"
    fi
    sleep 0.1
done

Lo importante aquí: las coordenadas de los monitores se cargan en tiempo real desde hyprctl monitors -j. Si conecto o desconecto una pantalla, el script se recalibra solo cada 60 segundos. Sin esto, las esquinas quedan apuntando a posiciones fantasmas después de un hotplug.

También tiene un cooldown de 1 segundo entre triggers para no disparar la acción 10 veces seguidas si dejo el cursor en la esquina.

inject-super-tab.py

Este es posiblemente el script más “hacky” de todo mi setup. Inyecta Super+Tab a nivel de kernel usando /dev/uinput:

fd = os.open('/dev/uinput', os.O_WRONLY | os.O_NONBLOCK)
fcntl.ioctl(fd, UI_SET_EVBIT, EV_KEY)
fcntl.ioctl(fd, UI_SET_KEYBIT, KEY_LEFTMETA)
fcntl.ioctl(fd, UI_SET_KEYBIT, KEY_TAB)

emit(fd, EV_KEY, KEY_LEFTMETA, 1)  # presiona Super
emit(fd, EV_KEY, KEY_TAB, 1)       # presiona Tab
time.sleep(0.05)
emit(fd, EV_KEY, KEY_TAB, 0)       # suelta Tab
emit(fd, EV_KEY, KEY_LEFTMETA, 0)  # suelta Super

fcntl.ioctl(fd, UI_DEV_DESTROY)

¿Por qué existe esto? Porque el plugin hyprexpo (el Exposé de Hyprland) se activa con Super + Tab, pero solo responde a eventos reales de teclado, no a comandos hyprctl dispatch. Necesitaba una forma de disparar Super+Tab desde un script externo (el hotcorner), y la única manera confiable fue crear un dispositivo de input virtual a nivel kernel.

Es overkill, sí. Pero funciona perfecto y no requiere permisos de root porque el usuario tiene acceso a /dev/uinput en Arch.

add-hyprbars-buttons.sh

Script para inyectar botones adicionales en hyprbars vía hyprctl:

hyprctl dispatch "hyprbars.add_button({bg_color='rgba(ff5555ff)', ...})"

Este es un respaldo por si los botones definidos en plugins.lua no se cargan bien. La sintaxis es la misma — Lua y hyprctl comparten el mismo formato de tablas para los plugins.

gtk.sh

Aplica el tema GTK al iniciar:

gsettings set org.gnome.desktop.interface gtk-theme 'catppuccin-mocha-blue-standard+default'
gsettings set org.gnome.desktop.interface icon-theme 'dreams'
gsettings set org.gnome.desktop.interface cursor-theme 'Bibata-Modern-Classic'
gsettings set org.gnome.desktop.interface color-scheme 'prefer-dark'

install-updates.sh

Actualiza AUR y Flatpak en un solo comando:

yay -Syu --noconfirm
flatpak update -y

Lo que aprendí en el proceso

hyprctl reload no es confiable con Lua

Este fue el dolor de cabeza más grande. En hyprlang, hyprctl reload funcionaba bien para cambios pequeños. Con Lua, un reload en sesión activa puede caer al fallback — pantalla negra, sin binds, solo la terminal de respaldo.

La solución: siempre hacer reboot para cambios grandes, o recargar sólo módulos específicos con hyprctl dispatch exec. Los cambios pequeños (como ajustar un gap o un bind) sí toleran reload, pero si tocas plugins o hooks, mejor reinicia.

Los plugins se configuran en config.reloaded

Los plugins necesitan estar cargados antes de que puedas configurarlos. Si pones la config de plugins fuera de hl.on("config.reloaded", ...), Hyprland intenta aplicarla antes de que hyprpm termine de cargar el plugin y falla silenciosamente.

hl.dsp.exec_raw() es tu amigo

No todos los comandos de hyprctl tienen equivalente en la API de Lua. resizeactive, hyprexpo:expo toggle y algunas operaciones de plugins solo funcionan con exec_raw. No es elegante, pero funciona.

La API de dispatchers es verbosa pero predecible

movewindowhl.dsp.window.move(), killactivehl.dsp.window.close(), togglefloatinghl.dsp.window.float({action = "toggle"}). Una vez que internalizas el patrón hl.dsp.<objeto>.<acción>(<tabla de parámetros>), todo hace sentido.

Los scripts bash son el pegamento

Por más que Lua sea el lenguaje de configuración, los scripts bash siguen siendo esenciales para lógica compleja: parsear JSON de hyprctl, encadenar múltiples comandos, o implementar hot corners. Lua configura Hyprland, bash lo opera.


Lo que sigue pendiente

Algunas cosas que aún tengo en mi radar:

  • vsHypr Theme Manager todavía genera .conf — necesita actualizarse para escribir modules/theme.lua nativo
  • Sintaxis de plugins — la API hl.plugin.NAME = {...} puede necesitar ajustes en futuras versiones de Hyprland
  • hyprexpo — el exec_raw("hyprexpo:expo toggle") es un workaround, esperando que el plugin exponga una API Lua nativa
  • Migrar comandos legacytogglefloating, resizeactive y centerwindow eventualmente necesitarán su equivalente en la API nueva

Repositorio

Si quieren ver el setup completo, está todo en GitHub con licencia MIT:

github.com/victorsosaMx/HyprlandLUA


Migrar a Lua fue más trabajo del que esperaba, pero el resultado es un setup más mantenible, más expresivo y preparado para lo que venga en el ecosistema Hyprland. Si estás considerando la migración, espero que este post te ahorre algunas de las horas que yo invertí debuggeando.

Si te sirve, una estrella en GitHub siempre se agradece.

Víctor Sosa 24 May 2026 hyprland, lua, wayland, dotfiles, arch-linux, scripting, bash, python permalink
Anterior Claude Code con DeepSeek V4: 10 veces más barato y el mismo…