A Material 3 desktop shell for Niri and Hyprland, running on IgnisLua.
This is a Lua port of Exo (Python Ignis). The whole configuration is plain Lua and SCSS: no Python, no runtime code generation.
What is included:
- bar with nine widgets (launcher, window info, media, workspaces, tasks, keyboard layout, system info tray, telemetry, clock)
- launcher (grid and list modes, pinned apps, search, app actions)
- quick center (tiles, sliders, notification list)
- notification cards, popups and history
- OSD, power menu, screen and shell corner overlays
- Material 3 Expressive component set and showcase window
- static themes (
themes/landroid.lua, six Landroid variants) that colour the whole shell without a wallpaper ormatugen - settings window with seven tabs (appearance, quick, bar, launcher, network, Bluetooth, services)
Exo needs IgnisLua — the shell is a configuration, the framework runs it. Clone both side by side, then:
# from the IgnisLua checkout, pointing at this config
./bin/ignis-bin init -c ../Exo/config.lua
lua5.5 bin/ignis init -c ../Exo/config.lua # or through the Lua CLIOr install the config as a user configuration and run it from anywhere:
./scripts/install_exo.sh # IgnisLua helper: copies this
# directory to ~/.config/ignis-lua
ignis init -c ~/.config/ignis-lua/config.luaWindow and command control (bind these in the compositor):
ignis toggle-window Launcher
ignis toggle-window QuickCenter
ignis open-window Settings
ignis open-window PowerMenu
ignis run-command recorder-record-screen
ignis run-command recorder-record-region
ignis run-command set-wallpaper /path/to/wallpaper.pngconfig.lua entry point
user_settings.lua settings tree with JSON persistence
colors.scss Material 3 palette (regenerated by the wallpaper script)
paths.lua config-relative paths
widget_proxy.lua helper for wrappers around GTK widgets
modules/
compositor.lua Niri/Hyprland abstraction
bar/ bar and its widgets
launcher/ app launcher
quickcenter/ quick center and sliders
notifications/ notification cards and popups
osd/ powermenu/ corners/ overlays
m3components/ Button, NewButton, Slider, NavigationRail,
connected button groups, M3 shape engine
m3test/ Material 3 showcase window
settings/ settings window and tabs
scripts/ theming, bar styles, launcher layout, recorder,
auto dark mode, notifications
styles/ SCSS
matugen/ matugen config and templates
Required: Niri or Hyprland, GTK4, libgtk4-layer-shell, and the
Material Symbols Outlined font — icons are ligatures, so without the font
the buttons show icon names as text:
mkdir -p ~/.local/share/fonts
curl -L -o ~/.local/share/fonts/MaterialSymbolsOutlined.ttf \
'https://github.com/google/material-design-icons/raw/master/variablefont/MaterialSymbolsOutlined%5BFILL%2CGRAD%2Copsz%2Cwght%5D.ttf'
fc-cache -fOptional, with graceful degradation when absent:
| Tool | Used for | Without it |
|---|---|---|
matugen |
wallpaper-driven colour schemes | the bundled colors.scss palette is used and the static themes in Settings → Appearance still work |
swww |
setting the wallpaper | matugen still regenerates colours |
gpu-screen-recorder |
screen recording | the recorder reports "No recorder" |
slurp |
region recording | region recording reports a missing dependency |
hyprlock |
lock screen | the power menu falls back to loginctl lock-session |
dart-sass/grass |
SCSS compilation | IgnisLua's built-in Lua SCSS compiler is used |
gnome-control-center |
Settings → Network/Bluetooth shortcuts | a notification says it is not installed |
The desktop notification daemon is a singleton: if another shell already owns
org.freedesktop.Notifications, Exo cannot take it and its popups and
notification center stay empty.
The port required a few additions and fixes in IgnisLua itself:
ignis/utils/scss.lua— dependency-free SCSS compiler (@use,$variables, namespaced variables, nesting/&,@keyframes,@media,rgba()with hex or named colours, Sassmix()).ignis/utils/sass.luafalls back to it when no external compiler is installed.ignis/options_manager.lua—OptionsManager/OptionsGroupwith JSON persistence, generatedset_*accessors,connect_optionand widget bindings; this is Exo's entire settings layer.ignis/dbus.lua— D-Bus object export works on the LuaGObject backend (aGObject.Closureis passed where the marshaller expects one), which makes IPC, notifications and the tray functional; error replies usereturn_dbus_error.src/ignis_launcher.c—bin/ignis-binno longer crashes: Lua 5.5 removedluaL_openlibs, so standard libraries are opened throughluaL_requiref, everydlsymresult is checked, the bundled LuaGObject path is added, andIGNIS_NO_PRELOAD=1skips library pre-binding.ignis/base_widget.lua— reactivevisiblebindings, string enum setters (widget:set_halign("start")),set_width_request/set_height_requestshims (GTK4 has no such methods), and retention of signal pads and callbacks.ignis/config_manager.lua— the config directory is added topackage.pathfor both?.luaand?/init.lua.ignis/services/fetch.lua—gtk_themeandicon_theme.ignis/window_manager.lua—suppress_osd, used by the OSD and sliders.
Four crash causes and one deadlock were found and fixed while bringing the shell up on Wayland.
Cause: NetworkService subscribes to NetworkManager GObject signals. Those are
emitted from inside libnm's own D-Bus message dispatch, and the handler called
sync_state(), which reads properties with synchronous D-Bus calls. A
synchronous call on the connection being dispatched never completes, so the
main loop blocked forever (the process sat in ppoll, IPC stopped answering,
pointer events were still delivered).
Fix: D-Bus signal callbacks (DBusProxy:signal_subscribe, watch_properties)
and NetworkManager GObject handlers now run deferred through an idle/timeout
callback. scripts/run_exo.sh also restarts the shell if IPC stops answering.
- Invalid GType passed to GI.
base_widgetprobed properties withGObject.ObjectClass.find_property(self._class, prop); the class record reached throughself._classcan be stale, which producedgi_repository_find_by_gtype: assertion 'gtype != G_TYPE_INVALID' failedand aSIGSEGVing_type_fundamental().notify()andconnect()now probe the property on the instance insidepcall. - Negative minimum size in the Material 3 slider.
min-height: 24pxwithmargin: -16px 0pxmade GTK compute-8and crash while laying out the quick center sliders. The stylesheet now usesmargin: 0pxfor the slider and its hidden value label. - Measuring the slider value label. Exo passed
draw_value = truewithvalue_pos = "top"while its CSS hides that label; GTK crashed while measuring it. The flags are dropped — visually identical. - LuaGObject freeing the
GClosurebehind a Lua signal callback while the signal is still connected, so the next emission jumped through a dangling pointer (g_signal_emit → g_closure_invoke → <junk>). IgnisLua now retains every signal pad and callback, and the LuaGObject fork only unrefs objects it actually owns.
Use the issue and pull request templates in .github/, and read
CONTRIBUTING.md first.
LGPL-2.1-or-later, matching IgnisLua. Exo upstream is MIT licensed.