Skip to main content
Version: 2.2 (dev)

Animation somewm-only

SomeWM animates by declaring a transition on a node of the tree and letting the solver move it. Nothing is interpolated in Lua. Every animation is off until a duration is set, and a duration of 0 is off.

The .animation property stores a persistent policy on clients, wiboxes, popups and widgets. Policies are copied on assignment and access. To change one, edit a copy and assign it back; assignments emit property::animation. The widget method :animate{...} requests one change without replacing that policy.

Layout changes​

Set the layout duration and every client box eases to its new place on a resize, a tag's layout change or a carousel scroll. When a client maps or closes, the other clients ease. The client itself appears or goes in one frame unless it has an entrance.

theme.animation_layout_duration = 0.15   -- seconds

From code, somewm.animation.layout_duration sets it; theme.animation_layout_duration and theme.animation_duration both win over it when set. Requiring somewm.layout_animation still sets it to 0.15, as it did in 1.4 and 2.0, and that module is deprecated. A client's first frame after the duration is set, or after a reload, does not animate.

Layout resize and swap.

Tiled windows ease into their new geometry when the master width changes and when the windows swap places.

Clients​

A client reads its durations and entrances from c.animation, a table with one key per trigger, and falls back to the theme.

TriggerWhenTheme durationTheme entrance
appearthe client mapsanimation_appear_durationanimation_appear
disappearthe client closesanimation_disappear_durationanimation_disappear
tagthe client enters or leaves the view with a tag switchanimation_tag_durationanimation_tag; default grow
focusthe border colour changes with focusanimation_focus_duration
maximizemaximized or fullscreen togglesanimation_maximize_duration
floatingfloating togglesanimation_floating_duration

theme.animation_duration is the duration for every trigger that has none of its own, layout included.

Maximise, restore, float and tile.

The same window moves between maximised, tiled and floating states, with a different duration for each transition.

An entrance is one of grow, scale, scale_top, scale_bottom, scale_left, scale_right, slide_left, slide_right, slide_up, slide_down or fade. grow sizes the frame up from nothing, so its box changes. The scale words shrink the finished picture about its centre or an edge midpoint, with the box unchanged (Scale). The slides come in from that edge of the screen, and fade blends the frame in from invisible. The same word runs backwards on the way out.

A list runs several effects together in one transition, with one duration:

theme.animation_appear = { "fade", "slide_left" }
  • One slide, one size effect (grow or one of the scale words) and fade combine, in any order.
  • A second slide direction, or a second size effect, warns once and the first one named stays.
  • A repeated word counts once. collapse is another name for grow.

{ "slide_left", "slide_right" } logs:

transition.enter: slide_left + slide_right conflict; slide_right is ignored, slide_left remains active

none names no entrance. Beside another effect it warns the same way. An explicit disappear = "none" closes the client without an animation instead of using the tag exit.

From a rule:

ruled.client.append_rule {
rule = { class = "mpv" },
properties = { animation = { appear = "fade", disappear = "fade", tag = "slide_left" } },
}

One key holds an entrance word, a list of them, or a duration. A client that names an entrance takes its duration from the theme. Each trigger in this rule animates when its own duration key (theme.animation_appear_duration, animation_disappear_duration or animation_tag_duration) is above 0, or when that key is unset and theme.animation_duration is above 0. The triggers can be enabled independently.

Fade.

A client fades in and out, followed by a popup using the same entrance and exit style.

Or from the theme for every client:

theme.animation_appear = "grow"
theme.animation_appear_duration = 0.2
theme.animation_tag_duration = 0.15
theme.animation_focus_duration = 0.1

A number in c.animation is a duration for that trigger. To ease one client's focus border over 0.3 seconds, reassign its policy:

local policy = c.animation or {}
policy.focus = 0.3
c.animation = policy

c.animation = false turns animation off for the client and its titlebar widgets. c.animation = nil removes the override so the client falls back to the theme and its titlebar widgets can use their own policies.

With a tag duration above 0, from theme.animation_tag_duration, theme.animation_duration or a number in c.animation.tag, a tag switch grows clients in and out. c.animation.tag or theme.animation_tag can name another entrance. A client on its way out animates its frame, the border and titlebars; the window's own pixels snap away on the first frame.

Grow and shrink.

Growing entrances and shrinking exits on clients, tag changes and a popup, shown with different durations.

Scale​

Fade, scale, and both together.

Three popups enter and leave together, with 0.8 seconds for each transition. The labels stay fixed so you can compare the effects.

Show Lua

Save this as /tmp/animation-comparison.lua, then run it in SomeWM 2.1:

somewm-client eval 'dofile("/tmp/animation-comparison.lua")'

The demo opens on your focused screen, runs twice, and closes itself. It uses your theme's background, foreground and font family. Animations must be enabled.

