birdland

Source: github.com/red-oxide/birdland

A modular, highly customizable desktop shell suite built with Quickshell and managed entirely through Nix Flakes and Home Manager. Designed with native support for the Niri compositor and dynamically themed via base24-themes.

Birdland is in its early days. Expect rough edges, and options and behavior that may change between versions.

Usage & configuration options →

Theming →

Components

Birdland is split into eight Quickshell applications, which can be used individually or as a complete suite:

  • bullpen: A status bar on every monitor, attachable to any screen edge (top, bottom, left, right), with that monitor's workspaces, the focused window, a clock, the media player, tray icons, and status indicators for CPU, memory, GPU, temperature, weather, network, volume, microphone and battery, plus toggles for do not disturb and keeping the session awake.
  • dugout: A quick application launcher that opens on the focused monitor.
  • lineup: A dock of favorite and running apps on every monitor.
  • outfield: Wallpapers on every monitor: an image, or a folder that rotates, with optional per-monitor choices.
  • announcer: Notification popups on the focused monitor.
  • jumbotron: A pop-up on the focused monitor when the volume, microphone mute or screen brightness changes.
  • tarp: A lock screen that locks on idle, before sleep and on loginctl lock-session, and turns the monitors off when idle.
  • gate: A login screen for greetd, set up through the NixOS module, with a user list, session list and power buttons.

Dugout and Lineup start apps through Niri, so each app runs in its own systemd scope: restarting either service (Home Manager does when it changes) leaves the apps open.


Installation

Add Birdland as an input to your flake.nix:

{
  inputs = {
    nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
    
    birdland.url = "github:chrisppy/birdland";
    # birdland.inputs.base24-themes.follows = "base24-themes";
  };

  outputs = { self, nixpkgs, birdland, ... }@inputs: {
    # Your flake outputs...
  };
}

Home Manager Module Usage

Import the Home Manager module provided by the flake and enable the suite:

{ inputs, pkgs, ... }: {
  imports = [
    inputs.birdland.homeManagerModules.default
  ];

  programs.birdland = {
    enable = true;
    systemd.enable = true; # Manages apps as graphical-session systemd services

    # A theme bundled with base24-themes, or a custom attribute set
    theme.scheme = "birdland";

    font = {
      family = "Inter";
      package = pkgs.inter;
    };

    bullpen = {
      enable = true;
      position = "top";
      units = "imperial";
      clock.format = "h:mm AP";  # 12-hour time
      date.format = "ddd MMM d"; # US-style date
      weather = {
        latitude = 39.28;
        longitude = -76.62;
      };
      # What goes where, in order (these are the defaults)
      layout = {
        left = [ "workspaces" ];
        center = [ "title" ];
        # A list is one island: modules sharing a pill
        right = [ "media" [ "cpu" "memory" "gpu" "temperature" ] "weather" [ "network" "volume" "microphone" "battery" ] "tray" [ "clock" "date" ] ];
      };
    };

    dugout = {
      enable = true;
    };

    lineup = {
      enable = true;
      position = "bottom";
      favorites = [
        "firefox"
        "foot"
        "org.gnome.Nautilus"
      ];
    };

    outfield = {
      enable = true;
      wallpaper = "~/Pictures/Wallpapers"; # an image or a folder
      outputs."DP-1" = ./ultrawide.jpg;    # optional, per monitor
      interval = 30;                       # minutes between images from a folder
    };

    announcer = {
      enable = true;
      position = "top-right";
      timeout = 5; # seconds
    };

    jumbotron = {
      enable = true;
      position = "bottom";
    };

    tarp = {
      enable = true;
      lockAfter = 10;      # minutes without input; null to never lock on idle
      screenOffAfter = 15; # minutes without input; null to leave monitors on
    };
  };
}

Configuration Options

