Drawing on screen¶
All drawing happens inside a paint callback (widgets, system tools, dialogs). Coordinates are in pixels relative to the window you're drawing in, with 0, 0 at the top-left.
The paint / invalidate model¶
Ethos doesn't redraw your window every frame. It calls paint when:
- you call
lcd.invalidate()(whole window) orlcd.invalidate(x, y, w, h)(a region), or - Ethos needs a redraw itself (screen shown, resized, overlay closed).
So the pattern is: detect change in wakeup, invalidate, draw in paint.
local function wakeup(widget)
local v = widget.source:value()
if v ~= widget.value then
widget.value = v
lcd.invalidate() -- schedule a paint
end
end
local function paint(widget)
-- draw everything from widget state; no expensive lookups here
end
Calling lcd.invalidate() unconditionally in wakeup works, but it repaints at full rate forever and wastes CPU on every visible screen. See Saving CPU.
Window size¶
Always lay out relative to the window. The same widget may be placed in a full-screen slot on an X20 (800×480) and a small slot on an X18 (480×320). Read the size in paint (it's cheap), and cache derived layout only if computing it is expensive.
Colors¶
lcd.color(lcd.RGB(255, 128, 0)) -- orange
lcd.color(lcd.RGB(255, 0, 0, 0.5)) -- 50 % transparent red
lcd.color(lcd.GREY(64)) -- dark grey
lcd.color(lcd.themeColor(THEME_DEFAULT_COLOR)) -- follow the user's theme
| Call | Use |
|---|---|
lcd.RGB(r, g, b [, alpha]) |
Make a color value. Compute once and keep it, don't rebuild in every paint |
lcd.GREY(value [, alpha]) |
Shades of grey |
lcd.color(c) |
Set the current drawing color |
lcd.themeColor(THEME_*) |
Read a color from the active theme. Text and backgrounds then work in light and dark themes |
lcd.getContrastingColor(bg) |
Black or white, whichever reads better on bg |
Prefer theme colors for text and frames
Hard-coded WHITE text disappears on a light theme. lcd.themeColor(THEME_DEFAULT_COLOR) for text and THEME_DEFAULT_BGCOLOR for fills keep your widget readable whatever theme the pilot uses. The full list is under Theme colors.
Text¶
lcd.font(FONT_L)
local tw, th = lcd.getTextSize("12.6V") -- measure with the current font
lcd.drawText(w - 4, 0, "12.6V", RIGHT) -- right-aligned at x = w - 4
lcd.drawText(w / 2, h / 2 - th / 2, "Hi", CENTERED)
lcd.drawNumber(10, 10, 12.63, UNIT_VOLT, 1) -- "12.6V", unit and decimals handled for you
Fonts from small to large: FONT_XXS, FONT_XS, FONT_S, FONT_M, FONT_L, FONT_XL, FONT_XXL, plus bold/italic variants. See Fonts.
Fitting text to a box: try fonts from large to small and use the first that fits.
local FONTS = { FONT_XXL, FONT_XL, FONT_L, FONT_M, FONT_S } -- built once, at file level
local function fitFont(text, maxW, maxH)
for i = 1, #FONTS do
lcd.font(FONTS[i])
local tw, th = lcd.getTextSize(text)
if tw <= maxW and th <= maxH then return tw, th end
end
return lcd.getTextSize(text) -- smallest font, may overflow
end
Measuring text costs time. If the text only changes occasionally, measure once when it changes and keep the result.
Shapes¶
lcd.drawLine(x1, y1, x2, y2)
lcd.drawRectangle(x, y, w, h [, thickness])
lcd.drawFilledRectangle(x, y, w, h)
lcd.drawCircle(cx, cy, r)
lcd.drawFilledCircle(cx, cy, r)
lcd.drawTriangle(x1, y1, x2, y2, x3, y3)
lcd.drawFilledTriangle(x1, y1, x2, y2, x3, y3)
lcd.drawAnnulusSector(cx, cy, innerR, outerR, startAngle, endAngle) -- arcs and dial gauges
lcd.pen(PEN_DOTTED) -- line style for subsequent lines
A dial gauge from annulus sectors. Angles are in degrees, 0–360, with 0 at the top running clockwise, so a classic 270° gauge starts at 225 (bottom-left). Open-source dashboards split any arc wider than 180° into two calls, and so does this helper:
local function drawArc(cx, cy, inner, outer, startAngle, endAngle)
local sweep = endAngle - startAngle
if sweep < 0 then sweep = sweep + 360 end
if sweep <= 180 then
lcd.drawAnnulusSector(cx, cy, inner, outer, startAngle, endAngle)
else -- split wide arcs in two
local mid = (startAngle + sweep / 2) % 360
lcd.drawAnnulusSector(cx, cy, inner, outer, startAngle, mid)
lcd.drawAnnulusSector(cx, cy, inner, outer, mid, endAngle)
end
end
local START = 225 -- bottom-left; 270° of travel ends bottom-right (135)
local function drawDial(cx, cy, r, percent, color)
lcd.color(lcd.GREY(60))
drawArc(cx, cy, r * 0.75, r, START, (START + 270) % 360) -- track
if percent > 0 then
lcd.color(color)
drawArc(cx, cy, r * 0.75, r, START, (START + 270 * percent / 100) % 360) -- value
end
end
Bitmaps and masks¶
| Bitmap | Mask | |
|---|---|---|
| Load | lcd.loadBitmap(path) |
lcd.loadMask(path) |
| Draw | lcd.drawBitmap(x, y, bmp [, w, h]) |
lcd.drawMask(x, y, mask) |
| Colors | Full color image (PNG/JPG/BMP) | Single-channel: drawn in the current lcd.color() |
| Source image | Any | Dark pixels are drawn, white is transparent. Draw icons black on white |
| Best for | Photos, logos, panels | Icons that should follow the theme color |
Mask polarity, verified in the simulator
A white-on-black icon loaded with lcd.loadMask shows up as a solid block with holes. Make mask images black shapes on a white background; the black parts get painted in the current color. The same applies to a system tool's icon.
The simulator console logs each image as it is decoded, e.g. Bitmap SCRIPTS:/mytool/icon.png loaded, RAM used: 4096 bytes. That's the quickest way to see what each asset costs.
-- load ONCE (file level or create), never inside paint
local icon = lcd.loadMask("gfx/battery.png")
local function paint(widget)
lcd.color(widget.alarm and lcd.RGB(220, 40, 40) or lcd.themeColor(THEME_DEFAULT_COLOR))
lcd.drawMask(4, 4, icon)
end
Bitmaps are the fastest way to run out of memory
Decoded images live in the Lua bitmap memory, which shares one region with the Lua heap. Load each image once, reuse the handle, prefer masks for icons, size images to how they're displayed, and set handles to nil when a tool closes. See Saving RAM.
Clipping¶
lcd.setClipping(x, y, w, h) restricts drawing to a rectangle. Use it for scrolling lists, bar fills and graphs that must not spill over. Reset it when you're done, either with lcd.setClipping() or by setting the whole window again: