Getting Started
installation
runtime requirements:
gtk4gtk4-layer-shelllibpulselibudev
install from crates:
cargo install lushell
create config
~/.config/lush/init.lua
local lush = require("lush")
local ui = lush.ui
ui.css("style.css")
lush.data.use("cpu", { interval = 2 })
lush.data.use("memory", { interval = 2 })
ui.windows({
ui.window({
name = "bar",
position = "top",
exclusive = true,
height = 30,
root = ui.hbox({
spacing = 8,
children = {
ui.label({
bind = "data.cpu.percent",
format = "cpu {value}%",
}),
ui.label({
bind = "data.memory.percent",
format = "mem {value}%",
}),
ui.clock({
format = "%H:%M:%S",
}),
},
}),
}),
})
will use your gtk theme by default if it supports gtk4
~/.config/lush/style.css
* {
font-family: monospace;
font-size: 14px;
}
run
with default config location:
lush
control from shell
lush ping
lush list
lush toggle bar
lush reload
lush reload-css
config lookup order
LUSH_CONFIG$XDG_CONFIG_HOME/lush/init.lua$HOME/.config/lush/init.lua./init.lua
if LUSH_CONFIG is a directory, lush uses <dir>/init.lua.
next
- learn runtime modules in runutime api
- see all widget fields in widgets reference
- see all emitted keys in signals reference
- copy ready patterns from recipes
Runtime API
local lush = require("lush")
top-level modules:
lush.uilush.datalush.statelush.signallush.windowslush.notificationslush.osdlush.schedulerlush.processlush.audiolush.store
lush.data
start providers explicitly:
lush.data.use("cpu", { interval = 2 })
lush.data.use("network", { interval = 1, iface = "wlan0" })
lush.data.use("disk", { path = "/" })
lush.data.use("audio")
lush.data.use("compositor", { output = "focused" })
stop a provider if you have to:
lush.data.unuse("cpu")
reactive watch:
local stop = lush.data.watch("cpu", { immediate = true }, function(snapshot)
print(snapshot["data.cpu.percent"])
end)
-- if you want to stop:
stop()
provider signal keys are listed in signals reference.
lush.state and lush.signal
both write to the same runtime signal bus.
lush.state.set("app.mode", "normal")
lush.signal.emit("app.mode", "normal")
watch one key:
local unwatch = lush.state.watch("app.mode", function(v)
print("mode: ", v)
end, { immediate = true })
watch many:
local unwatch = lush.state.watch_many({
"data.cpu.percent",
"data.memory.percent",
}, function(values)
print(values["data.cpu.percent"], values["data.memory.percent"])
end)
lush.windows
control named windows:
lush.windows.toggle("bar")
lush.windows.open("bar")
lush.windows.close("bar")
lush.windows.set_visible("control-center", true)
lush.process
watch shell command output:
local stop = lush.process.watch("date +%H:%M:%S", 1, function(out)
lush.state.set("custom.clock", out)
end, {
queue_policy = "latest", -- or "drop"
})
lush.scheduler
local id = lush.scheduler.after(1.2, function()
print("once")
end)
local every_id = lush.scheduler.every(5, function()
print("tick")
end)
lush.scheduler.cancel(every_id)
lush.osd
bind a named window to one or more signals and auto-hide:
lush.osd.create({
window = volume_osd, -- ui.window table with name
signals = { "data.audio.volume", "data.audio.muted" },
timeout = 1200,
})
use this instead of manual state.watch + scheduler show/hide loops.
widgets
window
lush.ui.window({...})
name: string?named windows are controllable fromlush.windows.*output: string|integer?monitor selector (index/name/connector)visible: booleandefaulttruelayer: "background"|"bottom"|"top"|"overlay"defaulttopexclusive: booleanreserve layer-shell zoneposition: "top"|"bottom"|"left"|"right"defaulttopanchorsanchor: string|{string,...}explicit anchors overridepositionwidth: integer?height: integer?margin_top|margin_bottom|margin_left|margin_right: integerdefault0root: widgetrequired
common widget
supported by all widget kinds:
class: stringclasses: {string,...}visible: booleanvisible_bind: stringsignal key mapped to widget visibilitywidth: integerheight: integerhexpand: booleanvexpand: booleanhalign: "fill"|"start"|"center"|"end"valign: "fill"|"start"|"center"|"end"class_bind: stringstate key to map to css class
state class example:
ui.label({
text = "net",
class_bind = "network.status",
})
if network.status = "down", widget gets css class state-down.
visibility bind example:
ui.image({
bind = "notification.slot1.icon",
visible_bind = "notification.slot1.icon",
})
visible_bind value rules:
- false when signal is: empty string,
0,false,off,no,hidden - true when signal is:
1,true,on,yes,visible,show - any other non-empty value is treated as true
containers
hbox
- fields:
children,spacing
vbox
- fields:
children,spacing
centerbox
- fields:
children,spacing - only first 3 children are used: start/center/end.
- currently only horizontal!
scroll
- fields:
children,h_policy,v_policy,overlay_scrolling,kinetic_scrolling,propagate_natural_width,propagate_natural_height,min_content_width,min_content_height - only first child is used as scroll content.
- defaults:
h_policy = "automatic"v_policy = "automatic"overlay_scrolling = false
- policy values for
h_policy/v_policy:automaticalwaysneverexternal
overlay
- fields:
children children[1]is base content.children[2..]are layered on top as overlays.
revealer
- fields:
children,reveal,reveal_bind,transition,duration - only first child is used as revealer content.
- defaults:
reveal = truetransition = "slide-down"duration = 250(milliseconds)
reveal_bindmaps signal text to visible state using the same truthy/falsey rules asvisible_bind.- transition values:
nonecrossfadeslide-rightslide-leftslide-upslide-down(default)
popover
- fields:
children,position,autohide,has_arrow children[1]is trigger widget;children[2]is popover content.- extra children are ignored.
- defaults:
position = "bottom"autohide = truehas_arrow = true
positionvalues:top,bottom,left,right
content widgets
label
- fields:
text,bind,binds,format,format_states,rules,on_click,max_chars,ellipsize on_click: shell string, Lua function, or button map table.- button map keys:
left,middle,right,wheel_up,wheel_down ellipsize:start,middle,end,none- renders with Pango markup and supports rule-based styling and text transforms.
- placeholders:
{value},{text},{state}, plus any key frombinds - default format:
- with
bind:{value} - without
bind:{text}
- with
format_states: optional map by state value (usually fromclass_bind) and/ordefault- basics:
- the format string is split into placeholder values (inside
{...}) and literal chunks (plain text likebat,%,(,)). - rules can target placeholder values (
target = "value"), literals (target = "literal"), or both (target = "any"/unset). - glob matching is supported in
matchandtoken:*= any sequence,?= single character. - numeric filters (
min/max) only apply when the matched text parses as a number.
- the format string is split into placeholder values (inside
- each entry in
rulesis checked in order; all matching rules are merged, and later rules override earlier fields. - rule filters:
target:value(placeholder values),literal(plain words/symbols in format string), orany(default)token: placeholder key glob filter (fortarget = "value"), supports*and?match: value/literal glob filter, supports*and?min/max: numeric filter for numeric values
- style keys on each rule:
format,class,color,background,weight,style,underline,font,size,rise,alpha,strikethrough
formaton a rule transforms matched text before styling (supports{value}).classon a rule applies CSS class(es) to matched text fragments (class = "warn"orclass = "warn pill").
minimal example:
ui.label({
bind = "data.battery.percent",
format = "bat {value}%",
rules = {
{ target = "literal", match = "bat", class = "label-prefix", color = "#928374", weight = "bold" },
{ target = "value", token = "value", format = "{value}%" },
{ target = "value", token = "value", max = 20, color = "#fb4934", weight = "bold" },
},
})
example:
ui.label({
binds = {
["battery.percent"] = "data.battery.percent",
["battery.state"] = "data.battery.state",
},
format = "bat {battery.percent} ({battery.state})",
rules = {
{ target = "literal", match = "bat", color = "#928374", weight = "bold" },
{ target = "value", token = "battery.state", match = "charging", color = "#8ec07c", weight = "bold" },
{ target = "value", token = "battery.state", match = "dis*", color = "#fabd2f", weight = "bold" },
{ target = "value", token = "battery.state", match = "full", color = "#b8bb26", weight = "bold" },
{ target = "value", token = "battery.percent", format = "{value}%" },
{ target = "value", token = "battery.percent", max = 20, color = "#fb4934", weight = "bold" },
{ target = "value", token = "battery.percent", min = 21, max = 40, color = "#fabd2f", weight = "bold" },
{ target = "value", token = "battery.percent", min = 41, color = "#8ec07c" },
},
})
button
- fields:
text,bind,format,format_states,on_click,angle on_click: shell string, Lua function, or button map table.- button map keys:
left,middle,right,wheel_up,wheel_down - placeholders:
{text},{value},{state} - default format:
{text} format_states: optional map by state value (usually fromclass_bind) and/ordefault- special shell actions:
lush.notifications.delete:{index}lush.notifications.clear
clock
- fields:
format,display_format,format_states,interval,bind,spacing,angle - defaults:
format = "%a %d.%b %H:%M:%S"interval = 1second
formatis the chrono/strftime pattern used to generate raw clock value.- placeholders in
display_format/format_states:{value},{time},{state} - default
display_format:{value} format_states: optional map by state value (usually fromclass_bind) and/ordefault
image
- fields:
path,bind,fit,can_shrink,on_click on_click: shell string, Lua function, or button map table.- button map keys:
left,middle,right,wheel_up,wheel_down fit:contain(default),cover,fill,scale-down- source resolution order:
- explicit file/URI
- icon theme lookup by name
progress
- fields:
bind,value,min,max,inverted - defaults:
min = 0max = 100value = min(whenbindhas no signal value)
bindshould contain a numeric value.- normalized fraction:
(value - min) / (max - min), clamped to0..1. inverted: fills opposite direction when true
slider
- fields:
bind,input_bind,value,min,max,step,scroll_step,orientation,inverted,draw_value,digits - defaults:
min = 0max = 100value = min(whenbindhas no signal value)step = 1scroll_step = steporientation = "horizontal"draw_value = falsedigits = 0
- reads from
bind. - writes user changes to
input_bindwhen set, otherwise writes back tobind. - mouse wheel changes slider value by
scroll_step(up increases, down decreases). digitscontrols numeric precision for emitted string values (0..6).
entry
- fields:
text,bind,input_bind,activate_bind,placeholder,max_chars,autofocus - defaults:
text = ""autofocus = false
- reads displayed text from
bindwhen present, otherwise usestext. - writes user edits to
input_bindwhen set, otherwise writes back tobind. - pressing Enter writes the current text to
activate_bindand incrementsactivate_bind.__user_seq. placeholdersets GTK placeholder text.max_charslimits entered text length.autofocusgrabs focus when the widget is mapped.
workspaces
- fields:
count,active_only,all_outputs,output,orientation,spacing,format,format_states,labels,state_labels,format_icons,show_clients,clients_max_items,clients_icon_size,clients_rules,clients_use_glyphs,clients_glyph_fallback,clients_spacing,angle orientation:horizontal(default) orverticalcountclamped to1..32active_only: show only focused/occupied/urgent workspaces when trueall_outputs: include every output when true; otherwise uses selected/focused output- placeholders in
format/format_states:{id},{label},{icon},{clients},{state} format_states: optional map by state (focused,occupied,unfocused,urgent,default) overridingformatlabels: base label array by tag indexstate_labels: optional map of state -> label array by tag indexformat_icons: optional icon map with fallback keys:{id}.{state}(example:1.focused){state}(example:occupied){id}(example:3)default
- client rendering:
show_clients: render per-workspace client widgets after the workspace labelclients_max_items: max clients per workspace (1..16, default4)clients_icon_size: icon size for image mode (8..64, default12)clients_spacing: spacing between rendered clients (0..24, default2)clients_use_glyphs: when true, render rule icon values as glyph text; when false, render as icon/path sourcesclients_rules: shared matching rules (class,title,icon,text) with*/?globs and last-match-wins behaviorclients_glyph_fallback: fallback glyph when no rule icon matches in glyph mode
- styling hooks:
.workspace-clientscontainer for per-workspace clients.workspace-client-glyphglyph client item.workspace-client-iconicon client item.focused-clientadded to the currently focused client item
- backend note:
- per-workspace client mapping is currently implemented for sway; other compositors may not populate workspace membership.
dock
- fields:
orientation,output,all_outputs,spacing,max_items,format,format_states,image_map,icon_size,on_click,angle orientation:horizontal(default) orverticaloutput: optional output selector like workspacesall_outputs: when true, ignoresoutputand uses compositor-global focus streammax_itemsclamped to1..128image_map: pattern map (*and?) -> image source- value with
/is treated as file path - otherwise treated as icon-theme name
- value with
- default render is icon-only; text is shown only when
format/format_statesis set - placeholders in
format/format_states:{label}default label ({title}){title}window title fallbacking to app id{class}app class/app id{app_id}compositor app id/class{identifier}compositor identifier (when available){index}1-based slot{state}focusedordefault
format_statescan override template forfocusedanddefaulticon_size: icon/image pixel size, clamped to8..128(default16)on_clickaccepts a string action or a table:- keys:
left,middle,right,wheel_up,wheel_down - values:
none,activate,close,minimize,restore - default behavior:
{ left = "activate" }
- keys:
tray
- fields:
orientation,spacing,icon_size,max_items,show_passive,hide_when_empty orientation:horizontal(default) orverticalspacing: gap between tray items (default6)icon_size: icon pixel size, clamped to8..128(default16)max_items: max displayed items, clamped to1..256(default32)show_passive: include passive items when true (defaulttrue)hide_when_empty: hides tray widget when it has no rendered items (defaulttrue)- click behavior:
- left click ->
Activate(x, y) - middle click ->
SecondaryActivate(x, y) - right click -> internal DBusMenu popover when available, else
ContextMenu(x, y) - wheel ->
Scroll(delta, "vertical")
- left click ->
- styling hooks:
.tray-container.tray-item.tray-icon.tray-active,.tray-passive,.tray-needsattention
list
- fields:
children,bind,count,spacing,orientation children[1]is row template cloned for each item.- template token expansion:
{item}-> e.g.item.1(or custom base + index){slot}-> alias of{item}{base}-> e.g.item.{index}-> row index (1,2, …)
- defaults:
bind = "item."count = 1(clamped1..256)orientation = vertical
- use
bind = "notification.slot"andvisible_bind = "{item}.visible"to render notification slots.
signals
all keys are string-based values on the shared signal bus.
window visibility keys
written by runtime when named windows are shown/hidden:
window.<name>.visible->"1"or"0"
notification live keys
primary active notification mirror:
notification.visiblenotification.idnotification.app_namenotification.summarynotification.titlenotification.bodynotification.iconnotification.urgencynotification.urgency_namenotification.changed(incremented once per logical notification update)notification.active_count(visible count in live stack)notification.history_count(visible count in history stack)
notification lifecycle event keys (last event snapshot):
notification.event(pushed|closed|history_cleared|history_deleted)notification.event_seq(incremented per lifecycle event)notification.event.idnotification.event.app_namenotification.event.summarynotification.event.titlenotification.event.bodynotification.event.iconnotification.event.urgencynotification.event.urgency_namenotification.event.history_index(0unlesshistory_deleted)
stack slots (MAX_NOTIFICATION_SLOTS = 3):
notification.slot1.*notification.slot2.*notification.slot3.*
each slot has:
id,app_name,summary,title,body,icon,urgency,urgency_name,visible
history slots (MAX_HISTORY_SLOTS = 32):
notification.history1.*…notification.history32.*
notification control keys
observed by notification runtime:
notification.history_clearornotification.history.clearnotification.history_deleteornotification.history.delete
trigger behavior:
- clear: clears all history
- delete: deletes one history row
data providers (lush.data)
cpu provider:
data.cpu.percentdata.cpu.state(normal|warn|critical)data.cpu.userdata.cpu.systemdata.cpu.idledata.cpu.total
memory provider:
data.memory.percentdata.memory.state(normal|warn|critical)data.memory.total_mbdata.memory.used_mbdata.memory.available_mbdata.memory.total_gbdata.memory.used_gbdata.memory.available_gb
network provider:
data.network.down_bpsdata.network.up_bpsdata.network.down_kibpsdata.network.up_kibpsdata.network.iface(allor requested interface name)data.network.state(idle|active)data.network.down_total_bytesdata.network.up_total_bytesdata.network.ssid(empty when unavailable/not Wi-Fi)data.network.wifi_strength_percent(0..100,0when unavailable)data.network.wifi_signal_dbm(empty when unavailable)
disk provider:
data.disk.pathdata.disk.total_percent(always100)data.disk.used_percentdata.disk.free_percentdata.disk.total_gbdata.disk.used_gbdata.disk.free_gbdata.disk.total_bytesdata.disk.used_bytesdata.disk.free_bytes
battery provider:
data.battery.percentdata.battery.state(charging|discharging|full|unknown|unavailable)data.battery.time_left_mindata.battery.power_w
audio provider:
data.audio.volume(0..150)data.audio.muted(1muted,0unmuted)data.audio.sink
bluetooth provider:
data.bluetooth.available(1when adapter exists, else0)data.bluetooth.powered(1on,0off)data.bluetooth.connected_countdata.bluetooth.connected_name(first connected device alias/name)data.bluetooth.connected_address(first connected device MAC)data.bluetooth.connected_battery_percent(0..100when bluezBattery1is available)data.bluetooth.adapter(adapter alias/name)data.bluetooth.state(unavailable|off|on|connected)data.bluetooth.summary
mpris provider:
data.mpris.available(1when at least one MPRIS player exists, else0)data.mpris.player(selected player identity)data.mpris.status(playing|paused|stopped|unknown)data.mpris.titledata.mpris.artistdata.mpris.albumdata.mpris.art_url(cover art URI/path)data.mpris.length_us(track length in microseconds)data.mpris.position_us(playback position in microseconds)data.mpris.summary
compositor provider:
data.compositor.namedata.compositor.summarydata.compositor.focused_maskdata.compositor.occupied_maskdata.compositor.urgent_maskdata.compositor.focused_workspacedata.compositor.focused_window.titledata.compositor.focused_window.app_iddata.compositor.focused_window.workspace
state and signal note
lush.state.set("x", "y") and lush.signal.emit("x", "y") are equivalent, both write to the same runtime bus and dispatch watchers.
recipes
patterns you can copy into your config.
volume osd popup
local lush = require("lush")
local ui = lush.ui
local volume_osd = ui.window({
name = "volume-osd",
visible = false,
layer = "overlay",
anchors = { "top", "left", "right" },
margin_top = 48,
root = ui.hbox({
class = "volume-osd",
spacing = 8,
children = {
ui.label({
class_bind = "data.audio.muted",
format_states = {
["1"] = "",
["0"] = "",
default = "",
},
}),
ui.progress({
bind = "data.audio.volume",
min = 0,
max = 100,
}),
ui.label({
binds = {
["v"] = "data.audio.volume",
["m"] = "data.audio.muted",
},
format_states = {
["1"] = "muted",
["0"] = "{v}%",
default = "{v}%",
},
}),
},
}),
})
lush.osd.create({
window = volume_osd,
signals = {
"data.audio.volume",
"data.audio.muted",
},
timeout = 1200,
})
focused window label
ui.label({
bind = "data.compositor.focused_window.title",
format = "{value}",
max_chars = 40,
ellipsize = "end",
})
workspace clients as glyphs
ui.workspaces({
output = "focused",
count = 5,
show_clients = true,
clients_use_glyphs = true,
clients_max_items = 4,
clients_glyph_fallback = "",
clients_rules = {
{ class = "*firefox*", icon = "" },
{ class = "foot", icon = "" },
},
})
hide widget by signal
ui.label({
bind = "data.bluetooth.connected_name",
visible_bind = "data.bluetooth.connected_count",
format = "{value}",
})
run external command and bind output
lush.process.watch("echo hello", 5, function(out)
lush.state.set("custom.value", out)
end)
ui.label({
bind = "custom.value",
format = "{value}",
})
troubleshooting
no windows appear
- run with logs:
RUST_LOG=debug lush -c /path/to/init.lua
- check config file is actually loaded.
- verify your
ui.windows({...})contains at least one window.
workspace widget is empty/disabled
if logs show compositor workspace backend unavailable, lush could not detect a supported backend.
- sway:
SWAYSOCKis set and reachable - hyprland:
HYPRLAND_INSTANCE_SIGNATUREexists and sockets are present
focus/workspace looks wrong on multi-monitor
ui.workspaces has output filtering behavior:
all_outputs = true: includes all outputsoutput = "focused": follows currently focused outputoutput = "DP-1"or numeric index: pins to one output
if your bar is on monitor A but keyboard focus moves to monitor B, output = "focused" will follow B.
contributing
- keep runtime behavior inside
require("lush")modules
widget architecture notes
- all widgets share
WidgetBasefields for common behavior (size, classes, alignment, visibility) - widget-specific fields live in
WidgetPropsvariants - shared post-build behavior is centralized via
finalize_widget(...) - for
WidgetKind, avoid duplicate maps: usefrom_lua_kind,as_lua_kind,css_class,allowed_fields
data architecture notes
- for common system metrics, prefer rust-native providers
- avoid adding shell-command polling in lua for data sources
- keep lua-side
lush.dataas a wrapper and signal composition layer - providers should be lazy: start on first
use/watch, stop on lastunuse
before commit
- docs include concrete examples
- no compatibility shim unless requested