OptionTypeDefaultDescription
programs.birdland.enablebooleanfalseEnable the Birdland Quickshell suite.
programs.birdland.systemd.enablebooleanfalseRun suite components as user systemd services.
programs.birdland.theme.schemetheme name / attrs"birdland"Bundled base24-themes theme name, or a custom theme attribute set.
programs.birdland.font.familystring"Inter"Default font family for the apps.
programs.birdland.font.packagepackagepkgs.interNix package for the font.
programs.birdland.bullpen.enablebooleanfalseEnable the Bullpen status bar.
programs.birdland.bullpen.positionenum"top"Screen edge (top, bottom, left, right).
programs.birdland.bullpen.layout.leftlist of islands[ "workspaces" ]Left part of the bar (top on side bars), in order. An island is a module, or a list of modules sharing one pill.
programs.birdland.bullpen.layout.centerlist of islands[ "title" ]Center of the bar, in order.
programs.birdland.bullpen.layout.rightlist of islands[ "tray" [ "clock" "date" ] ]Right part of the bar (bottom on side bars), in order.
programs.birdland.bullpen.clock.formatstring"hh:mm"Time on top/bottom bars, in Qt's date format.
programs.birdland.bullpen.clock.verticalFormatstring"hh\nmm"Time on side bars; each \n starts a new line.
programs.birdland.bullpen.date.formatstring"dd MMM, yyyy"Date on top/bottom bars, in Qt's date format.
programs.birdland.bullpen.date.verticalFormatstring"dd\nMMM"Date on side bars; each \n starts a new line.
programs.birdland.bullpen.unitsenum"metric"Temperatures in °C (metric) or °F (imperial).
programs.birdland.bullpen.weather.latitudenumber or nullnullWeather location; the weather is hidden until both are set.
programs.birdland.bullpen.weather.longitudenumber or nullnullWeather location.
programs.birdland.bullpen.feeds.commandstring"xdg-terminal-exec pressbox"Shell command run by clicking the feeds indicator.
programs.birdland.bullpen.feeds.intervalinteger120Seconds between unread-count checks.
programs.birdland.bullpen.feeds.notificationsbooleantrueNotify when new feed items arrive.
programs.birdland.dugout.enablebooleanfalseEnable the Dugout app launcher.
programs.birdland.lineup.enablebooleanfalseEnable the Lineup favorite apps dock.
programs.birdland.lineup.positionenum"bottom"Screen edge (top, bottom, left, right).
programs.birdland.lineup.favoriteslist of strings[]Desktop entry IDs of pinned apps in order.
programs.birdland.lineup.iconSizeinteger32Icon size: 16, 22, 24, 32, 48, 64, 96 or 128. The dock is the icon plus 12 px thick.
programs.birdland.outfield.enablebooleanfalseEnable the Outfield wallpapers.
programs.birdland.outfield.wallpaperpath / string or nullnullImage or folder for every monitor; null shows the theme background.
programs.birdland.outfield.outputsattrs of path / string{ }Per-monitor image or folder, by output name.
programs.birdland.outfield.intervalinteger30Minutes between images from a folder; 0 keeps the first.
programs.birdland.outfield.shufflebooleantrueRandom order instead of file-name order.
programs.birdland.outfield.fillenum"fill"fill (crop to cover), fit, stretch, center or tile.
programs.birdland.announcer.enablebooleanfalseEnable the Announcer notification popups.
programs.birdland.announcer.positionenum"top-right"Corner: top-right, top-left, bottom-right or bottom-left.
programs.birdland.announcer.timeoutinteger5Seconds a popup stays when the app doesn't say; critical ones stay.
programs.birdland.jumbotron.enablebooleanfalseEnable the Jumbotron volume and brightness pop-ups.
programs.birdland.jumbotron.positionenum"bottom"Screen edge (top, bottom, left, right).
programs.birdland.tarp.enablebooleanfalseEnable the Tarp lock screen. Needs systemd.enable, or start it yourself.
programs.birdland.tarp.lockAfterinteger or null10Minutes without input before locking; null never locks on idle.
programs.birdland.tarp.screenOffAfterinteger or null15Minutes without input before the monitors turn off; null leaves them on.
programs.birdland.tarp.swayidlebooleantrueSet up swayidle to lock on idle, before sleep and on loginctl lock-session.
programs.birdland.tarp.pamServicestring"login"PAM service that checks the password.
programs.birdland.tarp.wallpaperpath / string or nullnullImage or folder behind the lock; null uses Outfield's.
programs.birdland.tarp.fillenumOutfield'sHow the wallpaper fits the screen.

Bullpen layout

bullpen.layout sets what goes in the left, center and right parts of the bar, and in what order. On side bars, left is the top and right the bottom. Any module can go in any part, a part can be empty ([ ]), and a module left out of all three isn't shown.

The bar has no background: each entry is an island, a pill floating just off the screen edge. An entry is one module, or a list of modules that share a pill:

right = [ "media" [ "cpu" "memory" "temperature" ] "weather" [ "network" "volume" "battery" ] "tray" [ "clock" "date" ] ];

