- name
- pleamar
- description
- Build anything on the desktop with pleamar — shells (bars, docks, launchers, panels, notification centres, widgets, lock screens), apps and windows, animations and effects, and the pleamar-wm window manager (its look, layouts, key bindings, actions) — from the user's own ~/.config/pleamar. Use when asked to make, change or fix a bar, a widget, a shell, a panel, a launcher, a desktop app, a key binding, a window-manager behaviour or a theme with pleamar, or when touching any .plm or .luau file.
<!-- pleamar skill {VERSION}. Written by `pleamar --install-skill` and rewritten when pleamar updates: put your own notes in another skill. -->
# Writing pleamar scenes
pleamar is a desktop runtime in Rust with its own declarative language. A `.plm`
file is a scene: what is drawn and how it reacts. Everything declared there is
run by the **renderer** — springs, rules, layers, gestures — so it keeps moving
even if the logic stalls. An optional `.luau` file next to it is the logic, and
it only reports facts; it never draws.
**It is not QML.** Do not carry over QtQuick names, properties or idioms. There
is no `Rectangle`, no `anchors.fill`, no `Behavior`, no JavaScript in the scene.
## The rule that matters
**Never guess a keyword or a property. Check it.**
```sh
pleamar --grammar # every word the compiler accepts, by category
pleamar --check x.plm # reads the scene, says whether it is fine, exits
```
`--grammar` prints the real vocabulary: statements, the properties of each
element, functions, triggers, effects, springs, units, services and what each
service reports. If a word is not in that list, it does not exist. The compiler
suggests the closest one ("did you mean…?"), so a failed `--check` usually
tells you the right word.
After writing or editing any scene, run `--check` on it. Always. It is
instant and it catches everything: unknown names, wrong types, a property that
belongs to another element, a service asked for something it does not report.
## Where the answers are
The documentation travels with the binary, so it always matches the pleamar
that is installed:
```sh
pleamar --docs reference # the reference: grammar, every element and property, rules, layers, gestures, services, files
pleamar --docs guide # from zero to a bar, six steps
pleamar --docs recipes # whole scenes that work: lists, grids, gradients, paths, saved settings, windows, popups
pleamar --docs logic # what the .luau logic can and cannot do
pleamar --docs measuring # measuring cost and animations
pleamar --grammar # every word the compiler accepts
```
Read the reference before writing anything non-trivial; search it
(`pleamar --docs reference | grep -n -i "shadow"`) instead of guessing.
## Where the user's things go
Everything of the user's lives in **one folder, `~/.config/pleamar/`** —the
one they keep in their dotfiles—. Put what you make there, never in a
repository or a system path:
```text
~/.config/pleamar/
shells/NAME/NAME.plm a shell or app of theirs (+ NAME.luau for its logic,
+ anything it imports, relative to it)
autostart what starts with the desktop, one command a line
theme.plm a palette their scenes can import, if they want one
session.conf pleamar-wm only: monitors, keyboard, pointer, idle
keys.conf pleamar-wm only: key bindings and touchpad gestures
wm/session.plm pleamar-wm only: their own window manager
```
A shell works on **any Wayland compositor** (Hyprland, Sway, niri, KDE,
pleamar-wm): it is a layer-shell surface. To start it with the desktop, add
`pleamar --scene ~/.config/pleamar/shells/NAME/NAME.plm --no-hud` to
`autostart`; on Hyprland, `exec-once = pleamar --autostart` runs that file
(lines starting with `wm:` only run in pleamar-wm's own session). A scene
reloads itself when its file is saved, so the user sees each change live.
### pleamar-wm, the window manager
When the user runs pleamar-wm (their session is `pleamar-wm session`, and
`pleamar-wm init` makes the folder above), the window manager itself is a
pleamar scene: layouts, window decorations, animations, what happens on drag.
- **Key bindings** go in `keys.conf`, never in the scene:
`bind Super+b launch zen-browser`, `bind Super+q minimize`,
`unbind Super+t`, `gesture swipe3_down close`. Start the file with
`defaults` to keep pleamar-wm's; `pleamar-wm keys` prints them with the list
of actions (`close`, `minimize`, `restore_last`, `fullscreen`, `toggle_free`,
`overview`, `focus_next`, `focus_previous`, `move_left/right/up/down`,
`narrower`, `wider`).
- **An action is an event the window manager's scene declares**, so a new
behaviour is: declare `event my_action` in the scene, write
`on my_action { … }`, and bind it in `keys.conf`. Anything can also fire it:
`pleamar-wm --say wm "emit my_action"`.
- **Changing the window manager** means their own `wm/session.plm`: start from
a copy of the one that comes with it (`pleamar-wm scene >
~/.config/pleamar/wm/session.plm`) and change that; pleamar-wm uses theirs
when it exists.
- `pleamar-wm config` shows how it understood `session.conf`.
## The shape of a scene
```
language 0.1 // optional, first line: which language version it needs
import "common/palette.plm" // libraries, relative to this file
scene Name {
surface { size: full, 44; anchor: top } // the window it asks for
permissions { services: "clock" } // without this, the logic can do nothing
service clock as now { time: text } // system services, by name
fact open = false // what the logic may report
text title = "…"
model rows max 14 { label: text }
event chosen -> // `->` means the logic hears it too
prop x = 360 ~calm // something that moves: a spring
let mint = #9ed6bd // a name for an expression or colour
box { from: 0, 0; size: 200, 44; color: mint } // what is drawn
text title { at: 10, 22; anchor: left center; size: 12; color: #f5f7f5 }
zone box hit { from: 0, 0; size: 200, 44; cursor: pointer }
on press hit { toggle open } // rules, run by the renderer
follow x = if(open, 420, 360) // movement that carries itself
}
```
## Things that are easy to get wrong
- **Ranges are exclusive at the end.** `repeat i in 1..10` gives 1 to 9. A model
with `max 16` has records 0 to 15, and its logic fills `1..16` in Lua terms.
- **A `let` is a name for an expression, not a variable**: a small one is
written out wherever it is named. A big one is computed once a frame instead,
so chaining them (`let b = a * (1 - t) + 60 * t`) is fine — but keep each link
naming the one before it **once**, which is what `a * (1 - t) + k * t` does
and `a + (k - a) * t` does not.
- **`prop` is a spring, not a variable.** Do not set it every frame from the
logic; declare where it goes (`follow`, a rule, `impulse`) and let it travel.
**The spring is the property's**: `prop lid = 0 ~150ms` is what `lid: 1` uses,
and a rule only travels differently if it says so (`lid: 1 ~40ms`).
- **A property belongs to its element.** `corner` is a `box` thing, `radius` an
`ellipse` thing, `width`/`lines` are `text` things. `--check` lists the
valid ones when you miss.
- **Inside a layout (`row`/`column`) a child must say how much room it takes.**
Wrap loose shapes in `group { size: w, h; … }`.
- **`if()` and `mix()` work with colours too**: `if(urgent, amber, mint)`,
`mix(ink, mint, open)` —a comparison works as `t`—.
- **Text with holes** is `text "{a} · {b}"`, where `a` and `b` are live texts or
facts. For a number with decimals, `text number(expr, 2, " %")`.
- **A surface does not grow with what it holds, and a shadow needs room.**
`shadow: 0, 18, 44` reaches 62 px past its shape: leave it in `surface {
size: … }` or the shadow is cut into a straight line. A panel that grows is
the usual way to find this out: the renderer warns once, both for the shadow
("a shadow is cut: it needs 30 px below…") and for the drawing itself ("a
drawing is cut: it needs 36 px below…"), once it has been cut for three
seconds and never against an edge the surface is glued to. Only while it
runs, though, never in `--check`: where a card ends is a sum that exists
only while the scene is alive.
- **Zones are what catch the mouse.** A named shape only becomes a zone if a rule
names it, if it carries `active`, or if it is declared with `zone`.
- **A press goes to the zone declared LAST**, not to the smallest one. So a
grace zone —the big invisible rectangle that keeps a panel open while the
pointer crosses a gap— goes **before** what it wraps, or it swallows every
click inside it. This is the mistake that repeats: in marea-plm it happened
five times, always the same way, and every time it looked like "the button
does nothing". What is **hidden** does not catch it, though: a `show:` that is
false, or an `opacity:` that has reached zero —on a group, on a layout—
turns off the zones inside it. No need to repeat `active: open > 0.9` on
every zone of a panel that fades in; keep `active:` for what is visible and
still must not be pressed (a button mid-transition, a confirm that arms late).
- **Two rules in the same frame: the one declared LAST sets the value, and a
`while` reads the frame BEFORE them.** A pointer that jumps from one row to
another gives `leave` on the old one and `enter` on the new one in the same
frame, so whatever clears has to be declared **before** whatever sets, or the
row just entered is cleared by the row just left. Guarding it (`while thing ==
what_it_set`) does not save it: the guard is reading the old value.
- **A loose `clip` reaches further than it looks**: it clips everything after it
until the next named `surface`, not until the end of the block it seems to
belong to. Something written further down comes out clipped to a shape that
may be closed —that is, it does not come out at all—. Inside a `group`, it
ends with the group.
- **A shadow is black unless you say otherwise** (`shadow: dx, dy, blur, alpha,
colour`), and on a desktop of dark windows a black shadow has nothing to
darken: it reads as a dirty ring around the thing it was meant to lift. What a
small shape usually needs there is its own `rim`, which is light **inside** the
silhouette and touches nothing behind it. Everything about a shadow is an
expression, so it can show up only when there is something to cast one:
`shadow: 0, 2 * open, 12 * open, 32% * open`.
- **An svg is `figure`, not `image`.** `image` rasterises it into an atlas —a
sticker: it melts into nothing, it cannot be tinted by parts nor animated by
layers—. `figure hat = file "hat.svg"` reads the same file as paths, and
`figure hat.brim { … }` draws one layer (the `id` of its group in the file) in
its place inside the piece, so two layers drawn apart still fit together.
- **Permissions are per service and listening is not commanding**:
`services: "audio"` lets the logic know the volume; changing it needs
`"audio.volume"` or `"audio.*"`.
## When the language falls short: your own shader
Before faking an effect with a hundred shapes, write it. `shader aurora = file
"aurora.wgsl"` declares one, `shader aurora { at: …; size: …; values: …; colors: … }`
paints a box with it. The file has ONE function, `fn shade(s: Shader) ->
vec4<f32>` —straight colour and coverage for each point—, and reads `s.pos`,
`s.size`, `s.uv`, `s.time`, `s.pointer`, `s.hovered`, `s.a`/`s.b` (the eight
`values:`), `s.color`/`s.color2`, and `behind(s, at)` / `behind_frosted(s, at)`
for what is behind the surface. Only what it reads costs: without `s.time` it
does not keep the scene painting. `--check` validates the WGSL with its line.
Contract and rules: §8.1 of `pleamar --docs reference`.
A `group` can also treat what it holds as one thing: `blur: 6`, `glow: 14, 90%,
mint` (or without a colour, a bloom), `saturation`, `brightness`, `contrast`,
`hue: 120deg`, `mask: x1, y1 to x2, y2` / `mask: radial x, y radius r1 to r2`,
`mode: add | screen | multiply` (multiply only darkens what the scene painted). All animatable; §8.2. Two groups with effects cannot nest.
Particles are an element: `particles { at: x, y; count: 400; life: 0.8s .. 1.6s;
speed: 120 .. 220; direction: -90deg; spread: 40deg; gravity: 0, 260; size: 3, 1;
colors: a, b; shape: dot | square | spark; emit: cond }` (or `burst: event`).
Worked out on the card, thousands are cheap; §8.3.
A `text` can have `gradient:` (like a body's), `outline: w, color`, `shadow: dx,
dy, blur, alpha[, color]`, and per letter `letter_move: dx, dy`, `letter_opacity`,
`letter_scale`, where `letter` is its index and `letters` the count; §8.4.
An `image … = file "x.gif"` (or an animated PNG/WebP) plays by itself.
For smaller things there is maths: `noise(x)`, `noise(x, y)`, `random(k)`,
`sqrt`, `pow`, `fract`, `mod`, `atan2`, `length`… and `time`, the seconds since
the scene started (naming it keeps the scene painting). Gesture frames take
`bezier(x1, y1, x2, y2)`, CSS's cubic-bezier.
## The logic, if there is any
`scene.luau` next to `scene.plm`. Sandboxed Luau, own thread, cut off if a
handler runs longer than two seconds. It can only cross the boundary the scene
declares:
```lua
fact.open = true -- a declared fact
text.title = "hello" -- a declared live text
model.rows = { { label = "a" } } -- the whole list at once, atomically
emit("chosen", 3) -- a declared event
on("chosen", function(n) end) -- listen: events, "fact:x", "text:x", "press:zone"
after(200, f) every(1000, f) -- timers
run("date", {"+%H:%M"}, f) -- a command, if `permissions { run: … }` allows it
sys.watch("audio", f) -- a service; sys.ask(…) asks, sys.call(…) commands
```
Everything else is a mistake: the logic does not draw, does not move properties
and does not know about coordinates.
## Before saying it is done
Check it, open it, and look at it:
```sh
pleamar --check scene.plm # always, after every edit
pleamar --scene scene.plm --seconds 8 # opens, closes by itself
pleamar --scene scene.plm --record open,x # what a fact or spring is worth, frame by frame
pleamar --say NAME "fact open true" # talk to a running scene (NAME: its scene name, lower case)
pleamar --say NAME "get open" # ask it
```
To **see** it without taking over the user's screen, run it inside a
screenless pleamar-wm and read the picture it writes:
```sh
echo "pleamar --scene $PWD/scene.plm --no-hud" > /tmp/autostart
PLEAMAR_WM_AUTOSTART=/tmp/autostart PLEAMAR_HEADLESS_AT=4 PLEAMAR_HEADLESS_PNG=/tmp/look.png \
pleamar-wm headless --seconds 6
```
`PLEAMAR_HEADLESS_INPUT="900,40@2000 down@2500 up@2600 key:Super+q:16@3000"`
adds a mouse and keys there. Never drive the real mouse or keyboard.
Do not say an animation lasts what it was asked to last without measuring it
(`--record`, and `pleamar --docs measuring`).
GitHub에서 보기