Reusable Modules¶
EasyBar loads two widget roots. It recursively executes manual .lua files below widgets_dir, except reusable modules below shared/ or legacy lib/. It also executes activated package entrypoints below ~/.local/share/easybar/packages/active; package exports below that root's shared/ directory load only through require(...). Extension matching is case-insensitive.
Both widget roots and their shared/ directories are added to Lua's module search path before their files load, so code can continue to use standard require(...) calls without changing package.path. The manual root also supports the legacy lib/ fallback.
Recommended layout¶
~/.config/easybar/widgets/
├── simple/
│ └── clock.lua
├── github/
│ ├── widget.lua
│ └── README.md
├── brew/
│ ├── widget.lua
│ ├── policy.lua
│ └── README.md
├── shared/
│ ├── inbox.lua
│ ├── retry.lua
│ ├── text.lua
│ └── status/
│ └── init.lua
├── lib/
│ └── legacy.lua
└── assets/
└── github.svg
Lua files below shared/ and lib/ load only through require(...). Keep their top level declarative: create local functions or tables and return the public value. Do not start timers, commands, subscriptions, or inbox publishing until an explicit function is called by the consuming widget.
Keep small examples in the matching category. Use a service directory when an integration gains configuration, helper modules, documentation, tests, or assets. The filename inside that directory is your choice.
Do not install multiple presentation variants for the same service unless duplicate polling is intentional.
Create a module¶
A module normally returns one table containing its public functions:
-- ~/.config/easybar/widgets/shared/text.lua
local M = {}
function M.trim(value)
return tostring(value or ""):gsub("^%s+", ""):gsub("%s+$", "")
end
return M
Use it from any Lua file:
local text = require("text")
local value = text.trim(" ready ")
EasyBar resolves that call from:
<widgets_dir>/shared/text.lua
Package directories¶
For a larger module, use an init.lua file:
shared/
└── status/
└── init.lua
Then load it with:
local status = require("status")
Dots in module names map to subdirectories. For example:
local format = require("network.format")
resolves to:
<widgets_dir>/shared/network/format.lua
Shared text helper¶
The official shared package includes a small text module:
local text = require("text")
local clean = text.trim(command_output)
local short = text.truncate(clean, 80)
text.lua provides:
text.trim(value)text.truncate(value, maximum_length, omission?)
This module is installed and updated with the shared package. It is not a built-in part of the public easybar API.
Inbox data helper¶
The official inbox packages share the shared package's inbox.lua for three data-boundary operations:
local inbox = require("inbox")
local values = inbox.decode_array(easybar.json, command_output)
if values == nil then
local message = inbox.error_message(command_output, "The service returned invalid data")
-- Keep the last valid snapshot and publish `message` as an additional error item.
end
local timestamp = inbox.timestamp("2026-08-03T09:45:00.123+02:00")
inbox.lua provides:
inbox.decode_array(json_module, output)decodes a dense JSON array and returnsnilfor invalid JSON or an object-shaped response. Passeasybar.jsonexplicitly because modules do not receive the widget-scoped API automatically.inbox.error_message(output, fallback)trims and limits an error body toinbox.maximum_error_lengthcharacters, using the fallback when output is empty. The official value is 12,000 characters, safely below the native inbox body's byte limit even for UTF-8 text.inbox.timestamp(value)converts an ISO-8601 timestamp withZor a numeric timezone offset to Unix seconds. Fractional seconds are accepted and discarded. Invalid dates and timestamps without a timezone returnnil.
These helpers deliberately do not own snapshots, refresh scheduling, or actions. The publishing widget remains responsible for validating service-specific fields and deciding whether a failed refresh should retain existing items.
The shared package's retry.lua module coordinates asynchronous attempts through easybar.after(...). Pass
the widget-scoped API explicitly because modules do not receive easybar automatically:
local retry = require("retry")
retry.run(easybar, {
delays = { 2, 5 },
attempt = function(done, attempt_number)
return easybar.spawn_async({ "gh", "api", "notifications" }, {}, done)
end,
should_retry = retry.is_transient_network_error,
on_complete = function(output, code, attempts)
-- Runs once with the final result.
end,
})
The first attempt starts immediately. delays[1] is the wait before attempt 2, delays[2] is the
wait before attempt 3, and so on. When no delay remains, the last result is final.
retry.run(...) returns a RetryOperation with:
operation:is_active()operation:cancel()
Store that handle only when the widget has an actual cancellation or replacement policy. The retry
callbacks and host timers keep the operation alive until completion, so assigning an unused
active_refresh variable adds dead state without changing behavior.
When you do store the handle, clear it in on_complete and before cancellation:
local active_refresh
local function cancel_refresh()
local operation = active_refresh
active_refresh = nil
if operation ~= nil then
operation:cancel()
end
end
Cancellation stops either the active asynchronous command or the pending backoff timer and does not
call on_complete.
retry.is_transient_network_error(output, code) is a conservative heuristic for DNS, connection,
timeout, TLS, and common gateway failures. It never retries status 0 and never treats cancellation
status 130 as retryable. The retry helper is intended for idempotent reads. Do not automatically
retry updates, upgrades, acknowledgements, or other mutations because the remote operation may have
succeeded even when the local response was lost.
The module includes LuaLS annotations for RetryOptions, RetryOperation, attempt callbacks, retry
predicates, and completion callbacks. Keeping these annotations beside the implementation means
editors can validate custom retry policies without adding retry types to the global easybar stub.
Module lifetime and state¶
Lua caches successful require(...) calls in package.loaded. Requiring the same module again in
the same runtime returns the same value without executing the module a second time.
That means mutable module state is shared by every widget that requires the module. Prefer stateless helper modules unless shared state is intentional.
Restarting the Lua runtime or reloading EasyBar clears the process and therefore clears the module cache.
EasyBar API access¶
Every discovered file receives a widget-scoped easybar value during direct startup execution. The same file does not receive that injected value when Lua loads it later through standard require(...).
Keep reusable modules independent from easybar at top level. When a helper needs host-specific data, pass the value explicitly:
-- shared/widget_style.lua
local M = {}
function M.label(color, value)
return {
string = value,
color = color,
}
end
return M
-- clock.lua
local widget_style = require("widget_style")
local label = widget_style.label(easybar.theme.ref.text, os.date("%H:%M"))
Resolve files beside the current entrypoint with easybar.asset(...). Use easybar.asset("@/assets/name.svg") for assets shared from the configured widgets root, then pass the resolved path to a helper only when needed.
Naming and precedence¶
For manually managed widgets, EasyBar searches modules in this order:
<widgets_dir>/?.lua
<widgets_dir>/?/init.lua
<widgets_dir>/shared/?.lua
<widgets_dir>/shared/?/init.lua
<widgets_dir>/lib/?.lua
<widgets_dir>/lib/?/init.lua
Dots map to subdirectories. For example, require("brew.policy") resolves first to
<widgets_dir>/brew/policy.lua. A generic require("text") normally resolves to
<widgets_dir>/shared/text.lua when no top-level text.lua or text/init.lua exists.
Installed packages use the same root and shared/ patterns below:
~/.local/share/easybar/packages/active/
Package-managed widgets load before manual widgets, so their declared exports resolve from the managed activation tree. During manual widget startup, manual module paths take precedence and managed exports remain available as a fallback. Successful require(...) calls are cached process-wide, so use distinct module names when two packages must not share an implementation.
Use the widget name as the first component for private package modules. Keep generic module names in
shared/, and avoid names likely to collide with third-party Lua packages.
Errors¶
Every discovered .lua file is executed. A syntax error or top-level failure is reported for that file, its transactional changes are rolled back, and the remaining files continue loading.
A missing or failing require(...) call fails the consuming file in the same way. Files below shared/ and lib/ are not executed directly, so a broken support module is reported when a widget requires it.