Skip to content

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:

yaml
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 above

Every 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 ​

yaml
bar:
  gap: " "
  shrink_min: 3
  truncate_priority: [right, left, middle]
  enable_ellipsis: true
  ellipsis: "…"
  strict: false
  defaults: true
  outputs: all
  exit_without_monitors: false
  font: ""
  text: {}
  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 explicit gap entry, and they take theme.defaults styling; 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. A middle block never leaves its centred columns, so a long left module can cover it and it will simply not draw; put middle first if something there must always be visible.
  • enable_ellipsis / ellipsis: mark truncation points.
  • strict: any config issue aborts startup (and rejects hot reloads).
  • defaults: set false to 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.
  • font: the font family every panel draws with. Unset (the default) takes whatever your kitty.conf says. See Complex scripts.
  • text: how text a terminal cell grid cannot hold is drawn. See Complex scripts.
  • 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 ​

yaml
theme:
  vars:
    accent: "#7aa2f7"
    warn: orange
  defaults:
    fg: "@accent"
    bold: false
    states:
      hover: { bold: true }     # applies to every module's hover
  • vars: 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 under states:.

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:

yaml
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 all

Names 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:

yaml
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 side
  • left, 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.
  • bar and theme: merged key by key, so an override only says what differs. theme.vars are merged too, and theme.defaults merges per key (a state block replaces that state, not the whole states: 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.

Complex scripts ​

A terminal cell grid cannot hold Devanagari and its neighbours. Their marks reorder, stack and join, and the spacing ones carry real width that every terminal width table scores as zero, so the terminal allots a fraction of the columns the text needs and overlaps the rest. Kitty gives विकिपीडिया five cells, eighty pixels, for a hundred and thirty eight pixels of ink. No font and no width pinning fixes that: the cell count is the contract with every program, and the text does not fit it.

So pawbar shapes those scripts itself and draws them as a graphic over blank columns, at the terminal's own em and baseline. The columns keep their background, their underline and their clicks; only the ink is pawbar's. Truncation cuts at cluster boundaries, so a matra is never stranded from its base, and the ellipsis is drawn inside the image, flush against the ink.

This covers Devanagari, Bengali, Gurmukhi, Gujarati, Oriya, Tamil, Telugu, Kannada, Malayalam, Sinhala, Thai, Lao, Khmer, Myanmar, Tibetan, Arabic, Hebrew, Syriac and Thaana. Everything else is left to the terminal, which places it honestly: Latin, Greek, Cyrillic, CJK, Hangul, emoji, box drawing and Nerd Font glyphs are all untouched.

yaml
bar:
  font: ""              # family every panel draws with; unset reads kitty.conf
  text:
    baseline_nudge: 0   # shift a drawn run by this many pixels

Nothing here normally needs setting. pawbar asks kitty itself for the cell size, the em and the baseline it is drawing at, which honours your kitty.conf including include directives and symbol_map; naming a face for a script with symbol_map is how you choose the font a script is drawn in. bar.font pins the family on the panels instead, and bar.text.baseline_nudge is the escape hatch for when kitty cannot be asked and the derived baseline lands a pixel out.

Menus are not covered: they draw through the terminal, so complex scripts look there the way they look in any terminal.

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.

yaml
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 out
  • host: none gives 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 your kitty.conf by default, so menus use your fonts. none ignores it and uses kitty's built-in defaults, which is faster to start and looks nothing like your terminal.
  • overrides: extra -o settings 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:

max012345+
pointed at012222
the other000122

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:

yaml
left:
  - ws
  - gap: " │ "
middle:
  - clock:
      format: "{time:%H:%M}"
right:
  - volume

Separators 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:

yaml
right:
  - cpu
  - gap: ""          # cpu and ram flush together
  - ram
  - gap: " │ "       # a divider instead of the usual gap
  - clock

An 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:

sh
pawbar defaults            # list modules
pawbar defaults ws         # print ws's shipped defaults verbatim

You control the whole layer:

yaml
- 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 slate

With 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.

keymeaning
fg, bgcolors: CSS names, #hex, rgb(r,g,b), @var
bold, dim, italic, underline, blink, reverse, strikethroughbooleans
cursorpointer shape while hovering (CSS cursor names)
formatplaceholder format string (see below), or a list of them from widest to most compact
templateopt-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:

yaml
- 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 ~:

yaml
- 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:

yaml
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:

yaml
- 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.

yaml
- cpu: { priority: -1 }     # first to go compact
- clock: { priority: 1 }    # last

priority 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:

yaml
- 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:

yaml
- 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:

yaml
- 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:

  1. the module's shipped defaults file
  2. theme.defaults
  3. the entry's top-level block keys
  4. 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>, then theme.defaults.states.<s>, then the entry's states.<s>.

on: mouse bindings ​

yaml
on:
  left: toggle-mute              # a module verb (shorthand)
  right: { run: "pavucontrol" }
  middle: { notify: "hello" }
  scroll-up: volume-up
  hover: { set: expanded }       # state held while hovering

Buttons: 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.

Released under the BSD-3-Clause License.