An island whose modules all have nothing to show hides. The workspaces dots are a pill of their own, so on their own they get no island around them.

The center stays centered on the bar. A long window title is shortened with "…" to fit; outside the center it is capped at a quarter of the bar.

ModuleShowsNeeds
workspacesNiri workspaces; click one to switchNiri
titleFocused window title, on its monitor's bar (top/bottom bars only)Niri
clockTimeNothing
dateDateNothing
cpu, memoryUsageNothing (/proc)
gpuUsage, and temperature when availableAMD, Intel (i915, xe): nothing (sysfs). NVIDIA: nvidia-smi
temperatureCPU temperatureA hwmon sensor: k10temp, zenpower, coretemp...
weatherCurrent conditions from Open-MeteoInternet access and weather.latitude/longitude
networkWired, or Wi-Fi signal strengthNetworkManager, or systemd-networkd (networkctl; no captive-portal check)
volumeDefault output; click mutes, scroll changes itPipeWire with WirePlumber
microphoneDefault input; click mutes, scroll changes itPipeWire with WirePlumber
batteryCharge and charging stateUPower: services.upower.enable = true in your NixOS config
mediaThe playing track; click plays/pauses, right click next, middle click previousA player with MPRIS (most do)
trayTray icons; click activates, right click opens the menuApps with StatusNotifierItem icons
feedsUnread items; click opens the reader, right click refreshes the feedsA paired pressbox client
dndAnnouncer's do not disturb; click turns it on or offAnnouncer
idle-inhibitorWhether idle is inhibited; click keeps the session awake, or lets it idleA compositor with idle-inhibit (Niri has it)

Modules with nothing to show (no battery, no GPU sensor, no weather location, no media player, no tray icons, no paired pressbox) hide themselves. Icons come from the Symbols Nerd Font, which the Home Manager module installs.

The feeds indicator shows unread items on a pressbox server. It reads the server and token from pressbox's own client files, ~/.config/pressbox/client.json (written by pressbox's Home Manager module) and the token saved by pressbox pair, so there is nothing to set up in Birdland beyond adding "feeds" to a layout. It stays hidden until the machine is paired, shows a dim icon when everything is read, and a red ! when the server can't be reached or rejects the token. With feeds.notifications, new items also send a notification (shown by Announcer, if you use it).

The dnd indicator shows a dim bell while notifications show and a bright crossed-out bell while Announcer holds them back; click to switch. It is the same setting as birdland-announcer ipc call announcer toggleDnd, so a keybind and the bar stay in step. It stays hidden until Announcer has started in this session.

The idle-inhibitor indicator is a coffee cup: dim while the session idles normally, bright while it is kept awake. While it is on, idle timeouts don't fire, so Tarp neither locks nor turns the monitors off on idle (locking before sleep and on loginctl lock-session still works). It starts off each time Bullpen starts. A keybind can switch it too, whether or not the indicator is in the layout:

birdland-bullpen ipc call bullpen toggleIdleInhibitor    # prints the new state
birdland-bullpen ipc call bullpen setIdleInhibitor true
birdland-bullpen ipc call bullpen isIdleInhibited

The gpu indicator uses the first GPU it can read: AMD (gpu_busy_percent), then Intel, then NVIDIA. Intel usage is the share of time the render engine is out of its idle state (RC6 on i915, gt-c6 on xe). The engine stays awake briefly after each burst of work, so this can read higher than intel_gpu_top at light load. Integrated Intel GPUs have no temperature sensor of their own; their heat shows in the CPU temperature.

Reading usage wakes a sleeping GPU. That doesn't matter for the GPU driving your screen, which is awake anyway. NVIDIA is only polled when there is no AMD or Intel GPU, so Intel + NVIDIA laptops leave the NVIDIA GPU asleep. On a machine whose only readable GPU is a discrete one that sleeps, leave gpu out of the layout to save power.

The clock and date use Qt's date format, set with clock.format and date.format. The defaults, hh:mm and dd MMM, yyyy, show "17:24" and "01 Nov, 2026"; h:mm AP shows "5:24 PM", and yyyy-MM-dd shows "2026-11-01". A clock format with seconds (ss) updates every second. The tray hides icons their apps mark as idle ("passive"), as Waybar does.

The weather request sends only the configured coordinates to Open-Meteo, every 15 minutes (a minute after a failed request); click the weather to refresh it.

Outfield wallpapers

