Skip to content

Lua: Drawing

Tin Švagelj edited this page Apr 20, 2026 · 2 revisions

Drawing with Cairo

Conky exposes Cairo drawing functions through Lua bindings. These bindings are nearly identical to the Cairo C API — the only difference is how the drawing surface is obtained (conky_surface() instead of a platform-specific surface constructor). Other than that, any Cairo documentation, tutorial, or example translates directly to Conky's Lua environment.

Tip

Because the bindings mirror the C API so closely, you can use the Cairo manual as the authoritative reference. This page focuses on the drawing model, Conky-specific setup, and practical examples — not on repeating what the Cairo docs already cover.

The examples on this page assume a working Conky + Lua setup. If you haven't set that up yet, see How to Load and Use Lua Scripts.

Window Size

Conky is sized based on requirements of conky.text. If it's blank you'll need to specify minimum window dimensions:

conky.config = {
  minimum_height = 200,
  minimum_width = 200,
  -- ... your other settings ...
}
conky.text = [[]]

Warning

If you don't specify minimum_height and minimum_width and your conky.text is empty, conky window will have dimensions of 1×1 px.

Coordinate System

Cairo uses a standard 2D coordinate system where:

  • The origin (0, 0) is at the top-left corner of the Conky window
  • X increases to the right
  • Y increases downward
  • Units are in pixels by default (1 unit = 1 pixel, unless transforms are applied)

Coordinates can be fractional — see Anti-Aliasing for how this affects rendering.

Anything drawn outside the window bounds is silently clipped — it won't cause errors, but it won't be visible either.

How Cairo Drawing Works

Cairo uses a path-based drawing model similar to PostScript or HTML5 Canvas. Drawing is always a two-step process:

  1. Build a path — move to points, add lines, arcs, curves, or rectangles
  2. Apply an operation — stroke (outline) or fill the path
-- Step 1: build a path
cairo_move_to(cr, 10, 10)
cairo_line_to(cr, 190, 10)
cairo_line_to(cr, 190, 190)
cairo_close_path(cr)

-- Step 2: apply
cairo_stroke(cr)  -- draws the outline

After cairo_stroke() or cairo_fill(), the current path is consumed (cleared). Use cairo_stroke_preserve() / cairo_fill_preserve() to keep the path for further operations — for example, filling a shape with color and then stroking its outline:

cairo_rectangle(cr, 20, 20, 160, 160)
cairo_set_source_rgba(cr, 0.2, 0.4, 0.8, 0.5)
cairo_fill_preserve(cr)
cairo_set_source_rgba(cr, 1, 1, 1, 1)
cairo_set_line_width(cr, 2)
cairo_stroke(cr)

Key differences between stroke and fill:

  • cairo_fill() automatically closes open paths before filling — you don't need cairo_close_path() before it.
  • cairo_set_line_width(), cairo_set_line_cap(), and cairo_set_line_join() only affect cairo_stroke(). They have no effect on cairo_fill().

State

Cairo maintains a drawing state that affects all subsequent operations. This includes the current color, line width, font, transformation matrix, and clipping region. Use cairo_save() and cairo_restore() to push/pop state:

cairo_save(cr)
cairo_set_source_rgba(cr, 1, 0, 0, 1)
cairo_set_line_width(cr, 4)
-- draw something red and thick
cairo_restore(cr)
-- back to previous color and line width

Drawing Order

Operations are painted in order. Later draws cover earlier ones, like layers. To draw a shape with a background, draw the background first:

-- Background (drawn first, appears behind)
cairo_rectangle(cr, 0, 0, 200, 30)
cairo_set_source_rgba(cr, 0.1, 0.1, 0.1, 0.8)
cairo_fill(cr)

-- Foreground (drawn second, appears on top)
cairo_set_source_rgba(cr, 1, 1, 1, 1)
cairo_move_to(cr, 10, 20)
cairo_show_text(cr, "System Status")

Colors

Colors are set with cairo_set_source_rgba(cr, r, g, b, a) where each component is a float from 0.0 to 1.0.

cairo_set_source_rgba(cr, 1, 1, 1, 1)      -- white, fully opaque
cairo_set_source_rgba(cr, 1, 0, 0, 0.5)    -- red, 50% transparent
cairo_set_source_rgba(cr, 0, 0, 0, 0)      -- fully transparent

