Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
318 changes: 281 additions & 37 deletions lua/lockscreen.lua
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,18 @@ local beautiful = require("beautiful")

local lockscreen = {}

-- lgi/cairo is loaded lazily (only when blur is requested) so the
-- lockscreen still works on minimal systems without it, in plain
-- color or plain image modes.
local _cairo
local function get_cairo()
if _cairo ~= nil then return _cairo or nil end
local ok, lgi = pcall(require, "lgi")
if not ok or not lgi then _cairo = false; return nil end
_cairo = lgi.cairo or false
return _cairo or nil
end

-- State
local initialized = false
local surfaces = {} -- keyed by screen
Expand All @@ -46,9 +58,191 @@ local defaults = {
font_large = "sans bold 48",
clock_format = "%H:%M",
date_format = "%A, %B %d",
lock_screen = nil, -- screen object or function()->screen; default: screen.primary
lock_screen = false, -- screen object or function()->screen; default: screen.primary
bg_image = false, -- path to wallpaper image (covers entire screen, dimmed by overlay)
bg_image_overlay = "#000000aa", -- semi-transparent overlay on top of bg_image (67% opacity)
bg_image_blur = false, -- false | true | number | table | function(surface)->surface; see normalize_blur_spec below
}

-- Default blur parameters when user passes `true` or just a number.
local DEFAULT_BLUR_RADIUS = 15
local DEFAULT_BLUR_PASSES = 3

-- Sanity clamps so a stray `bg_image_blur = 10000` doesn't hang the
-- lockscreen render with pathological allocations.
local BLUR_RADIUS_MAX = 100
local BLUR_PASSES_MAX = 10

local function clamp(v, lo, hi)
if v < lo then return lo end
if v > hi then return hi end
return v
end

-- Duck-typed check: does this value look like a cairo ImageSurface we can
-- draw from? Used to validate the return value of user-supplied blur
-- functions before handing them back into the wibox render pipeline.
local function is_renderable_surface(s)
return type(s) == "userdata"
and type(s.get_width) == "function"
and type(s.get_height) == "function"
end

-- Multi-pass bilinear downscale/upscale blur. This is not a true Gaussian
-- kernel but produces a visually convincing soft blur at negligible cost,
-- which is ideal for a one-shot lockscreen render. Each pass shrinks the
-- surface by `scale` and expands back with bilinear sampling; stacking
-- several passes approximates a Gaussian response.
local function multipass_blur(surface, radius, passes)
if not surface then return surface end
local cairo = get_cairo()
if not cairo then return surface end -- lgi not installed: no-op

radius = clamp(tonumber(radius) or DEFAULT_BLUR_RADIUS, 1, BLUR_RADIUS_MAX)
passes = clamp(math.floor(tonumber(passes) or DEFAULT_BLUR_PASSES),
1, BLUR_PASSES_MAX)

local w = surface:get_width()
local h = surface:get_height()
if w <= 0 or h <= 0 then return surface end

-- Scale factor per pass: larger radius ⇒ more aggressive downscale.
-- Divide by `passes` so multi-pass and single-pass roughly agree on
-- perceived blur strength.
local scale = math.max(2, radius / passes)

local current = surface
for _ = 1, passes do
local sw = math.max(1, math.floor(w / scale))
local sh = math.max(1, math.floor(h / scale))

local small = cairo.ImageSurface.create(cairo.Format.ARGB32, sw, sh)
local cr = cairo.Context(small)
cr:scale(sw / w, sh / h)
local down_pat = cairo.Pattern.create_for_surface(current)
down_pat:set_filter(cairo.Filter.GOOD)
cr:set_source(down_pat)
cr:paint()

local expanded = cairo.ImageSurface.create(cairo.Format.ARGB32, w, h)
local cr2 = cairo.Context(expanded)
cr2:scale(w / sw, h / sh)
local up_pat = cairo.Pattern.create_for_surface(small)
up_pat:set_filter(cairo.Filter.GOOD)
cr2:set_source(up_pat)
cr2:paint()