Outfield draws a wallpaper on every monitor, in place of tools like swaybg. wallpaper and outputs take a Nix path (copied into the store, e.g. ./wall.jpg) or a string used where it is (e.g. "~/Pictures/Wallpapers"). A folder shows its JPEG, PNG, WebP, BMP, GIF and TIFF images, rotating every interval minutes with a crossfade, and picks up files you add or remove. Images are decoded at the screen's resolution, so large photos stay sharp without using much memory.

Control a running Outfield over IPC, e.g. from a Niri keybind:

birdland-outfield ipc call outfield next                       # next image from the folder
birdland-outfield ipc call outfield set ~/Pictures/beach.jpg   # every monitor, until reload
birdland-outfield ipc call outfield setOutput DP-1 ~/Pictures/wide
birdland-outfield ipc call outfield reload                     # back to the configured ones
birdland-outfield ipc call outfield current                    # what each monitor shows

To show the wallpaper in Niri's overview and between workspaces, add a layer rule to your Niri config (needs Niri 25.05 or later):

layer-rule {
    match namespace="^birdland-outfield$"
    place-within-backdrop true
}

Announcer notifications

Announcer is the session's notification server, so it replaces mako, dunst or swaync; run only one of them. Popups appear in a corner of the focused monitor, newest first, below the bar. Click one for the app's default action (or to close it), use its buttons for the others, or the cross to dismiss it. A popup stays while the pointer is over it, and critical ones (a low battery) stay until dismissed. There is no history: notifications are gone once closed.

Do not disturb drops all but critical notifications:

birdland-announcer ipc call announcer toggleDnd    # prints the new state
birdland-announcer ipc call announcer setDnd true
birdland-announcer ipc call announcer dismissAll

Bullpen's dnd indicator shows and switches the same setting. It is kept in $XDG_RUNTIME_DIR/birdland-announcer.dnd, so it lasts until you log out, even if Announcer restarts.

Jumbotron pop-ups

Jumbotron shows a short pop-up on the focused monitor when the volume, the microphone's mute or the screen brightness changes, whatever changed it. It doesn't change anything itself, so bind your keys to the usual tools in your Niri config, for example:

XF86AudioRaiseVolume allow-when-locked=true { spawn "wpctl" "set-volume" "@DEFAULT_AUDIO_SINK@" "0.05+" "-l" "1.0"; }
XF86AudioLowerVolume allow-when-locked=true { spawn "wpctl" "set-volume" "@DEFAULT_AUDIO_SINK@" "0.05-"; }
XF86AudioMute        allow-when-locked=true { spawn "wpctl" "set-mute" "@DEFAULT_AUDIO_SINK@" "toggle"; }
XF86AudioMicMute     allow-when-locked=true { spawn "wpctl" "set-mute" "@DEFAULT_AUDIO_SOURCE@" "toggle"; }
XF86MonBrightnessUp   allow-when-locked=true { spawn "brightnessctl" "set" "5%+"; }
XF86MonBrightnessDown allow-when-locked=true { spawn "brightnessctl" "set" "5%-"; }

Brightness is read from /sys/class/backlight, so it shows on laptops and is silent on desktop monitors.

Tarp lock screen

Tarp runs in the background as a user service and locks every monitor when asked; your password unlocks it. With tarp.swayidle (the default) it locks after lockAfter minutes without input, before the machine sleeps, and on loginctl lock-session. Before sleep, the lock command waits until Niri confirms every monitor is covered, so the desktop never shows on resume. To lock from a Niri keybind:

Mod+Alt+L { spawn "birdland-tarp" "ipc" "call" "tarp" "lock"; }

The password is checked by PAM's login service, which exists on every NixOS system and includes fingerprint login if you've enabled it there. To use a dedicated service, add security.pam.services.birdland-tarp = { }; to your NixOS configuration and set tarp.pamService = "birdland-tarp";.

With screenOffAfter (15 minutes by default) the monitors also turn off when idle; any input turns them back on. This uses swayidle too, so it needs tarp.swayidle.

If Tarp crashes while locked, Niri keeps the screens locked (a solid color). systemd restarts Tarp, which notices and locks again, so you can unlock as usual. If it doesn't come back, switch to a text console (Ctrl+Alt+F2), log in, and run systemctl --user restart tarp.

Gate login screen (NixOS)

Gate is a login screen for greetd, shown by the cage kiosk compositor, so it appears on one monitor (the last one connected). Enable it in your NixOS configuration, not Home Manager:

