Widgets: Wrappers, Clock, Volume, Battery, WiFi
You will learn
- How the wibox widget tree works: leaf widgets, containers, and layouts composed declaratively
- Shared helpers for icons and labels
- How to poll system state without freezing the compositor, using
gears.timerandawful.spawn.easy_async_with_shell - Updating widgets from keybindings through signals
- Why the battery widget exports an API instead of just a widget
Where We Are
Build clock, volume, battery, and WiFi widgets and add them to the stock bar. Chapter 04 replaces the bar itself.
To catch up: git checkout 02-keybindings.
The Widget Tree
Everything you see in an AwesomeWM bar, popup, or notification is built from widgets, and every widget is one of three kinds:
- Leaf widgets draw actual content.
wibox.widget.textboxdraws text,wibox.widget.imageboxdraws an image. They have no children. - Containers wrap exactly one child and change how it is drawn:
wibox.container.marginadds space around it,wibox.container.backgroundpaints a color behind it and can clip it to a shape. - Layouts arrange multiple children:
wibox.layout.fixed.horizontalplaces them in a row, each taking its natural width.
You compose these into a tree, and you do it declaratively: instead of constructing objects and calling add() on them, you pass wibox.widget() a nested table describing the whole tree. Each table level names its own type with a widget = ... key (for leaves and containers) or a layout = ... key (for layouts), and any array-part entries become children. Every other key is set as a property on that widget.
Shared helpers: wrappers.lua
Create the shared helpers in widgets/wrappers.lua.
The first helper turns an SVG file into an icon that matches the theme:
-- widgets/wrappers.lua
M.image_widget = function(image, color, hover_color)
local widget = wibox.widget({
image = recolor(beautiful.icon_dir .. image, color),
widget = wibox.widget.imagebox,
halign = "center",
valign = "center",
})
widget:connect_signal("mouse::enter", function(c)
c.image = recolor(beautiful.icon_dir .. image, hover_color or color)
end)
widget:connect_signal("mouse::leave", function(c)
c.image = recolor(beautiful.icon_dir .. image, color)
end)
return widget
end
recolor is gears.color.recolor_image: it takes an image path and a colour and returns the tinted image. The SVGs are in icons/.
Signals are named events. Attach handlers with connect_signal. Here, mouse::enter and mouse::leave change the icon colour; each handler receives the widget as c.
The second helper is the shape almost every bar widget takes, an icon with a text label next to it:
-- widgets/wrappers.lua
M.icon_with_text = function(w, text)
return wibox.widget({
{
w,
margins = {
top = beautiful.wibar_height / 4,
bottom = beautiful.wibar_height / 4,
},
widget = wibox.container.margin,
},
{
id = "text",
text = text,
widget = wibox.widget.textbox,
},
layout = wibox.layout.fixed.horizontal,
})
end
get_children_by_id("text") returns a list of widgets with id = "text". Use widget:get_children_by_id("text")[1] to update this textbox later.
The last helper is a spacer for the bar:
-- widgets/wrappers.lua
M.vertical_separator = function(width)
return wibox.widget({
bg = beautiful.bg_normal,
forced_width = width,
widget = wibox.container.background,
})
end
A background container without a child is a coloured block. forced_width sets its width. The file also includes a square_icon helper for an icon on a coloured background.
The Clock: Timers
Create widgets/clock.lua:
-- widgets/clock.lua
local clock = wrappers.image_widget("/clock.svg", beautiful.fg_normal)
local clock_widget = wrappers.icon_with_text(clock)
local set_clock = function()
local time = " " .. os.date("%I:%M")
local tbox = clock_widget:get_children_by_id("text")[1]
tbox.text = time
end
Call set_clock on a timer:
-- widgets/clock.lua
gears.timer({
timeout = 5,
autostart = true,
call_now = true,
callback = function()
set_clock()
end,
})
return clock_widget
A timer runs its callback every timeout seconds. autostart starts it on creation; call_now also runs the callback immediately. Return the widget so the bar can load it with require("widgets.clock").
Volume: Async Reads and Signals
Use awful.spawn.easy_async_with_shell(cmd, callback) to read the volume without blocking the compositor. The callback receives the output when the command finishes. A blocking call such as io.popen("wpctl ..."):read() freezes the session until it returns.
Use pamixer or wpctl, depending on which is installed:
-- widgets/volume.lua
local function get_volume_cmd()
return [[
if command -v pamixer >/dev/null 2>&1; then
pamixer --get-volume
elif command -v wpctl >/dev/null 2>&1; then
wpctl get-volume @DEFAULT_AUDIO_SINK@ | awk '{print int($2 * 100)}'
else
echo "0"
fi
]]
end
pamixer prints an integer; awk converts wpctl output such as Volume: 0.45 to the same format. get_mute_cmd() handles mute state similarly.
Attach a tooltip:
-- widgets/volume.lua
volume_widget.tooltip = awful.tooltip({
objects = { volume_widget },
})
awful.tooltip creates a small hover popup and attaches it to every widget in objects; setting its text property later changes what it shows. Now the update function:
-- widgets/volume.lua
local function update_volume()
awful.spawn.easy_async_with_shell(get_volume_cmd(), function(vol)
-- INFO: This stdout contains a \n character that messes up how the tooltip looks.
local vol_string = string.gsub(vol, "\n", "")
-- If there is no audio sink at all (a VM, a nested test instance, a machine with
-- PipeWire down) the command above prints nothing, and tonumber gives back nil.
-- Every comparison below would then throw, so pin it to a number here.
local volume_level = tonumber(vol_string) or 0
local tbox = volume_widget:get_children_by_id("text")[1]
volume_widget.tooltip.text = " " .. volume_level .. "%"
tbox.text = " " .. volume_level .. "%"
The rest of the callback reads mute state with get_mute_cmd() and selects a muted, low, medium, or high volume icon. The complete function is in the checkpoint branch.
Updating on a signal
The media helpers from chapter 02 emit volume::update:
-- keybindings.lua
local media_helpers = {
raise_volume = function()
awful.spawn("wpctl set-volume @DEFAULT_AUDIO_SINK@ 5%+")
awesome.emit_signal("volume::update")
end,
volume::update is a custom signal. It passes no arguments; the widget reads the volume when it receives the signal.
On the other end, the widget subscribes:
-- widgets/volume.lua
update_volume()
awesome.connect_signal("volume::update", function()
update_volume()
end)
The initial update_volume() call sets the display before any signal arrives.
Battery: A Widget With an API
The battery module returns a table:
-- widgets/battery.lua
-- The one battery implementation in this config. The wibar shows M.widget;
-- the dashboard profile and the lockscreen call M.get_status() and share the
-- same thresholds through M.level_color() / M.level_icon(), so all three
-- always agree about what the battery is doing.
local M = {}
The bar uses M.widget. The dashboard in chapter 11 and lockscreen in chapter 12 use M.get_status(cb), M.level_color(percent), and M.level_icon(percent, charging).
Parsing by Key, Not by Line
Read labelled fields from one battery. upower -e | grep BAT can match several devices, so the shell command selects one with head -n1. Parse by label: fully charged batteries omit time estimates, which changes the line order.
-- widgets/battery.lua
function M.get_status(cb)
awful.spawn.easy_async_with_shell(status_cmd, function(stdout)
stdout = stdout or ""
local percentage = tonumber(stdout:match("percentage:%s*(%d+)%%"))
local state = stdout:match("state:%s*([%w%-]+)") or "unknown"
local charging = state:lower():find("charging") ~= nil and state:lower():find("discharging") == nil
cb({
percentage = percentage,
state = state,
charging = charging,
time_to_empty = stdout:match("time to empty:%s*([^\n]-)%s*\n"),
time_to_full = stdout:match("time to full:%s*([^\n]-)%s*\n"),
})
end)
end
Missing fields return nil. Check both charging and discharging, since the latter contains the former. status_cmd uses upower when available, otherwise /sys/class/power_supply/BAT*, with the same labels for both.
Define the colour thresholds:
-- widgets/battery.lua
function M.level_color(percent)
if percent > 50 then
return beautiful.active_hover
elseif percent > 20 then
return beautiful.accent_hover
else
return beautiful.urgent_hover
end
end
All battery displays use these thresholds. M.level_icon selects a battery glyph, or the charging glyph when charging.
Hiding When There Is No Battery
The bar widget polls every 10 seconds. Emit the result for other displays and hide the widget when no battery is found:
-- widgets/battery.lua
local function update()
M.get_status(function(status)
-- Broadcast for every other battery display (the dashboard profile
-- listens), so the whole config shares this one 10-second poll
awesome.emit_signal("battery::update", status)
-- No percentage means no battery: hide the widget rather than parking a
-- permanent "N/A" in the bar.
if not status.percentage then
M.widget.visible = false
return
end
M.widget.visible = true
battery::update passes the status table so other displays can reuse the reading. A widget with visible = false takes no layout space. The rest of update() sets the percentage, icon, and time estimate tooltip.
WiFi
widgets/wifi.lua reads the SSID with iw or iwgetid every 60 seconds, then updates its textbox, tooltip, and icon:
-- widgets/wifi.lua
if string.find(ssid, "Not connected") or ssid == "" then
wifi.image = recolor(beautiful.icon_dir .. "/wifi-off.svg", icon_color)
else
wifi.image = recolor(beautiful.icon_dir .. "/wifi.svg", icon_color)
end
Wiring It Into the Bar
widgets/init.lua makes the folder importable as one module. Lua resolves require("widgets") to widgets/init.lua, which just aggregates:
-- widgets/init.lua
return {
battery = require("widgets.battery"),
clock = require("widgets.clock"),
volume = require("widgets.volume"),
wifi = require("widgets.wifi"),
wrappers = require("widgets.wrappers"),
}
Then the only change to rc.lua is requiring that table and swapping the stock text clock for our widgets in the bar's right section:
-- rc.lua
layout = wibox.layout.fixed.horizontal,
mykeyboardlayout,
wibox.widget.systray(),
widgets.volume,
widgets.wifi,
widgets.battery.widget,
widgets.clock,
s.mylayoutbox,
Use widgets.battery.widget in the bar. widgets.battery is the module table, not a widget.
Reload with Mod4+Ctrl+R and the stock bar now shows live volume, your SSID, battery percentage if you have one, and the clock. Tap a volume key and watch the number move instantly. Hover the widgets for tooltips.
Try It
- Make the clock 24-hour. One
os.dateformat change inwidgets/clock.lua. While you are in there, give the clock a tooltip (awful.tooltip, exactly like volume's) showing the full date on hover. - Put signal strength in the WiFi tooltip.
iw dev <interface> linkalso prints asignal: -52 dBmline. Extend the shell command to keep it, parse it by key, and append it to the tooltip text after the SSID.

Checkpoint
The finished code for this chapter is on the 03-widgets branch.
git checkout 03-widgets
somewm-client test start --config "$PWD/rc.lua" --name afs
Compare your work: git diff 02-keybindings 03-widgets