To convert standard 0–255 RGB values:

-- RGB(246, 155, 11)
cairo_set_source_rgba(cr, 246/255, 155/255, 11/255, 1)

A helper for hex colors:

function hex_color(hex, alpha)
    return tonumber(hex:sub(1, 2), 16) / 255,
           tonumber(hex:sub(3, 4), 16) / 255,
           tonumber(hex:sub(5, 6), 16) / 255,
           alpha or 1
end

cairo_set_source_rgba(cr, hex_color("F69B0B"))

The color (source) stays active until you change it. The same applies to line width, font, and other state properties.

Lines

Lines are built from cairo_move_to() and cairo_line_to(), then rendered with cairo_stroke().

cairo_set_line_width(cr, 2)
cairo_set_source_rgba(cr, 1, 1, 1, 1)
cairo_move_to(cr, 10, 50)
cairo_line_to(cr, 190, 50)
cairo_stroke(cr)

Tip

cairo_rel_move_to() and cairo_rel_line_to() work relative to the current point instead of using absolute coordinates. This is useful for drawing shapes at variable positions without recalculating every coordinate — move to the starting position once, then use relative offsets for the rest.

Line Width

cairo_set_line_width() sets the stroke thickness. The line is centered on the path — a 4px wide line extends 2px on each side.

Caution

Because the stroke is centered on the path, it encroaches inward on filled shapes. If you stroke a rectangle with a 4px border, 2px of that border overlaps the fill area. To compensate, inset the fill rectangle by half the border width:

local border = 4
local half = border / 2
-- Stroke border at the intended position
cairo_set_line_width(cr, border)
cairo_rectangle(cr, 20, 20, 100, 60)
cairo_stroke(cr)
-- Fill inset by half the border width
cairo_rectangle(cr, 20 + half, 20 + half, 100 - border, 60 - border)
cairo_fill(cr)

Line Caps

cairo_set_line_cap() controls how line endpoints are drawn:

  • CAIRO_LINE_CAP_BUTT — ends exactly at the endpoint (default)
  • CAIRO_LINE_CAP_ROUND — semicircle at each end
  • CAIRO_LINE_CAP_SQUARE — square extending half the line width past the endpoint

Line Joins

cairo_set_line_join() controls how corners are drawn when lines meet:

  • CAIRO_LINE_JOIN_MITER — sharp corners (default)
  • CAIRO_LINE_JOIN_ROUND — rounded corners
  • CAIRO_LINE_JOIN_BEVEL — flattened corners

Dashed Lines

cairo_set_dash() creates dashed or dotted lines:

cairo_set_dash(cr, {10, 5}, 2, 0)  -- 10px dash, 5px gap
cairo_move_to(cr, 10, 100)
cairo_line_to(cr, 190, 100)
cairo_stroke(cr)

Shapes

Rectangles

cairo_rectangle(cr, x, y, width, height) adds a rectangle to the current path. The path is already closed, so cairo_close_path() is not needed.

-- Filled rectangle
cairo_rectangle(cr, 20, 20, 100, 60)
cairo_set_source_rgba(cr, 0.3, 0.6, 0.9, 1)
cairo_fill(cr)

-- Outlined rectangle
cairo_rectangle(cr, 20, 20, 100, 60)
cairo_set_source_rgba(cr, 1, 1, 1, 1)
cairo_set_line_width(cr, 2)
cairo_stroke(cr)

-- Rounded corners via line join
cairo_rectangle(cr, 20, 20, 100, 60)
cairo_set_line_join(cr, CAIRO_LINE_JOIN_ROUND)
cairo_set_line_width(cr, 8)
cairo_stroke(cr)

Tip

cairo_rectangle() accepts negative width or height, which flips the drawing direction. This is a convenient way to draw bars that fill upward from a baseline:

-- Draw a bar from bottom (y=150) upward
cairo_rectangle(cr, 20, 150, 20, -fill_h)
cairo_fill(cr)

Arcs and Circles