{ inputs, ... }: {
  imports = [ inputs.birdland.nixosModules.default ];

  programs.niri.enable = true;      # adds the Niri session to choose from
  programs.birdland.gate = {
    enable = true;
    defaultSession = "niri";         # preselected; the first session otherwise
    wallpaper = ./login.jpg;         # copied into the store; optional
  };
}

At boot, greetd starts cage as the greeter user, and cage shows Gate. When you log in, Gate hands greetd the session's command (niri-session for Niri, from its .desktop file) and exits. cage exits with it, and greetd starts Niri as you.

It lists the users with isNormalUser = true (showing their description) and the installed Wayland sessions, remembers the last user and session, and has restart and shut down buttons.

OptionTypeDefaultDescription
programs.birdland.gate.enablebooleanfalseEnable greetd with the Gate greeter.
programs.birdland.gate.userslist of stringsall normal usersUsers to choose from, in order.
programs.birdland.gate.defaultSessionstring or nulldisplay manager's defaultSession selected until someone picks another, e.g. "niri".
programs.birdland.gate.powerbooleantrueShow restart and shut down buttons.
programs.birdland.gate.wallpaperpath or nullnullImage or folder behind the greeter.
programs.birdland.gate.fillenum"fill"How the wallpaper fits the screen.
programs.birdland.gate.theme.schemetheme name / attrs"birdland"Base24 theme, as in Home Manager.
programs.birdland.gate.font.familystring"Inter"Font family.
programs.birdland.gate.font.packagepackage or nullpkgs.interPackage providing the font.

Setting up greetd yourself

The module above is the easy route. To wire greetd, cage and Gate together yourself, give Gate a JSON settings file through GATE_CONFIG and have greetd run it inside cage. On NixOS, without the module:

{ config, inputs, lib, pkgs, ... }:
let
  gate = inputs.birdland.packages.${pkgs.stdenv.hostPlatform.system}.gate;
  gateConfig = pkgs.writeText "gate.json" (builtins.toJSON {
    users = [ { name = "chris"; realName = "Chris Palmer"; } ];
    # Every installed Wayland session, Niri's included
    sessionsDir = "${config.services.displayManager.sessionData.desktops}/share/wayland-sessions";
    defaultSession = "niri";
    statePath = "/var/lib/birdland-gate/state.json";
  });
in
{
  programs.niri.enable = true;

  services.greetd = {
    enable = true;
    # cage: -s allows switching virtual terminals, -m last uses one monitor
    settings.default_session.command =
      "${pkgs.coreutils}/bin/env GATE_CONFIG=${gateConfig} XDG_CACHE_HOME=/var/cache/birdland-gate ${lib.getExe pkgs.cage} -s -m last -- ${lib.getExe gate}";
  };

  fonts.packages = [ pkgs.nerd-fonts.symbols-only ];  # Gate's icons
  # Gate remembers the last user and session here; Qt keeps its cache here
  systemd.tmpfiles.rules = [
    "d /var/lib/birdland-gate 0750 greeter greeter - -"
    "d /var/cache/birdland-gate 0750 greeter greeter - -"
  ];
}

On other distributions, the same in /etc/greetd/config.toml, with Gate installed somewhere the greeter user can run it:

[terminal]
vt = 1

[default_session]
command = "env GATE_CONFIG=/etc/greetd/gate.json XDG_CACHE_HOME=/var/cache/birdland-gate cage -s -m last -- /usr/local/bin/birdland-gate"
user = "greeter"

and /etc/greetd/gate.json, readable by greeter. Create /var/lib/birdland-gate and /var/cache/birdland-gate, owned by greeter:

{
  "users": [{ "name": "chris", "realName": "Chris Palmer" }],
  "sessionsDir": "/usr/share/wayland-sessions",
  "defaultSession": "niri",
  "statePath": "/var/lib/birdland-gate/state.json"
}

Gate's settings: users (each name, optional realName), sessionsDir (a folder of Wayland session .desktop files), defaultSession (one of those files' names without .desktop), statePath (where the last user and session are remembered), power (false hides restart and shut down), and wallpaper and fill.

Theming

Birdland translates Base24 color schemes into QML properties via base24-themes. The generated palette is written to modules/theme/Base24Colors.qml at build time, so the Home Manager module rebuilds each app with your theme.scheme and font.family.

A custom theme is an attribute set of base00–base17, each six hex digits without a leading #. Base16 themes work too; the Base24 extension colors fall back to Base16 ones. See base24-themes for the format.