chore: refactor polybar 74da441c
Steve · 2026-08-30 19:30 19 file(s) · +747 −183
polybar/colors.ini (added) +17 −0
1 +
; Palette. Included at the top of config.ini, so ${colors.*} is available to
2 +
; every module file.
3 +
4 +
[colors]
5 +
background = #121113
6 +
background-alt = #333333
7 +
foreground = #c1c1c1
8 +
primary = #fbcb97
9 +
secondary = #5f8787
10 +
alert = #e78a53
11 +
disabled = #888888
12 +
13 +
; The bar window itself is invisible -- only the islands paint anything.
14 +
; Needs a compositor (xfwm4's is on) or this renders black.
15 +
transparent = #00000000
16 +
17 +
; vim:ft=dosini
polybar/config.ini +33 −172
16 16
;
17 17
;==========================================================
18 18
19 -
[colors]
20 -
background = #121113
21 -
background-alt = #333333
22 -
foreground = #c1c1c1
23 -
primary = #fbcb97
24 -
secondary = #5f8787
25 -
alert = #e78a53
26 -
disabled = #888888
19 +
; One transparent, full-width bar. The visible blocks are "islands": runs of
20 +
; modules that paint their own background between a pair of rounded caps.
21 +
; See modules/caps.ini for how that works and what a module owes an island.
22 +
;
23 +
;   colors.ini    the palette
24 +
;   fonts.ini     fonts, including the oversized one the caps are drawn with
25 +
;   layout.ini    which modules go in which island
26 +
;   modules/      one file per module; a file here is not enabled until it
27 +
;                 is named in layout.ini
28 +
;   scripts/      backing scripts for custom/script modules
29 +
;
30 +
; Relative includes resolve next to this file *as installed* -- polybar does
31 +
; not follow the symlink first -- so every one of them needs its own symlink
32 +
; in ~/.config/polybar.
27 33
28 -
; Two independent bars with a real gap between them: each is its own window,
29 -
; so the middle of the screen is actually empty and clicks land on whatever
30 -
; is underneath. Shared settings live in [bar/base], which is never launched
31 -
; directly -- launch.sh starts "left" and "right".
34 +
include-file = colors.ini
35 +
include-directory = modules
32 36
33 -
[bar/base]
37 +
[bar/main]
38 +
; 24pt = 32px at 96dpi. fonts.ini pins the cap glyph to this height, so the
39 +
; two move together.
34 40
height = 24pt
35 -
radius = 8
41 +
; Inset 32px from both screen edges, on any monitor width.
42 +
width = 100%:-64px
43 +
offset-x = 32px
36 44
offset-y = 6pt
37 45
38 -
; dpi = 96
39 -
40 -
background = ${colors.background}
46 +
; The islands are rounded, the bar is not: it is invisible.
47 +
radius = 0
48 +
background = ${colors.transparent}
41 49
foreground = ${colors.foreground}
42 50
43 51
line-size = 3pt
44 52
45 -
; Islands have visible edges now, so pad both sides evenly. A bare number
46 -
; here is character cells (10.40px at this font/size), not pixels -- use an
47 -
; explicit "px" suffix if you want pixel-exact. Raising this needs a matching
48 -
; bump to each bar's width, since a bar cannot grow to fit its content.
49 -
padding = 2
50 -
module-margin = 1
51 -
52 -
separator = |
53 -
separator-foreground = ${colors.disabled}
54 -
55 -
font-0 = BerkeleyMono Nerd Font:size=13;2
53 +
; No bar padding, no module-margin, no separator, on purpose: space between
54 +
; two modules is a see-through gap in the island. Spacing inside an island
55 +
; comes from each module's own format-padding, spacing between islands from
56 +
; [module/gap].
57 +
padding = 0
58 +
module-margin = 0
56 59
57 60
cursor-click = pointer
58 61
cursor-scroll = ns-resize
59 62
60 63
enable-ipc = true
61 64
62 -
; Widths are measured, not guessed: this font advances 10.40px per cell at
63 -
; size 13 / 96dpi, and the rendered content is 110px (left) and 379px (right).
64 -
; Polybar cannot fit a bar to its content, so each width is content + padding
65 -
; + a little slack. Every extra workspace costs ~31px on the left.
66 -
[bar/left]
67 -
inherit = bar/base
68 -
width = 150px
69 -
offset-x = 32px
70 -
71 -
modules-left = xworkspaces
72 -
73 -
; Slack here covers percentages reaching 3 digits (~42px) and a tray icon
74 -
; or two (~40px each). offset-x is measured from the right screen edge: the
75 -
; 452px is this bar's own width (420) plus the 32px margin, so the island
76 -
; stays put on any monitor width.
77 -
[bar/right]
78 -
inherit = bar/base
79 -
width = 420px
80 -
offset-x = 100%:-452px
81 -
82 -
; Only one bar may own the tray; this is it.
83 -
modules-right = filesystem pulseaudio memory cpu systray date
84 -
85 -
; wm-restack = generic
86 -
; wm-restack = bspwm
87 -
; wm-restack = i3
88 -
89 -
; override-redirect = true
90 -
91 -
; This module is not active by default (to enable it, add it to one of the
92 -
; modules-* list above).
93 -
; Please note that only a single tray can exist at any time. If you launch
94 -
; multiple bars with this module, only a single one will show it, the others
95 -
; will produce a warning. Which bar gets the module is timing dependent and can
96 -
; be quite random.
97 -
; For more information, see the documentation page for this module:
98 -
; https://polybar.readthedocs.io/en/stable/user/modules/tray.html
99 -
[module/systray]
100 -
type = internal/tray
101 -
102 -
format-margin = 8pt
103 -
tray-spacing = 16pt
104 -
105 -
[module/xworkspaces]
106 -
type = internal/xworkspaces
107 -
108 -
label-active = %name%
109 -
label-active-background = ${colors.background-alt}
110 -
label-active-underline= ${colors.primary}
111 -
label-active-padding = 1
112 -
113 -
label-occupied = %name%
114 -
label-occupied-padding = 1
115 -
116 -
label-urgent = %name%
117 -
label-urgent-background = ${colors.alert}
118 -
label-urgent-padding = 1
119 -
120 -
label-empty = %name%
121 -
label-empty-foreground = ${colors.disabled}
122 -
label-empty-padding = 1
123 -
124 -
[module/xwindow]
125 -
type = internal/xwindow
126 -
label = %title:0:60:...%
127 -
128 -
[module/filesystem]
129 -
type = internal/fs
130 -
interval = 25
131 -
132 -
mount-0 = /
133 -
134 -
label-mounted = %{F#fbcb97}󰋊%{F-} %percentage_used%%
135 -
136 -
label-unmounted = 󰋊 %mountpoint% not mounted
137 -
label-unmounted-foreground = ${colors.disabled}
138 -
139 -
[module/pulseaudio]
140 -
type = internal/pulseaudio
141 -
142 -
format-volume-prefix = "󰕾 "
143 -
format-volume-prefix-foreground = ${colors.primary}
144 -
format-volume = <label-volume>
145 -
146 -
label-volume = %percentage%%
147 -
148 -
label-muted = 󰝟 muted
149 -
label-muted-foreground = ${colors.disabled}
150 -
151 -
[module/xkeyboard]
152 -
type = internal/xkeyboard
153 -
blacklist-0 = num lock
154 -
155 -
label-layout = 󰌌 %layout%
156 -
label-layout-foreground = ${colors.primary}
157 -
158 -
label-indicator-padding = 2
159 -
label-indicator-margin = 1
160 -
label-indicator-foreground = ${colors.background}
161 -
label-indicator-background = ${colors.secondary}
162 -
163 -
[module/memory]
164 -
type = internal/memory
165 -
interval = 2
166 -
format-prefix = "󰍛 "
167 -
format-prefix-foreground = ${colors.primary}
168 -
label = %percentage_used:2%%
169 -
170 -
[module/cpu]
171 -
type = internal/cpu
172 -
interval = 2
173 -
format-prefix = "󰘚 "
174 -
format-prefix-foreground = ${colors.primary}
175 -
label = %percentage:2%%
176 -
177 -
[network-base]
178 -
type = internal/network
179 -
interval = 5
180 -
format-connected = <label-connected>
181 -
format-disconnected = <label-disconnected>
182 -
label-disconnected = %{F#888888}󰌙 %ifname%%{F-}
183 -
184 -
[module/wlan]
185 -
inherit = network-base
186 -
interface-type = wireless
187 -
label-connected = %{F#fbcb97}󰖩%{F-} %essid%
188 -
189 -
[module/eth]
190 -
inherit = network-base
191 -
interface-type = wired
192 -
label-connected = %{F#fbcb97}󰈀%{F-} %local_ip%
193 -
194 -
[module/date]
195 -
type = internal/date
196 -
interval = 1
197 -
198 -
date = %H:%M
199 -
date-alt = %Y-%m-%d %H:%M:%S
200 -
201 -
format = <label>
202 -
format-prefix = "󰅐 "
203 -
format-prefix-foreground = ${colors.primary}
204 -
205 -
label = %date%
65 +
include-file = fonts.ini
66 +
include-file = layout.ini
206 67
207 68
[settings]
208 69
screenchange-reload = true
polybar/fonts.ini (added) +14 −0
1 +
; Included inside [bar/main], so these are bar keys.
2 +
3 +
; %{T1} -- everything except the island caps.
4 +
font-0 = BerkeleyMono Nerd Font:size=13;2
5 +
6 +
; %{T2} -- the island cap glyphs in modules/caps.ini, and nothing else. This
7 +
; is the corner-radius knob: the glyph is a half circle 1.213em tall, so at
8 +
; 30pt (40px em) it stands 48px against a 32px bar and only the flat middle
9 +
; shows, leaving a ~6px rounded corner. Raise the size for a squarer corner,
10 +
; lower it towards 20pt to go back to a full pill. The ;7 centres the clipped
11 +
; glyph on the bar; re-check it after changing either number.
12 +
font-1 = BerkeleyMono Nerd Font:size=30;7
13 +
14 +
; vim:ft=dosini
polybar/launch.sh +7 −11
1 1
#!/bin/sh
2 -
# Start the polybar bars defined in ./config.ini (installed at
3 -
# ~/.config/polybar/config.ini), replacing any bars already running.
2 +
# Start the polybar bar defined in ./config.ini (installed at
3 +
# ~/.config/polybar/config.ini), replacing any bar already running.
4 4
#
5 -
# The layout is two islands -- "left" and "right" -- as separate bar windows
6 -
# with a real gap between them, so add any new bar to BARS below.
5 +
# There is one bar window, spanning the screen and fully transparent; the
6 +
# visible islands are built inside it out of modules (see modules/caps.ini).
7 7
#
8 8
# Meant to be run as the XFCE session client that used to be xfce4-panel —
9 9
# see ../xfce/polybar-panel.sh, which does that swap.
19 19
    i=$((i + 1))
20 20
done
21 21
22 -
BARS="left right"
23 -
24 -
for bar in $BARS; do
25 -
    polybar "$bar" &
26 -
done
22 +
polybar main &
27 23
28 -
# Stay alive as the session client for as long as the bars are up. polybar
29 -
# re-execs in place on `polybar-msg cmd restart`, so these PIDs survive it.
24 +
# Stay alive as the session client for as long as the bar is up. polybar
25 +
# re-execs in place on `polybar-msg cmd restart`, so this PID survives it.
30 26
wait
polybar/layout.ini (added) +14 −0
1 +
; Which modules make up which island. Included inside [bar/main].
2 +
;
3 +
; Every run of modules wrapped in cap-left ... cap-right renders as one
4 +
; rounded island. To split a side into two islands, close and reopen with a
5 +
; gap between them:
6 +
;
7 +
;   modules-right = cap-left cpu memory cap-right gap cap-left date cap-right
8 +
;
9 +
; Only one bar may own the tray, and this is the only bar.
10 +
11 +
modules-left = xworkspaces
12 +
modules-right = filesystem memory cpu systray pulseaudio weather date
13 +
14 +
; vim:ft=dosini
polybar/modules/caps.ini (added) +53 −0
1 +
; The island machinery, adopted from Murzchnvok/polybar-collection.
2 +
;
3 +
; [bar/main] is one full-width, fully transparent window. An island is just a
4 +
; run of modules that paint their own background, opened and closed by these
5 +
; two text modules: a half circle drawn in the island colour at font-1
6 +
; (%{T2}). The result is a rounded block only as wide as what is inside it --
7 +
; no bar widths to measure and no second bar window to keep in sync.
8 +
;
9 +
; The corner radius is font-1's *size*, and the trick is that the glyph is far
10 +
; taller than the bar: at 30pt it is 48px tall, the bar keeps the middle 32px,
11 +
; and a shallow 6px corner is all that shows. Bigger size, flatter corner --
12 +
; the inset at the bar's top and bottom edge works out to about
13 +
;
14 +
;   0.636 * em * (1 - sqrt(1 - (16 / (0.6065 * em))^2))      em = size * 4/3
15 +
;
16 +
; The %{O-8} is what keeps the cap from being fat: the glyph's advance is
17 +
; 0.6em (24px at 30pt), far more than the corner needs, so the cursor is
18 +
; pulled back 8px to leave a 16px cap. Which side of the glyph the pullback
19 +
; goes on matters. cap-left trims after, because its ink runs to the right
20 +
; into the next module's background, which is painted afterwards and covers
21 +
; it. cap-right trims before, because its ink runs left over the *previous*
22 +
; module -- so keep that pullback under that module's format-padding (10px)
23 +
; or the cap will paint over the last of its text.
24 +
;
25 +
; The contract for anything that goes in an island: set a background and some
26 +
; padding on every format the module can show, e.g.
27 +
;
28 +
;   format-background = ${colors.background}
29 +
;   format-padding = 1
30 +
;
31 +
; A format without a background leaves a see-through hole in the island. Note
32 +
; that it is format-<name>-background for modules with named formats
33 +
; (mounted, volume, connected, ...) -- there is no fallback to a bare
34 +
; format-background.
35 +
36 +
[module/cap-left]
37 +
type = custom/text
38 +
label = %{T2}%{T-}%{O-8}
39 +
label-foreground = ${colors.background}
40 +
41 +
[module/cap-right]
42 +
type = custom/text
43 +
label = %{O-8}%{T2}%{T-}
44 +
label-foreground = ${colors.background}
45 +
46 +
; A transparent spacer, for a real gap between two islands on the same side.
47 +
; One font-1 space, so it is a cap's worth of nothing: 24px.
48 +
[module/gap]
49 +
type = custom/text
50 +
label = %{T2} %{T-}
51 +
label-foreground = ${colors.transparent}
52 +
53 +
; vim:ft=dosini
polybar/modules/cpu.ini (added) +13 −0
1 +
[module/cpu]
2 +
type = internal/cpu
3 +
interval = 2
4 +
5 +
format = <label>
6 +
format-background = ${colors.background}
7 +
format-padding = 1
8 +
format-prefix = "󰘚 "
9 +
format-prefix-foreground = ${colors.primary}
10 +
11 +
label = %percentage:2%%
12 +
13 +
; vim:ft=dosini
polybar/modules/date.ini (added) +16 −0
1 +
[module/date]
2 +
type = internal/date
3 +
interval = 1
4 +
5 +
date = %H:%M
6 +
date-alt = %Y-%m-%d %H:%M:%S
7 +
8 +
format = <label>
9 +
format-background = ${colors.background}
10 +
format-padding = 1
11 +
format-prefix = "󰅐 "
12 +
format-prefix-foreground = ${colors.primary}
13 +
14 +
label = %date%
15 +
16 +
; vim:ft=dosini
polybar/modules/filesystem.ini (added) +18 −0
1 +
[module/filesystem]
2 +
type = internal/fs
3 +
interval = 25
4 +
5 +
mount-0 = /
6 +
7 +
format-mounted = <label-mounted>
8 +
format-mounted-background = ${colors.background}
9 +
format-mounted-padding = 1
10 +
label-mounted = %{F#fbcb97}󰋊%{F-} %used%
11 +
12 +
format-unmounted = <label-unmounted>
13 +
format-unmounted-background = ${colors.background}
14 +
format-unmounted-padding = 1
15 +
label-unmounted = 󰋊 %mountpoint% not mounted
16 +
label-unmounted-foreground = ${colors.disabled}
17 +
18 +
; vim:ft=dosini
polybar/modules/memory.ini (added) +13 −0
1 +
[module/memory]
2 +
type = internal/memory
3 +
interval = 2
4 +
5 +
format = <label>
6 +
format-background = ${colors.background}
7 +
format-padding = 1
8 +
format-prefix = "󰍛 "
9 +
format-prefix-foreground = ${colors.primary}
10 +
11 +
label = %used%
12 +
13 +
; vim:ft=dosini
polybar/modules/network.ini (added) +25 −0
1 +
; Not in layout.ini -- defined and ready, add wlan or eth to an island run.
2 +
[network-base]
3 +
type = internal/network
4 +
interval = 5
5 +
6 +
format-connected = <label-connected>
7 +
format-connected-background = ${colors.background}
8 +
format-connected-padding = 1
9 +
10 +
format-disconnected = <label-disconnected>
11 +
format-disconnected-background = ${colors.background}
12 +
format-disconnected-padding = 1
13 +
label-disconnected = %{F#888888}󰌙 %ifname%%{F-}
14 +
15 +
[module/wlan]
16 +
inherit = network-base
17 +
interface-type = wireless
18 +
label-connected = %{F#fbcb97}󰖩%{F-} %essid%
19 +
20 +
[module/eth]
21 +
inherit = network-base
22 +
interface-type = wired
23 +
label-connected = %{F#fbcb97}󰈀%{F-} %local_ip%
24 +
25 +
; vim:ft=dosini
polybar/modules/pulseaudio.ini (added) +17 −0
1 +
[module/pulseaudio]
2 +
type = internal/pulseaudio
3 +
4 +
format-volume = <label-volume>
5 +
format-volume-background = ${colors.background}
6 +
format-volume-padding = 1
7 +
format-volume-prefix = "󰕾 "
8 +
format-volume-prefix-foreground = ${colors.primary}
9 +
label-volume = %percentage%%
10 +
11 +
format-muted = <label-muted>
12 +
format-muted-background = ${colors.background}
13 +
format-muted-padding = 1
14 +
label-muted = 󰝟 muted
15 +
label-muted-foreground = ${colors.disabled}
16 +
17 +
; vim:ft=dosini
polybar/modules/systray.ini (added) +13 −0
1 +
; Only one tray may exist per session, so this module lives on exactly one
2 +
; bar. Padding rather than margin: a margin would be a hole in the island.
3 +
[module/systray]
4 +
type = internal/tray
5 +
6 +
format = <tray>
7 +
format-background = ${colors.background}
8 +
format-padding = 1
9 +
10 +
tray-background = ${colors.background}
11 +
tray-spacing = 8pt
12 +
13 +
; vim:ft=dosini
polybar/modules/weather.ini (added) +34 −0
1 +
; Weather from the US National Weather Service, via ../scripts/weather.
2 +
; Nothing here needs an API key -- api.weather.gov is open -- but it is US-only,
3 +
; and the script wants NWS_UA pointed at a real contact address.
4 +
5 +
[module/weather]
6 +
type = custom/script
7 +
8 +
; scripts/ is symlinked into ~/.config/polybar the same way rofi/scripts is.
9 +
exec = ~/.config/polybar/scripts/weather
10 +
click-left = ~/.config/polybar/scripts/weather detail
11 +
12 +
; The script serves a cached line until NWS_INTERVAL (15 minutes) is up, so
13 +
; polling this often costs a file read, not a request. It only sets how soon a
14 +
; refreshed line reaches the bar.
15 +
interval = 300
16 +
17 +
; The palette stays in colors.ini; the script prints plain text unless told
18 +
; otherwise, so pass the two colours it uses in from here.
19 +
env-NWS_ICON_COLOR = ${colors.primary}
20 +
env-NWS_ALERT_COLOR = ${colors.alert}
21 +
22 +
; Pin the location to skip the geolocation lookup, and put a real address in
23 +
; NWS_UA -- NWS asks for one it can contact about a misbehaving client.
24 +
; env-NWS_LAT = 36.1627
25 +
; env-NWS_LON = -86.7816
26 +
; env-NWS_UA = (dotfiles polybar, you@example.com)
27 +
28 +
format = <label>
29 +
format-background = ${colors.background}
30 +
format-padding = 1
31 +
32 +
label = %output%
33 +
34 +
; vim:ft=dosini
polybar/modules/xkeyboard.ini (added) +18 −0
1 +
; Not in layout.ini -- defined and ready, add xkeyboard to an island run.
2 +
[module/xkeyboard]
3 +
type = internal/xkeyboard
4 +
blacklist-0 = num lock
5 +
6 +
format = <label-layout><label-indicator>
7 +
format-background = ${colors.background}
8 +
format-padding = 1
9 +
10 +
label-layout = 󰌌 %layout%
11 +
label-layout-foreground = ${colors.primary}
12 +
13 +
label-indicator-padding = 2
14 +
label-indicator-margin = 1
15 +
label-indicator-foreground = ${colors.background}
16 +
label-indicator-background = ${colors.secondary}
17 +
18 +
; vim:ft=dosini
polybar/modules/xwindow.ini (added) +12 −0
1 +
; Not in layout.ini -- defined and ready, add xwindow to an island run. It
2 +
; grows and shrinks with the window title, and so does its island.
3 +
[module/xwindow]
4 +
type = internal/xwindow
5 +
6 +
format = <label>
7 +
format-background = ${colors.background}
8 +
format-padding = 1
9 +
10 +
label = %title:0:60:...%
11 +
12 +
; vim:ft=dosini
polybar/modules/xworkspaces.ini (added) +36 −0
1 +
[module/xworkspaces]
2 +
type = internal/xworkspaces
3 +
4 +
format = <label-state>
5 +
format-background = ${colors.background}
6 +
7 +
; Padding by prefix/suffix rather than format-padding, and a background on
8 +
; every state, both for the same reason: a label that carries its own
9 +
; background (label-active does) drops polybar back to the *bar's* colour for
10 +
; everything drawn after it -- and the bar is transparent, so anything left
11 +
; unpainted is a hole straight through the island.
12 +
format-padding = 0
13 +
format-prefix = " "
14 +
format-prefix-background = ${colors.background}
15 +
format-suffix = " "
16 +
format-suffix-background = ${colors.background}
17 +
18 +
label-active = %name%
19 +
label-active-background = ${colors.background-alt}
20 +
label-active-underline = ${colors.primary}
21 +
label-active-padding = 1
22 +
23 +
label-occupied = %name%
24 +
label-occupied-background = ${colors.background}
25 +
label-occupied-padding = 1
26 +
27 +
label-urgent = %name%
28 +
label-urgent-background = ${colors.alert}
29 +
label-urgent-padding = 1
30 +
31 +
label-empty = %name%
32 +
label-empty-background = ${colors.background}
33 +
label-empty-foreground = ${colors.disabled}
34 +
label-empty-padding = 1
35 +
36 +
; vim:ft=dosini
polybar/scripts/weather (added) +381 −0
1 +
#!/bin/sh
2 +
# Requires: curl, jq, a Nerd Font (the glyphs are nf-md-*), and dunst or any
3 +
# notification daemon for the `detail` action.
4 +
# Weather for the bar, straight from the US National Weather Service. It began
5 +
# as polybar-scripts' openweathermap-fullfeatured, but against api.weather.gov,
6 +
# so there is no API key to keep out of the repo. The trade is that NWS only
7 +
# covers the US and its territories: /points returns 404 for anywhere else and
8 +
# the module goes blank.
9 +
#
10 +
# The bar gets current conditions and nothing else -- an icon and a temperature,
11 +
# the same shape as every other module here. The forecast is a click away.
12 +
#
13 +
#   weather          one line for the bar (the default)
14 +
#   weather detail   a notification with the full forecast and any alerts
15 +
#
16 +
# NWS asks callers for a contactable User-Agent and for restraint, and this
17 +
# honours both. The grid lookup is cached for a day because a grid square does
18 +
# not move, and the rendered line for NWS_INTERVAL seconds, so the polybar
19 +
# restarts that screenchange-reload makes routine cost no requests at all. A
20 +
# failed fetch falls back to the last good line rather than blanking the bar.
21 +
set -u
22 +
23 +
# Blank asks the network where we are; set both to pin a location and skip
24 +
# the geolocation request entirely.
25 +
LAT=${NWS_LAT:-}
26 +
LON=${NWS_LON:-}
27 +
# us = degrees F, si = degrees C. Passed straight through to NWS for the
28 +
# forecast; observations always arrive in Celsius and are converted here.
29 +
UNITS=${NWS_UNITS:-us}
30 +
# Seconds a rendered line is reused before refetching. The module may poll
31 +
# faster than this -- it just gets the cache back until this expires.
32 +
INTERVAL=${NWS_INTERVAL:-900}
33 +
# NWS wants a User-Agent it can contact about a misbehaving client; an email
34 +
# address or a URL is what they ask for. Put yours here.
35 +
UA=${NWS_UA:-"polybar-nws (https://github.com/polybar/polybar-scripts)"}
36 +
# Polybar colour markup, left empty so the script prints plain text anywhere
37 +
# else. modules/weather.ini fills both in from colors.ini, which is where the
38 +
# palette belongs -- nothing in here should need editing to match the bar.
39 +
ICON_COLOR=${NWS_ICON_COLOR:-}
40 +
ALERT_COLOR=${NWS_ALERT_COLOR:-}
41 +
# 1 names the active alert on the bar as well as flagging it. Off by default:
42 +
# "Severe Thunderstorm Warning" is wider than everything else put together,
43 +
# and it is one click away in `detail` either way.
44 +
SHOW_ALERT_LABEL=${NWS_SHOW_ALERT_LABEL:-0}
45 +
46 +
SYMBOL=°
47 +
API=https://api.weather.gov
48 +
CACHE=${XDG_CACHE_HOME:-$HOME/.cache}/polybar-nws
49 +
GRID=$CACHE/grid
50 +
# Icon, temperature and alert event, tab separated -- deliberately not the
51 +
# finished bar string. The colours arrive from the module, so a bare run from a
52 +
# shell has none; caching a painted line would let whichever ran last decide
53 +
# how the bar looked for the next quarter hour, and would freeze the palette
54 +
# in until the cache expired.
55 +
LINE=$CACHE/line
56 +
# How long the /points and station lookup is trusted. A day, so a laptop that
57 +
# travelled overnight re-locates on its own.
58 +
GRID_TTL=86400
59 +
# vol uses 7303; a separate id so a volume popup and a forecast popup do not
60 +
# replace each other.
61 +
NOTIFY_ID=7304
62 +
63 +
# NWS condition token -> glyph. $1 is day or night, $2 the token lifted out of
64 +
# the icon URL; the full token list is at https://api.weather.gov/icons.
65 +
#
66 +
# These are nf-md (Material Design) glyphs, the family every other module here
67 +
# draws from. nf-weather has a finer-grained set, but it is a visibly lighter
68 +
# weight and read as pasted-in next to the solid icons around it.
69 +
#
70 +
# Every glyph below was checked at 13pt against the bar's own font. Material
71 +
# only splits day from night for clear and partly cloudy skies; nothing else
72 +
# needs it, since rain looks like rain whatever the hour.
73 +
get_icon() {
74 +
    case $2 in
75 +
        skc)                          d='󰖙'; n='󰖔';;
76 +
        few|sct)                      d='󰖕'; n='󰼱';;
77 +
        bkn|ovc)                      d='󰖐'; n='󰖐';;
78 +
        wind_skc|wind_few|wind_sct|wind_bkn|wind_ovc)
79 +
                                      d='󰖝'; n='󰖝';;
80 +
        rain)                         d='󰖖'; n='󰖖';;
81 +
        rain_showers|rain_showers_hi) d='󰖗'; n='󰖗';;
82 +
        tsra|tsra_sct|tsra_hi)        d='󰙾'; n='󰙾';;
83 +
        snow)                         d='󰖘'; n='󰖘';;
84 +
        blizzard)                     d='󰼶'; n='󰼶';;
85 +
        sleet|fzra|rain_snow|rain_sleet|snow_sleet|rain_fzra|snow_fzra)