-- Explicitly finish intermediates. `:finish()` releases the cairo
-- surface's backing memory synchronously (instead of waiting for
-- Lua GC to run the LGI __gc metamethod). We must NOT finish the
-- caller's original `surface` — only intermediates we allocated.
small:finish()
if current ~= surface then
current:finish()
end

current = expanded
end
return current
end

-- Normalize any user-supplied blur spec into a concrete transform function.
-- Accepted forms (permissive on purpose — somewm is used by programmers
-- who expect APIs to "do the right thing"):
--
-- false | nil -- no blur
-- true -- default blur (radius=15, passes=3)
-- <number> -- radius, default passes
-- { radius = N, passes = M } -- explicit control
-- function(surface) -> surface -- fully custom transform
--
-- Returns nil when blur is disabled, otherwise a function(surface)->surface.
local function normalize_blur_spec(spec)
if spec == nil or spec == false then return nil end
if type(spec) == "function" then return spec end
if spec == true then
return function(s) return multipass_blur(s, DEFAULT_BLUR_RADIUS, DEFAULT_BLUR_PASSES) end
end
if type(spec) == "number" then
if spec <= 0 then return nil end
return function(s) return multipass_blur(s, spec, DEFAULT_BLUR_PASSES) end
end
if type(spec) == "table" then
local radius = tonumber(spec.radius) or DEFAULT_BLUR_RADIUS
local passes = tonumber(spec.passes) or DEFAULT_BLUR_PASSES
if radius <= 0 or passes <= 0 then return nil end
return function(s) return multipass_blur(s, radius, passes) end
end
return nil -- unknown form: silently disable rather than crash lockscreen
end

-- Cache blurred surfaces keyed by (image path, blur spec signature) so we
-- don't rerun the blur pipeline per monitor, per rebuild_surfaces(), or per
-- lock. Misses are cached too (as `false`) to avoid reloading a known-bad
-- path on every call.
local bg_surface_cache = {}

local function spec_signature(spec)
local t = type(spec)
if t == "table" then
return string.format("t|%s|%s", tostring(spec.radius),
tostring(spec.passes))
end
if t == "function" then return "fn|" .. tostring(spec) end
return t .. "|" .. tostring(spec)
end

-- Resolve bg_image to a cairo surface (or nil if not set / failed to load).
-- When bg_image_blur is set, the surface is transformed through the
-- normalized blur function before being returned.
--
-- The blurred surface is cached for built-in blur specs (true/number/table)
-- so the pipeline doesn't re-run per monitor or per rebuild. Custom
-- function specs are NOT cached — user callbacks may close over mutable
-- state and expect to run each time. Callers who want to force a refresh
-- (e.g. after swapping the image file at the same path) can call
-- `lockscreen.invalidate_bg_cache()`.
local function load_bg_image()
if not config.bg_image then return nil end

local cacheable = type(config.bg_image_blur) ~= "function"
local key
if cacheable then
key = tostring(config.bg_image) .. "|"
.. spec_signature(config.bg_image_blur)
local cached = bg_surface_cache[key]
if cached ~= nil then
return cached or nil
end
end

local surface = gears.surface.load_uncached_silently(config.bg_image)
if not surface then
if cacheable then bg_surface_cache[key] = false end
return nil
end

local blur_fn = normalize_blur_spec(config.bg_image_blur)
if blur_fn then
local ok, result = pcall(blur_fn, surface)
if ok and is_renderable_surface(result) then
surface = result
end
-- If blur failed or returned garbage we silently keep the original
-- surface — a crisp wallpaper is a better fallback than a black
-- lockscreen.
end

if cacheable then bg_surface_cache[key] = surface end
return surface
end

--- Drop the cached bg_image surface(s). Call this if you swap the wallpaper
-- file on disk at the same path without changing the config.
function lockscreen.invalidate_bg_cache()
bg_surface_cache = {}
end

-- Count UTF-8 codepoints in a string (LuaJIT lacks the utf8 library)
local function utf8_len(s)
local count = 0
Expand Down Expand Up @@ -132,45 +326,57 @@ local function build_interactive_layout()
widget = wibox.container.constraint,
})

