Writing Modular Configs
Let’s now take a deep-dive into splitting your bspwm configuration into clean, maintainable, independently sourced modules — the way it was meant to be. I personally insist on writing modular bspwm configs. This is my own example bspwm configuration.
Why Modular?
If you have spent any time with bspwm, you know its configuration entry point is a single shell script: bspwmrc. By default, almost every guide on the internet treats bspwmrc as the one file where everything lives — border widths, gap sizes, autostart entries, window rules, monitor setup, all packed into one flat file that becomes completely unmanageable the moment your config grows beyond a few dozen lines.
The problem is not bspwm — it’s the habit. bspwmrc is a shell script, and shell scripts can do anything a shell can do: source other files, call functions, loop over arrays, read environment variables. The monolithic approach throws all of that power away.
The modular approach turns your bspwm config into a proper project — a directory of purposeful files, each responsible for one concern, each readable on its own, each swappable without touching anything else. You stop fearing your own config.
The Project Layout
A well-structured bspwm config lives under ~/.config/bspwm/ and looks like this:
~/.config/bspwm/
├── bspwmrc ← entry point, orchestrator only
├── bspwm.d/ ← all sourced config modules
│ ├── exports.sh ← PATH, environment variables
│ ├── settings.sh ← all tunable variables (the "config file")
│ ├── config.sh ← bspc calls, wrapped in functions
│ ├── monitor.sh ← workspace/monitor layout functions
│ ├── rules.sh ← bspc rule declarations
│ ├── autostart.sh ← programs launched on session start
│ ├── sxhkdrc ← keybindings (sxhkd)
│ ├── picom.conf ← compositor config
│ ├── dunstrc ← notification daemon config
│ └── exter_rules/ ← external rule scripts (optional)
├── src/ ← executable helper scripts
│ ├── bspterm
│ ├── bspbar
│ ├── bspfloat
│ ├── bspscreenshot
│ └── ...
├── apps/ ← per-application configs
│ └── alacritty/
│ ├── alacritty.toml
│ ├── fonts.toml
│ ├── colors.toml
│ └── colorschemes/ ← swappable color scheme files
├── widgets/ ← bar/widget configs
│ ├── polybar/
│ └── quickshell/
└── walls/ ← wallpapers
Each directory has a single clear job. bspwm.d/ is where your WM configuration modules live. src/ holds scripts that are exported onto $PATH. apps/ holds configs for external programs. widgets/ holds your bar ecosystem. Nothing bleeds into anything else.
The Entry Point: bspwmrc
The bspwmrc file should do almost nothing on its own. Its only job is to define the root path, source the modules in the right order, and call the top-level functions they expose.
#!/bin/bash
####################### IMPORT THE EXPORTS ################################
BSPDIRD="$HOME/.config/bspwm/bspwm.d"
source "$BSPDIRD/settings.sh"
source "$BSPDIRD/monitor.sh"
source "$BSPDIRD/config.sh"
###################### COMMAND THE BSPWM ###################################
# Set up monitors and workspaces
workspaces
# Configure bspwm in discrete passes
bspc_configure border
bspc_configure windows
bspc_configure scheme
bspc_configure status_prefix
bspc_configure monocle
bspc config focus_follows_pointer "true"
# Apply window rules
source "$BSPDIRD/rules.sh"
# Start applications
source "$BSPDIRD/autostart.sh"
Tip
Notice that
bspwmrcnever touches abspc configcall directly (except a single override at the bottom). Everybspc configcall is inside a function defined inconfig.sh.bspwmrcjust calls those functions. This means you can openbspwmrcand understand the entire session-startup sequence in under 20 lines.
The sourcing order matters. settings.sh must be sourced before config.sh because config.sh reads the variables that settings.sh defines. monitor.sh must come before workspaces() is called for obvious reasons. Get the dependency chain right and everything flows cleanly top to bottom.
Module 1: exports.sh — Environment and PATH
This is the first module settings.sh pulls in. It has one job: set environment variables that every other module and script will depend on.
#!/bin/bash
## Bspwm config directory
BSPDIR="$HOME/.config/bspwm"
## Export bspwm/bin dir to PATH
export PATH="${PATH}:$BSPDIR/src"
export XDG_CURRENT_DESKTOP='bspwm'
## Run java applications without issues
export _JAVA_AWT_WM_NONREPARENTING=1
The key line here is export PATH="${PATH}:$BSPDIR/src". Every script inside src/ — bspterm, bspbar, bspscreenshot, etc. — becomes available as a plain command anywhere in the config, in sxhkdrc, or in a terminal. You never have to write absolute paths.
Important
exports.shmust be sourced before anything else.settings.shsources it at the very top. If you reverse the order and try to reference$BSPDIRbefore it’s been exported, every path-based variable in your config will silently break.
Module 2: settings.sh — The Single Source of Truth
This is the crown jewel of the modular approach. settings.sh is where every tunable value in your bspwm setup lives. No magic numbers scattered across files. If you want to change your border color, you open settings.sh. If you want to toggle gaps off, you open settings.sh. Exactly one file.
#!/bin/bash
PTH="$HOME/.config/bspwm/bspwm.d"
source "$PTH/exports.sh"
################### Borders ############################
BSPWM_FBC='#414868' # focused border color
BSPWM_NBC='#1e1e2e' # normal border color
BSPWM_ABC='#000000' # active border color
IS_BSPWM_BORDER=true
if [[ $IS_BSPWM_BORDER == true ]]; then
BSPWM_BORDER='2'
else
BSPWM_BORDER='0'
fi
################# Presel ###############################
IS_BSPWM_PRESEL=false
if [[ $IS_BSPWM_PRESEL == true ]]; then
BSPWM_PFC='#6272a4'
else
BSPWM_PFC='#000000'
fi
################ Window Properties #####################
IS_BSPWM_GAPPED=true
IS_BSPWM_PADDED=false
if [[ $IS_BSPWM_GAPPED == true ]]; then
BSPWM_GAP='10'
else
BSPWM_GAP='0'
fi
BSPWM_SRATIO='0.50'
TOP_PADDING=10
BOTTOM_PADDING=10
LEFT_PADDING=10
RIGHT_PADDING=10
################# Scheme Properties ####################
IS_BSPWM_AUTOMATIC=true
AUTOMATIC_SCHEME='spiral'
INITIAL_POLARITY='second_child'
################ Monocle Properties ####################
IS_BSPWM_BORDERLESS_MONOCLE="true"
IS_BSPWM_GAPLESS_MONOCLE="true"
IS_BSPWM_SINGLE_MONOCLE="false"
################ Status Config #########################
STATUS_PREFIX=""
################# Widget Bar ###########################
# Switch between "polybar" and "quickshell" without touching autostart.sh
WIDGET_BAR="quickshell"
################ Optional Services #####################
IS_SNAPSERVER_ENABLED="true"
There are several patterns worth studying here.
Boolean gating with derived variables. Rather than having a raw boolean that does nothing, every IS_* boolean immediately derives the actual value used by bspc. IS_BSPWM_GAPPED=true sets BSPWM_GAP='10'. Flip the boolean to false and the gap becomes '0'. The config.sh functions read BSPWM_GAP — they never read the boolean directly. This means the calling code stays clean.
Grouping by concern. Border variables are together. Presel variables are together. Window properties, scheme properties, monocle properties — each has its own section with a comment banner. When you come back to this file six months later, you can scan it in 30 seconds and find exactly what you need.
Widget bar switching. WIDGET_BAR="quickshell" is a string variable read by autostart.sh. To switch your bar from Quickshell to Polybar, you change exactly one string in exactly one file. No hunting through autostart scripts for hardcoded launcher paths.
Note
settings.shdoes not issue a singlebspc configcall. It only defines variables. This separation is the entire point. The module that defines what you want is completely separate from the module that does something about it.
Module 3: config.sh — The Function Layer
config.sh contains functions that translate your settings into actual bspc config calls. It reads from settings.sh and acts. Nothing more.
#!/bin/bash
PTH="$HOME/.config/bspwm/bspwm.d"
source "$PTH/settings.sh"
# Configure borders
configure_border() {
if [[ $IS_BSPWM_BORDER == true ]]; then
bspc config border_width "$BSPWM_BORDER"
bspc config focused_border_color "$BSPWM_FBC"
bspc config normal_border_color "$BSPWM_NBC"
bspc config active_border_color "$BSPWM_ABC"
else
bspc config border_width "$BSPWM_BORDER"
fi
}
# Configure window layout
configure_windows() {
bspc config window_gap "$BSPWM_GAP"
bspc config split_ratio "$BSPWM_SRATIO"
bspc config initial_polarity "$INITIAL_POLARITY"
if [[ $IS_BSPWM_PRESEL == true ]]; then
bspc config presel_feedback true
bspc config presel_feedback_color "$BSPWM_PFC"
else
bspc config presel_feedback false
fi
if [[ $IS_BSPWM_PADDED == true ]]; then
bspc config top_padding "$TOP_PADDING"
bspc config bottom_padding "$BOTTOM_PADDING"
bspc config right_padding "$RIGHT_PADDING"
bspc config left_padding "$LEFT_PADDING"
fi
}
# Configure monocle layout
configure_monocle() {
bspc config borderless_monocle "$IS_BSPWM_BORDERLESS_MONOCLE"
bspc config gapless_monocle "$IS_BSPWM_GAPLESS_MONOCLE"
}
# Configure tiling scheme
configure_scheme() {
if [[ $IS_BSPWM_AUTOMATIC == true ]]; then
bspc config automatic_scheme "$AUTOMATIC_SCHEME"
fi
}
# Configure status prefix
configure_prefix() {
bspc config status_prefix "$STATUS_PREFIX"
}
# Public dispatcher
bspc_configure() {
case ${1} in
border) configure_border ;;
windows) configure_windows ;;
monocle) configure_monocle ;;
scheme) configure_scheme ;;
status_prefix) configure_prefix ;;
esac
}
The bspc_configure dispatcher at the bottom is the public API of this module. bspwmrc calls bspc_configure border, bspc_configure windows, etc. If you ever need to add a new configuration domain — say, pointer behavior — you write a configure_pointer() function and add one line to the case block. Everything else stays untouched.
Tip
The dispatcher pattern makes
config.shself-documenting. Anyone readingbspwmrcseesbspc_configure borderand immediately knows where to look for the border configuration implementation. They don’t have to grep through a flat file looking forbspc config border_width.
Module 4: monitor.sh — Workspace Layout
Monitor configuration is the most system-specific part of any bspwm setup. It changes when you plug in an external monitor, when you reinstall on a laptop, when you switch from single to dual screen. Keeping it isolated means you can swap it out without touching anything else.
#!/bin/bash
# Default setup: 8 workspaces across all detected monitors
workspaces() {
name=1
for monitor in $(bspc query -M); do
bspc monitor "$monitor" -d '1' '2' '3' '4' '5' '6' '7' '8'
(( name++ ))
done
}
# Dual monitor: laptop + external HDMI
two_monitors_workspaces() {
INTERNAL_MONITOR="eDP"
EXTERNAL_MONITOR="HDMI-A-0"
if [[ $(xrandr -q | grep "${EXTERNAL_MONITOR} connected") ]]; then
bspc monitor "$EXTERNAL_MONITOR" -d '' '' '' ''
bspc monitor "$INTERNAL_MONITOR" -d '' '' '' ''
bspc wm -O "$EXTERNAL_MONITOR" "$INTERNAL_MONITOR"
else
bspc monitor "$INTERNAL_MONITOR" -d '' '' '' '' '' '' '' ''
fi
}
# Triple monitor: 3-2-3 workspace distribution
three_monitors_workspaces() {
MONITOR_1="eDP"
MONITOR_2="HDMI-A-0"
MONITOR_3="HDMI-A-1"
bspc monitor "$MONITOR_1" -d '' ''
bspc monitor "$MONITOR_2" -d '' '' ''
bspc monitor "$MONITOR_3" -d '' '' ''
bspc wm -O "$MONITOR_2" "$MONITOR_1" "$MONITOR_3"
}
All three layouts are defined. Only one is called. In bspwmrc, the call is just workspaces. To switch to a dual monitor layout, you change that one word to two_monitors_workspaces. The logic for detecting a connected HDMI monitor is already there waiting.
Note
The
two_monitors_workspaces()function usesxrandr -qto detect if the external monitor is connected. This means it works correctly whether the external monitor is plugged in or not — it falls back to assigning all 8 workspaces to the laptop screen. You can use this same pattern to make your config laptop-safe even when developing on a multi-monitor desktop.
Tip
Use
bspc query -Mto enumerate monitors dynamically rather than hardcoding monitor names. The defaultworkspaces()function in this project does exactly that — it assigns desktops to every monitor it finds at startup, regardless of how many monitors there are or what they’re named.
Module 5: rules.sh — Window Rules
Window rules in bspwm are issued via bspc rule -a. In a flat bspwmrc, these pile up into an unreadable wall. The modular approach puts them in rules.sh, where they can be grouped by type, use arrays for batch application, and be read independently.
#!/bin/bash
################################# Declare Variables #####################################
# Apps to open in workspace 4
declare -a code=(
Geany
code-oss
)
# Apps to open in workspace 5
declare -a office=(
Gucharmap Atril Evince
libreoffice-writer libreoffice-calc libreoffice-impress
libreoffice-startcenter libreoffice Soffice
'*:libreofficedev' '*:soffice'
)
# Apps to open in workspace 7 (floating)
declare -a media=(
Audacity Music MPlayer Lxmusic Inkscape Gimp-2.10 obs
)
# Apps that should always float
declare -a floating=(
alacritty-float cavasik kitty-float Pcmanfm Onboard Yad
'Firefox:Places' Viewnior feh Nm-connection-editor scrcpy
)
################################## bspc rules ###########################################
# Adopt any orphaned windows from a previous session
bspc wm --adopt-orphans
# Remove all existing rules before re-applying
bspc rule -r '*:*'
# Workspace-specific rules
bspc rule -a firefox desktop='^2' follow=on focus=on
bspc rule -a chromium desktop='^2' follow=on focus=on
for i in "${code[@]}"; do bspc rule -a "$i" desktop='^4' follow=on focus=on; done
for i in "${office[@]}"; do bspc rule -a "$i" desktop='^5' follow=on focus=on; done
for i in "${media[@]}"; do
bspc rule -a "$i" desktop='^7' state=floating follow=on focus=on
done
# System settings
bspc rule -a 'VirtualBox Manager' desktop='^8' follow=on focus=on
bspc rule -a GParted desktop='^8' follow=on focus=on
# Always floating
for i in "${floating[@]}"; do
bspc rule -a "$i" state=floating follow=on focus=on
done
# Widget-specific rules
bspc rule -a vicinae state=floating follow=on focus=on \
border=false rectangle=700x500+350+768
# Quickshell overlay (truly unmanaged)
bspc rule -a '' name=quickshell \
state=floating layer=above sticky=on focusable=off border=off
# Stalonetray
bspc rule -a stalonetray state=floating manage=off
The array-based approach to rules is one of the biggest quality-of-life improvements in this whole setup. Instead of writing 10 individual bspc rule calls for every office application, you add to the office array and the loop handles it. Adding a new code editor to workspace 4 is a one-word change.
Tip
Always call
bspc rule -r '*:*'before applying your rules. This removes any leftover rules from the previous session (since bspwm rules accumulate acrossbspwmrcre-executions viabspc wm --restart). Without this line, reloading your config doubles all your rules.
Warning
Window class names in
bspc rule -aare case-sensitive and must match exactly whatxprop WM_CLASSreturns for that application. If a rule seems to have no effect, runxprop WM_CLASSand click the window to get the exact class name before debugging anything else.
Module 6: autostart.sh — Session Startup
autostart.sh is the last module sourced by bspwmrc. It starts long-running processes — the compositor, the keybinding daemon, the notification daemon, the bar, and optional services.
#!/bin/bash
BSPDIR="$HOME/.config/bspwm"
export PATH="${PATH}:$BSPDIR/src"
source "$BSPDIR/bspwm.d/settings.sh"
# Kill any processes from a previous session
killall -9 xsettingsd sxhkd dunst ksuperkey polybar quickshell qs snapserver
# Polkit authentication agent
if [[ ! $(pidof xfce-polkit) ]]; then
/usr/lib/xfce-polkit/xfce-polkit &
fi
# Keybinding daemon
sxhkd -c "$BSPDIR"/bspwm.d/sxhkdrc &
# Super key → menu key mapping
ksuperkey -e 'Super_L=Alt_L|F1' &
ksuperkey -e 'Super_R=Alt_L|F1' &
# Cursor shape
xsetroot -cursor_name left_ptr
# Music daemon
systemctl --user start mpd &
# Compositor
pkill picom
picom --config "$BSPDIR"/bspwm.d/picom.conf &
################################ Wallpaper ##########################################
if [[ $SINGLE_WALLPAPER == true ]]; then
feh --bg-fill "$WALLPAPER"
elif [[ $DISFREE_WALLPAPER == true ]]; then
feh -z --no-fehbg --bg-fill "$BSPDIR/walls/disfree"
else
feh -z --no-fehbg --bg-fill "$BSPDIR/walls"
fi
################################ Launch Widgets ######################################
bash "$BSPDIR"/widgets/"$WIDGET_BAR"/launch.sh &
################################ Optional Services ###################################
if [ $IS_SNAPSERVER_ENABLED == "true" ]; then
pactl load-module module-pipe-sink sink_name=snapfifo file=/tmp/snapfifo
pactl set-default-sink snapfifo
snapserver &
fi
# Notification daemon wrapper
bspdunst &
# Floating window rules daemon
bspfloat &
The wallpaper section reads three variables from settings.sh — SINGLE_WALLPAPER, DISFREE_WALLPAPER, and WALLPAPER — and picks the right feh invocation automatically. You never have to touch autostart.sh to change your wallpaper behavior; you change a variable in settings.sh.
The bar launch is bash "$BSPDIR"/widgets/"$WIDGET_BAR"/launch.sh. $WIDGET_BAR is "quickshell" or "polybar" — both have a launch.sh inside their respective directories. Switching bars is a single variable change in settings.sh.
Important
The
killallline at the top ofautostart.shis not optional. bspwm re-runsbspwmrconbspc wm --restart. Without killing previous instances, you accumulate multiple running copies of sxhkd, picom, polybar, and so on. This leads to duplicate keybinding handlers, visual glitches, and memory bloat. Always kill before restarting.
Tip
The pattern
bspdunstandbspfloatat the bottom are custom wrapper scripts that live insrc/. Becausesrc/is on$PATH, autostart just calls them by name. If you need to change how dunst is launched, you editsrc/bspdunst— you don’t have to touchautostart.shat all.
The src/ Directory — Portable Script Layer
One of the most underrated aspects of this layout is the src/ directory. It holds every custom script this config relies on, and because exports.sh puts it on $PATH, those scripts are available everywhere: in autostart.sh, in sxhkdrc, in a terminal, and in rofi menus.
Consider bspterm:
#!/usr/bin/env bash
DIR="$HOME/.config/bspwm/apps"
CONFIG="$DIR/alacritty/alacritty.toml"
if [ "$1" == "--float" ]; then
alacritty --class 'alacritty-float,alacritty-float' --config-file "$CONFIG"
elif [ "$0" == "--full" ]; then
alacritty --class 'alacritty-fullscreen,alacritty-fullscreen' \
--config-file "$CONFIG" \
-o window.startup_mode="'Fullscreen'" \
window.padding.x=30 window.padding.y=30 \
window.opacity=0.95 font.size=14
else
alacritty --config-file "$CONFIG" ${@}
fi
In sxhkdrc, you bind a key to bspterm. In a floating window rule, you can reference alacritty-float by class. If you ever switch from Alacritty to Kitty, you update bspterm in one place and your keybinding, your floating rules, and your autostart entries all just keep working.
Note
The same pattern applies to
bspbar,bspscreenshot,bspvolume,bspbrightness, and every other script insrc/. Each one is a thin wrapper that reads your config’s variables and translates a simple command into however complex the underlying invocation needs to be. The caller never needs to know.
Modular Alacritty: The apps/ Pattern
The modular principle extends beyond bspwm itself into the apps you run inside it. The apps/alacritty/ directory demonstrates a clean separation of concerns for a terminal emulator config:
apps/alacritty/
├── alacritty.toml ← imports the other files
├── fonts.toml ← font settings only
├── colors.toml ← active color scheme (a symlink or import)
└── colorschemes/ ← 60+ standalone color scheme files
├── Dracula.toml
├── Nord.toml
├── Tokyo_night.toml
└── ...
alacritty.toml imports its siblings:
import = [
"~/.config/bspwm/apps/alacritty/fonts.toml",
"~/.config/bspwm/apps/alacritty/colors.toml"
]
colors.toml is either a symlink to the active colorscheme or re-exports one. Switching themes is a matter of updating that one file or symlink. The RiceSelector script in src/ automates exactly this — it presents a rofi menu of available themes and updates the symlink, then reloads Alacritty.
Tip
Use this same pattern for every app that supports config imports:
kitty.confhasinclude,picom.confcan use@include, GTK’s settings support theme files. Whenever you find yourself wanting a “dark mode” toggle or a “rice switcher”, modular app configs are what make it possible without rewriting everything every time.
Modular Polybar: The widgets/ Pattern
The bar configuration follows the same decomposition pattern internally:
widgets/polybar/
├── launch.sh ← kills old instances, starts new ones per monitor
├── config.ini ← bar geometry and module lists
├── colors.ini ← color variables only
├── modules.ini ← all module definitions
├── decor.ini ← decorative elements (separators, glyphs)
└── gylphs.ini ← nerd font glyph constants
Polybar supports include-file directives, so config.ini just pulls everything in:
include-file = ~/.config/bspwm/widgets/polybar/colors.ini
include-file = ~/.config/bspwm/widgets/polybar/modules.ini
include-file = ~/.config/bspwm/widgets/polybar/decor.ini
include-file = ~/.config/bspwm/widgets/polybar/gylphs.ini
The launch.sh does something particularly elegant — it auto-detects hardware and patches the config:
#!/usr/bin/env bash
DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )"
CARD="$(light -L | grep 'backlight' | head -n1 | cut -d'/' -f3)"
INTERFACE="$(ip link | awk '/state UP/ {print $2}' | tr -d :)"
BAT="$(acpi -b)"
RFILE="$DIR/.module"
fix_modules() {
# If no backlight card found, disable the module
if [[ -z "$CARD" ]]; then
sed -i -e 's/backlight/bna/g' "$DIR"/config.ini
elif [[ "$CARD" != *"intel_"* ]]; then
sed -i -e 's/backlight/brightness/g' "$DIR"/config.ini
fi
# If no battery found (desktop), disable battery module
if [[ -z "$BAT" ]]; then
sed -i -e 's/battery/btna/g' "$DIR"/config.ini
fi
# If ethernet rather than wifi, swap network module
if [[ "$INTERFACE" == e* ]]; then
sed -i -e 's/network/ethernet/g' "$DIR"/config.ini
fi
}
launch_bar() {
killall -q polybar
while pgrep -u $UID -x polybar >/dev/null; do sleep 1; done
for mon in $(polybar --list-monitors | cut -d":" -f1); do
MONITOR=$mon polybar -q work-bar -c "${DIR}/config.ini" &
done
}
# Only patch modules once (tracked by .module sentinel file)
if [[ ! -f "$RFILE" ]]; then
fix_modules
touch "$RFILE"
fi
launch_bar
The sentinel file .module ensures fix_modules() only runs once — the first time after a fresh install. After that, launching the bar is just launching the bar.
Warning
If you copy this config to a new machine and the bar modules seem wrong (brightness not working, battery missing, wrong network interface), delete
.modulein the polybar directory and restart. Thefix_modules()function will re-detect hardware and patchconfig.inifresh.
Wiring It All Together: Source Order Dependency Graph
Here is the complete dependency chain at a glance:
bspwmrc
└── settings.sh
└── exports.sh (defines $BSPDIR, $PATH)
└── monitor.sh (defines workspaces(), two_monitors_workspaces(), etc.)
└── config.sh
└── settings.sh (reads all BSPWM_* variables)
└── rules.sh (sourced inline, no functions — runs immediately)
└── autostart.sh
└── settings.sh (reads $WIDGET_BAR, $DISFREE_WALLPAPER, etc.)
Notice that settings.sh is sourced in multiple places. This is fine — sourcing a file that only defines variables is idempotent. But it also means you need to make sure settings.sh itself has no side effects. No bspc config calls, no process launches, no file mutations. Variables only.
Caution
Never put
bspc configcalls directly insidesettings.sh. If you do, those calls execute at source time — before the WM has finished initializing, and beforemonitor.shhas set up desktops. You will get race conditions and silent failures that are genuinely painful to debug.
Live Reloading
One of the practical superpowers of this structure is that you can reload individual modules without restarting the entire session.
To reload just the window rules:
source ~/.config/bspwm/bspwm.d/rules.sh
To reload and re-apply all bspwm settings:
bspc wm --restart
bspc wm --restart re-executes bspwmrc in-place without killing your windows or your session. Because bspwmrc sources every module fresh, any change you made to settings.sh, config.sh, or rules.sh takes effect immediately. The killall in autostart.sh ensures old daemons are replaced cleanly.
To reload just your keybindings without touching anything else:
pkill sxhkd && sxhkd -c ~/.config/bspwm/bspwm.d/sxhkdrc &
Tip
Bind
bspc wm --restartto a key insxhkdrc. A common binding issuper + shift + r. This way, the edit-save-reload loop on your config takes about two seconds, which makes iterating on settings feel instant.
Adding a New Module
The architecture is designed to grow. To add a new configuration domain — say, pointer behavior — you follow a simple process.
First, add your variables to settings.sh:
################ Pointer Behavior ####################
IS_BSPWM_FFP="true"
FOCUS_FOLLOWS_POINTER="true"
IS_BSPWM_PFF="false"
POINTER_FOLLOWS_FOCUS="false"
POINTER_FOLLOWS_MONITOR="false"
Then, add a function to config.sh:
configure_pointer() {
bspc config focus_follows_pointer "$FOCUS_FOLLOWS_POINTER"
bspc config pointer_follows_focus "$POINTER_FOLLOWS_FOCUS"
bspc config pointer_follows_monitor "$POINTER_FOLLOWS_MONITOR"
}
Add it to the dispatcher:
bspc_configure() {
case ${1} in
border) configure_border ;;
windows) configure_windows ;;
monocle) configure_monocle ;;
scheme) configure_scheme ;;
status_prefix) configure_prefix ;;
pointer) configure_pointer ;; # ← new
esac
}
And add one line to bspwmrc:
bspc_configure pointer
That’s it. Three changes across three files, each change is exactly one logical unit, and nothing else in the config is disturbed.
Summary
The modular bspwm config pattern is not about complexity — it’s about making a complex system simple to reason about. Every file has one job. Every variable has one home. Every behavior has one place you go to change it.
| Module | Responsibility |
|---|---|
exports.sh | $PATH, environment variables |
settings.sh | All tunable values, boolean flags |
config.sh | Functions that issue bspc config calls |
monitor.sh | Workspace and monitor layout functions |
rules.sh | All bspc rule declarations |
autostart.sh | Process lifecycle management |
src/ | Portable helper scripts on $PATH |
apps/ | Per-application modular configs |
widgets/ | Bar and widget ecosystems |
When your config is structured this way, you can hand it to someone who has never seen your setup and they can find anything they need in under a minute. You can git-diff a theme change and see exactly which three lines changed. You can swap your entire bar from Polybar to Quickshell by changing one string. You can add a monitor layout for a new machine without looking at anything except monitor.sh.
That’s the goal. Build the config once, and then you never have to fight it again.