Configuration
pawbar reads ~/.config/pawbar/pawbar.yaml (override with PAWBAR_CONFIG or --config). The file is hot-reloaded: save it and the bar updates in place.
A config has six top-level sections, all optional:
bar: # bar-global settings
theme: # variables and bar-wide default styling
left: # modules anchored left
middle: # modules centered
right: # modules anchored right
outputs: # per-monitor overrides of everything aboveEvery problem in the config is reported with its file position and a "did you mean" hint. By default a broken module entry renders as an error chip (⚠name) while the rest of the bar runs; set bar.strict: true (or pass --strict) to refuse to start instead. pawbar --check validates the config and exits; pawbar --resolved prints the fully merged per-slot configuration for cascade debugging.
bar
bar:
gap: " "
shrink_min: 3
truncate_priority: [right, left, middle]
enable_ellipsis: true
ellipsis: "…"
strict: false
defaults: true
outputs: all
exit_without_monitors: false
kitty: {}
menus: {}gap: inserted between adjacent modules on a side, so you don't have to spell one out between every pair. Empty (the default) keeps modules flush. Automatic gaps are layout rather than modules: they are never added at a side's edge, never next to an explicitgapentry, and they taketheme.defaultsstyling; for anything else, write the join out.shrink_min: the floor, in columns, that an elastic placeholder is never shrunk below.truncate_priority: which anchors keep their content when the bar overflows; earlier wins. Must list all three.enable_ellipsis/ellipsis: mark truncation points.strict: any config issue aborts startup (and rejects hot reloads).defaults: setfalseto drop every module's shipped defaults bar-wide.outputs: which monitors get a bar. See Monitors.exit_without_monitors: leave instead of waiting when the compositor reports no monitors at all. See Monitors.kitty: how many kitty processes the bars and menus run in, and how they are configured. See Processes and memory.menus: how many menu panels are kept warm. See Processes and memory.
theme
theme:
vars:
accent: "#7aa2f7"
warn: orange
defaults:
fg: "@accent"
bold: false
states:
hover: { bold: true } # applies to every module's hovervars: named values. Reference them anywhere with@name. Built-in color names (@urgent,@good,@color112, ...) still work where your vars don't shadow them.defaults: a block applied to every module, plus per-state blocks understates:.
Monitors
One pawbar runs the whole desktop: it starts a bar on every monitor, pinned to it, and keeps them in step with the compositor. Plug a monitor in and its bar appears; unplug it and the bar goes away and comes back with the monitor. You never run pawbar twice — a second one refuses to start, because it would stack a duplicate bar on every screen.
Pick which monitors get a bar with bar.outputs:
bar:
outputs: all # every monitor (the default)
# outputs: eDP-1 # just the laptop screen
# outputs: [eDP-1, HDMI-A-1]
# outputs: none # no bars at allNames are the compositor's output names (hyprctl monitors, swaymsg -t get_outputs, wlr-randr). Naming a monitor that is not plugged in is not an error: its bar appears when it does. pawbar --output NAME overrides the selection for one run, and can be repeated.
With no monitors at all (every screen unplugged, a closed lid) pawbar waits. It holds no bars and no kitty, just the supervisor, and rebuilds everything when a monitor comes back. If you would rather it left, set bar.exit_without_monitors: true (or pass --exit-without-monitors) and it exits instead; use it when something else starts pawbar per seat and would start it again. outputs: none is not that case: it is a config that asks for no bars, so pawbar keeps running and honours the next reload.
Per-output overrides
The top-level outputs: section tailors the bar on one monitor. Each entry takes the same keys as the root document, applied over it:
bar:
gap: " "
left: [ws, title]
right: [volume, clock]
outputs:
HDMI-A-1:
bar: { gap: " " } # merged key by key over the base bar
right: [clock] # replaces the base list entirely
middle: # named with nothing under it: empty this sideleft,middle,right: replaced when the section mentions them. A list cannot be merged predictably, so an override is the whole side; mentioning a side with nothing under it clears it.barandtheme: merged key by key, so an override only says what differs.theme.varsare merged too, andtheme.defaultsmerges per key (a state block replaces that state, not the wholestates:map).
Everything else works as usual: issues are reported with their position, pawbar --check validates every output's section, and pawbar --resolved --output HDMI-A-1 prints that monitor's fully merged configuration.
Modules that mean something different per screen follow the monitor their bar is on: ws shows that monitor's workspaces and title the window it is showing. Menus open on the monitor they were clicked from, at that monitor's scale.
Processes and memory
Every bar and every menu panel is a kitty desktop panel, and they all live in one kitty process. That process is not started separately: the first bar is it, and it exits with the last one. A panel inside it costs a few megabytes of kitty plus the pawbar process drawing in it, which is around 35 MB, so the memory pawbar uses is roughly one kitty plus one small process per panel on screen.
Menus are the reason that matters. Spawning a panel to open a menu is slow enough to feel, so pawbar keeps some warm, off-screen and ready, and reuses them: a menu opens instantly out of the pool, and closing it parks the panel rather than throwing it away. The pool is what that memory buys.
bar:
kitty:
host: own # own (default) | none, a kitty process per panel
config: inherit # inherit (default) | none | /path/to/kitty.conf
overrides: [] # extra kitty -o settings for the instance
command: kitty # the kitty binary to run
menus:
target: 2 # warm panels one monitor wants
max: 4 # cap for the whole desktop; omit to work it outhost:nonegives every panel its own kitty process, which is how pawbar used to work. A kitty process costs a couple of hundred megabytes, so on a two-monitor desktop with a warm pool that is the difference between one of them and five.config: the shared instance reads yourkitty.confby default, so menus use your fonts.noneignores it and uses kitty's built-in defaults, which is faster to start and looks nothing like your terminal.overrides: extra-osettings for the instance. Panels sharing a kitty process cannot carry their own, so anything a bar or a menu needs goes here.
menus.target is how many warm panels one monitor wants: two, so that a menu and the submenu behind it both open instantly. menus.max caps the whole desktop, because a panel is pinned to one monitor when it is created and only ever opens there, so covering every monitor means paying for every monitor.
Panels are spread by intent: the monitor the pointer is on, or that a menu was last opened on, is filled to target first, and what is left of the budget hedges the others one panel at a time. With two monitors and target: 2:
max | 0 | 1 | 2 | 3 | 4 | 5+ |
|---|---|---|---|---|---|---|
| pointed at | 0 | 1 | 2 | 2 | 2 | 2 |
| the other | 0 | 0 | 0 | 1 | 2 | 2 |
Omitting max gives target + monitors - 1: the pointed-at monitor's whole chain, plus one panel on each of the others, so every bar's first menu opens warm. max: 0 turns pooling off entirely; menus still open, they just pay the spawn each time. A leased panel counts against the budget, so max is a cap on the panels that exist, not just the idle ones.
Modules
Each side is a list. An entry is a bare name, a name: {options} mapping, or name: "text": a scalar is shorthand for setting just format:
left:
- ws
- gap: " │ "
middle:
- clock:
format: "{time:%H:%M}"
right:
- volumeSeparators and gaps
bar.gap handles the usual case: uniform breathing room between every pair of modules, with nothing to write per entry. Where one join should differ, write a gap entry there and it wins:
right:
- cpu
- gap: "" # cpu and ram flush together
- ram
- gap: " │ " # a divider instead of the usual gap
- clockAn automatic gap is never inserted next to an explicit one, so entries replace bar.gap at that join rather than doubling up on it. gap takes block keys like any module (- gap: { format: " │ ", fg: "@dim" }). It replaces the old sep and space modules: write - gap: " │ " and - gap: " ".
A module that has nothing to show (mpris with no player, tray with no icons) takes up no room, and a separator left facing only empty modules is dropped with it, so you never get a stranded │ floating at the end of a side. A separator at the very edge of a side is kept: it divides that side from the rest of the bar rather than from a neighbour.
Shipped defaults
A module's default configuration is not baked into code: every module ships a small yaml file, in exactly this config syntax, that forms the bottom layer of its cascade. A bare - ws entry gets exactly the contents of that file; nothing more. Empty config means an empty bar, and everything the bar does is written down somewhere you can read:
pawbar defaults # list modules
pawbar defaults ws # print ws's shipped defaults verbatimYou control the whole layer:
- ws:
on:
left: ~ # unbind a shipped binding: null removes it
states:
active: ~ # drop a shipped state's styling entirely
- clock: { defaults: false } # start this entry from a blank slateWith defaults: false (per entry, or bar-wide under bar:) nothing is inherited and the entry becomes fully manual: a module that renders placeholders requires an explicit format, and every option the module declares (tick, warn_at, ...) must be set. Anything missing is a config error listing exactly which keys are absent.
Blocks (style + format)
A block is the styling surface every module shares. All keys are optional; unset keys inherit from the layer below.
| key | meaning |
|---|---|
fg, bg | colors: CSS names, #hex, rgb(r,g,b), @var |
bold, dim, italic, underline, blink, reverse, strikethrough | booleans |
cursor | pointer shape while hovering (CSS cursor names) |
format | placeholder format string (see below), or a list of them from widest to most compact |
template | opt-in Go text/template alternative to format |
priority sits alongside the block keys but is not one: it is per entry rather than per state, and orders which modules go compact first.
Block keys go directly at the top level of a module entry:
- clock:
fg: "@accent"
format: "{time:%H:%M}"Format strings
format uses placeholders: {name} inserts a value the module provides, {name:spec} formats it. Unknown placeholder names are config errors, so typos are caught at load time.
- Time values take a strftime layout:
{time:%A %d %B}. - Numbers take a printf spec without the
%:{vol:3}pads to 3,{load:.2f}renders two decimals. ~marks a value as elastic:{title~}.{{and}}are literal braces.
Each module's placeholders are listed in the module reference.
Power users can set template instead: a Go text/template over the same values ({{.vol}}), with round, strftime and shrink helper functions. format and template are mutually exclusive per block.
Elastic text
When the bar runs out of room something has to give, and by default it is whatever happens to sit at the end being trimmed away, which is rarely what you want. Mark the parts that should give way with ~:
- mpris:
format: "{icon} {title~} • {artists~}"Now the icon and the • are untouchable, and the title and artist shrink instead. They shrink fairly: the longer one gives way until the two are the same length, then they shorten together. Weights bias that split: {title~2} • {artists~1} keeps twice as many columns for the title.
The floor is bar.shrink_min columns; nothing shrinks past it, and a specifier still applies ({title~2:.60s}).
Each anchor is fitted into the room its position actually leaves it, in bar.truncate_priority order: the anchor listed first keeps its content, and the ones after it shrink into what remains. Note that a middle module is centered, so it splits the bar in half: with a clock in the middle, the right side can only use the columns past it, however empty the left half is.
If shrinking alone cannot close the gap, modules start stepping down; if that runs out too, the bar falls back to trimming blocks by bar.truncate_priority as before.
In a template, wrap the value in shrink instead:
template: '{{shrink .title}} • {{shrink 2 .artists}}'shrink marks its output, so a pipeline stage that rewrites that output ({{shrink .title | printf "%q"}}) loses the marking and the value becomes ordinary rigid text. Appending after it is fine.
Compact formats
Shrinking only makes text shorter. When a module would rather drop part of itself than have everything squeezed, give format a list, widest first:
- battery:
format:
- "{icon} {bat}% ({time})"
- "{icon} {bat}%"
- "{icon}"The bar shows the first entry that fits and steps down only as far as it has to. Nothing is dropped while any elastic text still has room to give, so ~ and a format list combine cleanly: text shortens first, structure goes last.
When several modules could step down, the one with the lowest priority goes first; ties break toward the middle of the bar, so the modules at the outer edges keep their detail longest.
- cpu: { priority: -1 } # first to go compact
- clock: { priority: 1 } # lastpriority defaults to 0 and can be set in a module's shipped defaults too. Levels are recomputed from scratch on every redraw, so widening the bar back out restores whatever a narrower one gave up.
This pairs naturally with hover, which re-expands a module under the pointer no matter how narrow the bar is:
- battery:
format: ["{icon} {bat}%", "{icon}"]
states:
hover: { format: "{icon} {bat}% ({time})" }template accepts a list in exactly the same way.
States
Modules expose named condition states they turn on and off themselves (muted, charging, high, ...). A state carries a block that overrides the base styling while active:
- volume:
format: "{icon} {vol}%"
states:
muted: { fg: "@warn", format: "{icon} --" }States can also override module options, not just styling. A state that sets tick: 1m on the clock changes the refresh rate while active.
You can also invent user states and toggle them from mouse bindings; that's how "click to switch format" works:
- clock:
format: "{time:%H:%M}"
states:
full: { format: "{time:%A %d %B %H:%M:%S}" }
on:
left: { cycle: [full] }Two built-in states always exist: hover (pointer over the module) and pressed (button held).
Merge priority
Low to high; later layers override earlier ones per key:
- the module's shipped defaults file
theme.defaults- the entry's top-level block keys
- active states, in order: condition states (module declaration order), user states,
hover,pressed. Each state's block is itself merged from the shipped defaults'states.<s>, thentheme.defaults.states.<s>, then the entry'sstates.<s>.
on: mouse bindings
on:
left: toggle-mute # a module verb (shorthand)
right: { run: "pavucontrol" }
middle: { notify: "hello" }
scroll-up: volume-up
hover: { set: expanded } # state held while hoveringButtons: left, right, middle, scroll-up, scroll-down, scroll-left, scroll-right, plus hover. A binding is one action or a list of actions:
verb: invoke a named action the module implements (bare strings are verb shorthand). Unknown verb names are config errors.run: spawn a command (string or argv list).notify: send a desktop notification.set: toggle a user state.cycle: cycle through user states: none, first, ..., last, none.
Shipped defaults may carry bindings (volume's scroll adjusts volume, clock's right-click opens the calendar); they are plain on: entries in the module's defaults file, visible via pawbar defaults <name>. An on: key for the same button replaces the shipped one, and binding a button to ~ (null) removes it.
Hot reload
Editing the config applies it live:
- unchanged entries keep running untouched,
- a changed entry is reconfigured in place when the module supports it, restarted otherwise,
- theme changes restyle everything without restarts,
- a file that fails to parse is ignored (last good config stays) and the error is logged.
Reordering entries restarts the moved modules; diffing is positional.