cairo_arc(cr, xc, yc, radius, angle1, angle2) draws an arc. Angles are in radians, measured clockwise from the positive X-axis (3 o'clock position):

  • 0 = right (3 o'clock)
  • π/2 = bottom (6 o'clock)
  • π = left (9 o'clock)
  • 3π/2 = top (12 o'clock)

If you prefer to think of 0° as the top of the circle (12 o'clock), subtract 90° before converting:

-- 0° at top, 90° at right, 180° at bottom, 270° at left
local function top_deg_to_rad(deg)
    return (deg - 90) * math.pi / 180
end

A full circle is an arc from 0 to :

-- Filled circle
cairo_arc(cr, 100, 100, 50, 0, 2 * math.pi)
cairo_fill(cr)

-- Outlined circle
cairo_arc(cr, 100, 100, 50, 0, 2 * math.pi)
cairo_stroke(cr)

cairo_arc_negative() draws in the counter-clockwise direction. With the same start and end angles, cairo_arc and cairo_arc_negative produce opposite halves of the circle:

-- Top half (clockwise from 270° to 90°)
cairo_arc(cr, 100, 100, 50, deg_to_rad(270), deg_to_rad(90))
cairo_stroke(cr)

-- Bottom half (counter-clockwise from 270° to 90°)
cairo_arc_negative(cr, 100, 100, 50, deg_to_rad(270), deg_to_rad(90))
cairo_stroke(cr)

To convert degrees to radians:

function deg_to_rad(deg)
    return deg * math.pi / 180
end

Positioning Elements on a Circle

To place elements (tick marks, labels, icons) at a specific angle around a circle, compute the X/Y coordinates using math.sin and math.cos:

-- angle in radians, center at (cx, cy)
local x = cx + radius * math.sin(angle)
local y = cy - radius * math.cos(angle)

math.sin gives the horizontal offset and math.cos gives the vertical offset. The cos is negated because Cairo's Y-axis points downward. With this convention, angle 0 is at the top (12 o'clock), and angles increase clockwise — matching how most people think about clock positions.

-- Draw 12 hour marks around a clock face
for i = 1, 12 do
    local angle = i * math.pi / 6  -- 30° per hour
    local inner = radius - 10
    local outer = radius - 2
    cairo_move_to(cr, cx + inner * math.sin(angle),
                      cy - inner * math.cos(angle))
    cairo_line_to(cr, cx + outer * math.sin(angle),
                      cy - outer * math.cos(angle))
    cairo_stroke(cr)
end

Note

Line width on arcs works the same as on straight lines — it extends equally inward and outward from the path. A circle with radius 50 and line width 20 has its inner edge at 40px from center and its outer edge at 60px.

Curves

cairo_curve_to(cr, x1, y1, x2, y2, x3, y3) draws a cubic Bezier curve from the current point to an endpoint (x3, y3). The shape of the curve is determined by two control points(x1, y1) and (x2, y2) — which act as "magnets" that pull the curve toward them without the curve necessarily passing through them:

  • The curve starts at the current point and heads toward (x1, y1)
  • It then bends toward (x2, y2) as it approaches the endpoint
  • It arrives at (x3, y3) coming from the direction of (x2, y2)
cairo_move_to(cr, 20, 100)       -- start point
cairo_curve_to(cr,
    20, 20,                       -- control point 1 (pulls curve up-left)
    180, 20,                      -- control point 2 (pulls curve up-right)
    180, 100)                     -- end point
cairo_stroke(cr)

There is also cairo_rel_curve_to() which takes offsets relative to the current point rather than absolute coordinates.

Closing Paths

cairo_close_path() connects the current point back to the start of the path with a straight line. This is important for proper line joins at the start of closed shapes — without it, the first and last segments won't have a join.

Text

Text rendering uses Cairo's text functions. The text cursor position set with cairo_move_to() refers to the text baseline — characters render above and to the right of that point.

cairo_select_font_face(cr, "Mono",
    CAIRO_FONT_SLANT_NORMAL, CAIRO_FONT_WEIGHT_NORMAL)
cairo_set_font_size(cr, 14)
cairo_set_source_rgba(cr, 1, 1, 1, 1)
cairo_move_to(cr, 10, 30)
cairo_show_text(cr, "Hello, Conky")
cairo_stroke(cr)

Font slant: CAIRO_FONT_SLANT_NORMAL, CAIRO_FONT_SLANT_ITALIC, CAIRO_FONT_SLANT_OBLIQUE

Font weight: CAIRO_FONT_WEIGHT_NORMAL, CAIRO_FONT_WEIGHT_BOLD

cairo_select_font_face() is Cairo's "toy" font API and only supports normal/bold weight and normal/italic/oblique slant. For variable font weights (light, thin, semibold, etc.), use fontconfig syntax in the family name instead — Cairo's toy API passes the family string to the system font matcher:

-- These rely on fontconfig matching the closest available weight
cairo_select_font_face(cr, "Noto Sans Light",
    CAIRO_FONT_SLANT_NORMAL, CAIRO_FONT_WEIGHT_NORMAL)
cairo_select_font_face(cr, "Roboto Thin",
    CAIRO_FONT_SLANT_NORMAL, CAIRO_FONT_WEIGHT_NORMAL)

Alternatively, Conky provides a high-performance text function that uses FreeType directly and supports full fontconfig font matching:

require("cairo_text_helper")

-- font name is matched by fontconfig, so weight variants work naturally
cairo_text_hp_show(cr, 10, 30, "Hello", "Roboto Light", 14)
cairo_text_hp_show(cr, 10, 60, "World", "Noto Sans:weight=200", 14)

Measuring Text

cairo_text_extents() returns dimensions of a text string, which is useful for centering or right-aligning text:

local extents = cairo_text_extents_t:create()
cairo_text_extents(cr, "centered text", extents)
local text_width = extents.width

-- Center text horizontally in a 200px wide window
local x = (200 - text_width) / 2
cairo_move_to(cr, x, 100)
cairo_show_text(cr, "centered text")

Text as Paths

cairo_text_path() adds text outlines to the current path instead of rendering them directly. This allows you to stroke text outlines, fill with gradients, or use text as a clipping mask.

Dynamic Content with conky_parse()

Any Conky variable can be used from Lua through conky_parse(). The result is always a string — use tonumber() when you need numeric values for calculations or drawing:

local cpu = tonumber(conky_parse("${cpu}"))
local ram = tonumber(conky_parse("${memperc}"))

This is the basis for data-driven visualizations — bars, gauges, graphs, and color indicators that respond to live system data.

Gradients and Patterns

Cairo supports linear and radial gradients through pattern functions.

Linear Gradient

local gradient = cairo_pattern_create_linear(0, 0, 200, 0)  -- left to right
cairo_pattern_add_color_stop_rgba(gradient, 0,   0.2, 0.6, 1, 1)    -- blue at start
cairo_pattern_add_color_stop_rgba(gradient, 0.5, 1,   0.9, 0.2, 1)  -- yellow at middle
cairo_pattern_add_color_stop_rgba(gradient, 1,   0.9, 0.2, 0.2, 1)  -- red at end

cairo_rectangle(cr, 0, 0, 200, 30)
cairo_set_source(cr, gradient)
cairo_fill(cr)
cairo_pattern_destroy(gradient)

Radial Gradient

local gradient = cairo_pattern_create_radial(100, 100, 10, 100, 100, 80)
cairo_pattern_add_color_stop_rgba(gradient, 0,   1, 1, 1, 1)          -- white center
cairo_pattern_add_color_stop_rgba(gradient, 0.6, 0.4, 0.4, 0.5, 1)   -- muted midtone
cairo_pattern_add_color_stop_rgba(gradient, 1,   0.1, 0.1, 0.1, 1)   -- dark edge

cairo_arc(cr, 100, 100, 80, 0, 2 * math.pi)
cairo_set_source(cr, gradient)
cairo_fill(cr)
cairo_pattern_destroy(gradient)

Warning

Patterns must be destroyed with cairo_pattern_destroy() when no longer needed, just like surfaces and contexts. Since draw hooks run on every update, failing to destroy patterns causes a resource leak — Conky will consume more and more memory over time until it's killed by the system or it freezes your desktop if your cgroups aren't configured to limit it.

Transforms

Cairo supports affine transformations on the coordinate system. These are useful for rotating elements, scaling drawings, or positioning complex groups of shapes.

cairo_save(cr)
cairo_translate(cr, 100, 100)  -- move origin to center
cairo_rotate(cr, math.pi / 4)  -- rotate 45 degrees
cairo_rectangle(cr, -25, -25, 50, 50)  -- centered at new origin
cairo_stroke(cr)
cairo_restore(cr)

Key functions:

For more complex transformations, you can apply an arbitrary transformation matrix directly:

local matrix = cairo_matrix_t:create()
cairo_matrix_init(matrix, sx, shy, shx, sy, tx, ty)  -- full affine matrix
cairo_transform(cr, matrix)  -- multiply with current transform
-- or
cairo_set_matrix(cr, matrix)  -- replace current transform entirely

Always wrap transforms in cairo_save()/cairo_restore() to avoid affecting subsequent drawing code. It's much simpler and less error-prone to save and restore state than to try and manually undo a sequence of transforms.

Clipping

Clipping restricts drawing to a region. Build a path and call cairo_clip() — all subsequent drawing is masked to that area:

cairo_save(cr)
cairo_arc(cr, 100, 100, 60, 0, 2 * math.pi)
cairo_clip(cr)
-- everything drawn here is clipped to the circle
cairo_paint(cr)  -- fills the clipped area with current source
cairo_restore(cr)

Example Ideas

These examples combine the concepts above with live Conky data to create practical widgets. They're meant as starting points — adapt them to your setup.

Tip

When developing visual widgets, temporarily replace conky_parse() calls with hardcoded values so you can test specific states without waiting for real system conditions:

-- local cpu = tonumber(conky_parse("${cpu}"))
local cpu = 85  -- test the "red" threshold

CPU Bar Meter

A vertical bar that fills based on CPU usage, with color changing at thresholds:

local cpu = tonumber(conky_parse("${cpu}"))
local bar_x, bar_y = 20, 20
local bar_w, bar_h = 20, 150
local fill_h = bar_h * cpu / 100

-- Background
cairo_rectangle(cr, bar_x, bar_y, bar_w, bar_h)
cairo_set_source_rgba(cr, 0.2, 0.2, 0.2, 0.8)
cairo_fill(cr)

-- Filled portion (draws from bottom up)
-- Check highest threshold first: if cpu > 50 came first, values
-- like 90 would match it and never reach the > 80 check.
if cpu > 80 then
    cairo_set_source_rgba(cr, 1, 0.2, 0.2, 1)      -- red
elseif cpu > 50 then
    cairo_set_source_rgba(cr, 1, 0.8, 0.2, 1)      -- yellow
else
    cairo_set_source_rgba(cr, 0.2, 0.8, 0.4, 1)    -- green
end
cairo_rectangle(cr, bar_x, bar_y + bar_h - fill_h, bar_w, fill_h)
cairo_fill(cr)

-- Border
cairo_rectangle(cr, bar_x, bar_y, bar_w, bar_h)
cairo_set_source_rgba(cr, 1, 1, 1, 0.3)
cairo_set_line_width(cr, 1)
cairo_stroke(cr)

The scaling formula fill_h = bar_h * value / max_value works for any range, not just percentages. For example, a temperature bar from 0–100°C:

local temp = tonumber(conky_parse("${hwmon 1 temp 1}"))
local max_temp = 100
local fill_h = bar_h * temp / max_temp

Circular Gauge

An arc-based gauge that shows memory usage:

local mem = tonumber(conky_parse("${memperc}"))
local cx, cy, radius = 100, 120, 50
local start_angle = math.pi * 0.75     -- 7 o'clock
local end_angle = math.pi * 2.25       -- 5 o'clock
local sweep = end_angle - start_angle
local value_angle = start_angle + sweep * mem / 100

-- Background arc
cairo_set_line_width(cr, 8)
cairo_set_source_rgba(cr, 0.3, 0.3, 0.3, 0.6)
cairo_arc(cr, cx, cy, radius, start_angle, end_angle)
cairo_stroke(cr)

-- Value arc
cairo_set_source_rgba(cr, 0.3, 0.7, 1, 1)
cairo_arc(cr, cx, cy, radius, start_angle, value_angle)
cairo_stroke(cr)

-- Label
cairo_select_font_face(cr, "Mono",
    CAIRO_FONT_SLANT_NORMAL, CAIRO_FONT_WEIGHT_BOLD)
cairo_set_font_size(cr, 18)
cairo_set_source_rgba(cr, 1, 1, 1, 1)
local label = mem .. "%"
local extents = cairo_text_extents_t:create()
cairo_text_extents(cr, label, extents)
cairo_move_to(cr, cx - extents.width / 2, cy + extents.height / 2)
cairo_show_text(cr, label)
cairo_stroke(cr)

Analog Clock

Uses transforms to draw clock hands at the correct angles:

local cx, cy, radius = 100, 100, 80

-- Clock face
cairo_set_source_rgba(cr, 0.15, 0.15, 0.15, 0.9)
cairo_arc(cr, cx, cy, radius, 0, 2 * math.pi)
cairo_fill(cr)

-- Hour marks
cairo_set_source_rgba(cr, 1, 1, 1, 0.6)
cairo_set_line_width(cr, 2)
for i = 1, 12 do
    local angle = i * math.pi / 6  -- 30 degrees per hour
    cairo_move_to(cr, cx + (radius - 10) * math.sin(angle),
                      cy - (radius - 10) * math.cos(angle))
    cairo_line_to(cr, cx + (radius - 2) * math.sin(angle),
                      cy - (radius - 2) * math.cos(angle))
    cairo_stroke(cr)
end

-- Hands (using current time)
local hours = tonumber(os.date("%I"))
local mins = tonumber(os.date("%M"))
local secs = tonumber(os.date("%S"))

local function draw_hand(angle, length, width)
    cairo_save(cr)
    cairo_translate(cr, cx, cy)
    cairo_rotate(cr, angle)
    cairo_set_line_width(cr, width)
    cairo_set_line_cap(cr, CAIRO_LINE_CAP_ROUND)
    cairo_move_to(cr, 0, 0)
    cairo_line_to(cr, 0, -length)
    cairo_stroke(cr)
    cairo_restore(cr)
end

-- Hour hand
cairo_set_source_rgba(cr, 1, 1, 1, 0.9)
draw_hand((hours + mins / 60) * math.pi / 6, radius * 0.5, 4)

-- Minute hand
cairo_set_source_rgba(cr, 1, 1, 1, 0.8)
draw_hand((mins + secs / 60) * math.pi / 30, radius * 0.7, 2)

-- Second hand
cairo_set_source_rgba(cr, 1, 0.3, 0.3, 0.8)
draw_hand(secs * math.pi / 30, radius * 0.75, 1)

Per-Core CPU Bars

Using a loop to draw a bar for each CPU core:

local num_cores = 8
local bar_w, bar_h = 12, 100
local spacing = 4
local start_x, start_y = 20, 30

for i = 1, num_cores do
    local usage = tonumber(conky_parse("${cpu cpu" .. i .. "}"))
    local x = start_x + (i - 1) * (bar_w + spacing)
    local fill_h = bar_h * usage / 100

    -- Background
    cairo_rectangle(cr, x, start_y, bar_w, bar_h)
    cairo_set_source_rgba(cr, 0.2, 0.2, 0.2, 0.6)
    cairo_fill(cr)

    -- Fill
    cairo_set_source_rgba(cr, 0.3, 0.7, 1, 0.9)
    cairo_rectangle(cr, x, start_y + bar_h - fill_h, bar_w, fill_h)
    cairo_fill(cr)
end

Performance Tips

Anti-Aliasing

Cairo anti-aliases by default, which smooths edges but can cause lines to appear blurry or shapes to overlap by a fraction of a pixel.

For straight lines, the most common fix is to place coordinates at pixel centers by offsetting by 0.5. Integer coordinates land on pixel edges, causing a 1px line to smear across two pixels:

-- Blurry: lands on pixel edge
cairo_move_to(cr, 10, 50)
cairo_line_to(cr, 190, 50)

-- Sharp: lands on pixel center
cairo_move_to(cr, 10.5, 50.5)
cairo_line_to(cr, 190.5, 50.5)

For curves and complex shapes, the 0.5 offset isn't practical. If anti-aliasing causes unwanted visual artifacts, disable it entirely:

cairo_set_antialias(cr, CAIRO_ANTIALIAS_NONE)

Batching Operations

Drawing 100 rectangles, then 100 circles (each followed by a stroke or fill) is faster than interleaving one of each 100 times. Batching similar operations allows the GPU to fill shapes while the CPU calculates the next batch.

Further Reading

Clone this wiki locally