86 +
                                      d='󰙿'; n='󰙿';;
87 +
        fog)                          d='󰖑'; n='󰖑';;
88 +
        haze|dust)                    d='󰼰'; n='󰼰';;
89 +
        smoke)                        d='󰩱'; n='󰩱';;
90 +
        hot)                          d='󱃂'; n='󱃂';;
91 +
        cold)                         d='󱃃'; n='󱃃';;
92 +
        tornado)                      d='󰼸'; n='󰼸';;
93 +
        hurricane|tropical_storm)     d='󰢘'; n='󰢘';;
94 +
        *)                            d='󰼯'; n='󰼯';;
95 +
    esac
96 +
97 +
    if [ "$1" = night ]; then printf '%s' "$n"; else printf '%s' "$d"; fi
98 +
}
99 +
100 +
# The condition lives in the icon URL's path, not in a field of its own:
101 +
#   .../icons/land/day/tsra_hi,40/tsra,60?size=medium
102 +
# Prints "day tsra_hi" -- the leading period wins, and both the ",40" chance
103 +
# suffix and any second period are dropped.
104 +
icon_parts() {
105 +
    printf '%s' "${1%%\?*}" | awk -F/ '
106 +
        {
107 +
            for (i = 1; i < NF; i++)
108 +
                if ($i == "day" || $i == "night") {
109 +
                    sub(/,.*/, "", $(i + 1))
110 +
                    print $i, $(i + 1)
111 +
                    found = 1
112 +
                    exit
113 +
                }
114 +
        }
115 +
        END { if (!found) print "day unknown" }'
116 +
}
117 +
118 +
# curl with the headers NWS expects, plus a retry: gridpoint requests return
119 +
# a 500 often enough that one attempt is not a fair test.
120 +
fetch() {
121 +
    curl -sfL --compressed --max-time 8 --retry 2 --retry-delay 1 \
122 +
        -H "User-Agent: $UA" -H "Accept: application/geo+json" "$1"
123 +
}
124 +
125 +
# $2 wrapped in polybar's colour markup, or left alone when $1 is empty.
126 +
paint() {
127 +
    if [ -n "$1" ]; then printf '%%{F%s}%s%%{F-}' "$1" "$2"; else printf '%s' "$2"; fi
128 +
}
129 +
130 +
# The bar line, built from the three cached fields: glyph, temperature, and the
131 +
# active alert event (empty when there is none). Painting happens here, at the
132 +
# last possible moment, so the module's colours apply to a cached reading too.
133 +
#
134 +
# The trailing space goes *inside* the colour run. polybar clips each %{F...}
135 +
# segment at the advance width of the text in it, and these glyphs draw wider
136 +
# than the single cell they advance -- put the space after the %{F-} and the
137 +
# overflow is cut off, leaving a half-rendered blob. It is the same reason
138 +
# every other module here writes format-prefix = "<glyph> " rather than
139 +
# hanging the space off the label.
140 +
format_line() {
141 +
    line="$(paint "$ICON_COLOR" "$1 ")$2$SYMBOL"
142 +
    if [ -n "$3" ]; then
143 +
        if [ "$SHOW_ALERT_LABEL" = 1 ]; then flag="󰀦 $3"; else flag='󰀦'; fi
144 +
        line="$(paint "$ALERT_COLOR" "$flag ")$line"
145 +
    fi
146 +
    printf '%s\n' "$line"
147 +
}
148 +
149 +
# One field out of a JSON blob on stdin's place, empty rather than "null".
150 +
j() { printf '%s' "$1" | jq -r "$2 // empty" 2>/dev/null; }
151 +
152 +
# Seconds since $1 was last written; effectively infinite if it is not there.
153 +
age() {
154 +
    if [ -f "$1" ]; then
155 +
        echo $(( $(date +%s) - $(stat -c %Y "$1") ))
156 +
    else
157 +
        echo 999999999
158 +
    fi
159 +
}
160 +
161 +
# Observations are always Celsius no matter what ?units= asked for, so the
162 +
# conversion happens here rather than being left to the API.
163 +
c_to_display() {
164 +
    [ -n "${1:-}" ] || return 1
165 +
    if [ "$UNITS" = us ]; then
166 +
        awk -v c="$1" 'BEGIN { printf "%.0f", c * 9 / 5 + 32 }'
167 +
    else
168 +
        awk -v c="$1" 'BEGIN { printf "%.0f", c }'
169 +
    fi
170 +
}
171 +
172 +
kmh_to_display() {
173 +
    [ -n "${1:-}" ] || return 1
174 +
    if [ "$UNITS" = us ]; then
175 +
        awk -v k="$1" 'BEGIN { printf "%.0f mph", k * 0.621371 }'
176 +
    else
177 +
        awk -v k="$1" 'BEGIN { printf "%.0f km/h", k }'
178 +
    fi
179 +
}
180 +
181 +
# Whether $obs is recent enough to show as "now". Stations that report hourly
182 +
# routinely skip a cycle, so the cutoff is two hours; past that the forecast is
183 +
# the better answer. busybox date cannot parse an ISO stamp with an offset, and
184 +
# jq's fromdateiso8601 insists on a "Z" -- NWS only ever sends "+00:00", so that
185 +
# is the one offset translated, and anything else counts as fresh rather than
186 +
# throwing away a good observation over a timestamp this cannot read.
187 +
fresh_obs() {
188 +
    [ -n "$obs" ] || return 1
189 +
    ts=$(j "$obs" '.properties.timestamp | sub("\\+00:00$"; "Z") | fromdateiso8601')
190 +
    [ -n "$ts" ] || return 0
191 +
    [ $(( $(date +%s) - ts )) -lt 7200 ]
192 +
}
193 +
194 +
# Resolve coordinates to the three URLs everything else needs, and cache them.
195 +
# NWS models the country as a grid of 2.5km squares; /points is the only way
196 +
# to learn which square a latitude and longitude fall in, and the answer is
197 +
# stable, so this runs once a day at most.
198 +
resolve_grid() {
199 +
    if [ -n "$LAT" ] && [ -n "$LON" ]; then
200 +
        loc="$LAT,$LON"
201 +
    else
202 +
        # Two providers because both are free endpoints that go away without
203 +
        # warning; either one is accurate enough to pick a 2.5km square.
204 +
        loc=$(curl -sf --max-time 6 https://ipinfo.io/json | jq -r '.loc // empty')
205 +
        [ -n "$loc" ] || loc=$(curl -sf --max-time 6 'http://ip-api.com/json/?fields=lat,lon' |
206 +
            jq -r 'if .lat then "\(.lat),\(.lon)" else empty end')
207 +
        [ -n "$loc" ] || return 1
208 +
    fi
209 +
210 +
    # NWS caps coordinate precision at four decimals: /points answers anything
211 +
    # longer with a 301 to the truncated form, and /alerts just returns a 500.
212 +
    # Round here so neither can surprise a hand-set NWS_LAT later on.
213 +
    loc=$(printf '%s' "$loc" | awk -F, '{ printf "%.4f,%.4f", $1, $2 }')
214 +
215 +
    points=$(fetch "$API/points/$loc") || return 1
216 +
    hourly=$(j "$points" '.properties.forecastHourly')
217 +
    daily=$(j "$points" '.properties.forecast')
218 +
    stations=$(j "$points" '.properties.observationStations')
219 +
    place=$(j "$points" '.properties.relativeLocation.properties |
220 +
        if .city then "\(.city), \(.state)" else empty end')
221 +
    [ -n "$hourly" ] && [ -n "$stations" ] || return 1
222 +
223 +
    # The station list is not sorted by distance despite looking like it is --
224 +
    # for Denver it leads with an airport 24km up the highway -- so pick the
225 +
    # nearest one properly. Flat-earth distance with a cosine correction on the
226 +
    # longitude is plenty over the handful of kilometres in play here.
227 +
    station=$(fetch "$stations" | jq -r --argjson lat "${loc%,*}" --argjson lon "${loc#*,}" '
228 +
        [ .features[]
229 +
          | { id: .properties.stationIdentifier,
230 +
              dx: ((.geometry.coordinates[0] - $lon) * (($lat * 3.14159265 / 180) | cos)),
231 +
              dy: (.geometry.coordinates[1] - $lat) } ]
232 +
        | min_by(.dx * .dx + .dy * .dy)
233 +
        | .id // empty')
234 +
    [ -n "$station" ] || return 1
235 +
236 +
    mkdir -p "$CACHE"
237 +
    printf '%s\n%s\n%s\n%s\n%s\n' "$hourly" "$daily" "$station" "$loc" "$place" > "$GRID"
238 +
}
239 +
240 +
# Populates grid_hourly, grid_daily, grid_station, grid_point and grid_place.
241 +
load_grid() {
242 +
    if [ "$(age "$GRID")" -gt "$GRID_TTL" ]; then
243 +
        resolve_grid || [ -f "$GRID" ] || return 1
244 +
    fi
245 +
    grid_hourly=$(sed -n 1p "$GRID")
246 +
    grid_daily=$(sed -n 2p "$GRID")
247 +
    grid_station=$(sed -n 3p "$GRID")
248 +
    grid_point=$(sed -n 4p "$GRID")
249 +
    grid_place=$(sed -n 5p "$GRID")
250 +
    [ -n "$grid_hourly" ]
251 +
}
252 +
253 +
# NWS hands back active alerts in no particular order, so severity has to be
254 +
# sorted on rather than assumed. Shared by the bar, which wants only the worst
255 +
# one, and by detail, which lists them all. The severity has to be bound before
256 +
# the ranking array is piped in, or the index lookup reads .severity off the
257 +
# array instead of off the alert.
258 +
ALERT_ORDER='[.features[].properties]
259 +
    | sort_by((.severity // "Unknown") as $s
260 +
              | (["Extreme", "Severe", "Moderate", "Minor"] | index($s)) // 9)'
261 +
262 +
# The most serious active alert for our point, or nothing.
263 +
active_alert() {
264 +
    fetch "$API/alerts/active?point=$grid_point" | jq -r "$ALERT_ORDER | .[0].event // empty"
265 +
}
266 +
267 +
render() {
268 +
    load_grid || return 1
269 +
270 +
    temp=
271 +
    icon_url=
272 +
    obs=$(fetch "$API/stations/$grid_station/observations/latest")
273 +
274 +
    # The observation is a real measurement and is what "now" ought to mean.
275 +
    if fresh_obs; then
276 +
        temp=$(c_to_display "$(j "$obs" '.properties.temperature.value')") || temp=
277 +
        icon_url=$(j "$obs" '.properties.icon')
278 +
    fi
279 +
280 +
    # Small stations drop out for hours at a time, and a four-hour-old reading
281 +
    # is worse than none. Period 1 of the hourly forecast covers the current
282 +
    # hour and stands in when that happens -- which is why this request sits
283 +
    # down here rather than up beside the observation: in the ordinary case the
284 +
    # station answers and it never runs at all.
285 +
    if [ -z "$temp" ] || [ -z "$icon_url" ]; then
286 +
        hourly=$(fetch "$grid_hourly?units=$UNITS")
287 +
        [ -n "$temp" ] || temp=$(j "$hourly" '.properties.periods[0].temperature')
288 +
        [ -n "$icon_url" ] || icon_url=$(j "$hourly" '.properties.periods[0].icon')
289 +
    fi
290 +
291 +
    [ -n "$temp" ] || return 1
292 +
    # NWS sends whole numbers today; this is here so a stray decimal cannot
293 +
    # turn up inside the bar label.
294 +
    temp=${temp%%.*}
295 +
296 +
    # Unquoted on purpose: icon_parts prints the two arguments get_icon takes.
297 +
    icon=$(get_icon $(icon_parts "$icon_url"))
298 +
    alert=$(active_alert)
299 +
300 +
    mkdir -p "$CACHE"
301 +
    printf '%s\t%s\t%s\n' "$icon" "$temp" "$alert" > "$LINE"
302 +
    format_line "$icon" "$temp" "$alert"
303 +
}
304 +
305 +
# The cached reading, repainted with whatever colours this run was given.
306 +
# Fails on a missing file, and on the painted single-field line older versions
307 +
# of this script wrote, so either one just falls through to a fresh fetch.
308 +
show_cached() {
309 +
    [ -f "$LINE" ] || return 1
310 +
    IFS=$(printf '\t') read -r icon temp alert < "$LINE" || return 1
311 +
    [ -n "$temp" ] || return 1
312 +
    format_line "$icon" "$temp" "$alert"
313 +
}
314 +
315 +
# The bar line, from cache when it is still fresh. A failed fetch prints the
316 +
# last good line however old it is: a stale temperature is more use on a bar
317 +
# than a module that vanishes whenever the wifi hiccups.
318 +
bar() {
319 +
    if [ "$(age "$LINE")" -lt "$INTERVAL" ] && show_cached; then
320 +
        return 0
321 +
    fi
322 +
    render && return 0
323 +
324 +
    # Nothing new and nothing cached is either the first run without a network
325 +
    # or a location NWS does not cover. Say so on stderr, where polybar's log
326 +
    # will keep it, and exit clean so polybar does not treat it as a crash.
327 +
    show_cached ||
328 +
        echo "${0##*/}: no forecast (offline, or outside NWS coverage)" >&2
329 +
    return 0
330 +
}
331 +
332 +
# Everything that does not fit on the bar: the alerts in full, what the nearest
333 +
# station is actually reading, and the NWS forecaster's own prose for the
334 +
# current period.
335 +
detail() {
336 +
    load_grid || { notify "Weather" "No NWS grid for this location"; return 1; }
337 +
338 +
    obs=$(fetch "$API/stations/$grid_station/observations/latest")
339 +
    daily=$(fetch "$grid_daily?units=$UNITS")
340 +
341 +
    body=""
342 +
343 +
    alerts=$(fetch "$API/alerts/active?point=$grid_point" |
344 +
        jq -r "$ALERT_ORDER"' | .[] | "\(.event) - \(.headline // "")"')
345 +
    [ -n "$alerts" ] && body="$alerts
346 +
347 +
"
348 +
349 +
    if fresh_obs; then
350 +
        temp=$(c_to_display "$(j "$obs" '.properties.temperature.value')") || temp=""
351 +
        hum=$(j "$obs" '.properties.relativeHumidity.value')
352 +
        wind=$(kmh_to_display "$(j "$obs" '.properties.windSpeed.value')") || wind=""
353 +
        # Whichever of the two "feels like" numbers is in season; NWS leaves
354 +
        # the other null rather than sending both.
355 +
        feels=$(c_to_display "$(j "$obs" '.properties.heatIndex.value')") ||
356 +
            feels=$(c_to_display "$(j "$obs" '.properties.windChill.value')") || feels=""
357 +
358 +
        line="$(j "$obs" '.properties.textDescription') $temp$SYMBOL"
359 +
        [ -n "$feels" ] && line="$line (feels $feels$SYMBOL)"
360 +
        [ -n "$hum" ] && line="$line, $(awk -v h="$hum" 'BEGIN { printf "%.0f", h }')% humidity"
361 +
        [ -n "$wind" ] && line="$line, wind $wind"
362 +
    else
363 +
        line="No recent reading from $grid_station"
364 +
    fi
365 +
    body="$body$line
366 +
367 +
$(j "$daily" '.properties.periods[0] | "\(.name): \(.detailedForecast)"')"
368 +
369 +
    notify "${grid_place:-Weather} ($grid_station)" "$body"
370 +
}
371 +
372 +
notify() {
373 +
    dunstify -r "$NOTIFY_ID" -t 15000 "$1" "$2" 2>/dev/null ||
374 +
        notify-send "$1" "$2"
375 +
}
376 +
377 +
case "${1:-bar}" in
378 +
    bar)    bar ;;
379 +
    detail) detail ;;
380 +
    *) echo "usage: ${0##*/} [bar|detail]" >&2; exit 2 ;;
381 +
esac
xfce/polybar-panel.sh +13 −0
19 19
CHANNEL=xfce4-session
20 20
BACKUP="${XDG_DATA_HOME:-$HOME/.local/share}/polybar-panel.backup"
21 21
22 +
# xfwm4's compositor shadows a dock as a rectangle the size of its window,
23 +
# ignoring what the window actually paints. polybar's bar spans the screen and
24 +
# is transparent everywhere except the islands, so that shadow shows up as a
25 +
# band hanging across the empty middle. Turning it off is the only knob for it;
26 +
# true is xfwm4's default, which --remove puts back.
27 +
dock_shadow() {
28 +
    command -v xfconf-query >/dev/null 2>&1 || return 0
29 +
    xfconf-query -c xfwm4 -p /general/show_dock_shadow -s "$1" 2>/dev/null ||
30 +
        xfconf-query -c xfwm4 -p /general/show_dock_shadow -n -t bool -s "$1"
31 +
}
32 +
22 33
# Where the polybar launcher lives. The repo layout is the default; point
23 34
# POLYBAR_LAUNCH somewhere else to use an installed copy instead.
24 35
LAUNCH=${POLYBAR_LAUNCH:-$(CDPATH= cd -- "$(dirname -- "$0")/../polybar" 2>/dev/null && pwd)/launch.sh}
63 74
        echo "restored $prop -> $cmd"
64 75
    done <"$BACKUP"
65 76
    rm -f "$BACKUP"
77 +
    dock_shadow true
66 78
    polybar-msg cmd quit >/dev/null 2>&1 || pkill -u "$(id -u)" -x polybar
67 79
    [ -n "${DISPLAY:-}" ] && command -v xfce4-panel >/dev/null 2>&1 &&
68 80
        (xfce4-panel >/dev/null 2>&1 &)
80 92
    done
81 93
    clients | grep -q "$LAUNCH" && found=1
82 94
    [ "$found" = 1 ] || { echo "polybar-panel: no xfce4-panel session client found" >&2; exit 1; }
95 +
    dock_shadow false
83 96
    if [ -n "${DISPLAY:-}" ]; then
84 97
        pgrep -u "$(id -u)" -x xfce4-panel >/dev/null 2>&1 &&
85 98
            xfce4-panel --quit >/dev/null 2>&1