local options = ... or {}
local awful = require("awful")
local beautiful = require("beautiful")
local gears = require("gears")
local wibox = require("wibox")

assert(require("somewm.animation").enabled, "Enable SomeWM animations first")
if _G.somewm_animation_comparison then _G.somewm_animation_comparison.stop() end
local target = awful.screen.focused()
if options.output then
target = assert(output.get_by_name(options.output), "Output not found").screen
end
local bg, fg = beautiful.bg_normal or "#282828", beautiful.fg_normal or "#ebdbb2"
local family = (beautiful.font or "sans 10"):gsub("%s+%d+%.?%d*$", "")
local function text(value, size, color, bold)
return wibox.widget {
markup = '<span foreground="' .. (color or fg) .. '">'
.. gears.string.xml_escape(value) .. '</span>',
font = family .. (bold and " Bold " or " ") .. size,
align = "center",
valign = "center",
widget = wibox.widget.textbox,
}
end
local effects = {
{ name = "Fade", enter = "fade", color = "#d79921" },
{ name = "Scale", enter = "scale", color = "#83a598" },
{ name = "Scale + fade", enter = { "scale", "fade" }, color = "#b8bb26" },
}
local state = { running = true, completed = false, timers = {}, cards = {}, screen = target }
_G.somewm_animation_comparison = state
function state.stop()
for _, timer in ipairs(state.timers) do timer:stop() end
for _, card in ipairs(state.cards) do card.animation = false; card.visible = false end
if state.board then state.board.visible = false end
state.running = false
end
local function later(seconds, callback)
local timer = gears.timer.start_new(seconds, function()
if not state.running then return false end
local ok, err = pcall(callback)
if not ok then state.error = tostring(err); state.stop() end
return false
end)
state.timers[#state.timers + 1] = timer
end

local columns = wibox.layout.flex.horizontal()
columns.spacing = 24
local slots = {}
for i, effect in ipairs(effects) do
slots[i] = wibox.widget { forced_height = 200, widget = wibox.widget.textbox }
columns:add(wibox.widget {
text(effect.name, 20, effect.color, true),
slots[i],
spacing = 12,
layout = wibox.layout.fixed.vertical,
})
end
local phase = text("Same duration. Same popup.", 16)
state.board = awful.popup {
screen = target, visible = true, ontop = true, input_passthrough = true,
bg = bg, fg = fg, minimum_width = 1004, maximum_width = 1004,
placement = awful.placement.centered,
widget = {
{
text("Three ways to appear", 27, fg, true),
text("Fade, scale, and both together · 0.8 seconds", 16),
columns,
phase,
spacing = 20,
layout = wibox.layout.fixed.vertical,
},
margins = 28,
widget = wibox.container.margin,
},
}

local function build_cards()
local board = state.board
if board.width ~= 1004 or board.height < 300 then return false end
local boxes = {}
for i, slot in ipairs(slots) do
local x = 28 + (i - 1) * 324 + 150
for y = 0, board.height - 1, 8 do
for _, hit in ipairs(board:find_widgets(x, y)) do
if hit.widget == slot then boxes[i] = hit; break end
end
if boxes[i] then break end
end
if not boxes[i] or boxes[i].height < 200 then return false end
end
state.geometry = board:geometry()
for i, effect in ipairs(effects) do
local box = boxes[i]
state.cards[i] = awful.popup {
screen = target, visible = false, ontop = true, input_passthrough = true,
placement = awful.placement.top_left,
offset = {
x = board.x - target.geometry.x + box.x + (box.width - 270) / 2,
y = board.y - target.geometry.y + box.y + (box.height - 170) / 2,
},
minimum_width = 270, maximum_width = 270,
minimum_height = 170, maximum_height = 170,
bg = bg, fg = fg, shape = gears.shape.rounded_rect,
border_width = 2, border_color = effect.color,
widget = {
{
text("Hello, SomeWM", 21, fg, true),
text("A real popup", 16),
spacing = 8,
layout = wibox.layout.fixed.vertical,
},
valign = "center", halign = "center",
widget = wibox.container.place,
},
animation = {
visible = { duration = 0.8, enter = effect.enter, exit = effect.enter },
},
}
end
return true
end

local function show(visible, label)
phase.text = label
for _, card in ipairs(state.cards) do card.visible = visible end
end
local function cycle(number)
state.phase = "enter"
show(true, "Entering together")
later(2.2, function()
state.phase = "exit"
show(false, "Leaving together")
end)
later(4, function()
if number < 2 then
cycle(number + 1)
else
state.phase = "final"
show(true, "Same duration. Same popup.")
later(2.2, function() state.completed = true; state.stop() end)
end
end)
end

local attempts = 0
local ready = gears.timer.start_new(0.05, function()
attempts = attempts + 1
local ok, built = pcall(build_cards)
if not ok or attempts > 100 then
state.error = ok and "Popup layout did not settle" or tostring(built)
state.stop()
return false
end
if not built then return true end
state.ready = true
state.phase = "ready"
later(1.2, function() cycle(1) end)
return false
end)
state.timers[#state.timers + 1] = ready
return "Animation comparison opened on screen " .. target.index

Download the Lua file. To stop early:

somewm-client eval 'if somewm_animation_comparison then somewm_animation_comparison.stop() end'

scale shrinks the complete picture, the surface, border and titlebar together, about the centre of the frame. scale_top, scale_bottom, scale_left and scale_right hold that edge's midpoint still and shrink towards it. The frame's box in the layout does not change and the client receives the same configure sizes as an unanimated map, so neighbours do not move and the client never redraws for the effect. Pointer events hit the picture where it is drawn, so mouse::enter fires on the scaled edge.

ruled.client.append_rule {
rule = { class = "mpv" },
properties = { animation = { appear = "scale_top", disappear = "scale_top" } },
}

Mid-flight, somewm-client clay tree prints the picture beside the box, scale 0.715333 anchor top presented 29,32 146x161 box 0,32 204x225: the box is the slot, presented is where the scaled picture is drawn, and the anchor is the edge held still.

A popup can scale and fade in together and scale out from its bottom edge:

local popup = awful.popup {
widget = wibox.widget.textbox("hello"),
placement = awful.placement.centered,
animation = { visible = { duration = 1, enter = { "scale", "fade" }, exit = "scale_bottom" } },
}
popup.visible = true

The dump line for that popup mid-entrance reads fade_alpha 0.366742 scale 0.633258 anchor center, and both values reach their end together at the end of the duration. { "grow", "scale" } logs transition.enter: grow + scale conflict; scale is ignored, grow remains active.

Wiboxes and popups​

A wibox or awful.popup animates its visibility:

local popup = awful.popup {
widget = require("wibox").widget.textbox("hello"),
animation = { visible = { duration = 0.2, enter = "fade", exit = "fade" } },
}

enter and exit take the same words and lists as a client. Setting visible then eases the box in or out. A wibox leaving the screen keeps its box and border for the duration, and its shadow goes on the first frame.

Setting the wibox's or popup's animation = false disables its transitions and those of its child widgets. Setting animation = nil removes that override.

Directional slides.

A popup enters and exits using slide_left, slide_right, slide_up and slide_down.

Widgets​

A widget eases a property with widget.animation, a table of property to duration in seconds:

local box = wibox.container.background()
box.animation = { bg = 0.3 }
box.bg = "#ff0000" -- eases over 0.3 s once the box has been drawn

A widget's first frame never animates, so the change eases only once the widget has been drawn with animation set.

Properties that animate:

WidgetProperties
wibox.container.backgroundbg, border_color, border_width
any widgetforced_width, forced_height
wibox.widget.progressbarvalue
wibox.widget.slidervalue

Any other property in the table warns once and that property snaps. On a wibox.container.background whose shape is not a rounded rectangle, bg, border_color and border_width snap and warn once. A widget's position inside its layout always snaps, so a spacing change moves it in one frame while its own size eases.

For one change without a standing field, call the method:

box:animate { bg = "#ff0000", duration = 0.3 }

It sets each named property and eases it once; the next plain assignment snaps again.

Widget timing and pulse.

Three widgets change size and colour at different speeds. The heart then grows and shrinks twice to make a gentle pulse.

widget.animation = false turns animation off for that widget and everything inside it, including children that set their own animation. The :animate{} method remains callable and applies its values immediately while animation is disabled. widget.animation = nil removes the override; an ancestor's animation = false still suppresses the subtree.

Each output keeps 200 transition records, including configured records that are currently idle. When the budget is exhausted, the output warns once and the remaining nodes snap.

Updating a policy​

Clients, wiboxes, popups and widgets copy .animation on assignment and access. Edit a copy and assign it back to apply changes:

local policy = box.animation or {}
policy.bg = 0.5
box.animation = policy

Assigning an edited policy notifies property::animation observers and schedules the update. Editing the original table or a returned copy alone leaves the stored policy unchanged.

Disabling animation​

Assign .animation = false to suppress an object and its descendants, including descendants with their own policies. A client's titlebar widgets are included. Assign nil to remove that override, and client theme defaults and descendant policies apply again.

Disable all transitions with:

require("somewm.animation").enabled = false

This disables client and widget transitions and the animation.visible transitions on wiboxes and popups. Active transitions finish on the next rendered frame, including retained exits. Set enabled = true to let later changes animate again. A completed transition does not restart, and stored policies are unchanged throughout. A running mousegrabber suppresses the same transitions, so a drag never fights an easing box.

Clients in the Tree for the frame these transitions move.