return wibox.widget({
local ui_content = wibox.widget({
{
{
clock,
fg = config.fg_color,
widget = wibox.container.background,
},
{
date_widget,
fg = config.fg_color,
widget = wibox.container.background,
},
{
forced_height = 40,
widget = wibox.container.background,
},
{
input_container,
halign = "center",
widget = wibox.container.place,
},
{
{
clock,
fg = config.fg_color,
widget = wibox.container.background,
},
{
date_widget,
status_text,
fg = config.fg_color,
widget = wibox.container.background,
},
{
forced_height = 40,
widget = wibox.container.background,
},
{
input_container,
halign = "center",
widget = wibox.container.place,
},
{
{
status_text,
fg = config.fg_color,
widget = wibox.container.background,
},
top = 16,
widget = wibox.container.margin,
},
spacing = 8,
layout = wibox.layout.fixed.vertical,
top = 16,
widget = wibox.container.margin,
},
halign = "center",
valign = "center",
widget = wibox.container.place,
spacing = 8,
layout = wibox.layout.fixed.vertical,
},
bg = config.bg_color,
halign = "center",
valign = "center",
widget = wibox.container.place,
})

-- Two-layer structure: wibox.container.background paints bg *under* bgimage,
-- so a single layer with bg=overlay + bgimage=wallpaper would put the dim
-- beneath the image and it would be invisible. Place the overlay on an
-- inner background so it paints *over* the outer's bgimage — children are
-- drawn after the parent's bg+bgimage pass.
local bg_image_surface = load_bg_image()
return wibox.widget({
{
ui_content,
bg = bg_image_surface and config.bg_image_overlay or config.bg_color,
widget = wibox.container.background,
},
bgimage = bg_image_surface,
widget = wibox.container.background,
})
end
Expand All @@ -188,14 +394,33 @@ end

-- Create a cover wibox for a non-interactive screen
local function create_cover(s)
local bg_image_surface = load_bg_image()
local wb = wibox({
visible = false,
ontop = true,
bg = config.bg_color,
-- When bg_image is set, the wibox itself must be transparent so that
-- the widget's bgimage + overlay is what the user sees.
bg = bg_image_surface and "#00000000" or config.bg_color,
x = s.geometry.x,
y = s.geometry.y,
width = s.geometry.width,
height = s.geometry.height,
-- Two-layer: outer holds bgimage, inner child paints the overlay *on top*
-- of it. (background renders bg before bgimage, so a single layer would
-- put the overlay under the image — invisible.)
-- The inner background has no child widget, so we force its size to
-- the screen geometry; otherwise its :fit() returns 0x0 and the
-- overlay paints a zero-size rectangle (i.e. doesn't dim anything).
widget = bg_image_surface and wibox.widget({
{
forced_width = s.geometry.width,
forced_height = s.geometry.height,
bg = config.bg_image_overlay,
widget = wibox.container.background,
},
bgimage = bg_image_surface,
widget = wibox.container.background,
}) or nil,
})
awesome.add_lock_cover(wb)
return wb
Expand Down Expand Up @@ -258,11 +483,30 @@ function lockscreen.init(opts)
border_color = "border_color_active",
error_color = "bg_urgent",
}
-- Fields where `false` is a meaningful value (disable) rather than
-- "unset". Using `or` chaining would silently promote a theme's
-- `lockscreen_bg_image_blur = true` over a user's explicit
-- `opts.bg_image_blur = false`; resolve these with explicit nil checks.
local falsey_valid = {
bg_image = true,
bg_image_blur = true,
lock_screen = true,
}
for k, default in pairs(defaults) do
config[k] = (opts[k])
or beautiful["lockscreen_" .. k]
or (theme_fallbacks[k] and beautiful[theme_fallbacks[k]])
or default
if falsey_valid[k] then
if opts[k] ~= nil then
config[k] = opts[k]
elseif beautiful["lockscreen_" .. k] ~= nil then
config[k] = beautiful["lockscreen_" .. k]
else
config[k] = default
end
else
config[k] = (opts[k])
or beautiful["lockscreen_" .. k]
or (theme_fallbacks[k] and beautiful[theme_fallbacks[k]])
or default
end
end
-- Special case: font fallback to beautiful.font
if not opts.font and not beautiful.lockscreen_font then
Expand Down
Loading