# Strand documentation Every page of the Strand site, as markdown. --- # Install Strand has no packaged release yet. There is no distribution package, no tagged version and no crate on crates.io. You build it from a checkout of the repository, or take the [prebuilt binaries](#prebuilt-binaries) GitHub keeps. ## What you need to build it - **Rust 1.97 or newer.** The workspace sets `rust-version = "1.97"`. - **The development files of fontconfig, xkbcommon, Wayland, PipeWire (with SPA) and PAM**, found through `pkg-config`, plus clang and libclang. On Ubuntu 24.04: ```sh sudo apt-get install pkg-config libfontconfig1-dev libxkbcommon-dev libwayland-dev \ libpipewire-0.3-dev libspa-0.2-dev clang libclang-dev libpam0g-dev ``` Other distributions name the packages differently. ## Build it ```sh git clone https://github.com/jakeb-grant/strand cd strand cargo build --release -p strand -p strand-auth ``` This writes two programs into `target/release/`: - `strand`, the shell and its command line; - `strand-auth`, the small helper the lock screen checks passwords with. It is the only program in the workspace that links libpam. Put both in the same directory on your `PATH`, for example: ```sh mkdir -p ~/.local/bin cp target/release/strand target/release/strand-auth ~/.local/bin/ strand --version ``` `strand --version` prints `strand 0.0.0`. Strand looks for `strand-auth` beside its own executable, then in `/usr/libexec/strand`, `/usr/lib/strand` and `/usr/local/libexec/strand`, and nowhere else: never on your `PATH`, since it is the program your password is handed to. When a config has a `lock` and there is no helper, `strand run` warns at start, or at the reload that adds the `lock`, that the lock would have no way to unlock, and `strand check` reports an error for the config. Both say where to put the helper: beside the `strand` that is running, with `cargo install --locked --path crates/strand-auth --root ` when that `strand` is in `/bin`, else by copying `target/release/strand-auth` into its directory. A shell with no lock screen runs without the helper and says nothing about it. ### Without the GPU backend The GPU backend is a default feature. To build without it: ```sh cargo build --release -p strand --no-default-features ``` That build links no wgpu, vello\_gpu or naga, and draws everything on the CPU. ### The other tools Two more programs live in the same repository, outside the shell: - `strand-dev`, the language server and the inspector: `cargo build --release -p strand-dev`. See [Editor setup](/tools/editor.html.md) and [the inspector](/tools/inspector.html.md). - `strand-eval`, the sandbox that replays a glitch report: `cargo build --release --manifest-path eval/Cargo.toml`. It is a workspace of its own, so it lands in `eval/target/release/`. See [Reporting a glitch](/tools/reporting.html.md). ## Prebuilt binaries Every build of the repository on GitHub keeps its release binaries for 30 days, as a workflow artifact named `strand-release-binaries`: `strand-gpu`, the default build, and `strand-cpu`, the build without the GPU backend. Both are built on Ubuntu 24.04. To use one, download the artifact from a green run's page under the repository's Actions tab, rename `strand-gpu` to `strand`, make it executable and put it on your `PATH`. The artifact holds no `strand-auth`, `strand-dev` or `strand-eval`. A lock screen still needs the helper built from source. ## What the compositor needs Strand runs on a Wayland compositor that offers `wl_compositor`, `wl_shm` and `zwlr_layer_shell_v1` (layer shell). Without one of them, `strand run` stops at start and names the one that is missing: "the compositor does not offer zwlr\_layer\_shell\_v1". Some features need one more protocol: | Protocol | Needed for | Without it | | --- | --- | --- | | `xdg_wm_base` | `window` surfaces and popups | windows and popups are not shown | | `ext_session_lock_manager_v1` | the `lock` surface | a `lock` cannot lock the session, and `strand run` warns at start | | `ext_background_effect_manager_v1`, with blur | compositor blur behind `blur` | `blur` draws its tint fallback, and Strand says why once | Hyprland blurs layer surfaces through its own layer rules instead: `strand compositor-rules` prints the rules for your config, to paste into your Hyprland config. Strand never applies them itself. They are Lua (`hl.layer_rule`) by default, and `--classic` prints hyprlang `layerrule` lines for an older config. Strand also uses these when the compositor offers them: `wp_viewporter`, `wp_fractional_scale_manager_v1`, `wp_presentation`, `wp_alpha_modifier_v1`, `wp_single_pixel_buffer_manager_v1`, `zwp_idle_inhibit_manager_v1`, `wl_data_device_manager` and `xdg_activation_v1`. `strand report` lists every global your compositor offers, with its version. The `workspaces`, `windows` and `wm` services read sway's, Hyprland's and niri's own IPC. On a compositor without one of those adapters, the windows and workspaces come from the standard protocols: `ext-foreign-toplevel-list-v1`, `zwlr_foreign_toplevel_management_v1` and `ext-workspace-v1`. ## Compositors Strand is tested on **sway**, **Hyprland**, **niri** and **labwc** on every change. Next: [Running a shell](/start/running.html.md). --- # Running a shell This page is the day-to-day loop: make a shell, start it, edit it while it runs, and check it from a terminal. [Level 1 of the guide](/guide/level-1.html.md) walks through the same loop with the hello bar. ## Where the config lives A shell is a directory of `.strand` files. By default it is `$XDG_CONFIG_HOME/strand`, or `~/.config/strand` when `XDG_CONFIG_HOME` is not set. Every command on this page that takes a directory uses that one when you give none. Every `.strand` file in the directory is part of one shell, down to three directories below it. There is no entry file and nothing to import. Files and directories whose names start with `.` are skipped, and symlinks are followed, so a config kept in a dotfiles repository and linked into place works. `strand check` warns about `.strand` files that sit deeper than three directories, since they are not loaded. ## Make a shell ```sh strand new ``` `strand new` writes one file, `bar.strand`, the guide's hello bar, and prints where it went: ```text wrote /home/you/.config/strand/bar.strand start it with `strand run` ``` It makes the directory if it is missing. It refuses a directory that already holds anything, even a hidden file, and writes nothing there. `strand new ~/shells/test` writes into another directory instead. ## Start it ```sh strand run ``` `strand run` stays in the foreground until the compositor goes away or it gets SIGINT or SIGTERM (Ctrl-C in its terminal). Before it ends it writes the `persist` state and settings it has not saved yet. `strand run ~/shells/test` runs another directory. If the directory has no `.strand` files yet, the shell starts with nothing to show and waits: the first file you save there appears. It says so once as it starts, in its log: "… nothing to run yet: no .strand file". ## Save to change it Leave `strand run` running. Strand watches the config directory, and each save changes the running shell instead of restarting it: a changed value animates to its new one, and state you did not touch is kept, such as an open popup or the text in a launcher. Files saved together, as "save all" does, count as one change. A save that does not check leaves the last good shell running. A quarter of a second later an overlay lists the problems, and the fix takes it away. [Troubleshooting](/help/troubleshooting.html.md#the-error-overlay) says what the overlay does. Strand keeps the last config that checked in `$XDG_CACHE_HOME/strand/last-good` (`~/.cache/strand/last-good` by default). A config that is broken when the shell starts runs that last good version, with the overlay up. With no last good version yet, only the overlay shows until you fix the file. ## Check it ```sh strand check ``` `strand check` reads every `.strand` file of the config as one program, type-checks it without running it, and prints what is wrong, with the file, the line and often a "did you mean" fix. It exits with an error status when it finds an error, so it works in scripts and before you start the shell. It ends with a summary line: ```text strand check: 1 file in /home/you/.config/strand: 0 errors, 0 warnings ``` `strand check ~/shells/test` checks another directory. Given a file, `strand check bar.strand` checks it together with the rest of its config and prints only the problems in that file. ## Format it ```sh strand fmt ``` `strand fmt` rewrites `.strand` files in Strand's one style. With no path it formats the config directory; it also takes directories and files. With `--check` it writes nothing, lists each file that is not formatted and exits with an error status when there is one: ```text not formatted: /home/you/.config/strand/bar.strand strand fmt: 2 files, 1 not formatted ``` A file with syntax errors is reported and left as it was. A formatted file replaces the old one through a rename, so a symlinked file stays a link. Your editor's format command uses the same formatter (see [Editor setup](/tools/editor.html.md)). ## Watch what it does ```sh strand watch ``` `strand watch` connects to the running shell and prints each event until the shell ends: every reload with its time, what kind of edit it was, which files it committed or held back and which state it kept, reset cells, runtime faults and notices. A save of the hello bar with a new height, then a typo in it, looks like this: ```text reload (20.6 ms): prop committed: /home/you/.config/strand/bar.strand reload (18.0 ms): no change held back: /home/you/.config/strand/bar.strand /home/you/.config/strand/bar.strand:5:14: error[check::unknown_prop]: unknown prop "heigth" on "bar"; did you mean "height"? ``` `strand watch --json` prints the same events as one JSON object per line, for scripts. Its first line, `{"event":"watching","v":1}`, says that no event after it is missed. ## Reload by hand ```sh strand reload strand reload --hard ``` You rarely need `strand reload`: saving already reloads. It rescans the config now and prints that reload's event as `strand watch` does. `strand reload --hard` also drops every state that is not `persist` and makes every surface again. Use it when the shell's state has gone somewhere you cannot get back from. While the lock screen is shown, a hard reload waits until you unlock. ## Change values from a terminal ```sh strand set theme.look mocha strand set audio.sink.volume +5% strand toggle launcher.open strand get theme.look ``` These talk to the running shell: - `strand set ` writes an exported `state`, a settings field, a service field the service lets you write, or a token. The value is read as the target's type. For a service's number, a signed number or percentage, such as `+5%`, steps from its current value. - `strand toggle ` flips an exported `bool` state. - `strand get ` prints an exported value, a settings field or a token. A path is `file.name` for a value a file exports (`launcher.open` is the `export state open` of `launcher.strand`), `service.field` for a service (`audio.sink.volume`) and `$name` for a token. `strand set '$accent' '#ff5555'` lays a token over the theme until `strand clear '$accent'` takes it off. Bind these commands to keys in your compositor ([Start it and bind keys](#start-it-and-bind-keys) shows how). ## Logs The shell logs to its standard error. `STRAND_LOG` sets how much: a level, one of `off`, `error`, `warn`, `info`, `debug` and `trace`, with `warn` the default. Each line names its level and where it came from: ```text strand: WARN strand::run::logic: no IPC socket (strand reload, strand watch): … ``` ```sh STRAND_LOG=info strand run ``` `info` adds what the shell does as it runs, such as each surface mapped, each reload and each service starting or stopping. The topic `damage` (`STRAND_LOG=damage`, or `STRAND_LOG=info,damage` with a level) prints one line per painted frame with the rectangles it repainted. ## Start it and bind keys Start `strand run` from your compositor's startup commands, so it runs in the session's environment. Strand does not grab keys either: your compositor keeps them, and a key runs a `strand` command. Every compositor works this way; these are the lines for three of them, starting the shell and opening the launcher on Super+D. sway, in `~/.config/sway/config`: ```sh exec strand run bindsym $mod+d exec strand toggle launcher.open ``` Hyprland, in its Lua config (`~/.config/hypr/hyprland.lua`, Hyprland 0.55 and later): ```lua hl.on("hyprland.start", function() hl.exec_cmd("strand run") end) hl.bind("SUPER + D", hl.dsp.exec_cmd("strand toggle launcher.open")) ``` niri, in `~/.config/niri/config.kdl`: ```kdl spawn-at-startup "strand" "run" binds { Mod+D { spawn "strand" "toggle" "launcher.open"; } } ``` Keys that should work while the screen is locked, such as volume and brightness, take a flag: `bindsym --locked` in sway, `{ locked = true }` as `hl.bind`'s third argument in Hyprland (with `repeating = true` there to repeat while held, which sway and niri do already), and `allow-when-locked=true` in niri. Your compositor's own documentation has the rest. The shell connects to the display `WAYLAND_DISPLAY` names, and the commands above reach it through a socket in `$XDG_RUNTIME_DIR` named for that display (`strand-wayland-1.sock`). Its log then goes wherever your compositor sends its programs' standard error. A second `strand run` on the same display finds the socket taken: it runs, but with no socket, so `strand watch` and the other commands keep reaching the first one. To run a second shell on purpose, give it its own `STRAND_SOCKET` and `STRAND_STATE_DIR`. --- # The Strand guide This guide takes you from `strand new` to a full desktop shell in five levels. Each level teaches a handful of ideas and ends with something you can use. Levels 1 to 3 build one bar, on every monitor, with workspace dots, the focused window's title, a clock with a calendar popup, a volume slider, the battery and the tray. Level 4 adds the surfaces that come and go (an on-screen display, notifications, a launcher, a lock screen and an admin password dialog) and a settings window, and level 5 your own theme, services, a control centre and a dock. Nothing at level N+1 appears in a level-N example. If you stop after any level, everything you have written still works. | Level | You learn | You build | Page | | --- | --- | --- | --- | | 1 | Surfaces, nodes, props, services as live values | The hello bar | [level-1.md](/guide/level-1.html.md) | | 2 | `$tokens`, `component`, `for`, `when`, `on click` | Workspace dots, tray | [level-2.md](/guide/level-2.html.md) | | 3 | `state`, `<->`, `if`, `popup`, `enter` | Clock with calendar, volume slider | [level-3.md](/guide/level-3.html.md) | | 4 | `on change … after`, `after … while`, `persist`, `export`, `list`, `Async` | OSD, notifications, launcher, lock screen, password dialog, settings window | [level-4.md](/guide/level-4.html.md) | | 5 | `tokens`, spring overrides `~`, `service from dbus`, `pages`, drag and drop, shaders, `file` | Control centre, dock, custom services | [level-5.md](/guide/level-5.html.md) | ## Every construct and its level The constructs the "You learn" column does not name are taught at these levels. | Level | Also taught here | | --- | --- | | 1 | `split` (with `start`, `center`, `end`); comma shorthands; expressions (`?.`, `??`, operators, calls such as `pct` and `join`) | | 2 | `on secondary`; `hit:`; the node booleans `hover` and `pressed` | | 3 | assignment and compound assignment (`=`, `+=`, `-=`, `*=`, `/=`); `else`; `exit`; `on scroll(dy)` | | 4 | `let`; `enum`; `match`; `every … while`; `on show`; `id:`; `@reset`; lambdas (`n => …`); `await`; the `lock` and `window` surfaces | | 5 | `set { }`; `keyframes` and `play`; `canvas`; `type` and `key`; `slot`; `fn`; a node's `box` and `pointer`; settings files (`state x from "file.toml" { … }`); SVG layer selectors (`#name { … }`); `service … from file`, `from dir`, `from listen`, `from poll` and `permit exec`; `action` | ## How to read the code blocks Every code block marked `strand` is the **whole** file it names, as it should be after that step. Copy it over the file and save; you never merge a fragment by hand. The block's tag says its level and its file: ```` ```strand level=2 file=dot.strand component Dot(ws: Workspace) { … } ``` ```` Steps run in page order, level 1 first. A file a step does not show stays as it was. A step can also need a file that is not `.strand`, such as level 5's template. Its block is tagged with its own language and the same `level=` and `file=`, and is that file's whole content too: ```` ```css level=5 file=colors.css.tmpl :root { … } ``` ```` Short pieces of syntax inside a sentence (`when hover { … }`), and untagged blocks (commands to type, keybind lines for your compositor), are not steps. ## What you need - A Wayland compositor with layer-shell, such as sway, Hyprland, niri or labwc, the four Strand is tested on. GNOME does not offer layer-shell. - The `strand` binary on your `PATH`. There is no release yet, so either build it from a checkout of the repository with `cargo build --release -p strand` (it lands in `target/release/strand`), or take the [prebuilt binaries](/start/install.html.md#prebuilt-binaries) and rename `strand-gpu` in them to `strand` (`strand-cpu` is the same without the GPU backend). `strand --version` tells you it is found. Then start at [level 1](/guide/level-1.html.md). ## More pages - [Plugins](/guide/plugins.html.md): a service written in Rust and built into your own `strand` binary (level 5, section 7). - The language itself, in full: [`docs/spec.md`](https://github.com/jakeb-grant/strand/blob/main/docs/spec.md) for what each construct means, [`docs/grammar.md`](https://github.com/jakeb-grant/strand/blob/main/docs/grammar.md) for how it is written. - Every element, prop, service, type, function and token, with its doc: [`docs/reference/`](https://github.com/jakeb-grant/strand/blob/main/docs/reference/README.md), generated from the schemas by `strand-dev reference`. --- # Level 1: the hello bar You learn: surfaces, nodes, props, and services as live values. You build: a bar on every monitor with the focused window's title, a clock and the battery. ## Make a shell ```sh strand new ``` This writes one file, `bar.strand`, into `~/.config/strand` (or `$XDG_CONFIG_HOME/strand` if you set it). It refuses a directory that already has files in it. Here is what it wrote: ```strand // bar.strand: a bar on every monitor. Every .strand file in this // directory is part of your shell; save one and the change is live. // Next steps: https://github.com/jakeb-grant/strand/blob/main/docs/guide/README.md bar Top { edge: top; height: 32 split { start { text windows.focused?.title ?? "" } center { text clock.format("%H:%M") } end { text pct(battery.percent) } } } ``` Start it: ```sh strand run ``` A bar appears along the top of every monitor. Plug in another monitor and it gets a bar too; unplug it and that bar goes. You wrote nothing to make that happen: a `bar` is on every monitor by default. Leave `strand run` running for the rest of the guide. You never restart it. Every save is applied to the running shell. ## Read it **Lines starting with `//` are comments.** They run to the end of the line. **`bar Top { … }` is a surface.** A surface is something the compositor shows: a bar, a panel, an on-screen display. `bar` is the kind, `Top` is the name you give it, and the braces hold everything in it. **`edge: top; height: 32` are props.** A prop is `name: value`. It ends at a `;` or at the end of the line, so these two lines mean the same: - `edge: top; height: 32` - `edge: top` on one line, `height: 32` on the next. Names are written in snake\_case: `max_width`, never `max-width` (that would be `max` minus `width`). **`split`, `start`, `text` are nodes.** Every node has the same shape: ``` kind [positional] { props; children } ``` The positional is one value written right after the kind, and the block is optional. `text clock.format("%H:%M")` is a `text` node whose positional is the text to show. `split` is a container with three parts: `start` is pinned left, `end` right, and `center` is truly centred on the bar, however wide the other two get. **`windows`, `clock` and `battery` are services.** A service is a live value: you read it, and when it changes, everything that read it updates. There is no polling and no timer to write. - `clock.format("%H:%M")` is the time as text. Because the format shows minutes, the clock wakes once a minute, and only that text redraws. - `battery.percent` is a number from 0 to 1. `pct(…)` shows it as a percentage. - `windows.focused` is the focused window, or `null` when nothing has focus. `?.` reads a field only if the value is not `null`, so `windows.focused?.title` is the title or `null`. `??` gives a fallback for `null`: here an empty string. A service starts the first time your shell reads it and stops a few seconds after nothing reads it any more. You never start or stop one. ## Change it Make the bar a little taller, give the split some room at its ends, and stop a long window title from pushing the clock around. Replace the whole file with this and save: ```strand // bar.strand: a bar on every monitor. Every .strand file in this // directory is part of your shell; save one and the change is live. // Next steps: https://github.com/jakeb-grant/strand/blob/main/docs/guide/README.md bar Top { edge: top; height: 36 split { pad: 0, 12 start { text windows.focused?.title ?? "" { max_width: 40%; ellipsis: end } } center { text clock.format("%H:%M") } end { text pct(battery.percent) } } } ``` `bar.strand`: ```strand // bar.strand: a bar on every monitor. Every .strand file in this // directory is part of your shell; save one and the change is live. // Next steps: https://github.com/jakeb-grant/strand/blob/main/docs/guide/README.md bar Top { edge: top; height: 36 split { pad: 0, 12 start { text windows.focused?.title ?? "" { max_width: 40%; ellipsis: end } } center { text clock.format("%H:%M") } end { text pct(battery.percent) } } } ``` The bar grows to its new height and nothing restarts. Three new things: - **A node's own props go in its block.** `split { pad: 0, 12 … }` sets the split's padding; the props come first, then the children. - **Props with several values take commas.** `pad: 0, 12` is 0 above and below, 12 left and right; one to four values read as in CSS (top, right, bottom, left). Commas, not spaces, separate the values, so `a - 1` and `a -1` can never mean different things. - **A positional and a block can go together.** `text … { max_width: 40%; ellipsis: end }` keeps the title under 40% of its container, `start` (the bar's left side, up to the clock), and cuts it with `…` at the end. Every visual change you make animates: change `height: 36` back to `height: 32`, save, and the bar springs to its new size. Animation is on by default; you never ask for it. ## When you make a mistake Change `height` to `heigth` and save. The bar keeps running exactly as it was: a broken save never takes your shell down. After a quarter of a second an overlay lists the problem, with the file, the line, a caret under the word, and a suggestion ("did you mean `height`?"). Click it to open the file in your editor. Fix the word, save, and the overlay goes. You can check a config without running it, too: ```sh strand check ``` It prints the same diagnostics and exits with an error if there are any: useful when the shell is not running, or in a script. ## What you have A bar on every monitor, live, animated, with no imports, no entry file and no restart. Next, [level 2](/guide/level-2.html.md) gives it a theme, workspace dots and a tray. --- # Level 2: a themed bar with workspace dots and a tray You learn: `$tokens`, `component`, `for`, `when`, `on click`. You build: a themed bar with a dot for each workspace on its monitor, and the system tray. ## Theme it with tokens Replace `bar.strand` with this and save: ```strand // bar.strand: one instance per monitor; `screen` is in scope bar Top { edge: top; height: 36; margin: $space.2, $space.2, 0 bg: $surface.alpha(0.72); blur: 24; radius: $radius.lg; shadow: $elevation.md font: $font.ui; color: $fg // inherited by every child split { pad: 0, $space.3 // centre is truly centred start { gap: $space.3 text windows.focused?.title ?? "" { color: $fg.muted; max_width: 40%; ellipsis: end } } center { text clock.format("%H:%M") } end { gap: $space.3 text pct(battery.percent) } } } ``` The bar lifts off the screen edge, rounds its corners, turns translucent and casts a shadow, and every change springs into place. **A name starting with `$` is a design token.** Tokens are the shell's shared values: spacing, radii, fonts, shadows and colours. You never declare these: Strand has a built-in theme. Its colours come from your desktop's accent colour (a blue if it has none), light or dark as your desktop is set. - `$space.2`, `$radius.lg`, `$font.ui` and `$elevation.md` are steps on scales. Use them instead of numbers and the whole shell stays consistent. - `$surface`, `$fg` (foreground), `$accent` and `$error` are colour roles. `$fg.muted` is a softer foreground for secondary text. - `$surface.alpha(0.72)` is the surface colour at 72% opacity. A name followed by `(` is a method: `alpha`, `mix`, `lighten` and `darken` work on any colour token in a prop. (Level 5's theme templates add a few more that only templates read.) Tokens are live. When your desktop switches between light and dark, the colours glide to the new palette; nothing reloads. Level 5 shows how to write your own theme. **`font` and `color` are inherited.** Set on the bar, they apply to every text in it, so no `text` needs its own. `$fg.muted` on the title overrides the colour for that one node. **`blur: 24` asks the compositor to blur what is behind the bar.** Strand never captures the screen. Where the compositor cannot blur, the bar gets a little more opaque instead, so text stays readable. ## Workspace dots A dot for each workspace: wide and bright for the focused one, dim for empty ones, red for urgent ones. Make a new file, `dot.strand`, next to `bar.strand`: ```strand component Dot(ws: Workspace) { box { size: 8; radius: full; bg: $fg.alpha(0.25); hit: grow(6) when ws.occupied { bg: $fg.muted } when ws.focused { width: 24; bg: $accent } // later `when` wins, like CSS when ws.urgent { bg: $error } when hover { bg: $accent.hover } when pressed { scale: 0.9 } on click { ws.focus() } } } ``` There is no import. Every `.strand` file in the directory is part of the same shell, so `Dot` can be used from `bar.strand` as it is. Put the dots at the start of the bar: ```strand // bar.strand: one instance per monitor; `screen` is in scope bar Top { edge: top; height: 36; margin: $space.2, $space.2, 0 bg: $surface.alpha(0.72); blur: 24; radius: $radius.lg; shadow: $elevation.md font: $font.ui; color: $fg // inherited by every child split { pad: 0, $space.3 // centre is truly centred start { gap: $space.3 row { gap: $space.1 for ws in workspaces.on(screen) { Dot ws } // keyed by ws.id, no max } text windows.focused?.title ?? "" { color: $fg.muted; max_width: 40%; ellipsis: end } } center { text clock.format("%H:%M") } end { gap: $space.3 text pct(battery.percent) } } } ``` Switch workspaces and the wide dot slides across. **`component Dot(ws: Workspace)` is your own node.** It takes one parameter, `ws`, of type `Workspace`. You call it like any other node: `Dot ws`, with `ws` as its positional. Parameters can also be passed by name in the block, and can have defaults: a `component Toast(n: Notification, dense: bool = false)` is called as `Toast n` or `Toast n { dense: true }`. **`for ws in workspaces.on(screen) { … }` makes one `Dot` per workspace.** `screen` is the monitor this copy of the bar is on (each monitor gets its own), so each bar shows only its own workspaces. There is no maximum: a tenth workspace gets a tenth dot. Workspaces bring their own key (their id), so when one is added or removed the dots for the others are kept, and the new dot is the only one drawn fresh. **`when cond { props }` is conditional style.** While `cond` is true, its props apply; when it turns false they spring back. Several `when`s may set the same prop, and the later one wins, as in CSS: a focused workspace that is also occupied is wide and `$accent`. - `hover` and `pressed` are true while the pointer is over the node, or a button is held on it. `focused` and `selected` are the other two. They always mean the node the `when` is in, here the `box`. - `ws.occupied`, `ws.focused` and `ws.urgent` are fields of the workspace, and update live like any service value. **`on click { ws.focus() }` runs when the node is clicked.** Inside the braces are statements; `ws.focus()` asks the compositor to switch to that workspace. Service values have actions like this one, called with `()`. **`hit: grow(6)` makes the dot easier to click.** An 8-pixel dot is a small target, so its clickable area grows 6 pixels on every side without changing how it looks. **The same name in two files.** Two files may each declare a component with the same name (or a `type`, `enum` or `fn`): say `dot.strand` and `menu.strand` both have a `component Item`. A file that declares a name uses its own, so `Item` in `menu.strand` is the menu's. A file without one of its own, like `bar.strand`, names the file it means: `dot.Item ws` or `menu.Item t`. A bare `Item` there is an error that names both files and offers both fixes: ```text × `Item` is declared in 2 files, `dot.strand` and `menu.strand`, and not in this one help: name the file it comes from: `dot.Item` or `menu.Item` (or declare `Item` in this file, which then uses its own) ``` Tokens follow the same rules: inside `dot.strand`, `$Item.gap` is the dot's `Item`'s token; in `bar.strand` it is `$dot.Item.gap`. A name only one file declares, like `Dot`, is used by its bare name everywhere. Adding the second `component Item` is what makes a bare `Item` in `bar.strand` an error. While `strand` is running, the reload holds the new file back and the bar keeps running the last version that loaded; the error in `bar.strand` says what to write, and once it is fixed both files load together, each `Item` keeping its state. `strand check` and the editor also put a note, not an error, on each declaration of a shared name, listing the other files. ## The tray Add the system tray at the end of the bar: ```strand // bar.strand: one instance per monitor; `screen` is in scope bar Top { edge: top; height: 36; margin: $space.2, $space.2, 0 bg: $surface.alpha(0.72); blur: 24; radius: $radius.lg; shadow: $elevation.md font: $font.ui; color: $fg // inherited by every child split { pad: 0, $space.3 // centre is truly centred start { gap: $space.3 row { gap: $space.1 for ws in workspaces.on(screen) { Dot ws } // keyed by ws.id, no max } text windows.focused?.title ?? "" { color: $fg.muted; max_width: 40%; ellipsis: end } } center { text clock.format("%H:%M") } end { gap: $space.3 text pct(battery.percent) for item in tray.items { image item.icon { size: 16 on click { item.activate() } on secondary { item.menu.open() } } } } } } ``` `bar.strand`: ```strand // bar.strand: one instance per monitor; `screen` is in scope bar Top { edge: top; height: 36; margin: $space.2, $space.2, 0 bg: $surface.alpha(0.72); blur: 24; radius: $radius.lg; shadow: $elevation.md font: $font.ui; color: $fg // inherited by every child split { pad: 0, $space.3 // centre is truly centred start { gap: $space.3 row { gap: $space.1 for ws in workspaces.on(screen) { Dot ws } // keyed by ws.id, no max } text windows.focused?.title ?? "" { color: $fg.muted; max_width: 40%; ellipsis: end } } center { text clock.format("%H:%M") } end { gap: $space.3 text pct(battery.percent) for item in tray.items { image item.icon { size: 16 on click { item.activate() } on secondary { item.menu.open() } } } } } } ``` `dot.strand`: ```strand component Dot(ws: Workspace) { box { size: 8; radius: full; bg: $fg.alpha(0.25); hit: grow(6) when ws.occupied { bg: $fg.muted } when ws.focused { width: 24; bg: $accent } // later `when` wins, like CSS when ws.urgent { bg: $error } when hover { bg: $accent.hover } when pressed { scale: 0.9 } on click { ws.focus() } } } ``` Each tray item is an `image` of its icon. A left click activates the app; **`on secondary`** is the other button. Here it calls `item.menu.open()`, which tells the app its menu is opening and sets `item.menu.opened` to true. An app that draws its own menu shows it then. Most apps (nm-applet, Discord, Steam) hand their menu to Strand instead, and Strand draws nothing until your bar has a `popup` showing `item.menu.items` while `item.menu.opened` is true. Bar (a) has no such popup, so for those apps a right click does nothing visible yet; [level 3](/guide/level-3.html.md) teaches `popup`, and the doc of the tray service's `TrayMenu` record gives that menu popup. ## What you have A themed bar with live workspace dots and a working tray, on every monitor. Next, [level 3](/guide/level-3.html.md) adds things that remember: a clock that opens a calendar, and a volume slider. --- # Level 3: things that remember You learn: `state`, `<->`, `if`, `popup`, `enter`. You build: a clock that opens a calendar, a volume control with a slider that slides out on hover, and a battery that shows only when there is one. At the end of this level your bar is complete, split into one file per component. ## A clock with a calendar Make `clock.strand`: ```strand component Clock { state open = false // one per monitor text clock.format("%a %d %H:%M") { // wakes once a minute on click { open = !open } popup { open: <-> open; Calendar } // anchored here; Esc or click-away closes } } component Calendar { state month = clock.today.month_start() col { pad: $space.3; gap: $space.2; bg: $surface; radius: $radius.lg; shadow: $elevation.lg row { align: center button "‹" { on click { month = month.add(months: -1) } } text month.format("%B %Y") { grow: 1; align: center } button "›" { on click { month = month.add(months: 1) } } } grid { columns: 7; gap: $space.1 for d in calendar.days(month) { text d.date.format("%e") { width: 28; height: 28; align: center when !d.in_month { color: $fg.faint } when d.date == clock.today { bg: $accent; color: $on_accent; radius: full } } } } } } ``` Then put the clock in the centre of the bar: ```strand // bar.strand: one instance per monitor; `screen` is in scope bar Top { edge: top; height: 36; margin: $space.2, $space.2, 0 bg: $surface.alpha(0.72); blur: 24; radius: $radius.lg; shadow: $elevation.md font: $font.ui; color: $fg // inherited by every child split { pad: 0, $space.3 // centre is truly centred start { gap: $space.3 row { gap: $space.1 for ws in workspaces.on(screen) { Dot ws } // keyed by ws.id, no max } text windows.focused?.title ?? "" { color: $fg.muted; max_width: 40%; ellipsis: end } } center { Clock } end { gap: $space.3 text pct(battery.percent) for item in tray.items { image item.icon { size: 16 on click { item.activate() } on secondary { item.menu.open() } } } } } } ``` Click the clock and a calendar opens, anchored to it. Press Escape, or click anywhere else, and it closes. **`state open = false` is a value your shell can change.** Everything you have read so far comes from a service; `state` is your own. It lives on the component, so each monitor's `Clock` has its own `open`: opening the calendar on one monitor leaves the others alone. **`open = !open` writes it.** `!` is "not", so a click flips it. Writes happen in handlers (`on click { … }` and the like). A prop is a live expression, never an assignment. Writing a number has shorthands: `x += 1`, `x -= d`, `x *= 2`, `x /= 2`. **`popup { … }` is a small surface anchored to its parent**, here the clock's text. It shows while its `open` prop is true. **`open: <-> open` is a two-way binding.** The popup reads `open` to know whether to show, and writes it back when it closes itself (Escape, a click away). One line replaces a prop plus a "closed" handler. The right-hand side must be something writable: a `state`, a settings field, or a service field that can be written. **`Calendar` keeps its own `state month`.** The arrows move it with `month.add(months: -1)`: a call can name its arguments. The grid shows one cell per day from `calendar.days(month)`, padded with the days of the months around it (`d.in_month` tells them apart), and today is filled with the accent colour. `$on_accent` is the colour for text on `$accent`, and `$fg.faint` is a fainter foreground still. The clock's format changed too, to show the day: `%a %d %H:%M`. It still wakes only once a minute. **Moving nodes into a new component starts them fresh.** The old centre `text` is gone and `Clock` is new, so nothing carried over. That is fine here, since the text had no state. In general a save keeps every node whose source still maps to it, with its state, and wrapping a node in a container such as `row` keeps it too; but a node you move into a new component, or into another one, is a new node. ## Volume Make `volume.strand`: ```strand component Volume { row { gap: $space.1 on scroll(dy) { audio.sink.volume -= dy * 0.05 } icon audio.sink.icon { on click { audio.sink.muted = !audio.sink.muted } } if hover { // the row's hover, latched while dragging slider { width: 90; value: <-> audio.sink.volume enter { width: 0; opacity: 0 } } } } } ``` Then add it to the end of the bar: ```strand // bar.strand: one instance per monitor; `screen` is in scope bar Top { edge: top; height: 36; margin: $space.2, $space.2, 0 bg: $surface.alpha(0.72); blur: 24; radius: $radius.lg; shadow: $elevation.md font: $font.ui; color: $fg // inherited by every child split { pad: 0, $space.3 // centre is truly centred start { gap: $space.3 row { gap: $space.1 for ws in workspaces.on(screen) { Dot ws } // keyed by ws.id, no max } text windows.focused?.title ?? "" { color: $fg.muted; max_width: 40%; ellipsis: end } } center { Clock } end { gap: $space.3 Volume text pct(battery.percent) for item in tray.items { image item.icon { size: 16 on click { item.activate() } on secondary { item.menu.open() } } } } } } ``` Point at the speaker icon and a slider slides out; drag it and the volume follows. Scroll anywhere on the row to change the volume, and click the icon to mute. **`on scroll(dy) { … }` takes a parameter.** `dy` is how far the wheel moved. Some events carry values like this; you name them in the parentheses. **`audio.sink.volume -= dy * 0.05` writes a service.** Some service fields can be written, like `state`: the default output's volume and its mute switch are two. Your write goes to the sound server, and when the volume changes from anywhere else (a media key, another app) the slider follows. **`if cond { … }` adds and removes nodes.** `when` changes props of a node that is always there; `if` decides whether its children exist at all. `if hover` reads the row's `hover`. It stays true while you drag the slider, even when the pointer leaves the row, so the slider never vanishes under you. `if` can have an `else { … }` block for when the condition is false. **`trigger: hover` opens a popup by pointing.** The slider sits inside the row, so `if hover` keeps it while you use it. A `popup` is a surface of its own: an `if hover` popup would close on the pointer's way to it. Give the popup `trigger: hover` instead and Strand opens and closes it: it opens while the pointer is on the element it belongs to, stays while the pointer is on either, and 200 ms more, long enough to cross the gap: ``` box { size: 28 when popout.open { bg: $surface.hi } // lit while its popup shows popup { id: popout; trigger: hover; pad: $space.3 text "More here" } } ``` The pointer is what opens such a popup. Its `open:` opens it from code as well, and holds it open while true (a peek handing over to it); once that is false, the pointer holds it as usual. `delay:` makes the pointer rest first, and `grace:` changes the 200 ms. Pointing at another `trigger: hover` element on the same bar swaps the popups at once. `popout.open` reads whether a popup is open, whatever opens it. **`enter { width: 0; opacity: 0 }` is where a node animates in from.** When the slider appears it starts at no width and transparent, and springs to its real size and full opacity. When it goes, it plays the same pose in reverse before it is removed: `exit` mirrors `enter` unless you write an `exit { … }` block of its own. **`value: <-> audio.sink.volume`** is the same two-way binding as the popup's: the slider shows the volume and writes it when dragged. A slider runs from 0 to 1, as the volume does. For a value with other ends, give them: with `state percent = 50`, `slider { value: <-> percent; range: 0, 100 }` shows `percent` and writes 0 to 100 back to it. `step: 5` beside the range moves the slider in whole steps of 5, and the value it writes is a whole step. A slider laid out taller than wide (`width: 24; height: 160`) runs upward. ## Battery Make `battery.strand`: ```strand component Battery { row { gap: $space.1 icon battery.icon text join(" · ", pct(battery.percent), dur(battery.time_left)) when battery.percent < 0.15 && !battery.charging { color: $error } } } ``` Then show it only when there is a battery: ```strand // bar.strand: one instance per monitor; `screen` is in scope bar Top { edge: top; height: 36; margin: $space.2, $space.2, 0 bg: $surface.alpha(0.72); blur: 24; radius: $radius.lg; shadow: $elevation.md font: $font.ui; color: $fg // inherited by every child split { pad: 0, $space.3 // centre is truly centred start { gap: $space.3 row { gap: $space.1 for ws in workspaces.on(screen) { Dot ws } // keyed by ws.id, no max } text windows.focused?.title ?? "" { color: $fg.muted; max_width: 40%; ellipsis: end } } center { Clock } end { gap: $space.3 Volume if battery.present { Battery } for item in tray.items { image item.icon { size: 16 on click { item.activate() } on secondary { item.menu.open() } } } } } } ``` On a desktop with no battery, nothing shows. On a laptop you see an icon, the charge and the time left, joined by a dot. `join` skips any part that is `null`, so when UPower does not know the time left you see only the charge. Below 15% and not charging, it turns red. ## Dots that grow in Last, let new workspace dots grow in instead of popping. Replace `dot.strand`: ```strand component Dot(ws: Workspace) { box { size: 8; radius: full; bg: $fg.alpha(0.25); hit: grow(6) when ws.occupied { bg: $fg.muted } when ws.focused { width: 24; bg: $accent } // later `when` wins, like CSS when ws.urgent { bg: $error } when hover { bg: $accent.hover } when pressed { scale: 0.9 } enter { width: 0; opacity: 0 } // exit mirrors enter on click { ws.focus() } } } ``` Make a new workspace: its dot grows from nothing, and the dots beside it glide aside. Close it and the dot shrinks away. ## What you have The whole bar, on every monitor: workspace dots, the focused window's title, a clock with its calendar, volume, battery and tray, in five files and about 80 lines. `bar.strand`: ```strand // bar.strand: one instance per monitor; `screen` is in scope bar Top { edge: top; height: 36; margin: $space.2, $space.2, 0 bg: $surface.alpha(0.72); blur: 24; radius: $radius.lg; shadow: $elevation.md font: $font.ui; color: $fg // inherited by every child split { pad: 0, $space.3 // centre is truly centred start { gap: $space.3 row { gap: $space.1 for ws in workspaces.on(screen) { Dot ws } // keyed by ws.id, no max } text windows.focused?.title ?? "" { color: $fg.muted; max_width: 40%; ellipsis: end } } center { Clock } end { gap: $space.3 Volume if battery.present { Battery } for item in tray.items { image item.icon { size: 16 on click { item.activate() } on secondary { item.menu.open() } } } } } } ``` `dot.strand`: ```strand component Dot(ws: Workspace) { box { size: 8; radius: full; bg: $fg.alpha(0.25); hit: grow(6) when ws.occupied { bg: $fg.muted } when ws.focused { width: 24; bg: $accent } // later `when` wins, like CSS when ws.urgent { bg: $error } when hover { bg: $accent.hover } when pressed { scale: 0.9 } enter { width: 0; opacity: 0 } // exit mirrors enter on click { ws.focus() } } } ``` `clock.strand`: ```strand component Clock { state open = false // one per monitor text clock.format("%a %d %H:%M") { // wakes once a minute on click { open = !open } popup { open: <-> open; Calendar } // anchored here; Esc or click-away closes } } component Calendar { state month = clock.today.month_start() col { pad: $space.3; gap: $space.2; bg: $surface; radius: $radius.lg; shadow: $elevation.lg row { align: center button "‹" { on click { month = month.add(months: -1) } } text month.format("%B %Y") { grow: 1; align: center } button "›" { on click { month = month.add(months: 1) } } } grid { columns: 7; gap: $space.1 for d in calendar.days(month) { text d.date.format("%e") { width: 28; height: 28; align: center when !d.in_month { color: $fg.faint } when d.date == clock.today { bg: $accent; color: $on_accent; radius: full } } } } } } ``` `volume.strand`: ```strand component Volume { row { gap: $space.1 on scroll(dy) { audio.sink.volume -= dy * 0.05 } icon audio.sink.icon { on click { audio.sink.muted = !audio.sink.muted } } if hover { // the row's hover, latched while dragging slider { width: 90; value: <-> audio.sink.volume enter { width: 0; opacity: 0 } } } } } ``` `battery.strand`: ```strand component Battery { row { gap: $space.1 icon battery.icon text join(" · ", pct(battery.percent), dur(battery.time_left)) when battery.percent < 0.15 && !battery.charging { color: $error } } } ``` `bar.strand`: ```strand // bar.strand: one instance per monitor; `screen` is in scope bar Top { edge: top; height: 36; margin: $space.2, $space.2, 0 bg: $surface.alpha(0.72); blur: 24; radius: $radius.lg; shadow: $elevation.md font: $font.ui; color: $fg // inherited by every child split { pad: 0, $space.3 // centre is truly centred start { gap: $space.3 row { gap: $space.1 for ws in workspaces.on(screen) { Dot ws } // keyed by ws.id, no max } text windows.focused?.title ?? "" { color: $fg.muted; max_width: 40%; ellipsis: end } } center { Clock } end { gap: $space.3 Volume if battery.present { Battery } for item in tray.items { image item.icon { size: 16 on click { item.activate() } on secondary { item.menu.open() } } } } } } ``` `dot.strand`: ```strand component Dot(ws: Workspace) { box { size: 8; radius: full; bg: $fg.alpha(0.25); hit: grow(6) when ws.occupied { bg: $fg.muted } when ws.focused { width: 24; bg: $accent } // later `when` wins, like CSS when ws.urgent { bg: $error } when hover { bg: $accent.hover } when pressed { scale: 0.9 } enter { width: 0; opacity: 0 } // exit mirrors enter on click { ws.focus() } } } ``` `clock.strand`: ```strand component Clock { state open = false // one per monitor text clock.format("%a %d %H:%M") { // wakes once a minute on click { open = !open } popup { open: <-> open; Calendar } // anchored here; Esc or click-away closes } } component Calendar { state month = clock.today.month_start() col { pad: $space.3; gap: $space.2; bg: $surface; radius: $radius.lg; shadow: $elevation.lg row { align: center button "‹" { on click { month = month.add(months: -1) } } text month.format("%B %Y") { grow: 1; align: center } button "›" { on click { month = month.add(months: 1) } } } grid { columns: 7; gap: $space.1 for d in calendar.days(month) { text d.date.format("%e") { width: 28; height: 28; align: center when !d.in_month { color: $fg.faint } when d.date == clock.today { bg: $accent; color: $on_accent; radius: full } } } } } } ``` `volume.strand`: ```strand component Volume { row { gap: $space.1 on scroll(dy) { audio.sink.volume -= dy * 0.05 } icon audio.sink.icon { on click { audio.sink.muted = !audio.sink.muted } } if hover { // the row's hover, latched while dragging slider { width: 90; value: <-> audio.sink.volume enter { width: 0; opacity: 0 } } } } } ``` `battery.strand`: ```strand component Battery { row { gap: $space.1 icon battery.icon text join(" · ", pct(battery.percent), dur(battery.time_left)) when battery.percent < 0.15 && !battery.charging { color: $error } } } ``` | File | Holds | | --- | --- | | `bar.strand` | the `Top` bar | | `dot.strand` | `Dot` | | `clock.strand` | `Clock` and `Calendar` | | `volume.strand` | `Volume` | | `battery.strand` | `Battery` | Next, [level 4](/guide/level-4.html.md) builds surfaces that come and go: an on-screen display for volume, notifications, an app launcher and a lock screen. --- # Level 4: surfaces that come and go You learn: `on change … after`, `after … while`, `persist`, `export`, `list`, `Async`; also `let`, `enum`, `match`, `every … while`, `on show`, `id:`, `@reset`, lambdas (`n => …`), `await` and the `lock` and `window` surfaces. You build: an on-screen display for volume and brightness, a notification stack, an app launcher, a lock screen, the dialog a program opens when it needs your password (with, for those who want one, a login screen in the same language) and a settings window. Your bar is always there. Everything on this page appears when something happens (a key, a notification, a locked session) and goes away again, but the last: a window, which stays until you close it. Each build is a file of its own beside the five from level 3; none of them changes the bar. ## An OSD for volume and brightness Make `osd.strand`: ```strand // osd.strand. Media keys stay in the compositor: strand set audio.sink.volume +5% enum Kind { volume, brightness } state kind = volume state shown = false on change audio.sink.volume, audio.sink.muted { kind = volume; shown = true } on change brightness.level { kind = brightness; shown = true } on change audio.sink.volume, audio.sink.muted, brightness.level after 1.2s { shown = false } let level = kind == volume ? (audio.sink.muted ? 0 : audio.sink.volume) : brightness.level osd Level { // focused monitor, overlay, click-through anchor: bottom; margin: 0, 0, 96; open: shown enter { opacity: 0; scale: 0.9 } row { width: 260; pad: $space.3; gap: $space.3; align: center radius: full; bg: $surface.alpha(0.8); blur: 24; shadow: $elevation.lg icon kind == volume ? audio.sink.icon : "display-brightness-symbolic" { size: 20 } meter level { grow: 1; height: 6; color: $accent; track: $fg.alpha(0.15) } text pct(level) { width: 4ch; align: end; font: $font.mono } } } ``` Nothing shows yet. Scroll on the bar's volume row, or drag its slider: a pill with the level fades in near the bottom of the screen, follows the volume while you move it, and fades out a little after you stop. `osd.strand`: ```strand // osd.strand. Media keys stay in the compositor: strand set audio.sink.volume +5% enum Kind { volume, brightness } state kind = volume state shown = false on change audio.sink.volume, audio.sink.muted { kind = volume; shown = true } on change brightness.level { kind = brightness; shown = true } on change audio.sink.volume, audio.sink.muted, brightness.level after 1.2s { shown = false } let level = kind == volume ? (audio.sink.muted ? 0 : audio.sink.volume) : brightness.level osd Level { // focused monitor, overlay, click-through anchor: bottom; margin: 0, 0, 96; open: shown enter { opacity: 0; scale: 0.9 } row { width: 260; pad: $space.3; gap: $space.3; align: center radius: full; bg: $surface.alpha(0.8); blur: 24; shadow: $elevation.lg icon kind == volume ? audio.sink.icon : "display-brightness-symbolic" { size: 20 } meter level { grow: 1; height: 6; color: $accent; track: $fg.alpha(0.15) } text pct(level) { width: 4ch; align: end; font: $font.mono } } } ``` **Keys stay in your compositor.** Strand does not grab keys. Bind your volume and brightness keys to the command line instead: ``` strand set audio.sink.volume +5% strand set audio.sink.volume -5% strand set brightness.level +5% strand set brightness.level -5% ``` Your compositor's config says which key runs which (how: [Start it and bind keys](/start/running.html.md#start-it-and-bind-keys)). `strand set` writes a service field the way a handler does. A signed number or percentage is a step from the current value, and it never goes below 0. **`osd Level { … }` is a new kind of surface.** A `bar` is on every monitor all the time. An `osd` is on the focused monitor, above everything, and clicks go through it to whatever is underneath. It shows while `open:` is true. Its `enter` pose works as it does on a node, and the compositor animates it, so the fade costs no repaint. **`enum Kind { volume, brightness }` declares your own set of choices.** `state kind = volume` starts as one of them. Wherever Strand knows a `Kind` is expected, you write the bare name: `kind = brightness`, `kind == volume`. **`on change a, b { … }` runs when a value changes.** The first handler runs when the volume or the mute switch changes, from anywhere: your keys, the bar's slider, another program. It never runs for the first value. When you log in, when you save a file and when the default output switches to your headphones, the value is new to Strand but nobody changed it, so the OSD stays hidden. That is why it does not pop up every time the shell starts. **`on change … after 1.2s` waits for quiet.** It runs once, 1.2 seconds after the last change. Hold the volume key and the OSD stays up; let go and it hides 1.2 seconds later. **`let level = …` is a value you derive.** A `let` is computed from what it reads and recomputed when any of that changes. You cannot assign to it: `level = 0` is an error that says so. Use `state` for what you write and `let` for what follows from it. `… ? … : …` picks one of two values: when `kind == volume`, the volume (or 0 while muted); otherwise the brightness. `meter level` draws it as a filled bar, empty at 0 and full at 1; a value with other ends takes a `range` (`meter memory.used { range: 0, memory.total }`), and `range: auto` scales the bar from 0 to a round number (1, 2, 5, 10, 20, …) above the largest value it showed in the last minute. `text pct(level)` draws it as a percentage in a fixed-width font, `4ch` wide, so the digits do not jump. ### The same with `match` Nested `?` and `:` get hard to read past two choices. `match` names each case. Replace `osd.strand`: ```strand // osd.strand. Media keys stay in the compositor: strand set audio.sink.volume +5% enum Kind { volume, brightness } state kind = volume state shown = false on change audio.sink.volume, audio.sink.muted { kind = volume; shown = true } on change brightness.level { kind = brightness; shown = true } on change audio.sink.volume, audio.sink.muted, brightness.level after 1.2s { shown = false } let level = match kind { volume => audio.sink.muted ? 0 : audio.sink.volume, brightness => brightness.level, } osd Level { // focused monitor, overlay, click-through anchor: bottom; margin: 0, 0, 96; open: shown enter { opacity: 0; scale: 0.9 } row { width: 260; pad: $space.3; gap: $space.3; align: center radius: full; bg: $surface.alpha(0.8); blur: 24; shadow: $elevation.lg icon kind == volume ? audio.sink.icon : "display-brightness-symbolic" { size: 20 } meter level { grow: 1; height: 6; color: $accent; track: $fg.alpha(0.15) } text pct(level) { width: 4ch; align: end; font: $font.mono } } } ``` The OSD behaves exactly as before. **`match kind { … }` picks the arm whose pattern matches.** Each arm is `pattern => value`. A match on an enum must cover every variant, so if you add `mic` to `Kind` later, Strand points at this `match` until you add a `mic =>` arm. `_ =>` matches anything that is left. In the second arm, `brightness` before the arrow is the `Kind` variant, since a pattern on `kind` can only be a `Kind`; `brightness.level` after the arrow is the service. ## Notifications Strand is a notification server. Make `toasts.strand`: ```strand // toasts.strand export state dnd = false persist let shown = notifications.popups.filter(n => !dnd || n.urgency == critical).take(5) panel Toasts { screens: focused; layer: overlay; anchor: top_right; margin: $space.3; keyboard: none open: shown.len > 0 col { width: 380; gap: $space.2 for n in shown { Toast n } // keyed by n.id automatically } } component Toast(n: Notification) { col { pad: $space.3; gap: $space.2; radius: $radius.lg; border: 1, $border bg: $surface.alpha(0.88); blur: 24; shadow: $elevation.lg enter { x: 420; opacity: 0 } exit { x: 420; opacity: 0; height: 0 } // siblings slide up to fill the gap when hover { bg: $surface.hi } when n.urgency == critical { border: 1, $error } after n.timeout ?? 6s while !hover && n.urgency != critical { n.expire() } on click { n.activate() } on secondary { n.dismiss() } row { gap: $space.2 image n.image ?? n.app.icon { size: 36; radius: $radius.sm } col { grow: 1 text join(" · ", n.app.name, n.summary) { weight: 600; ellipsis: end } text n.body { markup: basic; max_lines: 4; color: $fg.muted } } icon "window-close-symbolic" { size: 14; on click { n.dismiss() } } } if n.actions.len > 0 { row { gap: $space.1 for a in n.actions { button a.label { grow: 1; on click { a.invoke() } } } } } } } ``` Send yourself one from a terminal: ``` notify-send "Hello" "from Strand" ``` A card slides in at the top right of the focused monitor and slides out six seconds later. Point at it and it stays: the six seconds pause while you hover. Click it for its default action (usually, open what sent it); right-click it or click the cross to dismiss it. Send several and they stack; dismiss one and the ones below slide up. Only one program can be the notification server. If dunst, mako or another daemon is running, Strand says so instead of starting this one; stop it first. **A `panel` is a surface you place.** `screens: focused` puts it on the focused monitor only, `layer: overlay` above windows, `anchor: top_right` in that corner. `keyboard: none` means it never takes your keyboard away from the window you are typing in. It is open while there is something to show. **`n => …` is a lambda, a small function written inline.** `notifications.popups.filter(n => !dnd || n.urgency == critical)` keeps each popup for which the lambda is true: all of them normally, only the critical ones while do-not-disturb is on. `.take(5)` keeps the first five. Both keep each notification's key, so the `for` still knows which card is which as notifications come and go. **`after T while cond { … }` is a one-shot timer.** It runs once per card, after `cond` has held for `T` in total. While the pointer is on the card, `!hover` is false and the timer pauses; it picks up where it left off when the pointer leaves. A critical notification never counts down at all. `T` is the sender's timeout, or six seconds when it gave none. `n.expire()` ends the popup, as a timeout should. Once means once: after its body has run, the timer is done for as long as the card is on screen, and `cond` turning false and true again does not start it over. So it is not a grace delay. To do something each time a condition ends, a little after it ends (close a hover popup 200 ms after the pointer leaves it, unless the pointer comes back), use `on change … after` and check the condition in the body: ``` on change card.hover after 200ms { if !card.hover { open = false } } ``` There is a repeating twin, `every T while cond { … }`, which runs every `T` while `cond` holds. The lock screen below uses one. **`exit { … }` is written out here.** Without it, `exit` mirrors `enter` (level 3). This card enters sliding from the right, and leaves the same way while also shrinking its height to 0, so the cards below slide up as it goes rather than jumping. **`export state dnd = false persist` does two things.** - `persist` keeps the value across restarts. Turn do-not-disturb on, log out, log in: it is still on. - `export` lets the command line reach it as `toasts.dnd` (the file's name, then the value's). Bind a key to `strand toggle toasts.dnd` and do-not-disturb is one keypress. Without `export`, a top-level `state` or `let` belongs to its file: no other file and no command can read it. **A kept value and a changed default.** Say you later change the default to `true`. If you never turned do-not-disturb on or off, the new default simply applies. If you did, Strand keeps your value, and the reload overlay says it kept it. To throw your value away and start from the new default, put `@reset` in front of the declaration, `@reset export state dnd = true persist`, and save: that cell, and only that cell, starts over. Then take `@reset` out again: while it is there, every save (of any file) resets the cell again. ### Clear them all, one by one Replace `toasts.strand`: ```strand // toasts.strand export state dnd = false persist let shown = notifications.popups.filter(n => !dnd || n.urgency == critical).take(5) panel Toasts { screens: focused; layer: overlay; anchor: top_right; margin: $space.3; keyboard: none open: shown.len > 0 col { width: 380; gap: $space.2 button "Clear all" { on click { for n in notifications.popups { n.dismiss(); await sleep(80ms) } } } for n in shown { Toast n } // keyed by n.id automatically } } component Toast(n: Notification) { col { pad: $space.3; gap: $space.2; radius: $radius.lg; border: 1, $border bg: $surface.alpha(0.88); blur: 24; shadow: $elevation.lg enter { x: 420; opacity: 0 } exit { x: 420; opacity: 0; height: 0 } // siblings slide up to fill the gap when hover { bg: $surface.hi } when n.urgency == critical { border: 1, $error } after n.timeout ?? 6s while !hover && n.urgency != critical { n.expire() } on click { n.activate() } on secondary { n.dismiss() } row { gap: $space.2 image n.image ?? n.app.icon { size: 36; radius: $radius.sm } col { grow: 1 text join(" · ", n.app.name, n.summary) { weight: 600; ellipsis: end } text n.body { markup: basic; max_lines: 4; color: $fg.muted } } icon "window-close-symbolic" { size: 14; on click { n.dismiss() } } } if n.actions.len > 0 { row { gap: $space.1 for a in n.actions { button a.label { grow: 1; on click { a.invoke() } } } } } } } ``` Send three notifications and click "Clear all": the cards leave one after another, 80 ms apart, instead of all at once. A card can show more of what the sender says: `meter n.progress ?? 0` for a download's progress (0 to 1), `n.category` (`email.arrived`) to pick an icon, and, when `n.can_reply` is true (a chat app), an `input` whose `on activate { n.reply(draft) }` sends the reply back to the app. **`await` waits inside a handler.** `sleep(80ms)` returns an `Async` (next section) that finishes after 80 ms, and `await` pauses the handler until it does. While it waits, the rest of the shell runs on: other handlers, timers, animations. `for` in a handler runs its block once for each item of the list as it was when the loop started. A handler that is waiting is cancelled when its node goes away. That is why the button is always in the panel rather than inside an `if shown.len > 1`: with one card left, the `if` would remove the button, and the loop with it, before the last card went. ## An app launcher Make `launcher.strand`: ```strand // launcher.strand. Bind a key to: strand toggle launcher.open export state open = false state query = "" let hits = apps.search(query) // Async<[Hit]>: fuzzy + frecency, keeps last result panel Launcher { screens: focused; layer: overlay; anchor: center; keyboard: exclusive open: <-> open // Escape, click-away and focus loss write false enter { opacity: 0; scale: 0.96 } // animated by the compositor, no repaint on show { query = "" } col { width: 600; pad: $space.3; gap: $space.2; radius: $radius.xl; clip: true bg: $surface.alpha(0.82); blur: 32; shadow: $elevation.xl input { text: <-> query; placeholder: "Search apps"; font: $font.title; focus: true; nav: results } list { id: results; max_height: 420 // virtualised; arrows move selection for h in hits { row { pad: $space.2; gap: $space.3; radius: $radius.md; align: center when hover { bg: $surface.hi } when selected { bg: $accent.container } on activate { h.app.launch(); open = false } image h.app.icon { size: 32 } col { grow: 1 text h.app.name { marks: h.ranges; mark_color: $accent; weight: 600 } text h.app.comment ?? "" { font: $font.caption; color: $fg.muted; ellipsis: end } } } } } if hits.len == 0 && query != "" && !hits.pending { text "No matches" { color: $fg.muted } } } } ``` Bind a key such as Super+D to open it, as above: ``` strand toggle launcher.open ``` Press it: a search box fades in at the centre of the screen. Type a few letters and the apps that match appear, the matched letters in the accent colour, the apps you use most near the top. Arrow keys move the selection, Return launches it, Escape or a click elsewhere closes the launcher. Open it again and the box is empty. `launcher.strand`: ```strand // launcher.strand. Bind a key to: strand toggle launcher.open export state open = false state query = "" let hits = apps.search(query) // Async<[Hit]>: fuzzy + frecency, keeps last result panel Launcher { screens: focused; layer: overlay; anchor: center; keyboard: exclusive open: <-> open // Escape, click-away and focus loss write false enter { opacity: 0; scale: 0.96 } // animated by the compositor, no repaint on show { query = "" } col { width: 600; pad: $space.3; gap: $space.2; radius: $radius.xl; clip: true bg: $surface.alpha(0.82); blur: 32; shadow: $elevation.xl input { text: <-> query; placeholder: "Search apps"; font: $font.title; focus: true; nav: results } list { id: results; max_height: 420 // virtualised; arrows move selection for h in hits { row { pad: $space.2; gap: $space.3; radius: $radius.md; align: center when hover { bg: $surface.hi } when selected { bg: $accent.container } on activate { h.app.launch(); open = false } image h.app.icon { size: 32 } col { grow: 1 text h.app.name { marks: h.ranges; mark_color: $accent; weight: 600 } text h.app.comment ?? "" { font: $font.caption; color: $fg.muted; ellipsis: end } } } } } if hits.len == 0 && query != "" && !hits.pending { text "No matches" { color: $fg.muted } } } } ``` **`apps.search(query)` returns an `Async`.** Searching takes a moment, so the result is not a list but an `Async<[Hit]>`, a value that is still arriving. It keeps its last result while the next search runs, so the list never blanks while you type. It also tells you how it is going: - `hits.pending` is true while a search is running; - `hits.error` is the reason the last one failed, or `null`; - `hits ?? []` is the result, or `[]` while there is none yet. You cannot forget the waiting case: using an `Async` where a plain value is expected is an error. A list is read through in the two ways the launcher uses: `for h in hits` and `hits.len` read the last result, empty before the first one. `!hits.pending` in the last line stops "No matches" from flashing while a search is still running. An app may offer more than starting it: `h.app.actions` lists its extra actions (a new window, a private one), each with a `name` to show and `a.launch()` to start it, for a menu beside the hit. **`list { … }` is a container for long lists.** It looks like a `col`, but it only builds the rows you can see, so a thousand apps cost no more than ten. It also has a selection: arrow keys move it, `selected` is true on the selected row, and Return fires that row's `on activate`. **`id: results` names a node, and `nav: results` points at it.** The input keeps the keyboard focus so you can type, and `nav:` sends the arrow keys and Return to the list named `results`. An `id:` also lets you read another node's booleans, as in `when results.hover { … }`. **`keyboard: exclusive`** takes the keyboard while the launcher is open, so you type into it without clicking it first. While it has the keys, `keyboard.alt`, `ctrl`, `shift` and `logo` say which modifiers you hold: a window switcher opened on Alt+Tab can pick on Alt's release with `on change keyboard.alt { if !keyboard.alt { … } }`. **`open: <-> open`** works as the popup's did at level 3: the toggle opens it, and Escape, a click away or focus moving elsewhere writes `false` back. **`on show { query = "" }`** runs each time the surface opens. Every surface has `on show` and `on hide`. **`await` is the handler-side way to wait for an `Async`.** A binding reads `.pending` and shows what there is so far; a handler that needs the finished value waits for it with `await`, as "Clear all" waited for `sleep`. ## A lock screen Make `lock.strand`: ```strand // lock.strand. Bind a key, or your idle daemon, to: strand set lock.locked true export state locked = false lock { open: <-> locked state secret = "" state dots = 1 every 400ms while auth.busy { dots = dots % 3 + 1 } let checking = match dots { 1 => "Checking.", 2 => "Checking..", _ => "Checking..." } bg: $surface col { width: 100%; height: 100%; align: center; justify: center; gap: $space.3 text clock.format("%H:%M") { font: $font.title } input { width: 280; type: password; placeholder: "Password"; text: <-> secret; focus: true on activate { if secret != "" { auth.submit(secret) }; secret = "" } } if auth.busy { text checking { color: $fg.muted } } else if auth.failed { text "Wrong password" { color: $error } } } } ``` Before you lock anything, check the file: `strand check ~/.config/strand` must report no error in `lock.strand`. Then bind a key to: ``` strand set lock.locked true ``` An idle daemon can run the same command. Locking before the machine sleeps needs care: `strand set` returns as soon as `locked` is written, and the compositor puts the lock up a moment later. If the machine suspends in between, your unlocked desktop shows for an instant when it wakes. With swayidle, `-w` makes it wait for the command before the machine sleeps, and a short pause after `strand set` gives the lock time to come up: ``` swayidle -w before-sleep 'strand set lock.locked true && sleep 1' ``` To lock after ten idle minutes without a daemon, add to `lock.strand`: `on change idle.after(600s) { if idle.after(600s) { locked = true } }`. Locking before the machine sleeps still needs swayidle's `before-sleep`, as above. Press the key: every monitor is covered, with the time and a password field. Type your password and press Return: "Checking..." counts its dots while the password is checked, and the session unlocks. A wrong password says so and empties the field. **`lock { … }` is the lock screen.** It uses the compositor's session lock (`ext-session-lock`), so while it is up nothing else on your desktop takes input, and if Strand itself stops, the session stays locked. A config has at most one `lock`, and it needs no name. - It is shown while `open:` is true. `strand set lock.locked true` locks, and a successful unlock writes `false` back through `<->`. - **`auth.submit(secret)` checks the password** in a small separate helper, through PAM: the `strand` PAM service, or `login` with a one-time warning when `/etc/pam.d/strand` is missing. Only a success unlocks. A `lock` in which nothing calls `auth.submit` is an error: it would have no way out. - `auth.busy` is true while a password is being checked, and `auth.failed` after one was refused. The field sends nothing when it is empty, because an empty Return pressed to wake the screen would otherwise count as a failed attempt. - `auth.message` is what PAM last told you, as it says it: "Place your finger on the fingerprint reader" when your PAM stack has a fingerprint reader, which then unlocks with no password at all. A lock that might meet one shows it: `if auth.message != "" { text auth.message }`. - It shows on every monitor, like a bar, with `screen` in scope on each, and on a monitor you plug in while locked too. Its `state` is one for all of them: there is one password field however many monitors show it, and your keys go to whichever monitor the compositor gives them, so the dots you type show everywhere; when the compositor moves the keys to another monitor, they carry on in the field. Because that `state` is shared, it cannot start from `screen` (`state w = screen.width` in a lock is an error); read `screen` in props or a `let` instead: `let big = screen.width > 2000`. Its timers and `on change` handlers are the lock's too, so they run once, not once per monitor: the `every 400ms` above steps the dots one at a time however many monitors you have, and so it cannot read `screen` either. An element's own handlers, such as the field's `on activate`, run on the monitor where they happen. Each monitor's lock is a surface of its own, about 7 MB on a 1280×720 monitor. `screens: focused` puts it on the focused monitor only, with a plain background in the lock's colour on the others. - Which layer, where and how it takes the keyboard are the session lock's to decide: above everything, filling the monitor, always. Setting `layer`, `anchor` or `keyboard` on a `lock` is a warning that says so. - **It fails closed.** If anything in your lock faults, a built-in password field appears in its place, so you can always unlock. A fault on one monitor only leaves that monitor plain and the others as they were. Edits to the lock that you save while locked wait until you unlock. **`every 400ms while auth.busy { … }`** is the repeating timer from the toasts section. While a check runs it steps `dots` through 1, 2, 3, 1, … (`%` is the remainder); when the check ends it stops, and nothing runs until the next one. **`match dots { 1 => …, 2 => …, _ => … }`** matches numbers too. A match on a number has no fixed list of cases, so it must end with `_`. **`let checking`** sits inside the `lock`, next to its `state`. A `let` can go in a surface's or component's tree as well as at the top of a file, next to the values it reads. ## An admin password dialog Some programs need your password before they change the system: `pkexec`, a settings page adding a printer or a Wi-Fi network for every user. They ask polkit, and polkit asks your session's *authentication agent* to show a dialog. Strand can be that agent. Make `polkit.strand`: ```strand // polkit.strand. Shown when a program asks polkit for your password panel Polkit { screens: focused; layer: overlay; anchor: center; keyboard: exclusive open: polkit.request != null state answer = "" on show { answer = "" } col { width: 420; pad: $space.4; gap: $space.3; radius: $radius.xl bg: $surface; shadow: $elevation.xl text "Authentication needed" { font: $font.title } text polkit.request?.message ?? "" if polkit.request?.command != null { text polkit.request?.command ?? "" { color: $fg.muted } } text "Password for " + (polkit.request?.user ?? "") { color: $fg.muted } input { type: (polkit.request?.echo ?? false) ? text : password placeholder: polkit.request?.prompt ?? ""; text: <-> answer; focus: true on activate { if answer != "" { polkit.respond(answer) }; answer = "" } } if polkit.busy { text "Checking..." { color: $fg.muted } } else if polkit.failed { text "Wrong password, try again" { color: $error } } else if (polkit.request?.info ?? "") != "" { text polkit.request?.info ?? "" } row { gap: $space.2 box { pad: $space.2; radius: $radius.md; on click { polkit.cancel() }; text "Cancel" } if polkit.waiting > 0 { text join(" ", polkit.waiting, "more") { color: $fg.muted } } } } } ``` If your compositor's config starts another agent (an `exec polkit-gnome-authentication-agent-1` or `lxpolkit` line), remove that line and log in again first: only one agent serves a session. Then try it from a terminal: ``` pkexec true ``` A dialog opens in the middle of the screen with what the program wants and whose password it needs. Type it and press Return: `pkexec` goes ahead. A wrong one says so and asks again; Cancel tells the program no. `polkit.strand`: ```strand // polkit.strand. Shown when a program asks polkit for your password panel Polkit { screens: focused; layer: overlay; anchor: center; keyboard: exclusive open: polkit.request != null state answer = "" on show { answer = "" } col { width: 420; pad: $space.4; gap: $space.3; radius: $radius.xl bg: $surface; shadow: $elevation.xl text "Authentication needed" { font: $font.title } text polkit.request?.message ?? "" if polkit.request?.command != null { text polkit.request?.command ?? "" { color: $fg.muted } } text "Password for " + (polkit.request?.user ?? "") { color: $fg.muted } input { type: (polkit.request?.echo ?? false) ? text : password placeholder: polkit.request?.prompt ?? ""; text: <-> answer; focus: true on activate { if answer != "" { polkit.respond(answer) }; answer = "" } } if polkit.busy { text "Checking..." { color: $fg.muted } } else if polkit.failed { text "Wrong password, try again" { color: $error } } else if (polkit.request?.info ?? "") != "" { text polkit.request?.info ?? "" } row { gap: $space.2 box { pad: $space.2; radius: $radius.md; on click { polkit.cancel() }; text "Cancel" } if polkit.waiting > 0 { text join(" ", polkit.waiting, "more") { color: $fg.muted } } } } } ``` **Reading `polkit` makes Strand the agent.** While a config reads the `polkit` service, Strand registers with polkit as your session's agent, and stops five seconds after nothing reads it any more, as every service does. Remove every read of `polkit` (this file, and the bar line below if you add it) and Strand is no agent. - `polkit.request` is the request waiting, or `null`. Its `message` says what it is for, `user` whose password it needs, and `prompt` what the check asks ("Password: "), as PAM says it. `echo` is true when PAM asks for something that is not a secret, which the input then shows as typed. `command` is the command it would run, with its arguments (`pkexec`'s), or `null`: show it, since `message` names only the program. - `polkit.respond(answer)` answers. The password goes to polkit's own helper, `polkit-agent-helper-1`, which checks it and tells polkit; Strand never checks it itself, and wipes it once sent. An answer reaches only the request that asked for it: one typed just as its request went is dropped, never given to the next. Pass the password to nothing else: your config sees what is typed into it, and a declared action's arguments can be read by every user in the process list. `strand check` warns when a password reaches something that shows, keeps or sends it (a `text`, an exported or persisted state, a command, another service), however many `let`s and components it passes through; `answer.len` and `answer != ""` are fine to show. - `polkit.cancel()` refuses, and the program is told so. - `polkit.busy` is true while an answer is checked, and `polkit.failed` once one was refused. - When several programs ask at once, they wait their turn: `polkit.waiting` counts them. - When several administrators could answer (the action needs an administrator, and your machine has more than one), `polkit.request?.users` lists them, and `polkit.choose(name)` asks for another one's password instead. **Only one agent per session.** A desktop that ships its own agent (GNOME's, KDE's, `polkit-gnome` started from your compositor's config) keeps it: Strand does not fight it. `polkit.available` is then false and `polkit.error` says another agent serves this session (it also says why when polkit's helper is not installed or Strand runs outside a login session); once that agent goes, Strand takes over. A bar can say so: `if !polkit.available { text polkit.error }`. Like the lock's `auth`, nothing of `polkit` is kept in a glitch report: while the dialog shows nothing is recorded and `strand report` refuses to save one. `strand call` refuses its actions: a password is typed into the dialog, never sent from a terminal. ### A login screen The same language can draw the screen you log in on. greetd, a small login manager, runs a *greeter* and tells it how to log a user in; Strand's `greetd` service speaks to it. The greeter is a config of its own, outside `~/.config/strand`, so this is not a step. Save it as `/etc/greetd/strand/greeter.strand`: ``` // /etc/greetd/strand/greeter.strand: the login screen greetd shows state user = "" state answer = "" panel Login { layer: overlay; anchor: center; keyboard: exclusive open: true col { width: 360; pad: $space.4; gap: $space.3; radius: $radius.xl bg: $surface; shadow: $elevation.xl text clock.format("%H:%M") { font: $font.title } input { placeholder: "User"; text: <-> user; focus: greetd.prompt == "" on activate { if user != "" { greetd.login(user) } } } if greetd.prompt != "" { input { type: greetd.echo ? text : password; placeholder: greetd.prompt text: <-> answer; focus: true on activate { greetd.submit(answer); greetd.start(["sway"]); answer = "" } } } if greetd.busy { text "Logging in..." { color: $fg.muted } } else if greetd.failed { text greetd.error { color: $error } } if greetd.message != "" { text greetd.message { color: $fg.muted } } } } ``` Strand draws on a compositor, so greetd runs one for it: sway here, but any compositor with layer-shell does (cage, which shows one app window, does not). Point greetd at it in `/etc/greetd/config.toml`, with the user your distribution made for the greeter (`greeter` on Arch, `_greetd` on Debian and Ubuntu): ``` [default_session] command = "sway --config /etc/greetd/sway-config" user = "greeter" ``` and make `/etc/greetd/sway-config` run the greeter and close sway after it: ``` exec "strand run /etc/greetd/strand; swaymsg exit" ``` The greeter user must be able to run `strand` (installed system-wide, on its `PATH`) and read `/etc/greetd/strand`. Type your name and press Return: greetd asks for your password, and the field for it appears. Return again logs you in and starts sway as you. - `greetd.login(user)` starts a login; greetd's questions arrive as `greetd.prompt` (`greetd.echo` when the answer may be shown) and PAM's other texts as `greetd.message`. - `greetd.submit(answer)` answers. A wrong one sets `greetd.failed` and `greetd.error`, and the same user is asked again. Typed before greetd asks, it answers the first question only when that one is secret. As with the dialog, pass the answer to nothing else. - `greetd.start(["sway"])` is the session to start, each text one word. Called right after the answer, it waits for the login to succeed; greetd then starts it, and `strand run` exits so it can. - `greetd.cancel()` drops the login under way. Run outside greetd, or on a socket neither root nor your own user serves, `greetd.available` is false and every action sets `greetd.error` instead. While the login screen shows, nothing is recorded for a glitch report. ## A settings window Everything so far floats above your windows and goes away by itself. A settings page is different: you open it, tile it beside your editor or move it aside, switch to something else and come back to it, and close it when you are done. That is a window. Make `settings.strand`: ```strand // settings.strand. Bind a key to: strand toggle settings.open export state open = false window Settings { open: <-> open // its close button writes false title: "Settings" width: 360 bg: $surface; font: $font.ui; color: $fg id: w col { pad: $space.4; gap: $space.3 if !w.decorated { // no title bar from the compositor row { id: bar; align: center on change bar.pressed { if bar.pressed { w.move() } } // drag the title bar to move it text "Settings" { grow: 1; font: $font.title } button "Close" { on click { w.close() } } } } text "Volume" { color: $fg.muted } row { gap: $space.2; align: center slider { grow: 1; value: <-> audio.sink.volume } button audio.sink.muted ? "Unmute" : "Mute" { on click { audio.sink.muted = !audio.sink.muted } } } if brightness.available { text "Brightness" { color: $fg.muted } slider { value: <-> brightness.level } } } } ``` Bind a key to it, as the launcher's: ``` strand toggle settings.open ``` Press it: a window titled "Settings" opens, as any app's would. A tiling compositor tiles it beside your other windows and a floating one places it; you move, resize, maximise and close it the way you do every window, and it shows in your task bar and Alt+Tab. Drag the volume slider and the bar's speaker follows. `settings.strand`: ```strand // settings.strand. Bind a key to: strand toggle settings.open export state open = false window Settings { open: <-> open // its close button writes false title: "Settings" width: 360 bg: $surface; font: $font.ui; color: $fg id: w col { pad: $space.4; gap: $space.3 if !w.decorated { // no title bar from the compositor row { id: bar; align: center on change bar.pressed { if bar.pressed { w.move() } } // drag the title bar to move it text "Settings" { grow: 1; font: $font.title } button "Close" { on click { w.close() } } } } text "Volume" { color: $fg.muted } row { gap: $space.2; align: center slider { grow: 1; value: <-> audio.sink.volume } button audio.sink.muted ? "Unmute" : "Mute" { on click { audio.sink.muted = !audio.sink.muted } } } if brightness.available { text "Brightness" { color: $fg.muted } slider { value: <-> brightness.level } } } } ``` **`window Settings { … }` is an app's window**, where `panel` is a part of the shell. - It is shown while `open:` is true. The compositor's close (the title bar's button, your close-window key) and `w.close()` write `false` back through `<->`. Bind it two-way: with `open: open` or no `open:` at all, `strand check` warns that the close would do nothing. Escape and clicking another window leave it open. - The compositor decides where it goes and how large it is. `width: 360` is the size it asks for (its content's while unset, as a panel's), `min_width` and `max_width` the sizes it takes; tiled or maximised, it is laid out at the size it is given. So `screens`, `layer`, `anchor`, `margin` and `keyboard` (and the panel's `attach` and `scrim`) are errors on a window: those are the compositor's. - `title:` is the name your task bar and window switcher show. Its app id, which compositor window rules match, is `strand-Settings` (`app_id:` changes it). - The compositor draws its title bar and borders where it offers to. One that does not leaves them to the window, and `w.decorated` (on its `id:`, as `popout.open` is a popup's at level 3) is false: the `if` draws a title and a Close button then. `w.focused`, `w.maximized`, `w.fullscreen` and `w.tiled` follow the compositor too. `w.maximize()`, `w.minimize()` and `w.fullscreen()` ask it from any handler. `w.move()` and `w.resize(edge)` start a drag from the press, so call them while the button is still down, from `on change bar.pressed` as the title bar above does, not from `on click`, which runs at the release, when the compositor no longer takes them. With those, a title bar of your own does what a compositor's does (`decorations: none` asks for that always). **Panel or window?** A panel belongs to the shell: it sits on a layer over or under every window, where you anchor it, on the monitors you name, and Escape or a click elsewhere puts it away (a launcher, a control centre, a calendar). A window belongs among your windows: the compositor places it, tiles it, lists it and focuses it, and it stays until you close it (settings, a picker, a tool you keep open). If you would want to tile it, move it to another workspace or find it again in Alt+Tab, make it a window. ## What you have Your level-3 bar, plus six surfaces that appear when they are needed. | File | Holds | Opened by | | --- | --- | --- | | `osd.strand` | the `Level` OSD | a volume or brightness change | | `toasts.strand` | the `Toasts` panel and `Toast` | a notification | | `launcher.strand` | the `Launcher` panel | `strand toggle launcher.open` | | `lock.strand` | the lock screen | `strand set lock.locked true` | | `polkit.strand` | the `Polkit` password dialog | a program asking polkit | | `settings.strand` | the `Settings` window | `strand toggle settings.open` | Next, [level 5](/guide/level-5.html.md) gives the shell a theme of your own, then builds services of your own, a control centre and a dock. --- # Level 5: your own theme, services and effects You learn: `tokens`, spring overrides `~`, `service from dbus`, `pages`, drag and drop, shaders, `file`; also `set { }`, `keyframes` and `play`, `canvas`, `type` and `key`, `slot`, `fn`, a node's `box` and `pointer`, settings files, SVG layer selectors, and `service … from file | dir | listen | poll` with `permit exec`, and `action`. You build: a theme file, a CSS file of your colours that other programs can read, services Strand does not ship, a control centre and a dock. This is the last level. After it you have met every construct in the language; [`docs/spec.md`](https://github.com/jakeb-grant/strand/blob/main/docs/spec.md) has the full rules for each. ## 1. A theme of your own Until now your shell has used the built-in theme. Make `theme.strand`: ```strand // theme.strand enum Look { auto, light, dark, wallpaper, mocha } export state look = auto persist state prefs from "~/.config/strand/prefs.toml" { accent: color = #7aa2f7; wallpaper: path = "~/Pictures/wall.jpg"; compact: bool = false } let dark = match look { auto => system.dark, light => false, _ => true } let scheme: Palette = match look { wallpaper => material(image: prefs.wallpaper, variant: tonal_spot, dark: dark) ?? material(seed: prefs.accent, dark: dark), // while quantising, or if missing mocha => import("catppuccin:mocha"), _ => material(seed: system.accent ?? prefs.accent, dark: dark, contrast: system.contrast), } tokens base { space { 1: 4px; 2: 8px; 3: 12px; 4: 16px } radius { sm: 6px; md: 10px; lg: 14px; xl: 20px; full: 999px } font { ui: "Inter" 13px 500; title: "Inter" 18px 600; caption: "Inter" 11px; mono: "JetBrains Mono" 12px } motion { spatial: spring(3000, 0.75); effects: spring(1600, 1); bouncy: spring(380, 0.75) } elevation { md: 0 2px 8px $shadow.alpha(0.25) lg: 0 8px 24px $shadow.alpha(0.3), 0 1px 2px $shadow.alpha(0.2) xl: 0 16px 48px $shadow.alpha(0.4) } surface.hi: $surface.mix($fg, 8%) fg.muted: $fg.alpha(0.65) fg.faint: $fg.alpha(0.35) accent.hover: $accent.mix($on_accent, 8%) accent.container: $accent.alpha(0.22) border: oklch(from $surface, l: l + 0.12) } tokens compact extends base { override space { 1: 2px; 2: 4px; 3: 8px; 4: 12px } } use tokens prefs.compact ? compact : base, palette scheme file "~/.cache/strand/accent.txt" { text: $accent } // kept in step with every switch ``` Little changes at first: these are the built-in theme's values, written out. (If Inter or JetBrains Mono is not installed, put fonts you have in the `font` line.) Now try: ``` strand set theme.look mocha strand set theme.look light strand set theme.look auto ``` Each time, every colour in every surface glides to the new palette, and nothing reloads: the open launcher keeps its query, the calendar its month. The value is persisted, so the look you leave it on is the one you log in to. **Tokens come in three tiers.** | Tier | What it holds | Here | | --- | --- | --- | | Palette | The colour roles: Material 3's roles (`$surface`, `$fg`, `$accent`, `$on_accent`, `$error`, `$outline`, …) | `use … palette scheme` | | Base tokens | Scales, and roles derived from the palette | `tokens base { … }` | | Component tokens | Knobs a component of yours exposes | section 4 | **A palette is a value.** `material(seed: c, dark: d)` builds the whole set of roles from one colour, the way Android's Material You does. `material(image: path)` builds it from a picture: your wallpaper. `import("catppuccin:mocha")` reads a published scheme (Catppuccin, base16 and base24, matugen output and W3C design-token files). Every source fills every role; a role a source lacks is derived. `material(image: …)` takes a moment, so it is an `Async` (level 4), and `?? material(seed: …)` is the palette to use until the wallpaper is read, or if the file is missing. The wallpaper file is watched: replace it, or point the setting at another, and the theme follows. Until the new palette is ready the old one stays, so the screen never flashes default colours. **Hue roles.** Beside the Material roles, every palette has thirteen colours for things that must stay recognisably coloured, such as a green "charged" or a red "recording": `$red`, `$orange`, `$yellow`, `$green`, `$cyan`, `$blue`, `$magenta`, and `$bright_red` through `$bright_magenta` (no bright orange). A scheme that has these hues (base16, Catppuccin) gives its own; a seed or a wallpaper gets each one nudged toward your accent so they sit well together. **`tokens base { … }` declares base tokens.** A group (`space { 1: 4px; … }`) gives `$space.1` to `$space.4`. A token can be an expression over the palette: `fg.muted: $fg.alpha(0.65)` stays a softer `$fg` whatever `$fg` becomes, frame by frame while a palette glides. **`tokens compact extends base { … }`** starts from `base` and changes some of it. Changing a token you inherited needs the word `override`, so a typo is an error ("unknown token") instead of a new token nobody reads. **`use tokens …, palette …` picks the set and the palette in force.** Both are expressions, so the choice is live: `prefs.compact ? compact : base` switches the spacing when the setting changes. **`let dark = match look { … }`** turns your `Look` into light or dark: `auto` follows the desktop (`system.dark`), `light` is light, and `_`, every other look, is dark. `let scheme: Palette = match look { … }` picks the palette the same way. **`state prefs from "~/.config/strand/prefs.toml" { … }` is a settings file.** Each field is `name: type = default`, read as `prefs.accent`. It is a plain TOML file you, or anyone, can edit by hand: ```toml # ~/.config/strand/prefs.toml accent = "#e06c75" compact = true ``` Save it and the shell follows, field by field. A field with a bad value keeps its last good value and says why; the others still apply. A broken file keeps every last good value. A deleted line goes back to its default. It works the other way too. `strand set prefs.compact true` writes the file, and so would a `<->` binding or a handler assigning `prefs.compact = true`. Writes keep your comments, spacing and order. **A theme switcher anywhere** is one line, which the control centre in section 4 uses: `segmented { options: Look; value: <-> theme.look }`. **The last line keeps a file in step with the theme.** Open `~/.cache/strand/accent.txt`: it holds your accent colour, `#rrggbb`, and every switch of the look rewrites it. Section 2 explains `file`. **Trying a colour without saving it.** `strand set '$accent' '#ff5555'` sets a token for the running shell only, over whatever the theme says, and `strand clear '$accent'` takes it off. (Quote both: a shell reads `$accent` as a variable and `#` as a comment.) While it is set, it wins over your files, and when a save changes what it hides, the reload overlay says so and offers to clear it. ## 2. Your colours for other programs Strand ships no themes for other programs. It writes your palette in forms any program, or any of the community's base16/base24 templates, can use. Make the template, `colors.css.tmpl`, in your config directory. It is not a `.strand` file: it is the text to write, with holes. ```css level=5 file=colors.css.tmpl :root { --accent: {{ $accent }}; --on-accent: {{ $on_accent }}; --surface: {{ $surface }}; --fg: {{ $fg }}; --fg-muted: {{ hex($fg.muted, alpha: true) }}; --glow: {{ hex($accent.alpha(0.4), alpha: true) }}; } ``` Then make `colors.strand`: ```strand // colors.strand: keeps ~/.cache/strand/colors.css in step with the theme file "~/.cache/strand/colors.css" { template: "colors.css.tmpl" } ``` Open `~/.cache/strand/colors.css`: each hole is replaced by its value, a colour written `#rrggbb`. Run `strand set theme.look mocha` and the file is rewritten with Catppuccin's colours, once, after the switch (never with the colours halfway through it). A stylesheet, a web dashboard or a script can read these custom properties. **A hole is `{{ expression }}`**, any expression over your tokens. A colour on its own is written `#rrggbb`. `hex(c, alpha: true)` writes `#rrggbbaa`, which `$fg.muted` needs since it is translucent; a translucent colour without `alpha: true` is an error, never quietly made opaque. `argb(c)` writes `0xaarrggbb`. A literal `{{` is `{{ "{{" }}`. An error names the template's line and column, and nothing is written until it is fixed. Templates can read a little more than props can: - `$surface.light()` and `$surface.dark()` are a token in the other mode, so one file can carry both: with `look` on `auto` and a dark desktop, `{{ $surface.light() }}` is the surface you would have in light mode. When the theme gives the same mode either way (`mocha` is always dark), there is no other mode, and the hole needs a fallback: `{{ $surface.light() ?? $surface }}`. - `.red()`, `.green()` and `.blue()` (0 to 255) and `.opacity()` (0 to 1) take a colour apart, for formats that want numbers. - `$palette.name` is the scheme's name, and `$palette.mauve`, `$palette.base0d` and so on are the source's own colours by their own names. They work in props too: a config ported from a Catppuccin shell can say `bg: $palette.mantle ?? $surface`, the fallback for a palette that has no `mantle`. **`file "path" { … }`** keeps one file in step. It may stand at the top level of any `.strand` file, as here and in `theme.strand`, since it draws nothing. Two `file`s may not write the same path. It writes only when the text would change, atomically, and catches up when the shell starts. It never overwrites a file it did not write: if `colors.css` already exists, Strand asks first. Removing the `file` stops the updates and leaves the file where it is. Editing `colors.css.tmpl` rewrites it at once. For one line, `text:` replaces the template file, as in the last line of `theme.strand`: `file "~/.cache/strand/accent.txt" { text: $accent }`. To tell a program its colours changed, add `run:`, a command run 200 ms after the file is written. Running a program needs your permission, `permit exec` (section 3): ``` permit exec "notify-send" file "~/.cache/strand/colors.css" { template: "colors.css.tmpl"; run: ["notify-send", "Colours updated"] } ``` **`strand export`** writes the same things once, to standard output or, atomically, to a file with `-o`: ``` strand export json # the palette as data strand export base24 -o ~/.cache/strand/base24.yaml # for base16/base24 templates strand export --template ~/.config/strand/colors.css.tmpl # any template of yours ``` It asks the running shell; with none running it works the theme out from your config, and says "run strand once, or fix the theme" if it cannot. ## 3. Services of your own Strand's services cover the usual things; look in the [reference](/reference/services/index.html.md) before reaching for `/proc` or the compositor's command line: - audio, with each program's stream and its own volume (`audio.streams`: a microphone light is `audio.streams.any(s => s.direction == input)`), and the programs taking video (`video.streams`) for a camera or screen-cast light; - battery, with the charge UPower recorded over past hours (`battery.history(10800s)`); - network, with the bytes a second in and out (`network.rx`, `network.tx`) and your saved connections, a VPN toggle being `c.up()` and `c.down()` on an item of `network.connections`; - media, with every player (`media.players`) and `p.select()` to make one the player `media.*` follows; - memory: in use, cached, free and swap; - the compositor: its binding mode (`wm.mode`), each window's place (`win.rect`) and `win.move_to(ws)`, the keyboard's layouts (`keyboard.next_layout()`); - idle: a screensaver is `panel Saver { open: idle.after(300s) }`, and `inhibit_idle: caffeine` on a bar keeps the screen awake while `caffeine` is true, with no idle daemon or `systemd-inhibit`. For the rest, you declare one. Make `services.strand`: ```strand // services.strand: services Strand does not ship service ppd from dbus system "net.hadess.PowerProfiles" { profile: text rw = ActiveProfile } service host from file "/proc/sys/kernel/hostname" { name: text } service temps from poll ["sensors", "-j", "coretemp-*"] every 5s { cpu: float = temp1_input } permit exec "sensors" ``` Nothing shows yet; the control centre in the next section reads all three. A service starts when something first reads it and stops a few seconds after the last reader goes, as the built-in ones do. **`from dbus`** reads a D-Bus object. `ppd` is the power profiles daemon on the system bus, and its `profile` field is the daemon's `ActiveProfile` property. `rw` marks it writable: assigning `ppd.profile = "power-saver"` in a handler (or binding it with `<->`) sets the property, which the control centre below does when you click a profile. Strand checks every field against the object's introspection, so a misspelt property is an error with a did-you-mean. If the daemon is not running, that is a warning: your config is not broken because a daemon is down. **`from file`** reads a file and watches it. The kernel keeps the name your computer runs under in `/proc/sys/kernel/hostname`: one word, on every Linux system. (`/etc/hostname` is not as dependable: some systems leave it out, and it may hold `#` comment lines.) A file that is neither JSON nor `key=value` lines is read whole, whatever the key: `host.name` is that word. Files under `/proc` change without saying so, so after you rename the machine `host.name` shows the new name once the service starts again: when Strand restarts, or after nothing has read it for a few seconds. A JSON file is read by key. So is a file of `key=value` (or `key: value`) lines such as `/etc/os-release`, with blank lines and `#` comment lines skipped and the double or single quotes around a value removed, but only when every other line is a pair: one line that is neither makes the whole file text again, and every field reads all of it. A field without `= key` reads the key with its own name. **`from dir`** lists a directory's files and watches it: `service walls from dir "~/Pictures/walls" { files: [File] }` gives `walls.files`, each file's `path`, `name`, `modified` date and `size`, by name, and a wallpaper picker is `for f in walls.files key f.path`. A file you add or delete shows at once, without polling; hidden files and folders inside are left out. **`from poll … every T`** reads again every `T`, and only while something showing reads it, or while a `graph` keeps history of it: then, hidden, it reads at the coarser of `T` and a twentieth of the graph's `history` (at least 2 s), and the graph draws each hidden read as a line from the one before. Given a file path (`"/sys/class/…"`), it reads the file. Given a command, it runs it: `sensors -j` prints JSON, and `= temp1_input` finds the first `temp1_input` in it, however deep. Values are converted to the declared type. `sensors` alone lists every chip it knows (the ACPI thermal zone, the graphics card, the disks), each with its own `temp1_input`, and the first one found is not always the CPU's. `"coretemp-*"` asks for Intel's CPU sensor only, whose `temp1_input` is the whole package. On an AMD processor write `"k10temp-*"` instead (its `temp1_input` is `Tctl`). Run `sensors` in a terminal to see the chip names your machine has. With no chip of that name, `sensors` fails every 5 s and `temps.cpu` stays `0`. Nothing marks the service failed and `strand watch` shows nothing; the only trace is a line in the terminal running `strand run` each time, ``strand: WARN strand_services::custom: `sensors` failed (exit status: 1)``. **`permit exec` lists the programs your config may run.** A service that runs a command (`from poll` of a command, or `from listen`) is an error without it, so a shared config can never run a program you did not allow. `permit exec` with no list allows any. **`from listen`** runs a command and reads each line it prints as an update, for programs that report changes as they happen: `service player from listen ["playerctl", "--follow", "status"] { status: text }`, with `permit exec "playerctl"`, has `player.status` follow your media player as it plays and pauses. Lines that come faster than a frame are merged, so the fields show the latest; when every line matters (a log, a stream of events), handle `line` instead: with `state log = ""`, `on player.line(doc) { log = log + doc.status + "\n" }` runs once a line, with that line's fields in `doc`. **`action` runs a command from a handler.** For what no service does (opening an app of your own, a compositor's dispatch, a tool with only a command line), declare the command once at the top level, its parameters in it, and call it like a function: `action dispatch(ws: int) ["hyprctl", "dispatch", "workspace", ws]`, with `permit exec "hyprctl"`, then `on click { dispatch(3) }`. Each parameter is exactly one argument of the program, as it is: a title with spaces or quotes is never split, and no shell stands between strand and the program. (The program reads the argument its own way: `["sh", "-c", cmd]` runs `cmd` as a shell script, and `strand check` warns about that; a value starting with `-` may look like an option.) The program must be written out and allowed by `permit exec`, so every command your config can run is listed where it is declared: `strand check` lists them all with their parameters. The command runs on its own, detached; if it cannot start or exits with an error, the terminal running `strand run` and `strand watch` say so. A `from dbus` service can declare actions too, calling a method of its object: `action hold(profile: text, reason: text, app: text) = HoldProfile` in `ppd`'s block is `ppd.hold("performance", "gaming", "strand")` in a handler, checked against the object's introspection as its fields are. **An action can give back what its command prints.** Write the type after the parameters: `action calc(q: text) -> text ["qalc", "-t", q]` (with `permit exec "qalc"`). Then `calc(query)` is not something to run from a click but a value that loads, like `apps.search(query)` in the launcher: `text calc(query) ?? ""` shows the answer and runs the command again each time `query` changes, ending the run before if it has not finished. While it runs, `.pending` is true and the last answer stays shown; if the program fails, `.error` says how. A `text` result is what the program printed; a number, an enum, a record or a list is read from it as a `from poll` service reads a command's output (JSON or `key=value` lines into the type's fields). In a handler, `let r = await calc(q)` waits for it. **An action's name tells whether it runs, and stops it.** A screen recorder runs until you stop it: `action rec(file: path) ["wf-recorder", "-f", file]`, with `permit exec "wf-recorder"`. `rec.running` is true while its command runs, `rec.started` is when it began (a `Date`, to the minute), and `rec.stop()` in a handler stops it the way Ctrl-C in a terminal would, so the recorder finishes its file; one that ignores that is ended after a few seconds. One button does both: `on click { if rec.running { rec.stop() } else { rec("/tmp/clip.mp4") } }`, and `text rec.running ? "Stop" : "Record"` labels it. An action with a result has the same three: `calc.running` while any of its calls runs, and `calc.stop()` ends them, each call's `.error` saying it was stopped. For a service none of these can describe, write it in Rust (section 7). ## 4. A control centre Make `control.strand`: ```strand // control.strand. Bind a key to: strand toggle control.open export state open = false enum View { main, devices } fn degrees(c: float) -> text { join("", c.round(), "°") } component Tile(label: text, active: bool = false) tokens { radius: $radius.lg; pad: $space.3 } { col { grow: 1; gap: $space.2; pad: $Tile.pad; radius: $Tile.radius; bg: $surface.hi when active { bg: $accent.container } text label { font: $font.caption; color: $fg.muted } slot // the caller's children go here } } panel Control { screens: focused; layer: overlay; anchor: top_right; margin: $space.2; keyboard: on_demand open: <-> open enter { opacity: 0; x: 24 } state view = main on show { view = main } col { width: 360; pad: $space.3; gap: $space.3; radius: $radius.xl bg: $surface.alpha(0.86); blur: 32; shadow: $elevation.xl set { $surface: $surface.mix($accent, 4%) } // a tinted surface for this panel row { align: center; gap: $space.2 text host.name { grow: 1; weight: 600; ellipsis: end } text degrees(temps.cpu) { font: $font.mono; color: $fg.muted } } pages current: view { page main { col { gap: $space.2 Tile "Appearance" { segmented { options: Look; value: <-> theme.look } } row { gap: $space.2 set { $Tile.pad: $space.2 } Tile "Volume" { slider { value: <-> audio.sink.volume } } if brightness.available { Tile "Brightness" { slider { value: <-> brightness.level } } } } Tile "Power profile" { row { gap: $space.1 for p in ["power-saver", "balanced", "performance"] key p { text p { grow: 1; font: $font.caption; color: $fg.muted when ppd.profile == p { color: $accent; weight: 600 } on click { ppd.profile = p } } } } } Tile "Bluetooth" { active: bluetooth.powered row { gap: $space.1 button bluetooth.powered ? "On" : "Off" { grow: 1; on click { bluetooth.powered = !bluetooth.powered } } button "Devices ›" { grow: 1; on click { view = devices } } } } } } page devices { col { gap: $space.1 row { align: center; gap: $space.2 button "‹" { on click { view = main } } text "Bluetooth devices" { grow: 1; weight: 600 } } for d in bluetooth.devices { row { pad: $space.2; gap: $space.2; radius: $radius.md; align: center when hover { bg: $surface.hi } on click { if d.connected { d.disconnect() } else { d.connect() } } icon d.icon { size: 20 } text d.name { grow: 1; ellipsis: end } if d.connected { text "Connected" { font: $font.caption; color: $accent } } } } } } } } } ``` Bind a key to `strand toggle control.open` and press it. A panel slides in at the top right: your system's name and CPU temperature, then tiles for the look, volume, brightness (on a laptop), the power profile in use (click one to switch) and Bluetooth. Pick "mocha" and the panel itself changes colour as you watch. "Devices ›" slides to a second page that lists your Bluetooth devices; "‹" slides back. `theme.strand`: ```strand // theme.strand enum Look { auto, light, dark, wallpaper, mocha } export state look = auto persist state prefs from "~/.config/strand/prefs.toml" { accent: color = #7aa2f7; wallpaper: path = "~/Pictures/wall.jpg"; compact: bool = false } let dark = match look { auto => system.dark, light => false, _ => true } let scheme: Palette = match look { wallpaper => material(image: prefs.wallpaper, variant: tonal_spot, dark: dark) ?? material(seed: prefs.accent, dark: dark), // while quantising, or if missing mocha => import("catppuccin:mocha"), _ => material(seed: system.accent ?? prefs.accent, dark: dark, contrast: system.contrast), } tokens base { space { 1: 4px; 2: 8px; 3: 12px; 4: 16px } radius { sm: 6px; md: 10px; lg: 14px; xl: 20px; full: 999px } font { ui: "Inter" 13px 500; title: "Inter" 18px 600; caption: "Inter" 11px; mono: "JetBrains Mono" 12px } motion { spatial: spring(3000, 0.75); effects: spring(1600, 1); bouncy: spring(380, 0.75) } elevation { md: 0 2px 8px $shadow.alpha(0.25) lg: 0 8px 24px $shadow.alpha(0.3), 0 1px 2px $shadow.alpha(0.2) xl: 0 16px 48px $shadow.alpha(0.4) } surface.hi: $surface.mix($fg, 8%) fg.muted: $fg.alpha(0.65) fg.faint: $fg.alpha(0.35) accent.hover: $accent.mix($on_accent, 8%) accent.container: $accent.alpha(0.22) border: oklch(from $surface, l: l + 0.12) } tokens compact extends base { override space { 1: 2px; 2: 4px; 3: 8px; 4: 12px } } use tokens prefs.compact ? compact : base, palette scheme file "~/.cache/strand/accent.txt" { text: $accent } // kept in step with every switch ``` `services.strand`: ```strand // services.strand: services Strand does not ship service ppd from dbus system "net.hadess.PowerProfiles" { profile: text rw = ActiveProfile } service host from file "/proc/sys/kernel/hostname" { name: text } service temps from poll ["sensors", "-j", "coretemp-*"] every 5s { cpu: float = temp1_input } permit exec "sensors" ``` `control.strand`: ```strand // control.strand. Bind a key to: strand toggle control.open export state open = false enum View { main, devices } fn degrees(c: float) -> text { join("", c.round(), "°") } component Tile(label: text, active: bool = false) tokens { radius: $radius.lg; pad: $space.3 } { col { grow: 1; gap: $space.2; pad: $Tile.pad; radius: $Tile.radius; bg: $surface.hi when active { bg: $accent.container } text label { font: $font.caption; color: $fg.muted } slot // the caller's children go here } } panel Control { screens: focused; layer: overlay; anchor: top_right; margin: $space.2; keyboard: on_demand open: <-> open enter { opacity: 0; x: 24 } state view = main on show { view = main } col { width: 360; pad: $space.3; gap: $space.3; radius: $radius.xl bg: $surface.alpha(0.86); blur: 32; shadow: $elevation.xl set { $surface: $surface.mix($accent, 4%) } // a tinted surface for this panel row { align: center; gap: $space.2 text host.name { grow: 1; weight: 600; ellipsis: end } text degrees(temps.cpu) { font: $font.mono; color: $fg.muted } } pages current: view { page main { col { gap: $space.2 Tile "Appearance" { segmented { options: Look; value: <-> theme.look } } row { gap: $space.2 set { $Tile.pad: $space.2 } Tile "Volume" { slider { value: <-> audio.sink.volume } } if brightness.available { Tile "Brightness" { slider { value: <-> brightness.level } } } } Tile "Power profile" { row { gap: $space.1 for p in ["power-saver", "balanced", "performance"] key p { text p { grow: 1; font: $font.caption; color: $fg.muted when ppd.profile == p { color: $accent; weight: 600 } on click { ppd.profile = p } } } } } Tile "Bluetooth" { active: bluetooth.powered row { gap: $space.1 button bluetooth.powered ? "On" : "Off" { grow: 1; on click { bluetooth.powered = !bluetooth.powered } } button "Devices ›" { grow: 1; on click { view = devices } } } } } } page devices { col { gap: $space.1 row { align: center; gap: $space.2 button "‹" { on click { view = main } } text "Bluetooth devices" { grow: 1; weight: 600 } } for d in bluetooth.devices { row { pad: $space.2; gap: $space.2; radius: $radius.md; align: center when hover { bg: $surface.hi } on click { if d.connected { d.disconnect() } else { d.connect() } } icon d.icon { size: 20 } text d.name { grow: 1; ellipsis: end } if d.connected { text "Connected" { font: $font.caption; color: $accent } } } } } } } } } ``` **`pages current: view { page main { … }; page devices { … } }`** shows one page at a time: the `page` whose name is `view`'s value. Changing `view` slides from one to the other. A page that is not shown is taken down, so what it reads stops: the device list is not followed while you are on the main page. Page names are values of `current`'s type, here the `View` enum's variants. **`slot` is where a component puts the children you give it.** `Tile "Volume" { slider { … } }` draws the tile's box and label, and the slider where `Tile` wrote `slot`. Every tile looks the same; what is in it is up to the caller. A component without `slot` takes no children. **`fn degrees(c: float) -> text { … }` is a function of your own.** Its body's last line is its value. A `fn` is pure: it computes a value from its arguments and cannot assign, call an action or `await`, so calling it anywhere is safe. The builtin functions it can use are listed in [the function reference](/reference/functions.html.md): besides `pct` and `dur`, `num(x, digits: 1)` writes a number with fixed decimals, `bytes(n)` a size (`1.5 GiB`), `number(t)` reads a number from text (`null` when it is not one), `sin`, `cos`, `atan2`, `sqrt` and `pi` do geometry (angles in degrees, clockwise from the right, so a half turn is `180deg`, not `pi`), and a list folds with `xs.reduce(0, (sum, x) => sum + x)` and grows with `xs + [x]`. A panel with many tiles can let them flow: `row { wrap: true; … }` runs its children on in another row when one is full. A strip wider than the panel scrolls sideways in `scroll { axis: horizontal; snap: true; … }`, which settles each wheel step and swipe on a tile's start. **`component Tile(…) tokens { radius: …; pad: … }` gives the component tokens of its own,** read inside as `$Tile.radius` and `$Tile.pad`. They are the knobs someone using `Tile` can turn without editing it. **`set { … }` overrides tokens for a node and everything inside it.** - `set { $surface: $surface.mix($accent, 4%) }` on the panel's column gives the whole panel a surface tinted with the accent. On the right, `$surface` is the value from outside, so this is not a loop. Tokens derived from `$surface` derive again inside: every tile's `$surface.hi` is a step up from the tinted surface, not the normal one. - `set { $Tile.pad: $space.2 }` on the row of sliders makes the two tiles in it tighter. The other tiles keep `$space.3`. **`for p in [...] key p`** loops over a list you wrote. Service items (workspaces, devices) carry their own keys; your own data needs one, and `key p` says each name is its own key. Strand uses keys to tell items apart when the list changes, so it insists. `segmented { options: Look; value: <-> theme.look }` reads `Look`, the enum from `theme.strand`, and `theme.look`, its exported state. An `enum` is shared by every file; a `state` is shared only when it is exported. ## 5. A dock Make `dock.strand`: ```strand // dock.strand: a dock on every monitor. Drag icons to reorder; right-click to unpin type Pin { app: AppId; label: text } state pins: [Pin] key app = [ Pin(app: "firefox", label: "Firefox"), Pin(app: "org.gnome.Nautilus", label: "Files"), ] persist keyframes hop { 0%, 100% { y: 0 }; 40% { y: -10 }; duration: 450ms } bar Dock { edge: bottom; height: 64; margin: 0, $space.2, $space.2 bg: $surface.alpha(0.72); blur: 24; radius: $radius.xl; shadow: $elevation.md row { grow: 1; justify: center; align: center; gap: $space.2 on drop(p: Pin, at: int) { pins.move(p.app, at) } for p in pins key p.app { let app = apps.all.find(a => a.id == p.app) // the installed app, or null image app?.icon ?? "application-x-executable" { size: 44; drag: p; tooltip: p.label enter { scale: 0; opacity: 0 } when app == null { opacity: 0.4 } when hover { scale: 1.15 ~ $motion.bouncy } on click { app?.launch(); play hop } on secondary { pins.remove_key(p.app) } } } } } ``` A dock appears at the bottom of every monitor. Point at an icon and it grows with a bounce; click it and it hops while the app starts. Drag an icon along the dock and the others glide out of its way. Right-click one to unpin it. Your order is kept across restarts. `dock.strand`: ```strand // dock.strand: a dock on every monitor. Drag icons to reorder; right-click to unpin type Pin { app: AppId; label: text } state pins: [Pin] key app = [ Pin(app: "firefox", label: "Firefox"), Pin(app: "org.gnome.Nautilus", label: "Files"), ] persist keyframes hop { 0%, 100% { y: 0 }; 40% { y: -10 }; duration: 450ms } bar Dock { edge: bottom; height: 64; margin: 0, $space.2, $space.2 bg: $surface.alpha(0.72); blur: 24; radius: $radius.xl; shadow: $elevation.md row { grow: 1; justify: center; align: center; gap: $space.2 on drop(p: Pin, at: int) { pins.move(p.app, at) } for p in pins key p.app { let app = apps.all.find(a => a.id == p.app) // the installed app, or null image app?.icon ?? "application-x-executable" { size: 44; drag: p; tooltip: p.label enter { scale: 0; opacity: 0 } when app == null { opacity: 0.4 } when hover { scale: 1.15 ~ $motion.bouncy } on click { app?.launch(); play hop } on secondary { pins.remove_key(p.app) } } } } } ``` An app id is its desktop file's name without `.desktop` (`org.gnome.Nautilus`). A pin whose app is not installed shows a faded generic icon, so you can still drag it or right-click it away. **`type Pin { app: AppId; label: text }` declares a record**, a value with named fields. Build one with its name and named arguments: `Pin(app: "firefox", label: "Firefox")`. **`state pins: [Pin] key app = […]` is a keyed list.** `[Pin]` is a list of pins, and `key app` says each pin is known by its `app`. Change it with `pins.push(p)`, `pins.insert(i, p)`, `pins.remove_key(k)`, `pins.move(k, i)` and `pins.update(k, …)`. Because each item is known by its key, an icon keeps its place, its animation and anything it holds when the order changes, and glides to its new spot. `persist` keeps the list across restarts. **Drag and drop.** `drag: p` makes the icon draggable, carrying its pin. `on drop(p: Pin, at: int)` on the row receives it with the index where it was dropped, and `pins.move` puts it there. Things dragged from other programs (files, apps, text) arrive as a `Drop` value: `on drop(d: Drop, at: int)`. **`~` picks a node's animation for one prop.** Every prop springs with the theme's motion; `scale: 1.15 ~ $motion.bouncy` uses the bouncy spring instead, while this `when` applies. `~ 200ms` animates over a fixed time, `~ ease(out_back, 300ms)` with an easing curve, and `~ instant` not at all. **`keyframes hop { … }` is a timed animation**: at 40% of its 450 ms the icon is 10 px up, at 0% and 100% where it belongs. `play hop` in a handler plays it once on the node the handler is on. **`let app = apps.all.find(a => a.id == p.app)`** finds the installed app with the pin's id, or `null` when it is not installed. You need the app itself, not its id, to call `launch()`. `app?.icon` is `null` when `app` is, and `?? "application-x-executable"` puts the generic icon in its place; `app?.launch()` does nothing when `app` is `null`. Every pin draws exactly one icon, the missing ones included, and that matters for drag and drop: the `at` a drop reports counts the icons drawn in the row, leaving out the one you are dragging, and `pins.move` counts the pins, taking the moved one out first. Were a missing pin to draw nothing, the two would disagree and a dragged icon would land one place off. With more than one monitor there is a dock on each, and you can drag an icon from one to another. The other dock draws the same pin too, and its `at` leaves that icon out as well, so the pin lands where you let it go on either dock. ### Icons that swell toward the pointer A dock can grow its icons by how near the pointer is, smoothly, rather than one icon at a time. Change the row and the icon in `dock.strand`: ```strand // dock.strand: a dock on every monitor whose icons swell toward the pointer type Pin { app: AppId; label: text } state pins: [Pin] key app = [ Pin(app: "firefox", label: "Firefox"), Pin(app: "org.gnome.Nautilus", label: "Files"), ] persist keyframes hop { 0%, 100% { y: 0 }; 40% { y: -10 }; duration: 450ms } bar Dock { edge: bottom; height: 64; margin: 0, $space.2, $space.2 bg: $surface.alpha(0.72); blur: 24; radius: $radius.xl; shadow: $elevation.md row { id: icons; grow: 1; justify: center; align: center; gap: $space.2 on drop(p: Pin, at: int) { pins.move(p.app, at) } for p in pins key p.app { let app = apps.all.find(a => a.id == p.app) image app?.icon ?? "application-x-executable" { size: 44; drag: p; tooltip: p.label cursor: pointer // How far the pointer is from this icon's centre, along the row. let off = (icons.pointer?.x ?? -1000) - (self.box.x - icons.box.x + 22) scale: 1 + 0.4 * (1 - off.abs() / 120).max(0) enter { scale: 0; opacity: 0 } when app == null { opacity: 0.4 } on click { app?.launch(); play hop } on secondary { pins.remove_key(p.app) } } } } } ``` Run your pointer along the dock: the icon under it is 1.4 times its size and its neighbours a little less, by how far they are, so the swell follows the pointer instead of jumping from icon to icon. `dock.strand`: ```strand // dock.strand: a dock on every monitor whose icons swell toward the pointer type Pin { app: AppId; label: text } state pins: [Pin] key app = [ Pin(app: "firefox", label: "Firefox"), Pin(app: "org.gnome.Nautilus", label: "Files"), ] persist keyframes hop { 0%, 100% { y: 0 }; 40% { y: -10 }; duration: 450ms } bar Dock { edge: bottom; height: 64; margin: 0, $space.2, $space.2 bg: $surface.alpha(0.72); blur: 24; radius: $radius.xl; shadow: $elevation.md row { id: icons; grow: 1; justify: center; align: center; gap: $space.2 on drop(p: Pin, at: int) { pins.move(p.app, at) } for p in pins key p.app { let app = apps.all.find(a => a.id == p.app) image app?.icon ?? "application-x-executable" { size: 44; drag: p; tooltip: p.label cursor: pointer // How far the pointer is from this icon's centre, along the row. let off = (icons.pointer?.x ?? -1000) - (self.box.x - icons.box.x + 22) scale: 1 + 0.4 * (1 - off.abs() / 120).max(0) enter { scale: 0; opacity: 0 } when app == null { opacity: 0.4 } on click { app?.launch(); play hop } on secondary { pins.remove_key(p.app) } } } } } ``` **`icons.pointer` is where the pointer is in the row**, a point with `x` and `y` measured from the row's top-left corner, or `null` while the pointer is not over it (`?? -1000` puts it far away then, so nothing swells). Any node has one; `self.pointer` is the node's own. It changes at most once a frame. While you hold a press that began on a node, it keeps following the pointer even off the node, in the place the node had when you pressed, so a grip that moves with what you drag cannot run away from your hand. **`self.box` is the icon's place on the dock**, so `self.box.x - icons.box.x + 22` is its centre along the row. `.abs()` is a number's distance from zero and `.max(0)` keeps it from going below zero. **`cursor: pointer` shows the hand over an icon**, as over a link; a node without one shows the arrow, an input the text cursor. To ask before unpinning, open a menu where you right-clicked: give the icon a box around it holding `popup { anchor: pointer; open: <-> menu; … }` with an "Unpin" button, and set `menu = true` in `on secondary`. **`anchor: pointer`** opens the popup at the click rather than below its element: a context menu. ## 6. Escape hatches Some things are easier to draw than to describe. Each of these needs a file of your own beside the config, so this section shows them without steps. - **Shaders.** `shader "aurora.wgsl" { u_speed: 0.4; u_tint: $accent }` runs your own WGSL fragment shader; props named `u_…` are its uniforms, and may be any expression, tokens included. It is checked when the config loads and reloads when you save the `.wgsl` file. It runs on the GPU, which starts only while it is visible; without a usable GPU it draws nothing, and `strand report` says why (a `GPU:` warning in the log says it too). - **Canvas.** `canvas { draw: (c) => … }` draws with code: `c.line`, `c.rect`, `c.circle` and `c.text`, painted with `c.fill` and `c.stroke`, sized by `c.width` and `c.height`. Use it for charts and visualisers the built-in `graph`, `arc` and `spectrum` do not cover; `audio.levels(audio.sink, 24)` is the sound playing now as 24 bars from 0 to 1, low pitch first, for one you draw yourself: `let lv = audio.levels(audio.sink, 8)`, then `canvas { width: 80; height: 24; draw: (c) => { for i in [0, 1, 2, 3, 4, 5, 6, 7] { c.rect(i * 10, c.height * (1 - lv[i]), 8, c.height * lv[i]) }; c.fill($accent) } }` draws eight bars. Shapes collect into one path, and `c.fill` or `c.stroke` paints the shapes since the last paint, then starts a new path: to fill and stroke a shape, draw it again before the stroke. A shape with no paint after it draws nothing, `c.fill` before any shape paints the whole canvas, and later paints cover earlier ones. For shapes of your own, draw a path point by point: `c.move(x, y)` starts it, `c.to(x, y)` draws a line on, `c.arc(x, y, r, from: a, to: b)` an arc of a circle (angles clockwise from the right, as `rotate` turns), and `c.close()` joins it back to its start. `c.move(20, 20); c.arc(20, 20, 16, from: -90deg, to: -90deg + share * 360deg); c.close(); c.fill($accent)` is a pie slice. `c.fill(paint, evenodd: true)` lets a shape inside another cut a hole: two circles make a ring. `draw` runs again when what it reads changes, not every frame, so it cannot read `t` or `wave(…)`; to animate a canvas, animate its own props (`rotate: t * 90deg`), or use a shader. - **Thumbnails.** `thumbnail w` shows a window `w` live, while it is on screen, and `thumbnail screen` a still of a monitor, taken each time it is shown: `for s in screens.all key s.id { thumbnail s }` is an overview of your monitors. A still is taken just before the surface showing it appears, so on its own monitor it shows what was there; one that appears later in a surface already open is taken then, and shows that surface. In a `lock`, `thumbnail screen` is the desktop as it was just before the lock covered it, on each monitor's lock; a monitor plugged in while locked has no still and draws nothing, so the lock's own background shows there. To style that monitor its own way, name the thumbnail and read `blank`: `thumbnail screen { id: still }`, then `when still.blank { … }`. Mind what that shows: a locked screen is for whoever is at the machine, and a lock's `thumbnail screen` shows them your desktop as you left it, open windows and all, at the size you draw it. A still is kept at 1280×720 at most, scaled up past that. - **SVG layers.** `svg "gauge.svg" { #needle { rotate: level * 270deg } }` draws an SVG and binds props to the element with `id="needle"` inside it: `rotate`, `scale`, `x`, `y`, `opacity` and `fill`. Draw a gauge in an editor, name the needle, and it turns with your data. Its path, like an `image`'s, is relative to your config directory: `svg "icons/gauge.svg"` is in the `icons` folder beside your files. - **Your own icons.** Put SVG or PNG files in an `icons/` folder beside your config and name them as an icon theme would: `icon "wifi"` draws `icons/wifi.svg` (or `wifi-symbolic.svg`, or a `.png`), and on a machine without the file it falls back to the icon theme. A file whose name ends in `-symbolic` is drawn in `color`, like a theme's symbolic icon; any other is drawn as you made it. Every icon name is looked up there first, so `icons/firefox.svg` also replaces Firefox's icon wherever an app's or tray item's icon is shown. Adding, editing or removing a file there redraws the icons at once. ## 7. Plugins When no `from dbus`, `from file`, `from poll` or `from listen` service can describe what you need, a service can be written in Rust and built into your own `strand` binary. [The plugin page](/guide/plugins.html.md) shows how. ## What you have | File | Holds | | --- | --- | | `theme.strand` | `Look`, the settings, your token sets and `accent.txt` | | `colors.strand` and `colors.css.tmpl` | your colours as CSS custom properties | | `services.strand` | `ppd`, `host` and `temps` | | `control.strand` | the `Control` panel and `Tile` | | `dock.strand` | `Pin`, the pins and the `Dock` bar | Together with levels 1 to 4: a bar on every monitor, an OSD, notifications, a launcher, a lock screen, a password dialog, a control centre and a dock, all in one language, themed from one file, and live on every save. --- # Plugins: a service written in Rust Most services need no code: `service … from dbus`, `from file`, `from dir`, `from listen` and `from poll` (level 5) cover a D-Bus object, a file, a directory and a command. When a service needs more than that, write it in Rust and build your own `strand` binary with it inside. That binary is a **plugin**: it answers every `strand` command (`run`, `check`, `call`, …) and serves your service beside the builtin ones. **Unstable.** The plugin builder is git-only and may change in any commit. Pin the revision you build against. ## The crate A plugin is a binary crate that depends on `strand` and `strand-services`, both from the same git URL and revision (the `#[service]` derives name `strand_services`; two revisions of it do not compile together): ```toml [dependencies] strand = { git = "https://github.com/jakeb-grant/strand", rev = "" } strand-services = { git = "https://github.com/jakeb-grant/strand", rev = "" } ``` Its `main.rs` declares the service and hands it to the builder. This is `crates/strand/examples/plugin_hello.rs` in the repository, a `hello` service with a greeting, a count and one action: ```rust use strand_services::{Call, Cx, Msg, ServiceError, Store, service}; // mimalloc and no transparent huge pages, as `strand` runs. strand::process_setup!(); const SCHEMA: &str = " /// The plugin example's service. service hello { /// The last greeting, `hello, `. greeting: text /// How many greetings there have been; 41 after its first read. count: int rw /// Greets `name`: sets `greeting` and adds one to `count`. action greet(name: text) } "; #[derive(Call, Debug)] enum HelloAction { Greet(String), } #[service(name = "hello", schema = SCHEMA, action = HelloAction)] #[derive(Store, Clone, Debug, Default, PartialEq)] struct Hello { greeting: String, #[store(rw)] count: i64, } impl Hello { async fn run(mut cx: Cx) -> Result<(), ServiceError> { cx.update(|s| s.count = 41); cx.ready(); while let Some(m) = cx.recv().await { match m { Msg::Write(w) => { let n: i64 = w.value().map_err(ServiceError::from)?; cx.report(&w, |s| s.count = n); } Msg::Action(HelloAction::Greet(name)) => { cx.update(|s| { s.greeting = format!("hello, {name}"); s.count += 1; }); } _ => {} } } Ok(()) } } fn main() -> std::process::ExitCode { strand::Shell::builder().service::().run() } ``` - The schema is what `strand check`, hovers and completion see. Every entry needs a `///` doc: `--write-schemas` (below) refuses a schema with a hover-less name. - The service name is lower case: letters, digits and `_`. A builtin's name (`battery`, `clock`, …) is refused. - `run` starts when a config first reads `hello` and stops 5 s after the last reader goes, as a builtin service does, unless a `graph` keeps its history (below). `cx.ready()` says the first read is in. Add `thread` to `#[service(…)]` for a body that blocks; it then gets a thread of its own. - A registration the builder cannot serve (a bad or taken name, a schema that does not apply, or one that declares more than its own service: another service, an element, function, value, method or token; the records and enums its service uses are its own) stops every command with a message, before anything runs. - `Msg` is `#[non_exhaustive]`: match it with a `_ => {}` arm, as above, so a message a later Strand adds does not break your build. ### When a graph keeps your service's history A config's `graph hello.count { history: 60s }` shows the last minute of the value as soon as it mounts, so Strand keeps your service running while no visible reader holds it (it is *kept*), for as long as the loaded config has such a graph. Your body is told with `Msg::Keep(Some( period))` (`Msg::Keep(None)` when no graph keeps it any more; `cx.kept()` says which now) and `Msg::Visible(false)` as usual. What being kept means for you depends on how your service gets its values: - **It reports changes as they happen** (a D-Bus signal, a file watch, an event stream): nothing to do. It is not stopped 5 s after its last reader goes while it is kept, and each value it reports is drawn as a step at the moment it came. - **It samples on a clock** (reads `/proc` or runs a command every second): tick with `strand_services::Ticker`. `Ticker::new(&cx, every)` ticks every `every` while a reader is visible, at the kept period (`cx.period(every)`: the coarser of `every` and the period Strand asks for) while hidden and kept, and never otherwise; call `tick.follow(&cx)` after each message, and when it returns `true` (it ticks again after not ticking) take a fresh baseline if you take differences, as `cpu` does. A body that asks `cx.period` says it samples on a clock, so its hidden samples are drawn as lines from the one before across the period they could have changed in. A loop of your own instead of a `Ticker` should sleep for `cx.period(every)` (`None`: wait for the next message). - **It runs a stream only while watched** (`#[store(stream)]`, `cx.watched(field)`): a hidden stream stays off, kept or not, and a graph of it holds the last value it had. ## The two things every plugin must copy `strand`'s size and memory figures hold for your binary only if it is built and set up the way `strand` is: 1. **`strand::process_setup!();` at the crate root**, once. It makes mimalloc the allocator and turns transparent huge pages off before the first allocation. A library cannot do this for the binary that links it. Without it the shell runs, but uses about twice the memory, and the binary warns on every start: ``warning: this binary does not invoke `strand::process_setup!()` …``. 2. **The workspace's release profile**, every line of it, in your crate's `Cargo.toml`: ```toml [profile.release] lto = "fat" codegen-units = 1 [profile.release.package.strand-services] opt-level = "z" [profile.release.package.zbus] opt-level = "z" [profile.release.package.zvariant] opt-level = "z" [profile.release.package.zbus_names] opt-level = "z" [profile.release.package.tokio] opt-level = "z" [profile.release.package.swayipc-types] opt-level = "z" [profile.release.package.logind-zbus] opt-level = "z" [profile.release.package.strand-icons] opt-level = "s" [profile.release.package.strand-introspect] opt-level = "z" [profile.release.package.toml_edit] opt-level = "s" [profile.release.package.toml_parser] opt-level = "s" [profile.release.package.fontique] opt-level = "s" [profile.release.package.quick-xml] opt-level = "s" [profile.release.package.calloop] opt-level = "s" [profile.release.package.xkeysym] opt-level = "s" [profile.release.package.miette] opt-level = "z" [profile.release.package.chrono] opt-level = "s" [profile.release.package.freedesktop-desktop-entry] opt-level = "z" [profile.release.package.pipewire] opt-level = "z" [profile.release.package.libspa] opt-level = "z" [profile.release.package.ureq] opt-level = "z" [profile.release.package.ureq-proto] opt-level = "z" [profile.release.package.rustls] opt-level = "z" [profile.release.package.rustls-native-certs] opt-level = "z" ``` Leave your own crate at the release default, opt-level 3: do not add `opt-level = "s"` or `"z"` under `[profile.release]`. Fat LTO optimises the whole program at the level of the crate that links it, so a plugin built for size slows the render path with it. ## Schemas for `strand check` and your editor `strand check` and the language server (`strand-dev lsp`) never load plugin code. Your binary writes its schema to a file they read: ```sh cargo build --release target/release/my-shell --write-schemas ``` It writes `hello.schema` into `$XDG_DATA_HOME/strand/schemas/` (`~/.local/share/strand/schemas/` by default), prints each path, and removes the files it wrote earlier for services it no longer has. Files another binary wrote are listed and left alone. Run it again whenever the schema changes; a running shell and the language server read the directory when they start. The stock `strand` reads these files too, so `strand check` passes a config that reads `hello.greeting`. If you run the stock `strand` on such a config, the service has no code behind it: reads give its defaults, writes and calls are refused, and a notice names the file. Run your plugin binary instead, or delete the file if you no longer use that plugin. ## Running it Run your binary wherever you ran `strand` (your compositor's autostart line): ```sh my-shell run ``` The other commands work from either binary, since they talk to the running shell over its socket: `strand call hello.greet world`, `strand set hello.count 0`, `strand get` of a value your config exports. A config uses the service like a builtin one: ``` export let greeting = hello.greeting bar Top { text greeting on click { hello.greet("you") } } ``` --- # A bar with a calendar popup A clock in the middle of the bar that opens a month calendar when you click it. Escape, or a click anywhere else, closes it. It is the clock of the guide's bar, with one `when` added to light it while the calendar shows; the guide's [level 3](/guide/level-3.html.md#a-clock-with-a-calendar) builds it step by step. ## The files Put both files in your config directory, `~/.config/strand`. ```strand // bar.strand: a bar on every monitor with the clock in the middle bar Top { edge: top; height: 36 bg: $surface; font: $font.ui; color: $fg split { pad: 0, $space.3 center { Clock } } } ``` ```strand component Clock { state open = false // one per monitor text clock.format("%a %d %H:%M") { // wakes once a minute when cal.open { color: $accent } // lit while the calendar shows on click { open = !open } popup { id: cal; open: <-> open; Calendar } // anchored here; Esc or click-away closes } } component Calendar { state month = clock.today.month_start() col { pad: $space.3; gap: $space.2; bg: $surface; radius: $radius.lg; shadow: $elevation.lg row { align: center button "‹" { on click { month = month.add(months: -1) } } text month.format("%B %Y") { grow: 1; align: center } button "›" { on click { month = month.add(months: 1) } } } grid { columns: 7; gap: $space.1 for d in calendar.days(month) { text d.date.format("%e") { width: 28; height: 28; align: center when !d.in_month { color: $fg.faint } when d.date == clock.today { bg: $accent; color: $on_accent; radius: full } } } } } } ``` Save them while `strand run` is running and the bar appears. Nothing restarts. Here the clock is clicked, then › moves to the next month, and a second click on the clock closes it: `bar.strand`: ```strand // bar.strand: a bar on every monitor with the clock in the middle bar Top { edge: top; height: 36 bg: $surface; font: $font.ui; color: $fg split { pad: 0, $space.3 center { Clock } } } ``` `clock.strand`: ```strand component Clock { state open = false // one per monitor text clock.format("%a %d %H:%M") { // wakes once a minute when cal.open { color: $accent } // lit while the calendar shows on click { open = !open } popup { id: cal; open: <-> open; Calendar } // anchored here; Esc or click-away closes } } component Calendar { state month = clock.today.month_start() col { pad: $space.3; gap: $space.2; bg: $surface; radius: $radius.lg; shadow: $elevation.lg row { align: center button "‹" { on click { month = month.add(months: -1) } } text month.format("%B %Y") { grow: 1; align: center } button "›" { on click { month = month.add(months: 1) } } } grid { columns: 7; gap: $space.1 for d in calendar.days(month) { text d.date.format("%e") { width: 28; height: 28; align: center when !d.in_month { color: $fg.faint } when d.date == clock.today { bg: $accent; color: $on_accent; radius: full } } } } } } ``` ## How it works - **The popup's state lives on `Clock`.** `state open = false` is declared in the component, so each monitor's bar has its own `open`: opening the calendar on one monitor leaves the others closed. - **`open: <-> open` is a two-way binding.** The popup shows while `open` is true. When Escape or a click away closes it, the popup writes `false` back, so the next click on the clock opens it again. - **A `popup` is anchored to the element it is written in**, here the clock's text. On a bar it opens away from the bar's edge: below the clock on a top bar. - **`cal.open`** is true while the popup named `cal` is open. The `when` on the text uses it to colour the clock while its calendar shows. - **`clock.format("%a %d %H:%M")`** shows minutes, so it wakes the shell once a minute. - **`calendar.days(month)`** gives the days of the weeks that cover `month`, padded with days of the months around it; `d.in_month` tells them apart. `month.add(months: -1)` moves a month back. ## Variations - **A popup that touches the bar.** A popup keeps a 6 px gap from the element it is anchored to unless you set its `margin`; `margin: 0` closes the gap. - **More in the popup.** A popup holds any tree. Put a `col` around `Calendar` and add to it, such as `text clock.format("%A")` for the day's name. - **A clock on a bar of its own design.** The clock is a component, so `Clock` goes anywhere a node does: in the `start` or `end` of the split, or in a panel. Every element and prop used here is in the element reference, and the `clock` and `calendar` services are in the [service reference](/reference/services/index.html.md). --- # An app launcher A search box in the middle of the screen that finds your apps as you type, best match first, and starts the one you pick. A key opens it; Escape, a click elsewhere or focus moving away closes it. The guide's [level 4](/guide/level-4.html.md#an-app-launcher) explains each line of it. ## The file ```strand // launcher.strand. Bind a key to: strand toggle launcher.open export state open = false state query = "" let hits = apps.search(query) // Async<[Hit]>: fuzzy + frecency, keeps last result panel Launcher { screens: focused; layer: overlay; anchor: center; keyboard: exclusive open: <-> open // Escape, click-away and focus loss write false enter { opacity: 0; scale: 0.96 } on show { query = "" } col { width: 600; pad: $space.3; gap: $space.2; radius: $radius.xl; clip: true bg: $surface.alpha(0.82); blur: 32; shadow: $elevation.xl input { text: <-> query; placeholder: "Search apps"; font: $font.title; focus: true; nav: results } list { id: results; max_height: 420 // virtualised; arrows move selection for h in hits { row { pad: $space.2; gap: $space.3; radius: $radius.md; align: center when hover { bg: $surface.hi } when selected { bg: $accent.container } on activate { h.app.launch(); open = false } image h.app.icon { size: 32 } col { grow: 1 text h.app.name { marks: h.ranges; mark_color: $accent; weight: 600 } text h.app.comment ?? "" { font: $font.caption; color: $fg.muted; ellipsis: end } } } } } if hits.len == 0 && query != "" && !hits.pending { text "No matches" { color: $fg.muted } } } } ``` The launcher fades and scales in over seven apps, then "ter" is typed a letter at a time and the list narrows to the apps that match. `launcher.strand`: ```strand // launcher.strand. Bind a key to: strand toggle launcher.open export state open = false state query = "" let hits = apps.search(query) // Async<[Hit]>: fuzzy + frecency, keeps last result panel Launcher { screens: focused; layer: overlay; anchor: center; keyboard: exclusive open: <-> open // Escape, click-away and focus loss write false enter { opacity: 0; scale: 0.96 } on show { query = "" } col { width: 600; pad: $space.3; gap: $space.2; radius: $radius.xl; clip: true bg: $surface.alpha(0.82); blur: 32; shadow: $elevation.xl input { text: <-> query; placeholder: "Search apps"; font: $font.title; focus: true; nav: results } list { id: results; max_height: 420 // virtualised; arrows move selection for h in hits { row { pad: $space.2; gap: $space.3; radius: $radius.md; align: center when hover { bg: $surface.hi } when selected { bg: $accent.container } on activate { h.app.launch(); open = false } image h.app.icon { size: 32 } col { grow: 1 text h.app.name { marks: h.ranges; mark_color: $accent; weight: 600 } text h.app.comment ?? "" { font: $font.caption; color: $fg.muted; ellipsis: end } } } } } if hits.len == 0 && query != "" && !hits.pending { text "No matches" { color: $fg.muted } } } } ``` ## Bind a key Strand does not grab keys. Your compositor runs the command instead: ```sh strand toggle launcher.open ``` Bind it to a key such as Super+D in your compositor (how: [Start it and bind keys](/start/running.html.md#start-it-and-bind-keys)). `strand toggle` flips an exported `bool` state. `launcher.open` is the file's name, then the state's: `export` is what lets the command line reach it. ## How it works - **`apps.search(query)`** matches the apps' names fuzzily, then their keywords and generic names, and ranks by how often and how lately you launched each. An empty query lists every app, the most used first. - **It returns an `Async`.** A search takes a moment, so `hits` keeps its last result while the next one runs, and the list never blanks while you type. `hits.pending` is true while a search runs, which is why "No matches" waits for `!hits.pending`. - **`list` lays out and mounts only its visible rows**, however many apps there are. It has a selection: the arrow keys move it, `when selected` styles that row, and Return runs its `on activate`. - **`nav: results`** sends the arrow keys and Return from the input to the list with `id: results`, so you keep typing while you move the selection. - **`keyboard: exclusive`** takes the keyboard while the launcher is open, so you can type at once. - **`on show { query = "" }`** empties the box each time it opens. - **`marks: h.ranges`** highlights the letters that matched in `$accent`. ## Variations - **More than starting an app.** `h.app.actions` lists the extra actions an app's desktop entry offers (a new window, a private one), each with a `name` and `a.launch()`. - **A calculator in the same box.** An action that gives back what its command prints turns a command-line calculator into a value that follows `query`; see [calling a D-Bus method or a declared action](/how-to/dbus-and-actions.html.md#an-action-that-gives-back-a-value). --- # A notification stack Cards at the top right of the focused monitor, one per notification, that slide in when a notification arrives and out when it goes. Strand is the notification server, so nothing else needs to run. The guide's [level 4](/guide/level-4.html.md#notifications) explains it line by line. ## The file ```strand // toasts.strand export state dnd = false persist let shown = notifications.popups.filter(n => !dnd || n.urgency == critical).take(5) panel Toasts { screens: focused; layer: overlay; anchor: top_right; margin: $space.3; keyboard: none open: shown.len > 0 col { width: 380; gap: $space.2 for n in shown { Toast n } // keyed by n.id automatically } } component Toast(n: Notification) { col { pad: $space.3; gap: $space.2; radius: $radius.lg; border: 1, $border bg: $surface.alpha(0.88); blur: 24; shadow: $elevation.lg enter { x: 420; opacity: 0 } exit { x: 420; opacity: 0; height: 0 } // siblings slide up to fill the gap when hover { bg: $surface.hi } when n.urgency == critical { border: 1, $error } after n.timeout ?? 6s while !hover && n.urgency != critical { n.expire() } on click { n.activate() } on secondary { n.dismiss() } row { gap: $space.2 image n.image ?? n.app.icon { size: 36; radius: $radius.sm } col { grow: 1 text join(" · ", n.app.name, n.summary) { weight: 600; ellipsis: end } text n.body { markup: basic; max_lines: 4; color: $fg.muted } } icon "window-close-symbolic" { size: 14; on click { n.dismiss() } } } if n.actions.len > 0 { row { gap: $space.1 for a in n.actions { button a.label { grow: 1; on click { a.invoke() } } } } } } } ``` Only one program can own the notification service on the session bus. If dunst, mako or another daemon is running, Strand says so, names it and says how to stop it, and takes the name as soon as it is free. Then send yourself one: ```sh notify-send "Hello" "from Strand" ``` ## What it looks like Here a first notification arrives, a second comes under it, and the first is dismissed: it slides out while its height folds, and the card below slides up into its place. The example gives the panel a fixed `height`, because every frame of a picture here must be one size; in your shell the panel grows and shrinks with its cards. `toasts.strand`: ```strand // toasts.strand export state dnd = false persist let shown = notifications.popups.filter(n => !dnd || n.urgency == critical).take(5) panel Toasts { screens: focused; layer: overlay; anchor: top_right; margin: $space.3; keyboard: none open: shown.len > 0 height: 180 // fixed here so every frame is one size col { width: 380; gap: $space.2 for n in shown { Toast n } // keyed by n.id automatically } } component Toast(n: Notification) { col { pad: $space.3; gap: $space.2; radius: $radius.lg; border: 1, $border bg: $surface.alpha(0.88); blur: 24; shadow: $elevation.lg enter { x: 420; opacity: 0 } exit { x: 420; opacity: 0; height: 0 } // siblings slide up to fill the gap when hover { bg: $surface.hi } when n.urgency == critical { border: 1, $error } after n.timeout ?? 6s while !hover && n.urgency != critical { n.expire() } on click { n.activate() } on secondary { n.dismiss() } row { gap: $space.2 image n.image ?? n.app.icon { size: 36; radius: $radius.sm } col { grow: 1 text join(" · ", n.app.name, n.summary) { weight: 600; ellipsis: end } text n.body { markup: basic; max_lines: 4; color: $fg.muted } } icon "window-close-symbolic" { size: 14; on click { n.dismiss() } } } if n.actions.len > 0 { row { gap: $space.1 for a in n.actions { button a.label { grow: 1; on click { a.invoke() } } } } } } } ``` ## How it works - **`notifications.popups`** is the list of notifications to show now. Each one leaves it when it expires or is dismissed, and its card plays its `exit` pose. - **The `for` is keyed by each notification's id**, which the service gives it. When one goes, the other cards keep their state and slide to their new places. - **`after n.timeout ?? 6s while !hover && …`** counts down only while its condition holds: pointing at a card pauses it, and a critical notification never counts down. The time is the sender's, or six seconds when it gave none. `n.expire()` ends the popup as a timeout does. - **`exit { …; height: 0 }`** shrinks the leaving card's height as it slides away, so the cards under it move up smoothly. - **A click** runs the notification's default action (`n.activate()`); a right click or the cross dismisses it. ## Do not disturb `export state dnd = false persist` is your own switch. `persist` keeps it across restarts, and `export` lets a key reach it: ```sh strand toggle toasts.dnd ``` While it is on, the `filter` keeps only critical notifications. The others still arrive and wait in `notifications.popups`: the server sets no timers of its own, and only a card counts down. Turn it off and they show. ## Variations - **Progress.** `meter n.progress ?? 0` in the card shows how far a download or a copy has got, for senders that say (0 to 1). - **More or fewer at once.** `.take(5)` shows at most five cards; change the number. - **The service's own switch.** `notifications.dnd` is a writable field of the service: while it is true, new notifications that are not critical skip `popups` altogether (they are still kept in `notifications.all`). --- # A volume and brightness OSD A pill near the bottom of the screen that shows the volume or the screen brightness when either changes, and fades away a moment after you stop. It never shows when you log in or save a file. The guide's [level 4](/guide/level-4.html.md#an-osd-for-volume-and-brightness) explains each line. ## The file ```strand // osd.strand. Media keys stay in the compositor: strand set audio.sink.volume +5% enum Kind { volume, brightness } state kind = volume state shown = false on change audio.sink.volume, audio.sink.muted { kind = volume; shown = true } on change brightness.level { kind = brightness; shown = true } on change audio.sink.volume, audio.sink.muted, brightness.level after 1.2s { shown = false } let level = kind == volume ? (audio.sink.muted ? 0 : audio.sink.volume) : brightness.level osd Level { // focused monitor, overlay, click-through anchor: bottom; margin: 0, 0, 96; open: shown enter { opacity: 0; scale: 0.9 } row { width: 260; pad: $space.3; gap: $space.3; align: center radius: full; bg: $surface.alpha(0.8); blur: 24; shadow: $elevation.lg icon kind == volume ? audio.sink.icon : "display-brightness-symbolic" { size: 20 } meter level { grow: 1; height: 6; color: $accent; track: $fg.alpha(0.15) } text pct(level) { width: 4ch; align: end; font: $font.mono } } } ``` ## Bind the keys Strand does not grab keys: your compositor keeps them and runs `strand set`. These are the commands for the volume and brightness keys: ```sh strand set audio.sink.volume +5% strand set audio.sink.volume -5% strand set brightness.level +5% strand set brightness.level -5% ``` Bind them to the keys in your compositor, so they work while the screen is locked and repeat while held (how: [Start it and bind keys](/start/running.html.md#start-it-and-bind-keys)). On a service field, a signed number or percentage is a step from the value it has now, and it never goes below 0. ## What it looks like The volume steps up three times, a few frames apart, as a held key does. The OSD springs in at the first change, follows the level, and fades out 1.2 seconds after the last one. `osd.strand`: ```strand // osd.strand. Media keys stay in the compositor: strand set audio.sink.volume +5% enum Kind { volume, brightness } state kind = volume state shown = false on change audio.sink.volume, audio.sink.muted { kind = volume; shown = true } on change brightness.level { kind = brightness; shown = true } on change audio.sink.volume, audio.sink.muted, brightness.level after 1.2s { shown = false } let level = kind == volume ? (audio.sink.muted ? 0 : audio.sink.volume) : brightness.level osd Level { // focused monitor, overlay, click-through anchor: bottom; margin: 0, 0, 96; open: shown enter { opacity: 0; scale: 0.9 } row { width: 260; pad: $space.3; gap: $space.3; align: center radius: full; bg: $surface.alpha(0.8); blur: 24; shadow: $elevation.lg icon kind == volume ? audio.sink.icon : "display-brightness-symbolic" { size: 20 } meter level { grow: 1; height: 6; color: $accent; track: $fg.alpha(0.15) } text pct(level) { width: 4ch; align: end; font: $font.mono } } } ``` ## How it works - **`osd Level { … }`** is on the focused monitor, above everything, and clicks pass through it to what is underneath. It shows while `open:` is true. - **`on change a, b { … }`** runs when a watched value changes, from anywhere: your keys, a slider, another program. It never runs for the first value, nor for a value a reload writes, nor when `audio.sink` becomes another device because the default output changed: the new device's volume is only the new baseline. That is why the OSD stays hidden when the shell starts, when you save a file and when the default output changes. - **`on change … after 1.2s`** waits for quiet: it runs once, 1.2 seconds after the last change, so holding a key keeps the OSD up. - **`let level`** follows whichever value changed last, and 0 while the output is muted. - **`meter level`** fills from 0 to 1, and `text pct(level)` shows it as a percentage in a fixed-width font, `4ch` wide, so the digits do not jump. ## Variations - **Several values in one place.** Past two choices, a `match` reads better than nested `?` and `:`; the guide shows the same OSD [with `match`](/guide/level-4.html.md#the-same-with-match). - **Rolling digits.** `roll: true` on the `text` rolls the digits when the number changes. - **On every monitor.** `screens: all` on the `osd` puts a copy on each monitor, with `screen` in scope in each. --- # A theme from your wallpaper Take every colour of the shell from your wallpaper, and follow it when the wallpaper changes. Strand reads the picture, builds a Material 3 palette from it, and springs every colour on screen to the new palette. For the whole theme file, with token sets and a look switcher, see the guide's [level 5](/guide/level-5.html.md#1-a-theme-of-your-own). ## The files ```strand // theme.strand: the palette comes from the wallpaper state prefs from "prefs.toml" { wallpaper: path = "~/Pictures/wall.jpg"; accent: color = #7aa2f7 } let scheme: Palette = material(image: prefs.wallpaper, dark: system.dark) ?? material(seed: prefs.accent, dark: system.dark) // until the picture is read use palette scheme file "~/.cache/strand/accent.txt" { text: $accent } // kept in step with every switch ``` A bar to see it on: ```strand bar Top { edge: top; height: 32 bg: $surface; font: $font.ui; color: $fg split { pad: 0, $space.3 start { box { size: 12; radius: full; bg: $accent } } center { text clock.format("%H:%M") } end { text pct(battery.percent) { color: $fg.muted } } } } ``` Point the setting at your wallpaper. `prefs.toml` is in your config directory and is plain TOML; Strand makes it the first time a value is written: ```toml file=prefs.toml wallpaper = "~/Pictures/mountains.jpg" ``` or, from a terminal or a wallpaper script: ```sh strand set prefs.wallpaper ~/Pictures/mountains.jpg ``` Here the bar starts in a purple picture's colours; then the setting points at another picture, and every colour springs to its palette: `theme.strand`: ```strand // theme.strand: the palette comes from the wallpaper state prefs from "prefs.toml" { wallpaper: path = "~/Pictures/wall.jpg"; accent: color = #7aa2f7 } let scheme: Palette = material(image: prefs.wallpaper, dark: system.dark) ?? material(seed: prefs.accent, dark: system.dark) // until the picture is read use palette scheme file "~/.cache/strand/accent.txt" { text: $accent } // kept in step with every switch ``` `bar.strand`: ```strand bar Top { edge: top; height: 32 bg: $surface; font: $font.ui; color: $fg split { pad: 0, $space.3 start { box { size: 12; radius: full; bg: $accent } } center { text clock.format("%H:%M") } end { text pct(battery.percent) { color: $fg.muted } } } } ``` ## How it works - **`material(image: …)`** builds a palette from a picture. It quantises a 128 px downscale of the image away from the logic thread, and caches the result by the file's content, so the same picture is not quantised twice. - **It is an `Async`.** Reading the picture takes a moment, so its value is not there at first. `?? material(seed: …)` is the palette to use until it is. While a new picture is read the old palette holds, so the screen never flashes other colours. - **The file is watched**, and so is the target of a link to it. A script that writes a new picture over it, or swaps the link, re-themes the shell; so does pointing `prefs.wallpaper` at another file. - **`use palette scheme`** makes it the palette in force. With no `tokens` of your own, the built-in spacing, radii, fonts and springs stay as they are. - **The last palette is kept** across restarts, so the shell starts in your wallpaper's colours rather than the default ones. - **`dark: system.dark`** follows your desktop's light or dark setting. When the palette changes, the colours spring to the new one in OKLab, and the shell's state stays as it was: an open launcher keeps its query. [Tokens and theming](/concepts/theming.html.md#how-a-swap-animates) says how a swap animates. ## Your colours for other programs The `file` line writes your accent to `~/.cache/strand/accent.txt` as `#rrggbb` and rewrites it after every switch, once, with the new palette's value. A `file` with a `template:` writes a whole stylesheet or config; `strand export` writes the palette once: ```sh strand export json # the palette as data strand export base24 -o ~/.cache/strand/base24.yaml # for base16/base24 templates ``` Strand ships no per-app themes or templates: these are generic outputs, and the community's base16 and base24 templates work with the `base24` export. The guide's [level 5](/guide/level-5.html.md#2-your-colours-for-other-programs) writes a CSS file. ## Variations - **Another flavour of palette.** `material(image: …, variant: vibrant)` picks another Material 3 scheme: `tonal_spot` (the default), `vibrant`, `expressive`, `neutral`, `monochrome`, `fidelity`, `content`, `rainbow` or `fruit_salad`. - **More contrast.** `contrast: system.contrast` follows the desktop's contrast preference, 0 for normal and 1 for high. - **A published scheme instead.** `import("catppuccin:mocha")` is a palette too, and so are base16 and base24 schemes, matugen output and W3C design-token files. --- # A settings file Keep the choices of your shell in a plain TOML file that anyone can edit, with types, defaults and live updates, and change them from the shell itself or from a terminal. A settings file is one declaration: `state name from "file.toml" { fields }`. ## The files ```strand // bar.strand: the bar's choices live in prefs.toml beside this file state prefs from "prefs.toml" { seconds: bool = false; position: Edge = top; accent: color = #7aa2f7 } use palette material(seed: prefs.accent, dark: system.dark) bar Top { edge: prefs.position; height: 32 bg: $surface; font: $font.ui; color: $fg split { pad: 0, $space.3 center { text clock.format(prefs.seconds ? "%H:%M:%S" : "%H:%M") } end { button prefs.seconds ? "Hide seconds" : "Show seconds" { on click { prefs.seconds = !prefs.seconds } } } } } ``` A settings declaration at the top of a file belongs to that file, as any top-level `state` does; `export state prefs from …` would let other files read it as `bar.prefs`. Here it is before there is a settings file. A click on the button sets `prefs.seconds`, and the clock shows them: `bar.strand`: ```strand // bar.strand: the bar's choices live in prefs.toml beside this file state prefs from "prefs.toml" { seconds: bool = false; position: Edge = top; accent: color = #7aa2f7 } use palette material(seed: prefs.accent, dark: system.dark) bar Top { edge: prefs.position; height: 32 bg: $surface; font: $font.ui; color: $fg split { pad: 0, $space.3 center { text clock.format(prefs.seconds ? "%H:%M:%S" : "%H:%M") } end { button prefs.seconds ? "Hide seconds" : "Show seconds" { on click { prefs.seconds = !prefs.seconds } } } } } ``` And the settings themselves, which you can leave out: a missing file gives every field its default. ```toml file=prefs.toml # The bar's choices. Strand keeps these comments when it writes. seconds = true position = "bottom" accent = "#e06c75" ``` ## How it works - **Each field is `name: type = default`**, read as `prefs.seconds`. A relative path is beside your config; `~/…` is in your home directory. - **Edits apply live, field by field.** Save the TOML file and the bar follows: the clock shows seconds, the bar moves to the bottom edge, the colours spring to the new accent. - **A bad value keeps that field's last good value**, with a diagnostic; the other fields still apply. A TOML syntax error keeps every last good value. A line you delete goes back to its default. - **It is two-way.** The button assigns `prefs.seconds`, and Strand writes the file, keeping its comments, spacing and order. A `<->` binding writes it the same way. Writes are saved after 250 ms of quiet. - **From a terminal**, `strand set` writes a field, as the button does, and `strand get` reads it: ```sh strand set prefs.position top strand get prefs.accent ``` With the file's name first, `bar.prefs.position` is the same field; when two files declare settings named `prefs`, only that form works. - **The value's type decides what the file may say:** `true` or `false` for a `bool`, a quoted `"#rrggbb"` for a `color`, an enum's variant by name for an enum (`position = "bottom"`). ## Where the file lives Writes follow symbolic links and replace the file through a temporary file and a rename, so a settings file in a dotfiles repository stays a link. A file you cannot write, such as one in `/nix/store`, gets the write in an overlay in Strand's state directory instead, with a notice. ## Variations - **Controls bound to it.** `segmented { options: Edge; value: <-> prefs.position }` is a picker for the edge, a segment for each of `Edge`'s variants; `options: [Edge.top, Edge.bottom]` offers only those two. A field `level: float = 0.5` takes `slider { value: <-> prefs.level }` the same way. - **Several files.** Each `state … from` is its own file; give them different names (`prefs`, `layout`). --- # Calling a D-Bus method or a declared action Strand's [built-in services](/concepts/services.html.md) cover the usual things. For the rest, a config declares what it needs in a line or two: a D-Bus object's properties and methods, or a command to run. Handlers then call them like functions, and `strand check` checks every call. ## The files ```strand // services.strand: what no built-in service does permit exec "hyprctl", "qalc", "wf-recorder" service ppd from dbus system "net.hadess.PowerProfiles" { profile: text rw = ActiveProfile action hold(profile: text, reason: text, app: text) = HoldProfile } action dispatch(ws: int) ["hyprctl", "dispatch", "workspace", ws] action calc(q: text) -> text ["qalc", "-t", q] action rec(file: path) ["wf-recorder", "-f", file] ``` ```strand state query = "" bar Top { edge: top; height: 32 bg: $surface; font: $font.ui; color: $fg split { pad: 0, $space.3 start { gap: $space.2 button "1" { on click { dispatch(1) } } button "2" { on click { dispatch(2) } } } center { gap: $space.2 input { width: 160; text: <-> query; placeholder: "2 + 2" } text calc(query) ?? "" { color: $fg.muted } } end { gap: $space.2 button ppd.profile { on click { ppd.hold("performance", "a long build", "strand") } } button rec.running ? "Stop" : "Record" { on click { if rec.running { rec.stop() } else { rec("/tmp/clip.mp4") } } } } } } ``` Here is the bar with the power profile given as `balanced` and `calc` answered as qalc would answer it. "2 + 2" is typed, then Record is clicked: `services.strand`: ```strand // services.strand: what no built-in service does permit exec "hyprctl", "qalc", "wf-recorder" service ppd from dbus system "net.hadess.PowerProfiles" { profile: text rw = ActiveProfile action hold(profile: text, reason: text, app: text) = HoldProfile } action dispatch(ws: int) ["hyprctl", "dispatch", "workspace", ws] action calc(q: text) -> text ["qalc", "-t", q] action rec(file: path) ["wf-recorder", "-f", file] ``` `bar.strand`: ```strand state query = "" bar Top { edge: top; height: 32 bg: $surface; font: $font.ui; color: $fg split { pad: 0, $space.3 start { gap: $space.2 button "1" { on click { dispatch(1) } } button "2" { on click { dispatch(2) } } } center { gap: $space.2 input { width: 160; text: <-> query; placeholder: "2 + 2" } text calc(query) ?? "" { color: $fg.muted } } end { gap: $space.2 button ppd.profile { on click { ppd.hold("performance", "a long build", "strand") } } button rec.running ? "Stop" : "Record" { on click { if rec.running { rec.stop() } else { rec("/tmp/clip.mp4") } } } } } } ``` ## A D-Bus object as a service `service ppd from dbus system "net.hadess.PowerProfiles" { … }` is the power profiles daemon on the system bus. - **`profile: text rw = ActiveProfile`** is its `ActiveProfile` property, read as `ppd.profile` and followed live. `rw` makes it writable: `ppd.profile = "power-saver"` in a handler, a `<->` binding or `strand set ppd.profile balanced` sets the property. - **`action hold(…) = HoldProfile`** declares one of the object's methods. A handler calls it as `ppd.hold("performance", "a long build", "strand")`; each parameter is one argument of the method. The call does not hold the service up, and its answer is waited for however long the daemon takes. - **It is checked against the object's introspection**, like its properties: a misspelt method or property is an error with a did-you-mean, and so is a wrong number of arguments. When Strand cannot read the object's methods, they are not checked and a warning says so. A service declared this way starts when the shell first reads it and stops 5 s after its last reader goes, as the built-in ones do. ## A declared action runs a command `action dispatch(ws: int) ["hyprctl", "dispatch", "workspace", ws]` declares a command once, at the top level of a file. `dispatch(1)` in a handler runs it. - **The command is a list.** Its first item is the program, written out; the others are texts and the action's parameters. Each parameter is exactly one argument, never split on spaces, and no shell stands between Strand and the program. - **`permit exec` lists the programs a config may run.** An action whose program it does not allow is a load error. `strand check` lists every action with its command, so you can see what a config runs before you run it. - **It runs detached.** The handler does not wait for it. If the program cannot start, or exits with an error, Strand logs it and `strand watch` shows a notice. ## An action that gives back a value `action calc(q: text) -> text ["qalc", "-t", q]` has a result type, so its command's output is its value. In a binding, `calc(query)` is an `Async`, as `apps.search(query)` is: - it runs when first read, and again each time `query` changes, ending a run still going; - it has 10 seconds, as a polled command has; - the last answer stays shown while the next one runs (`.pending`), and `.error` says why a run failed; - `?? ""` gives the empty text until there is an answer. A `text` result is what the program printed, trimmed. A number, a `bool`, a colour or an enum is read from the printed text; a record `type` of your own or a list is read from JSON, and a record also from `key=value` lines. In a handler, `let r = await calc(q)` waits for it. ## An action you can stop An action's name also reads as its run. `rec.running` is true while its command runs, and `rec.stop()` in a handler stops it as Ctrl-C in a terminal would (`SIGINT`, then `SIGTERM` and `SIGKILL` if the program will not end), so a recorder finishes its file. One button starts and stops it, with no pid file. ## From a terminal A service's actions can be called from a terminal too, with `strand call`: ```sh strand call notifications.clear ``` For a service no declaration can describe, a plugin can add one: a binary built from Strand's Rust library, git-only and marked unstable. The guide's [plugins page](/guide/plugins.html.md) shows how. --- # The language in brief A Strand shell is a directory of `.strand` files. Each file declares surfaces, components, state and the rest; together they make one program, checked as a whole before anything runs. This page names the pieces. The [guide](/guide/index.html.md) teaches them in order, and [grammar.md](https://github.com/jakeb-grant/strand/blob/main/docs/grammar.md) is the exact syntax. ## Files Your config is a directory, `~/.config/strand` (`$XDG_CONFIG_HOME/strand`) unless you name another one to `strand run`. Every `.strand` file in it, up to three directories deep, is loaded. There are no imports and no entry file: a component declared in one file is usable in every other. What a file declares at the top level is either shared by every file (components, surfaces, enums, types, functions, token sets, keyframes, actions and services) or the file's own (its `state` and `let`). `export` shares a value too: `export state open = false` in `launcher.strand` is read elsewhere, and from the command line, as `launcher.open`. ## Surfaces and nodes A **surface** is something the compositor shows: a `bar`, `panel`, `osd`, `window` or `lock`. Its body is a tree of **nodes**, and every node is written the same way: ```strand kind positional { props; children } ``` The positional is one value, such as the text of a `text`. Anything in braces is a prop or a child. Here is a whole shell in one file: ```strand bar Top { edge: top; height: 32 bg: $surface; font: $font.ui; color: $fg row { pad: 0, $space.3; gap: $space.3; align: center text "Strand" { weight: 600 } spacer {} text clock.format("%a %d %H:%M") text pct(battery.percent) { color: $fg.muted } } } ``` `bar Top` is on every monitor. `row` lays its children out side by side, and `spacer` takes the room between them. `text` draws text. The element reference lists every element with its props and events. A **component** is a node kind of your own: ```strand component Chip(label: text, on: bool = false) { text label { pad: $space.1, $space.2; radius: full when on { bg: $accent; color: $on_accent } } } ``` It is called like a built-in element, `Chip "Wi-Fi" { on: true }`, with its first parameter as the positional and the rest as props. ## Props A prop is `name: value`, ended by a line break or `;`. Several values take commas: `pad: 0, $space.3` is no padding at the top and bottom and `$space.3` at the sides, as in CSS. `font`, `weight` and `color` are inherited: set on a node, they apply to everything inside it that does not set its own. A `when` changes props while a condition holds, and the last one written wins, as in CSS: ```strand text "Battery" { when battery.percent < 0.2 { color: $error } when hover { color: $accent } } ``` `hover`, `pressed`, `focused` and `selected` are the state of the node the `when` is written in. ## Expressions Every prop's value is a live **expression**. `text clock.format("%H:%M")` is not set once: it shows the clock's value whenever it changes. You write what a prop should be, and Strand keeps it so. [State and reactivity](/concepts/state.html.md) says how. Expressions are small and typed: | Kind | Examples | | --- | --- | | Numbers and lengths | `36`, `0.72`, `4px`, `40%`, `4ch` (a bare number is px) | | Durations and angles | `200ms`, `1.2s`, `5min`, `270deg` | | Colours | `#7aa2f7`, `$accent`, `$surface.alpha(0.8)` | | Text | `"Search apps"`, joined with `+`; no interpolation | | Lists | `[1, 2, 3]`, `apps.search(q).take(5)` | | Optional values | `n.image ?? n.app.icon` (`??` gives the right side when the left is `null`) | | Choices | `muted ? 0 : volume`, `match kind { volume => …, brightness => … }` | | Functions | `x => x * 2`, `fn double(x: int) -> int { x * 2 }` | A value that might be `null` must be unwrapped with `?.` or `??` before it is used, and `strand check` says where you forgot. A `match` must cover every case. ## Trees that change `if`, `match` and `for` add and remove nodes: ```strand if battery.charging { icon "battery-charging-symbolic" } for ws in workspaces.all { text ws.name } ``` A `for` gives each item its own copy, kept by the item's **key**: a workspace by its id, a notification by its id. When the list changes, copies whose key stays keep their state and slide to their new place. A list of plain values names its key, `for p in pins key p.app`. ## Handlers `on click { … }` and the other `on` blocks are **handlers**: the only place where values are written and actions run. ```strand button "Mute" { on click { audio.sink.muted = !audio.sink.muted } } ``` `after 6s { … }` and `every 1s { … }` are timers, and `on change x { … }` runs when `x` changes. A handler may `await` a slow call, and other handlers run while it waits. ## Checked before it runs `strand check` reads the whole config and reports every problem it finds, each with where it is and, where it can, a fix: an unknown name with a did-you-mean, a wrong type, a `null` not handled. A running shell checks each save the same way, and keeps running the last good version while a file has errors. --- # State and reactivity Strand has three kinds of value. You write props as expressions over them, and Strand keeps every prop up to date as they change. You never write code that updates the screen. ## Three kinds of value - **`state x = 0`** is a cell you write: a popup's open flag, a search query, the month a calendar shows. Its default is evaluated once, when the cell is made. - **`let x = expr`** is a value derived from others. It is read-only, and recomputed only when something it reads has changed, and only when something reads it. - **Service fields** such as `audio.sink.volume` or `clock.today` are live values that the system changes. A few are writable; the [service reference](/reference/services/index.html.md) marks them `rw`. ```strand state open = false let level = audio.sink.muted ? 0 : audio.sink.volume bar Volume { edge: bottom; height: 32 bg: $surface; font: $font.ui; color: $fg row { pad: 0, $space.3; gap: $space.2; align: center icon audio.sink.icon { size: 16 } text pct(level) { on click { open = !open } } if open { slider { width: 120; value: <-> audio.sink.volume } } } } ``` `level` is derived from two service fields. The text shows it and changes when either does. A click writes `open`, and the slider appears. Here the volume then changes from outside, and both follow: `volume.strand`: ```strand state open = false let level = audio.sink.muted ? 0 : audio.sink.volume bar Volume { edge: bottom; height: 32 bg: $surface; font: $font.ui; color: $fg row { pad: 0, $space.3; gap: $space.2; align: center icon audio.sink.icon { size: 16 } text pct(level) { on click { open = !open } } if open { slider { width: 120; value: <-> audio.sink.volume } } } } ``` ## Bindings record what they read A program is one graph of values. Each prop, `let` and condition records exactly what it read the last time it ran. When a value is written: - if the new value equals the old one, nothing downstream runs; - otherwise only what read it is recomputed, and only the part of the screen that changed is painted again; - when nothing is written, nothing runs and no frame is drawn. Writes made together count as one change. Every binding sees a consistent set of values, never a mix of old and new, and the screen gets one set of changes for them. ## Where writes happen Values are written only in **handlers**: the bodies of `on`, `after` and `every`. A handler assigns with `=`, `+=`, `-=`, `*=` and `/=` to a `state`, a field of one, a service's `rw` field or a field of a settings file. Assigning to a `let`, a prop or a component's parameter is a load error with a fix. ## Where state lives Where you declare a `state` decides how many copies there are: | Declared | One cell for | | --- | --- | | At the top level of a file | The whole program | | Directly in a component's body | Each place the component is used | | Directly in a surface's block | Each instance of the surface (a bar has one per monitor) | | In a `for` body | Each item, in each instance | Anywhere else is a load error that says where to move it. State never sits on a leaf element such as a `text`, so editing an element never takes state with it. ## Two-way bindings: `<->` A widget that changes a value takes a two-way binding: ```strand slider { value: <-> audio.sink.volume } input { text: <-> query } popup { open: <-> open; Calendar } ``` The widget shows the value, and when you drag, type or dismiss, it writes the value back. The target must be writable: a `state`, a service's `rw` field or a settings field. A prop that only shows a value takes a plain expression; `<->` is for the props the reference marks two-way, such as `value`, `text` and `open`. ## Values that take time: `Async` A search or a wallpaper palette is not ready at once. Such calls give an `Async`, which keeps its last value while the next one loads: - `.pending` is true while a load runs, and `.error` says why the last one failed; - `x ?? fallback` gives the value it holds, or `fallback` while it has none; - using an `Async` where a `T` is needed is a load error, so the waiting case is never forgotten. `apps.search(query)`, `material(image: …)` and [a declared action with a result](/how-to/dbus-and-actions.html.md#an-action-that-gives-back-a-value) are `Async`. ## Events and timers - `on change a, b { … }` runs when a watched value changes. It never runs for the first value, nor for a value a reload writes, so an OSD does not flash at boot or on save. - `on change a after 1.2s { … }` runs once, 1.2 s after the last change. - `after 6s while !hover { … }` runs once, after its condition has held for six seconds in all; `every 1s { … }` runs every second. - An event such as `on notifications.received(n)` is never merged: every one is delivered, in order. ## Keeping state across restarts - **`persist`** on a `state` keeps its value across restarts. - **`export`** on a top-level `state` or `let` makes it readable from other files and from the command line: `strand toggle launcher.open` flips `export state open = false` in `launcher.strand`. - **Settings files** keep choices in a TOML file you can edit; see [a settings file](/how-to/settings-file.html.md). ## Loops are caught A `let` or token that depends on itself is a load error naming the cycle. A handler that writes one cell more than 30 times a second is warned about and slowed to 30 writes a second, keeping its last value. Your input does not count: a scroll or a drag may write as fast as it moves, and so may `<->` and the command line. [Live reload](/concepts/live-reload.html.md) says what happens to state when you save a file. --- # Tokens and theming Colours, spacing, radii, fonts, shadows and springs are **tokens**, read as `$name`: `bg: $surface`, `pad: $space.3`, `radius: $radius.lg`. A theme gives every token its value. Change the theme and everything that reads a token follows, animated, with nothing rebuilt. ## Three tiers | Tier | What it holds | Examples | | --- | --- | --- | | Palette | The colour roles: Material 3's roles one to one, plus 13 hue roles | `$surface`, `$fg`, `$accent`, `$on_accent`, `$error`, `$green` | | Base tokens | Scales, and roles derived from the palette | `$space.2`, `$radius.lg`, `$font.ui`, `$motion.spatial`, `$fg.muted` | | Component tokens | Knobs a component of yours exposes | `$Toast.radius` | The [token reference](/reference/tokens.html.md) lists every palette role and base token. **Hue roles** are colours that must stay recognisable: `$red`, `$orange`, `$yellow`, `$green`, `$cyan`, `$blue`, `$magenta`, and a brighter one of each but orange, `$bright_red` through `$bright_magenta`. A scheme that has these hues gives its own. For one that does not, each base hue is the plain hue moved a little toward your accent, and each bright one is its base made lighter in a dark palette and darker in a light one. ## The built-in theme With no theme of your own, Strand uses its built-in one: its base tokens, and the palette ```strand material(seed: system.accent ?? #7aa2f7, dark: system.dark, contrast: system.contrast) ``` so the shell follows your desktop's accent colour, light or dark setting and contrast preference, live. ## A theme file A theme is ordinary declarations in any file. This one switches between two looks and makes spacing tighter on demand: ```strand // theme.strand enum Look { auto, mocha } export state look = auto persist export state compact = false persist let scheme: Palette = match look { mocha => import("catppuccin:mocha"), auto => material(seed: system.accent ?? #7aa2f7, dark: system.dark), } tokens roomy { space { 1: 4px; 2: 8px; 3: 12px; 4: 16px } } tokens tight extends roomy { override space { 1: 2px; 2: 4px; 3: 8px; 4: 12px } } use tokens compact ? tight : roomy, palette scheme ``` ```strand bar Top { edge: top; height: 32 bg: $surface; font: $font.ui; color: $fg row { pad: 0, $space.3; gap: $space.2; align: center box { size: 12; radius: full; bg: $accent } text clock.format("%H:%M") spacer {} text pct(battery.percent) { color: $fg.muted } } } ``` - **A palette is a value.** `material(seed: …)` builds every role from one colour, as Android's Material You does; `material(image: …)` from a picture ([a theme from your wallpaper](/how-to/wallpaper-theme.html.md)); `import("…")` reads a published scheme: Catppuccin, base16 and base24 schemes, matugen output and W3C design-token files. Every source fills every role, and a role a source lacks is derived. - **`tokens name { … }`** declares a token set. A set that names only some base tokens, as `roomy` names only `space`, takes the rest from the built-in theme. `extends roomy` starts a set from another one, and changing a token it inherits needs the word `override`, so a misspelt name is an error, never a new token nobody reads. - **`use tokens …, palette …`** picks the set and the palette in force. Both are expressions, so the choice is live: `strand set theme.compact true` tightens the spacing, and `strand set theme.look mocha` changes every colour. A token can be an expression over others, and it stays one: `fg.muted: $fg.alpha(0.65)` is re-evaluated whenever `$fg` changes, every frame of a palette change included. Colours have the methods `alpha`, `mix`, `lighten` and `darken`, and `oklch(from $c, l: l + 0.12)` derives a colour from another's channels. A raw hex colour in a prop is a warning: use a token, so the theme can change it. Defaults in settings files and `material(seed:)` are exempt. ## Overrides for part of a tree `set { … }` as an item of a node overrides tokens for that node and everything inside it: ```strand col { set { $surface: $surface.mix($accent, 4%) } // every node in here sees the tinted surface, // and $surface.hi is derived again from it } ``` On the right of a `set`, `$surface` is the value from outside, so this is not a loop. A component can expose tokens of its own: ```strand component Tile(label: text) tokens { radius: $radius.lg; pad: $space.3 } { text label { radius: $Tile.radius; pad: $Tile.pad } } ``` `set { $Tile.pad: $space.2 }` around some tiles makes those tighter without editing `Tile`. ## How a swap animates Choosing another palette, a desktop setting changing, a new wallpaper, `strand set` and saving a theme file all take the same path: 1. The tokens that depend on what changed are resolved again. 2. Only the palette's roles spring to their new values, in OKLab. Every derived token is computed from them each frame, so it is exact at every step of the animation. 3. Text colours keep a contrast of at least 3:1 against the backgrounds they are paired with, throughout. Where a swap between light and dark makes that impossible, the surface takes one picture of its old frame and crossfades from it instead. 4. What cannot be animated, such as a font, changes at once. 5. Files a `file` element writes are written once, with the new values, never with the in-between ones. Here a bar switches from Catppuccin Latte to Mocha and back. The surface, the text, the accent dot and the muted text, which is derived from `$fg`, move to the new palette together; the pictures for light and dark mode are the same, since an imported scheme is light or dark by itself. `swap.strand`: ```strand enum Look { latte, mocha } state look = latte use palette match look { latte => import("catppuccin:latte"), mocha => import("catppuccin:mocha") } bar Top { height: 40 bg: $surface; font: $font.ui; color: $fg row { pad: 0, $space.3; gap: $space.2; align: center box { size: 12; radius: full; bg: $accent } text "Strand" spacer {} text "12:00" { color: $fg.muted } } } ``` Nothing remounts: an open launcher keeps its query and a calendar its month. The last palette is kept, so the shell starts in it rather than flashing the default colours first. ## Your colours for other programs `file "~/.cache/strand/accent.txt" { text: $accent }` keeps a file in step with the theme, and `strand export json`, `strand export base24` and `strand export --template` write the palette for other programs. Strand ships no per-app themes; the guide's [level 5](/guide/level-5.html.md#2-your-colours-for-other-programs) shows both. ## Trying a colour `strand set '$accent' '#ff5555'` sets a token over what the theme says. It is kept in Strand's state directory, so it stays across restarts until `strand clear '$accent'` takes it off. Quote both: a shell reads `$accent` as a variable and `#` as a comment. --- # Layout Strand lays nodes out with flexbox, as the web does: containers place their children in rows and columns, and sizes, growth and alignment are props. If you know CSS flexbox, most of this is familiar. ## Containers | Container | Places its children | | --- | --- | | `row` | Side by side, left to right | | `col` | One under another | | `stack` | Over each other, in one cell its size | | `grid` | In a grid of `columns:` columns | | `split` | In three parts, `start`, `center` and `end`, with `center` centred on the whole whatever the others hold | | `scroll` | In a view that scrolls along its `axis:` | | `list` | In a column that scrolls, laying out and mounting only its visible rows | `spacer` takes the space left in its row or column, and `box` is a plain box to style. A surface, a `box` and a `popup` lay their children out as a `stack` does. ```strand bar Top { edge: top; height: 36 bg: $surface; font: $font.ui; color: $fg split { pad: 0, $space.3 start { gap: $space.2 for ws in workspaces.all { box { size: 8; radius: full; bg: $fg.faint when ws.focused { width: 20; bg: $accent } } } } center { text clock.format("%H:%M") } end { gap: $space.2 text pct(battery.percent) text pct(audio.sink.volume) { width: 4ch; align: end } } } } ``` The clock stays in the middle of the bar however wide the two sides are, and the focused workspace's dot grows to a pill. Here the focus moves from workspace 1 to 3, then to 2: `bar.strand`: ```strand bar Top { edge: top; height: 36 bg: $surface; font: $font.ui; color: $fg split { pad: 0, $space.3 start { gap: $space.2 for ws in workspaces.all { box { size: 8; radius: full; bg: $fg.faint when ws.focused { width: 20; bg: $accent } } } } center { text clock.format("%H:%M") } end { gap: $space.2 text pct(battery.percent) text pct(audio.sink.volume) { width: 4ch; align: end } } } } ``` ## Sizes - **`width`, `height` and `size`** take logical pixels (`36` is `36px`), a percentage of the parent (`40%`) or `ch`, the width of a character in the node's font (`4ch`). Unset, or `auto`, a node is the size of what it holds. - **`min_width`, `max_width`, `min_height` and `max_height`** bound it: `max_height: 420` on a list makes it scroll past 420 px. - **`pad`** is space inside a node's edge and **`margin`** space outside it. Both take one to four values, as in CSS: `pad: 0, $space.3` is none above and below and `$space.3` at the sides. - **`gap`** is the space between a container's children. A text that does not fit is cut with `ellipsis: end`, or wraps up to `max_lines`. ## Sharing space: grow and shrink - **`grow: 1`** gives a child a share of the space left in its row or column. Two children with `grow: 1` split it evenly. - **`shrink`** says how much a child gives up when its row or column is too small: 1 by default, and `shrink: 0` keeps its size. An `image` or `icon` with a size in pixels or `ch` does not shrink unless you say so, so icons keep their size in a crowded bar. - **`wrap: true`** on a `row` or `col` runs its children on into another line when one is full. ## Alignment - **`align`** places a container's children on its cross axis (up and down in a `row`, across in a `col`): `start`, `center`, `end`, `stretch` or `baseline`. On a `text` it aligns the text's lines. - **`justify`** shares the main axis among them: `start`, `center`, `end`, `space_between`, `space_around` or `space_evenly`. - **In a `stack`**, `align` places every child on both axes. A child fills the stack on an axis where it has no size of its own, and sits at the start of one where it has. There is no alignment for one child alone. Put it in a `row` or `col` that places it (`row { justify: end; … }`), or take it out of the flow with `place: absolute` and place it with `x` and `y`. ## Container queries `self.width` and `self.height` are the node's own laid-out size, so a node can change with the room it has: ```strand row { when self.width < 300 { gap: $space.1 } … } ``` A query switches with 4 px of slack, so a size hovering on the line does not flicker. ## What lays the tree out again Layout runs only when something that affects it changes: a size, a `pad`, a text, a node added or removed. Then: - **Paint-only props never cause layout.** `x`, `y`, `scale`, `rotate` and `opacity` move or fade what is already laid out, so animating them costs only painting. - **A size that springs** lays out again only the part of the tree under the nearest ancestor whose size does not change, on each frame of the spring. - **A surface the size of what it holds** (a panel, an OSD, a popup) springs its own size when that changes, growing and shrinking alike. - **A `list`** lays out only the rows in its view, however long it is. [Motion](/concepts/motion.html.md) says how those changes animate, and [performance](/concepts/performance.html.md) what they cost. --- # Motion Every visual prop in Strand moves to its new value on a spring. You do not ask for an animation: when a value changes, whether from state, a service, a theme swap or a saved file, the renderer carries the prop from where it is drawn to where it should be. ## Springs by default Movement and size spring along `$motion.spatial`; colour and opacity along `$motion.effects`. The built-in theme sets them, with a third, looser spring for overrides: ```strand motion { spatial: spring(3000, 0.75); effects: spring(1600, 1); bouncy: spring(380, 0.75) } ``` A spring is a stiffness and a damping ratio. Below a ratio of 1 it overshoots a little and settles; at 1 it arrives without overshooting. Your own theme can change any of them, and every prop that uses the token follows. ## Choosing a curve with `~` A prop takes its own curve after `~`: a spring (`$motion.bouncy`, `spring(500, 0.5)`), a duration (`300ms`), a named curve or a cubic bézier, or `instant`. ```strand x: on ? 196 : 0 ~ $motion.bouncy ``` The three knobs below move when `on` flips. The first uses the default, the second `$motion.bouncy`, the third a timed `300ms`. Scrub through the frames to see each one, and read the curves under the picture: the legend gives each curve as the renderer resolved it. `shell.strand`: ```strand state on = false bar Springs { height: 128 bg: $surface col { pad: 16, 20; gap: 14 row { gap: 12; align: center text "default" { width: 64; color: $fg.muted } box { width: 220; height: 24; radius: 12; bg: $surface.hi box { id: spatial width: 24; height: 24; radius: 12; bg: $accent x: on ? 196 : 0 } } } row { gap: 12; align: center text "bouncy" { width: 64; color: $fg.muted } box { width: 220; height: 24; radius: 12; bg: $surface.hi box { id: bouncy width: 24; height: 24; radius: 12; bg: $accent x: on ? 196 : 0 ~ $motion.bouncy } } } row { gap: 12; align: center text "300ms" { width: 64; color: $fg.muted } box { width: 220; height: 24; radius: 12; bg: $surface.hi box { id: timed width: 24; height: 24; radius: 12; bg: $accent x: on ? 196 : 0 ~ 300ms } } } } } ``` ## Interrupted motion keeps its speed A value that changes while its prop is still moving retargets the spring from where it is, with the velocity it has, so an interrupted animation never jumps or restarts. ## A curve per direction The `~` expression is read with the value, so one prop can come and go at different speeds: ```strand opacity: open ? 1 : 0 ~ (open ? 160ms : 1s) ``` fades in over 160 ms and out over a second. A `~` written in a `when` applies while that `when` wins, so the same reads: ```strand opacity: 0 ~ 1s when open { opacity: 1 ~ 160ms } ``` Changing only the curve moves nothing. ## Coming and going: `enter` and `exit` `enter { … }` is where a node animates in from, and `exit { … }` where it animates to before it goes. Without an `exit`, a node leaves the way it came. ```strand enter { opacity: 0; scale: 0.96 } exit { x: 420; opacity: 0; height: 0 } ``` Poses work on surfaces, the branches of an `if`, the copies of a `for`, list rows and pages. When a node is added, removed or moved, its siblings glide to their new places: a leaving card with `height: 0` in its `exit` lets the cards under it slide up. [A notification stack](/how-to/notifications.html.md) shows it frame by frame, and [the OSD](/how-to/osd.html.md) a surface springing in. A panel, OSD or popup that is the size of what it holds springs its own size when that changes, growing and shrinking alike. ## Timed animations: `keyframes` For a movement that is not a change of value, such as a shake or a pulse, declare keyframes and play them from a handler: ```strand keyframes shake { 0%, 100% { x: 0 }; 25% { x: -6 }; 75% { x: 6 }; duration: 300ms } state answer = "" panel Ask { anchor: center; keyboard: exclusive input { width: 240; text: <-> answer; placeholder: "Type yes"; focus: true on activate { if answer != "yes" { play shake } } } } ``` `play shake` plays it once on the node the handler is on: here the input shakes when Return is pressed on anything but "yes". ## What cannot spring Some values have no in-between, and change at once: fonts, and layout lengths such as `pad` (a paint offset such as `x` springs instead). A list of shadows is padded to the same length, then springs. With the desktop's reduced-motion setting on, every transition is instant, and looping animations and effects stop. ## What moves without relayout `x`, `y`, `scale`, `rotate` and `opacity` are paint-only: animating them never lays the tree out again. A size that springs lays out only the part of the tree under the nearest ancestor whose size stays put, and reordered list items glide to their new places. --- # Surfaces and monitors A **surface** is something the compositor shows: a bar along an edge, a panel, an on-screen display, a popup, a window or the lock screen. Every node you write lives in one. Surfaces are declared at the top level of a file, except `popup`, which is written inside the element it opens from. ## The kinds | Kind | What it is | By default | | --- | --- | --- | | `bar` | A strip along a screen edge | On every monitor, top edge, `top` layer; keeps its thickness free of windows | | `panel` | Launchers, notification stacks, dashboards | On the focused monitor, `top` layer; windows go under it | | `osd` | A passing display: volume, brightness | On the focused monitor, `overlay` layer; clicks pass through it | | `popup` | A menu or card anchored to an element | Opens below its element (away from the edge on a bar), 6 px from it; mounts its children only while shown | | `window` | An app's window, which the compositor manages | Tiles, floats and moves as any app's window does | | `lock` | The lock screen | On every monitor; at most one per config | Bars, panels and OSDs are layer-shell surfaces: the compositor puts them on a **layer** above or below windows, `background`, `bottom`, `top` or `overlay` (`layer: overlay` puts a panel above everything). Each one has a stable namespace, `strand-`, so compositor rules can match it: `strand-Top` for `bar Top`. ## What every surface takes - **`open:`** says whether it is shown. A two-way `open: <-> open` lets the surface close itself: Escape, a click away and focus loss write `false`. A `window` is the exception: only the compositor's close and `w.close()` do. - **`anchor:`** places a panel or OSD on its screen: `center`, an edge (`top`, `bottom`, `left`, `right`) or a corner (`top_right`, …). `margin:` keeps it off the edges it is anchored to. - **`keyboard:`** is how it takes the keyboard: `none` (the default), `on_demand` (when you click it) or `exclusive` (while it is shown, as a launcher needs). - **`on show`** and **`on hide`** run as it opens and closes, and `enter { … }` and `exit { … }` are where it animates from and to. - **`inhibit_idle: true`** keeps the screen from dimming or locking while the surface is shown. A bar reserves its edge, so windows are laid out beside it. `exclusive:` on a `bar` or `panel` says how much: `true` its thickness (a bar's default), a length in px, or `false` for none (a panel's default). `attach: top` on a panel or popup grows it out of that edge with concave corners. ## Monitors A `bar` is on every monitor. Each monitor gets its own instance, with its own state and with `screen`, that monitor, in scope: ```strand bar Top { edge: top; height: 32 bg: $surface; font: $font.ui; color: $fg split { pad: 0, $space.3 start { gap: $space.2 for ws in workspaces.all.filter(w => w.screen == screen.name) { text ws.name { color: $fg.muted when ws.active { color: $accent } on click { ws.focus() } } } } end { text screen.name { color: $fg.faint } } } } ``` ```strand panel Dock { screens: all; anchor: bottom; margin: 0, 0, $space.2 row { width: screen.width / 2; height: 48; radius: $radius.lg; bg: $surface } } ``` On one monitor, `DP-1`, the two draw this. The active workspace moves from 1 to 2: `bar.strand`: ```strand bar Top { edge: top; height: 32 bg: $surface; font: $font.ui; color: $fg split { pad: 0, $space.3 start { gap: $space.2 for ws in workspaces.all.filter(w => w.screen == screen.name) { text ws.name { color: $fg.muted when ws.active { color: $accent } on click { ws.focus() } } } } end { text screen.name { color: $fg.faint } } } } ``` `dock.strand`: ```strand panel Dock { screens: all; anchor: bottom; margin: 0, 0, $space.2 row { width: screen.width / 2; height: 48; radius: $radius.lg; bg: $surface } } ``` - **`screens:`** picks the monitors: `all`, `focused`, a monitor's name (`"DP-1"`) or a list of names. A bar's default is `all`. - **A `panel` or `osd` is on the focused monitor** unless `screens:` says otherwise. Then it is one surface the compositor places, and `screen` is not in scope in it: reading it is a load error that says to add `screens: all`. - **With `screens: all`**, a panel or OSD is on each monitor, as a bar is, with its own state and `screen` on each. - **Plugging and unplugging.** A monitor plugged in gets its instances. An unplugged monitor's instances are kept with their state for 30 seconds and come back with it if the monitor does. Monitors are told apart by make, model and description, so a monitor moved to another port is still the same one. State declared in a component or a surface is per instance, so a calendar opened on one monitor's bar stays closed on the others. State declared at the top level of a file is one for the whole shell. ## Popups `popup { … }` is written inside the element it belongs to and opens from it, so it needs no position: ```strand text clock.format("%H:%M") { on click { open = !open } popup { open: <-> open; Calendar } } ``` It nests (a menu's submenu is a popup in a popup), `anchor: pointer` opens it at the click, as a context menu, and `trigger: hover` opens it while the pointer is on its element. A popup takes the keyboard only when a click or a key opened it. [A bar with a calendar popup](/how-to/calendar-popup.html.md) is a whole example. ## Windows A `window` is a surface the compositor manages as an app's window: it tiles or floats, can be moved and resized, and appears in task bars. The compositor decides its size and place, so it takes no `screens:`, `layer:`, `anchor:`, `margin:` or `keyboard:` (each is a load error); `width` and `height` are the size it asks for. `title:` sets its title and `app_id:` the id window rules match, `strand-` by default. Give it `id: w` and `w.focused`, `w.maximized` and `w.tiled` follow the compositor, while `w.move()`, `w.maximize()` and `w.close()` ask it. ## The lock screen `lock { … }` is the lock screen, on the `ext-session-lock` protocol. It is on every monitor, with one password shared between them, and something in it must call `auth.submit`, or it is a load error, since it would have no way out. It fails closed: if anything in it faults, a built-in password field takes its place. A save that changes it waits until you unlock. The guide's [level 4](/guide/level-4.html.md) builds one. --- # Services A **service** is the system as values: the battery, the audio devices, the workspaces, the clock. You read its fields in expressions, as you read state, and the screen follows them as they change. You never poll or subscribe. ```strand text pct(battery.percent) // follows the battery icon audio.sink.icon // the default output's icon for ws in workspaces.all { text ws.name } // every workspace, live ``` ## Built-in services Strand has 28 built-in services. Each one is in the [service reference](/reference/services/index.html.md), with every field, function, action and event. | Service | What it is | | --- | --- | | `apps` | Installed apps, from their desktop entries, with search | | `audio` | Audio devices and the programs' streams, from PipeWire | | `auth` | The lock screen's password check, through PAM | | `battery` | The battery, from UPower | | `bluetooth` | Bluetooth adapters and devices, from BlueZ | | `brightness` | The screen's backlight | | `calendar` | Month grids for calendars | | `clipboard` | The clipboard, and a history of what you copied | | `clock` | The date and time | | `cpu`, `memory`, `disk` | Processor load, memory use, disk usage | | `greetd` | A login screen, when Strand runs as greetd's greeter | | `idle` | Whether you are away | | `keyboard` | Lock keys, layouts and modifiers | | `media` | The active media player, over MPRIS | | `network` | The network, from NetworkManager or iwd | | `notifications` | The notification server, which the shell is | | `outputs` | The monitors' configuration | | `polkit` | The admin password dialog, as the session's polkit agent | | `screens` | The monitors | | `session` | The login session and power: lock, suspend, log out | | `system` | The desktop's appearance settings: dark, accent, contrast | | `tray` | The system tray | | `video` | The programs using a camera or casting a screen | | `windows` | The open windows | | `wm` | The compositor: its name and binding mode | | `workspaces` | The workspaces | ## Started when read A service starts when the shell first reads it, is shared by everything that reads it, and stops 5 seconds after its last reader goes away or is no longer shown. A shell that never reads `bluetooth` never talks to BlueZ. One that reads it only in a closed popup stops it a few seconds after the popup closes. A service also wakes the shell only as often as what you read changes. `clock.format("%H:%M")` wakes once a minute, and `"%H:%M:%S"` once a second. ## Reading, writing and acting - **Fields** are read anywhere: `battery.percent`, `audio.sink.volume`. Items in a list, such as each workspace or notification, carry their own key, so a `for` over them keeps each copy's state as the list changes. - **Writable fields**, marked `rw` in the reference, are assigned in a handler or bound with `<->`: `audio.sink.volume = 0.5`, `slider { value: <-> audio.sink.volume }`. - **Actions** do something: `ws.focus()`, `n.dismiss()`, `h.app.launch()`. They run only in handlers, and their errors are values: a failing action never stops the shell. - **Events** are delivered one by one, in order: `on notifications.received(n) { … }`. Where a daemon may be missing (NetworkManager, BlueZ, logind, the backlight), the service's `available` field says whether it answers, so a config can hide what cannot work. A missing daemon never stops the shell. ## Services of your own What the built-ins do not cover, a config declares in a line or two: ```strand service ppd from dbus system "net.hadess.PowerProfiles" { profile: text rw = ActiveProfile } service temp from poll ["sensors", "-j"] every 5s { cpu: float = package } permit exec "sensors" ``` | Source | Reads | | --- | --- | | `from dbus` | A D-Bus object's properties, followed live, and its methods as actions | | `from file` | A file (JSON, `key=value` lines or plain text), again each time it changes | | `from dir` | A directory's files, again each time one is added, removed or changed | | `from listen` | The lines a command prints, as they come | | `from poll … every T` | A command's output, or a file, every `T` while a reader is shown | Their fields are checked like the built-ins'. A `from dbus` service is checked against the object's introspection, so a misspelt property is an error with a did-you-mean. Running a command needs `permit exec`, naming the programs allowed. A custom service starts and stops as the built-ins do, and saving a change to its declaration restarts only that service. ## Actions as a fallback When there is no service for something, a declared **action** runs a command, and a `from dbus` service can call the object's methods: ```strand permit exec "hyprctl" action dispatch(ws: int) ["hyprctl", "dispatch", "workspace", ws] ``` `dispatch(3)` in a handler runs it, with each parameter as exactly one argument and no shell in between. An action with a result type, such as `-> text`, gives the command's output as a value. [Calling a D-Bus method or a declared action](/how-to/dbus-and-actions.html.md) shows each form with a whole config. For what no declaration can describe, a plugin adds a service in Rust; see the guide's [plugins page](/guide/plugins.html.md). --- # Live reload Saving a file is a change of state, not a restart. While `strand run` is running, every save is checked, and what changed is merged into the running shell: an open launcher keeps its query, a calendar its month, and a colour you changed glides to its new value. ## What each edit does | You edit | What happens | State kept? | | --- | --- | --- | | A token's value | The colours spring to it | Yes | | A prop or binding | Patched in place; animates from where it is | Yes | | A node added or removed | Its `enter` or `exit` plays; its siblings glide | Yes, elsewhere | | A `state`'s default | Taken only if the value was never changed | Yes, if you changed it | | A `state`'s name or type | That one cell resets, with a warning | No, that cell only | | A handler's code | Restarted; an `await` in progress is cancelled and reported | Yes | | A timer's duration | The time left is rescaled | Yes | | A surface's layer, namespace or kind | Only that surface is made again | Yes | | A custom service's declaration | Only that service restarts; built-ins never do | Yes | | Anything in the `lock` | Waits until you unlock | Yes | A handler counts as changed when its own code changed, or a `fn` or `let` it reaches did. An unchanged handler keeps what it was doing. ## How Strand keeps state Each node keeps its identity while its source text still maps to it. Editing a node's own props never resets it, and wrapping it in a container keeps it. After that a `key` or `id` decides, then the node's place among siblings of the same kind. A node whose kind changed is a new node. Where the edit is truly ambiguous, the state resets with a warning: Strand never guesses. Because state lives on components, surfaces and list items, never on a leaf element, editing an element cannot take a popup's open state with it. Nodes moved into another component are new nodes there, and their state starts over. A surface's state is keyed by its place in the program, so renaming a surface keeps it. A bar's state on each monitor is keyed by the monitor and survives a reload. ## Changed defaults A cell takes a new default only if it still holds the old one. If you changed it, it keeps your value, and the reload overlay says so: ```text launcher.query: kept "fir" (default changed) [reset] ``` A value a reload writes never fires `on change`, so an OSD does not flash on save. ## When a save has errors A save is checked before anything changes: 1. Strand waits 15 ms after the last write, so "save all" in your editor is one reload. 2. A file whose content did not change stops here. 3. The changed files are checked with everything that refers to them, off the drawing path; the shell keeps running meanwhile. 4. The largest set of changes that checks clean is applied in one step, with no blank frame. The rest waits with its diagnostics. 5. On an error the running shell stays as it was. The last good sources are kept, so a config that is broken when Strand starts runs its last good version. If an error is still there 250 ms after the save, an overlay opens listing every diagnostic, with where it is and a fix where there is one. Clicking one opens the file at that line in your editor (`$STRAND_EDITOR`, `$VISUAL` or `$EDITOR`). A save you fix within the 250 ms, such as one your editor formats, never opens it. A fault while the shell runs, such as a value that cannot be computed, freezes only its own component, outlined in red, until a save fixes it. The rest of the shell runs on. ## Starting over - **`@reset`** on a `state` resets that one cell to its default when the save is applied, and again on every later reload while it is written, so remove it after one save. - **`strand reload --hard`** drops every state that is not `persist` and makes every surface again. Settings files are untouched. - **`strand reload`** rescans the config now. ## Watching reloads `strand watch` prints each reload as it happens: the files, what kind of edit it was, which cells were kept or reset, how long it took and its diagnostics. `strand watch --json` gives the same as JSON lines. --- # Performance Strand aims to be fast when something moves and to cost nothing when nothing does. This page says how it gets there, and how to measure your own shell. ## An idle shell does no work The program is one graph of values (see [state and reactivity](/concepts/state.html.md)). When nothing is written, nothing is recomputed and no frame is asked for. A service wakes the shell only as often as what you read from it changes: a clock that shows minutes wakes it once a minute, and the battery when the battery changes. Services nobody reads are not running at all. ## Only what changed is painted Each frame repaints only the area that changed, its **damage**. When the clock ticks, Strand repaints the clock's text, not the bar. On the demo bar (`strand run --demo`), 2560 px wide at scale 1 and 1.25, a minute tick, even one that changes every digit and the text's width, repaints at most 2,000 px². Layout is just as narrow. `x`, `y`, `scale`, `rotate` and `opacity` never lay the tree out again, and a size that springs lays out only the part of the tree under the nearest ancestor whose size stays put (see [layout](/concepts/layout.html.md#what-lays-the-tree-out-again)). ## CPU and GPU Strand draws on the CPU by default, into shared memory, with exact damage: that is the cheapest way to draw a bar whose clock ticks. When a large area animates, the GPU is faster, so a surface moves to it: - **A surface moves to the GPU** at the first large frame (at least 200,000 pixels of damage) of an animation, such as a panel opening, a scroll or a theme crossfade, or at the third large frame in a row of something that animates with no spring, such as a shader's clock. - **It goes back to the CPU** once it has been settled, with only small frames or none, for 3 seconds. A surface that animates again within 30 seconds of going back waits 15 seconds the next time, so one you use every few seconds is not handed back and forth. - **A small animation the CPU draws slowly** (4 ms or more a frame) keeps its surface on the GPU. - **The GPU device is started with the shell**, off the drawing path, and kept, so an animation never waits for it. Where no GPU can exist, Strand never asks for one. After 3 lost devices in one run the GPU stays off until Strand restarts, and everything is drawn on the CPU. `strand report` shows each surface's backend and why. Strand's own build is held to memory and speed budgets, set in [design.md](https://github.com/jakeb-grant/strand/blob/main/docs/design.md#rendering-performance-and-memory-budget). They catch a change that makes Strand slower or bigger, and are never a limit on what your shell may do. ## Measuring your own shell `strand report` describes the running shell: the process's PSS, the GPU's status, and for each surface its backend, why it is on the GPU and its frame times (p50, p95 and p99 over the frames drawn among its last 600 paints). --- # Editor setup Strand's editor support is a language server and a tree-sitter grammar. Both live outside the shell, so they cost `strand` nothing. The repository ships no editor plugin or editor configuration; this page gives the settings that start them in Helix and Neovim. ## The language server The language server is `strand-dev lsp`. Build `strand-dev` from the checkout and put it on your `PATH`: ```sh cargo build --release -p strand-dev cp target/release/strand-dev ~/.local/bin/ ``` `strand-dev lsp` speaks the Language Server Protocol over standard input and output. An editor starts it for files ending in `.strand`. ### What it does - **Diagnostics**: what `strand check` finds in the config's files (all of it but a missing `strand-auth` helper), for every file of the config: when a file opens or is saved, and 200 ms after you stop typing. - **Completion** after `$`, `.` and `<->`, and of element, component, keyword, prop and event names and enum values. - **Hover**: a name's type, and its documentation from the schemas. - **Go to definition**, across the config's files. - **Rename** across files: a `state`, a `let`, a component, a token, and a component's prop at its call sites. A rename that would break the config, or one of a built-in name, is refused. - **Code actions**: the "did you mean" fixes the diagnostics carry, a missing `key`, and "extract component", which moves the selected subtree into a new component. - **Formatting**, with the formatter `strand fmt` uses. It checks a file together with the rest of its config: the config directory, when the file is part of it; else an editor workspace folder that holds `.strand` files and the file; else the file's own directory. That is the rule `strand check ` uses, with the workspace folder added. Changes made to the files outside the editor are seen too. Two initialization options change its defaults: `debounceMs` (how long after typing it waits, default 200) and `configDir` (the config directory, default the one `strand check` uses). ## Helix Add this to `~/.config/helix/languages.toml`: ```toml use-grammars = { only = ["strand"] } [language-server.strand] command = "strand-dev" args = ["lsp"] [[language]] name = "strand" scope = "source.strand" file-types = ["strand"] comment-token = "//" indent = { tab-width = 2, unit = " " } language-servers = ["strand"] [[grammar]] name = "strand" source = { path = "/path/to/strand/crates/strand-dev/tree-sitter-strand" } ``` `use-grammars` keeps the build step below to this one grammar; leave it out if you build other grammars yourself. Set `path` to your checkout. Then build the grammar and copy the highlight query: ```sh mkdir -p ~/.config/helix/runtime/grammars ~/.config/helix/runtime/queries/strand hx --grammar build cp /path/to/strand/crates/strand-dev/tree-sitter-strand/queries/highlights.scm \ ~/.config/helix/runtime/queries/strand/ hx --health strand ``` On Arch Linux the program is called `helix`, not `hx`. The queries use nvim-treesitter's capture names. Helix reads them, but a capture its themes name differently is not coloured: Helix themes call numbers `constant.numeric`, for one, where the query says `number`. ## Neovim Neovim 0.11 and later configure language servers with `vim.lsp.config`. In `~/.config/nvim/init.lua`: ```lua vim.filetype.add({ extension = { strand = "strand" } }) vim.lsp.config("strand", { cmd = { "strand-dev", "lsp" }, filetypes = { "strand" }, }) vim.lsp.enable("strand") ``` For highlighting, compile the grammar into Neovim's parser directory and copy its queries: ```sh cd /path/to/strand/crates/strand-dev/tree-sitter-strand mkdir -p ~/.local/share/nvim/site/parser ~/.local/share/nvim/site/queries/strand cc -shared -fPIC -O2 -I src src/parser.c src/scanner.c \ -o ~/.local/share/nvim/site/parser/strand.so cp queries/*.scm ~/.local/share/nvim/site/queries/strand/ ``` and start it for `.strand` buffers: ```lua vim.api.nvim_create_autocmd("FileType", { pattern = "strand", callback = function() vim.treesitter.start() end, }) ``` ## VS Code and other editors There is no VS Code extension for Strand. VS Code starts a language server only through an extension, and the repository has none, nor a TextMate grammar for its highlighting. Any editor whose language client can run a command over standard input and output for `*.strand` files can use `strand-dev lsp` as it is. ## The tree-sitter grammar The grammar is in [`crates/strand-dev/tree-sitter-strand`](https://github.com/jakeb-grant/strand/blob/main/crates/strand-dev/tree-sitter-strand): `grammar.js`, the parser generated from it (`src/parser.c`, beside a hand-written `src/scanner.c`), and four queries in `queries/`: - `highlights.scm`, the highlighting; - `indents.scm`, one indent level inside every block and bracketed list; - `injections.scm`, which injects only into comments (as the `comment` language), since strings hold no other language; - `locals.scm`, the scopes, definitions and references. All four use nvim-treesitter's capture names. The grammar follows [`docs/grammar.md`](https://github.com/jakeb-grant/strand/blob/main/docs/grammar.md), and its generated files are tree-sitter 0.27.0's. --- # The `strand` command Everything `strand` does is one of its commands: `strand [args]`. This page is made from what the binary prints for `strand --help` and for each command's own `--help`, and the site's build checks it against the binary, so it lists exactly the commands and flags `strand` has. ## Commands | Command | What it does | | --- | --- | | [`strand run`](#strand-run) | run the config \[dir\] (default $XDG\_CONFIG\_HOME/strand; --demo: the M0 hello bar) | | [`strand check`](#strand-check) | check the config without running it \[dir \| file\] | | [`strand fmt`](#strand-fmt) | format .strand files in place \[--check\] \[paths\] | | [`strand watch`](#strand-watch) | stream reload events, faults and notices (--json) | | [`strand reload`](#strand-reload) | rescan now (--hard drops non-persisted state) | | [`strand get`](#strand-get) | read an exported value, a settings field or a token ('$accent') (--json) | | [`strand set`](#strand-set) | write an exported value, a settings field or an rw service field (strand set theme.look mocha; audio.sink.volume \+5%; '$accent' overlays a token) | | [`strand toggle`](#strand-toggle) | flip an exported boolean | | [`strand call`](#strand-call) | invoke a service action (strand call notifications.clear) (--json) | | [`strand clear`](#strand-clear) | drop a runtime overlay (strand clear '$accent') | | [`strand new`](#strand-new) | write a starter shell into \[dir\] (default $XDG\_CONFIG\_HOME/strand; refuses a non-empty dir) | | [`strand export`](#strand-export) | write the palette: json \| base24 \| --template \ \[--palette \\] \[-o \\] | | [`strand compositor-rules`](#strand-compositor-rules) | print blur rules for the compositor | | [`strand report`](#strand-report) | dump compositor, protocols, scales, memory, GPU and frame stats (--json); with words (strand report the clock jumped), save a glitch report | | [`strand replay`](#strand-replay) | replay a glitch report \[report-dir\] (default: the newest) in the evaluation sandbox and open its viewer (--no-open); needs strand-eval next to strand | ## Options | Option | | | --- | --- | | `--write-schemas` | write this plugin binary's service schemas for strand check and the LSP | | `-V`, `--version` | print the version | ## Environment Not passed to programs the shell starts. | Variable | | | --- | --- | | `STRAND_SOCKET` | the shell's socket (default $XDG\_RUNTIME\_DIR/strand-\.sock) | | `STRAND_STATE_DIR` | the shell's state directory (default $XDG\_STATE\_HOME/strand) | ## `strand run` ```text usage: strand run [dir | --demo] ``` ## `strand check` ```text usage: strand check [dir | file] ``` Parses and type-checks every .strand file under the directory (default $XDG\_CONFIG\_HOME/strand) as one config and prints diagnostics; exits non-zero on errors. Given a file, checks it with the rest of its config (the default directory if the file is in it, else the file's directory) and prints the diagnostics in that file, and a missing lock helper. ## `strand fmt` ```text usage: strand fmt [--check] [--] [dir | file]... ``` Formats .strand files in place (a directory means its .strand files, as `strand check` loads them; default $XDG\_CONFIG\_HOME/strand). With --check, writes nothing, lists the files that are not formatted and exits non-zero if there are any. Files with syntax errors are reported and left as written. ## `strand watch` ```text usage: strand watch [--json] ``` ## `strand reload` ```text usage: strand reload [--hard] ``` ## `strand get` ```text usage: strand get [--json] ``` ## `strand set` ```text usage: strand set ``` ## `strand toggle` ```text usage: strand toggle ``` ## `strand call` ```text usage: strand call [--json] [args…] ``` ## `strand clear` ```text usage: strand clear <'$token' | file.settings.field> ``` ## `strand new` ```text usage: strand new [dir] ``` ## `strand export` ```text usage: strand export json | base24 | --template [--palette ''] [-o ] ``` ## `strand compositor-rules` ```text usage: strand compositor-rules [dir] [--classic] ``` Prints Hyprland layer rules that blur behind the surfaces of the config at dir (default $XDG\_CONFIG\_HOME/strand) that ask for `blur`, matched by their strand-\ namespaces, for you to paste into your Hyprland config. The default is Hyprland 0.56's Lua config (hl.layer\_rule); --classic prints hyprlang `layerrule =` lines for older versions. Nothing is applied to the running session. ## `strand report` ```text usage: strand report [--json] [what you saw…] ``` ## `strand replay` ```text usage: strand replay [report-dir] [--no-open] [--out DIR] [--to SCN] [--output NAME] [--traced SECONDS|all] [--frames all|failing|none] [--gpu] strand replay clip [report-dir] --from T [--to T] [--node SEL]... [--note TEXT] [--json] [--save FILE.zip] ``` Replays a glitch report (default: the newest under the state directory's reports/) in strand-eval, the evaluation sandbox, found next to strand or on PATH, which takes these options; `clip` makes a clip of the replay instead. --- # Inspector The inspector shows what Strand knows about one element of your running shell: where it comes from in your files, where it was laid out, which tokens its props read and which of its state was kept. The shell draws only the outline of the picked element and the repaint flashes; the rest runs in a terminal, in `strand-dev inspect`. ## Start it Build `strand-dev` as [Editor setup](/tools/editor.html.md#the-language-server) says, start your shell with `strand run`, then in a terminal: ```sh strand-dev inspect ``` It connects to the shell's socket, by the same rule the `strand` commands use (`$STRAND_SOCKET`, else the socket in `$XDG_RUNTIME_DIR` named for your display), or to the socket path you give it. It starts in pick mode: click an element on any monitor. While you pick, clicks go to the inspector, not to the shell's handlers. Enter stops picking without a pick. One click picks one element, and the shell takes its input back. The element stays outlined, and the terminal shows its pane. A lock screen is never picked, and pick mode ends when the screen locks. ## The pane For a `box`, the pane looks like this: ```text box 4.0 /c/bar.strand:3:5 identity [DP-2] id: target #0 in bar layout surface 2: 0,0 200×40 props bg: #f38ba8 width: 200 tokens bg ← $probe.c (base) = #f38ba8 state bar.clicks [kept] ``` - The first line is the element's kind, its node id and where it is written. - **why**, for a `shader` node that draws nothing: the reason, such as no GPU. - **identity**: what the shell knows the element by across reloads: its place in the instance (here the bar on `DP-2`), its `id:`, its position among siblings of the same kind, and the component (else the file) it is written in. - **layout**: the box it was drawn in, on each surface that shows it, or "not drawn". - **props**: its props as the shell holds them now. - **tokens**: for each prop that reads a token, the chain of tokens back to where the value is set, with where each is defined and its value. - **state**: the state cells of the component, surface and list item the element is mounted in, then the cells its props read, each with its badges: `[kept]` while a cell holds a value a reload kept, `[persisted]` for a `persist` cell, and the layer a setting's value comes from. ## Commands Each command is a line typed into the inspector's terminal. | Command | What it does | | --- | --- | | `p` | pick an element | | `r` | reset the element's subtree: its state back to the defaults | | `e` | open the element's source in `$VISUAL` or `$EDITOR` at its line | | `set ` | write a literal prop value back to the element's source | | `token ` | write the literal value of a token the element reads back to its definition | | `f` | turn repaint flashing on or off | | `?` | list the commands | | `q` | quit (Ctrl-C too) | When the inspector quits, the shell ends pick mode, the outline and the flashing. **Repaint flashing** tints each area the shell repaints for 300 ms, so you can see what a change redraws. ## Writing back `set` and `token` change your file, and the shell then reloads it as it would after a save: ```text set width 240 token accent #7aa2f7 ``` They write only a literal: a number, a colour, a string. A value that is an expression, a binding or a reference to a token is refused, since changing it would change what the value means. A token that the palette or an overlay sets is refused too. The file must still be the one the shell compiled. If it changed since, the edit is refused: save it so the shell reloads, then edit again. The new text replaces the file atomically and follows symlinks, so a linked config stays linked. A file you cannot write, such as one in the Nix store, is refused with the reason, and the inspector offers to open it in your editor instead. --- # Reporting a glitch When something on screen looks wrong, such as a bar that dips for a frame or a popup that jumps, Strand can save what led up to it, replay it and cut out the moment for whoever fixes it. You type one command; the rest is optional. ## Save a report While the shell runs, type in a terminal what you saw: ```sh strand report the clock jumped when the launcher opened ``` Strand saves a report and prints where it went, with one line to paste to your coding agent: ```text saved a glitch report to /home/you/.local/state/strand/reports/2026-10-26_19-51-11 paste this to your coding agent: Strand glitch report "the clock jumped when the launcher opened" at … ``` The report is a folder under `reports/` in Strand's state directory (`$STRAND_STATE_DIR` when it is set, else `$XDG_STATE_HOME/strand`, else `~/.local/state/strand`), named by the local date and time, readable only by you. Strand keeps the newest 20 and deletes older ones. ### What it holds While it runs, Strand records what drove the shell in a fixed 256 KB buffer in memory: pointer moves, clicks, scrolls and keys, each service's changes, the `strand set` and `toggle` commands it was sent, reloads, and surfaces and monitors coming and going. When the buffer is full the oldest entries go first, so how far back it reaches depends on how busy the shell is; a replay uses at most the last two minutes. Nothing goes to disk until you report. The report holds that recording, every service's state at that moment, a picture of each surface as Strand last drew it, a copy of your config's files, and your words. It never holds what you type: a key that types a character is counted, not kept. Nothing is kept while the screen is locked or an admin password dialog or a login screen shows, and a report asked for then is refused. `strand set system.replay false` turns the recording off, and it stays off across restarts. A report then holds everything but the recording. ### Without words `strand report` with no words prints a description of the running shell instead: the compositor's globals with their versions, the monitors and their scales, the memory the shell uses, the GPU's status, and each surface's backend and frame times. It is what to attach to a bug about your hardware or compositor. `--json` prints it as JSON. ## Watch it replayed ```sh strand replay ``` `strand replay` replays the newest report, or the folder you name, in the evaluation sandbox. The sandbox is a separate program, `strand-eval`, which is not part of the shell. Build it from the checkout and put it beside `strand`: ```sh cargo build --release --manifest-path eval/Cargo.toml cp eval/target/release/strand-eval ~/.local/bin/ ``` Without it, `strand replay` says so and stops. The replay draws on the CPU into a stand-in compositor of its own, and never touches your session's display. It writes into the report's `replay/` folder and prints, in one line, whether its frame at the moment you reported matched your live shell (VERIFIED or DRIFTED), then where `replay.txt` and the viewer are. Then it opens the viewer in your browser. `--no-open` leaves it closed. The viewer shows the frame large with the timeline under it: scrub through the recorded window to find the moment. ## Make a clip A clip hands the agent exactly the moment and the elements that look wrong. In the viewer: 1. Drag across the timeline to set when, or press `[` at the start and `]` at the end while you scrub. 2. Click the elements on the frame, or drag a rectangle over them. 3. Add a note if you like. 4. Press **Copy for agent** and paste the text, or **Save clip** for one `.zip` with the frames cropped around those elements. The same clip comes from a terminal, once the report has been replayed: ```sh strand replay clip --from -3.2s --to -2.4s --node '#card' ``` Times count back from the report (`-3.2s`, or `-3200ms`), and `--to` defaults to the report itself. `--node` names an element by its `#id`, its key or a `file:line` in your config. `--note` adds a note, and `--save clip.zip` writes the `.zip`. The viewer shows this command for what you selected. --- # Playground Every example on this site, running in your browser. It is Strand's own compiler and renderer, with the mock services the docs use and the steps each example's scenario plays. Pick an example, play its steps or run them one at a time, point at it and click, and edit its config: it runs again as you type, and a mistake shows what `strand check` would print. ## What runs here The examples draw with the CPU renderer, the same code that draws a bar on your screen, into a canvas. Time is a virtual clock that ticks every 1/60 s of real time, so motion runs at the speed it would on your desktop. Bars, panels, OSDs and popups are shown; windows and lock screens are not, nor is the GPU's work. Service values are the mock's, the icons are the few files the examples draw, and the fonts are Liberation Sans, regular and bold. Nothing is downloaded until you open this page. --- # Elements Every element is written `kind [positional] { props; children }`. Each page lists an element's own props and events; the props every node takes are on [`node`](/reference/elements/node.html.md). These pages are generated from the schema `strand check` checks against. ## Prop groups ## Elements
arcAn arc or ring gauge: arc { value: cpu.usage; sweep: 270deg; stroke: 4, $accent }. barA bar, on every monitor with screen in scope. boxA plain box to style. buttonA button with a label: button "‹" { on click { … } }. canvasDraws with code: canvas { draw: (c) => … }. centerThe centre part of a split, truly centred. colLays out its children in a column. effectA built-in effect: effect sparks { … }. endThe end part of a split. fileKeeps a file in step with the theme: file "~/.cache/strand/colors.css" { template: "colors.css.tmpl"; run: ["reload-colors"] }. graphA sparkline with its own history: graph cpu.usage { history: 60s }. gridLays out its children in a grid. iconAn icon by name: icon "wifi" looks in the config's icons/ folder (wifi.svg or .png, then wifi-symbolic), then the icon theme. imageA picture by file path (image "art.png", relative to the config directory) or by icon name, as icon finds it. inputA text field: input { text: <-> query }. lettersAnimates each letter of the text it is written in: text "hi" { letters { y: 2 * wave(1s, phase: index * 0.1) } }. listVirtualised; arrow keys move the selection. lockThe lock screen, on ext-session-lock: on every monitor its screens: picks (every one by default), one instance a monitor sharing the lock's states, timers and on change handlers. lottieA Lottie animation: lottie "loader.json" { speed: 1 }. mergeGoo merge: children melt together within the given distance. meterA bar filled to its value: meter level, meter memory.used { range: 0, memory.total }. osdAn on-screen display: on the focused monitor, in the overlay layer and click-through; with screens: all (or names) on each of those, with screen in scope. pageA page of pages, shown when its value is current. pagesShows one page at a time, with directional transitions. panelA layer-shell panel: launchers, notification stacks, dashboards. particlesParticles: particles { rate: 20; life: 1.2s; sprite: dot(3) }. popupAnchored to its parent element; nests (tray menus). rowLays out its children in a row. scrollScrolls its content. segmentedOne choice of several: segmented { options: Look; value: <-> theme.look }. shaderYour own WGSL shader: shader "aurora.wgsl" { u_speed: 0.4 }. sliderA slider: slider { value: <-> audio.sink.volume }. spacerTakes up the space left. spectrumAn audio spectrum: spectrum audio.sink { bars: 48; style: mirror }. splitStart, centre and end, with a truly centred middle. stackStacks its children on top of each other. startThe start part of a split. svgAn SVG whose layers bind by id: svg "icon.svg" { #needle { … } }. textText: text clock.format("%a %d %H:%M"). thumbnailA live thumbnail of a window (thumbnail w), or a still of a screen, taken each time it is shown (thumbnail screen). tooltipA tooltip with content of its own. windowA window the compositor manages (xdg_toplevel): it tiles, floats, moves, minimises and shows in task bars as an app's window does (a settings window, a picker).
--- # Services A service is a live value: read it and the shell updates when it changes (spec.md, section 8.1). It starts the first time a program reads it and stops a few seconds after nothing does. A field marked `rw` can be written (assignment, `<->`, `strand set`); an `action` is called only from a handler; `on name.event(…)` handles an event. The records they hand out are in [types.md](/reference/types.html.md). Each service has a page of its own. These pages are generated from the schema `strand check` checks against.
appsInstalled apps, from the desktop entries in every applications/ directory ($XDG_DATA_HOME, then $XDG_DATA_DIRS; NoDisplay, Hidden, OnlyShowIn/NotShowIn and TryExec honoured), watched live. audioAudio devices and the programs' streams, from PipeWire, and levels for a visualiser of one's own (audio.levels). authThe lock screen's password check: a PAM helper of its own (strand-auth, a separate process) checks each password with the strand PAM service, or login when /etc/pam.d/strand is missing. batteryThe battery, from UPower: its combined display device, followed live, and every power source it reports. bluetoothBluetooth, from BlueZ, followed live: the first adapter, the devices paired with it, and the devices in range while a pairing page reads them. brightnessThe screen backlight: read from /sys/class/backlight (watched, so a brightness key shows at once) and set through logind's SetBrightness (no root or udev rules). calendarMonth grids for calendars. clipboardThe clipboard, from the compositor's data control (ext-data-control-v1, else wlr-data-control: Hyprland, sway, niri, labwc): the selection, a value copied from a handler, and the history. clockThe date and time. cpuProcessor load, sampled once a second while a reader is visible. diskDisk usage of the mounted filesystems (statvfs), read every 10 s while a reader is visible. greetdA login screen for greetd: when Strand runs as greetd's greeter, its socket ($GREETD_SOCK) logs a user in and starts their session. idleWhether the user is away, from the compositor's idle notifications (ext-idle-notify-v1). keyboardThe keyboard's lock keys, layouts and modifiers. mediaThe active media player, over MPRIS: every org.mpris.MediaPlayer2.* on the session bus, the one playing (the latest to start) chosen, else the latest to pause, unless one was picked with p.select(), which holds until that player leaves or another starts playing; players lists them all. memoryMemory use, sampled once a second while a reader is visible. networkThe network, followed live: from NetworkManager, or from iwd (net.connman.iwd) where NetworkManager does not run. notificationsThe notification server: the shell owns the freedesktop notification name, and says so clearly if another daemon (dunst, mako) has it. outputsThe monitors' configuration, from the compositor's zwlr_output_manager_v1 (wlr-output-management: sway, Hyprland, niri, labwc): every output, on or off, with its modes, place, scale and rotation, changed together with outputs.apply and checked first with outputs.test. polkitStrand's own polkit authentication agent: the password dialog when a program asks for something that needs one. screensMonitors. sessionThe login session and the machine's power, from logind (org.freedesktop.login1): a power menu. systemThe desktop's appearance settings, from the XDG desktop portal and followed live, and the machine's host name, kernel and uptime. trayThe system tray: a StatusNotifierItem host, with a watcher of its own when the session has none, and each item's DBusMenu. videoThe programs taking video, from PipeWire: a camera or screen-cast indicator. windowsToplevel windows, from ext-foreign-toplevel-list. wmThe compositor. workspacesWorkspaces, from ext-workspace-v1; Hyprland, niri and sway IPC fill in what the protocol does not cover yet.
--- # Types The records, enums, aliases and opaque types of the builtin schema. The language's own types (`int`, `float`, `bool`, `text`, `color`, `length`, `duration`, `T?`, `[T]`, `Async` and the rest) are in spec.md, section 7.1. ## Records ### `AccessPoint` A Wi-Fi network in range, from the last scan; access points of one network name are one entry (the strongest). Keyed by `ssid`. Keyed by `ssid`. | Field | Type | | | --- | --- | --- | | `ssid` | `text` | The network's name. | | `strength` | `float` | Signal strength, 0 to 1. | | `secure` | `bool` | It needs a password: a WPA, WPA2 or WPA3 personal or a WEP network, which `connect_with` joins (a network menu's password field). An open or Enhanced Open network takes none, and an enterprise (802.1X) one joins through its saved profile, with `connect()`. | | `active` | `bool` | It is the network joined (`network.ssid`); not while it is still being joined or being left (`network.connecting`, `network.disconnecting`). | | Method | | | --- | --- | | `action connect()` | Joins it: with its saved connection if there is one, else a new one (for a `secure` network NetworkManager asks its secret agent for the password; iwd joins only a network it knows this way); an enterprise (802.1X) network with no saved profile is refused (it is set up in the system's network settings, or iwd's provisioning file). A failure, including one NetworkManager reports later (no password, a wrong one, a timeout), is `network.failed`. | | `action connect_with(password: text)` | Joins a `secure` network with `password` (its WPA key or WEP key), saved in its connection: a network menu's password field. On any other network it fails. A failure is `network.failed`. | ### `ActionRun` (lockmon) A declared action's commands as they run, read through the action's name: `rec.running`, `rec.started` and `rec.stop()` for `action rec(…) […]`. Only an action's name reads as one. | Field | Type | | | --- | --- | --- | | `running` | `bool` | A command of it runs: true while any does (an action with a result may run several at once). | | `started` | `Date?` | When the command running longest started, to the minute; null while none runs. | | Method | | | --- | --- | | `action stop()` | Stops every command of it now running, and the re-run queued behind one: SIGINT to each one's process group, SIGTERM 2 s later, SIGKILL 2 s after that. | ### `App` An installed app, from its desktop entry (watched live). Keyed by `id`. | Field | Type | | | --- | --- | --- | | `id` | `text` | Its desktop entry id (`org.gnome.Nautilus`, `kde-dolphin`: the entry's path below `applications/`, `/` as `-`, without `.desktop`). | | `name` | `text` | Its name, in the user's language. | | `comment` | `text?` | Its comment, if the entry has one. | | `icon` | `text` | Its icon: `image h.app.icon`. An icon name of the desktop's icon theme, or a path; `application-x-executable` when the entry names none the theme has. | | `categories` | `[text]` | Its desktop entry categories. | | `actions` | `[AppAction]` | The actions its entry offers besides starting it (`New Window`, `New Private Window`), keyed by `id`: a launcher's or a dock's menu, `for a in h.app.actions { button a.name { on click { a.launch() } } }`. | | Method | | | --- | --- | | `action launch()` | Starts it (its `Exec` line, detached; in a terminal when the entry asks for one) and counts the launch for frecency: `on activate { h.app.launch() }`. | ### `AppAction` One of an app's desktop actions (`[Desktop Action new-window]`). Keyed by `id`. | Field | Type | | | --- | --- | --- | | `id` | `text` | Its id in the desktop entry (`new-window`). | | `name` | `text` | Its name, in the user's language. | | `icon` | `text?` | Its own icon, when it names one the icon theme has (or a path). | | `app` | `text` | The `id` of the app it belongs to (`App.id`). | | Method | | | --- | --- | | `action launch()` | Starts it (its own `Exec` line, as `launch()` starts the app's) and counts as a launch of its app for frecency. | ### `AudioDevice` An audio sink or source, from PipeWire. Keyed by `id`. | Field | Type | | | --- | --- | --- | | `id` | `int` | Its PipeWire id. | | `name` | `text` | Its node name. | | `description` | `text` | A readable description of it. | | `volume` | `float rw` | Volume, 0 to 1, on the cubic scale `wpctl` and `pactl` show (above 1 when another program amplified it). Writable: `audio.sink.volume -= dy * 0.05`; every channel takes the new value. | | `muted` | `bool rw` | Muted. Writable: `audio.sink.muted = !audio.sink.muted`. | | `icon` | `text` | An icon name for its volume and mute state. | | `default` | `bool` | It is the default device. | | Method | | | --- | --- | | `action make_default()` | Makes it the default device. | ### `AudioMeter` A peak meter `audio.levels` runs: an item of `audio.meters`. Keyed by `device`. | Field | Type | | | --- | --- | --- | | `device` | `int` | The device it reads (`AudioDevice.id`). | | `bands` | `[float]` | Its latest spectrum: 64 bands from low to high pitch, each 0 to 1; empty while the device is quiet. | ### `AudioStream` A program's audio stream, from PipeWire: what it plays or records. Keyed by `id`. | Field | Type | | | --- | --- | --- | | `id` | `int` | Names it while it lives: PipeWire's `object.serial` for it, which, unlike its global id, no later stream takes. | | `app` | `text` | The program's name, such as `Firefox`. | | `icon` | `text` | An icon name for the program. | | `direction` | `StreamDirection` | `output` while it plays, `input` while it records a source (a microphone in use), `monitor` while it records what a sink plays (a visualiser, a recorder of the desktop's sound): a microphone light is `audio.streams.any(s => s.direction == input)`. | | `volume` | `float rw` | Its own volume, on the scale a device's uses. Writable: `st.volume = v`. | | `muted` | `bool rw` | Muted. Writable: `st.muted = !st.muted`. | ### `BluetoothDevice` A Bluetooth device: one paired with the adapter (`bluetooth.devices`) or one in range (`bluetooth.nearby`), keyed by `address`. | Field | Type | | | --- | --- | --- | | `address` | `text` | Its address. | | `name` | `text` | Its name. | | `connected` | `bool` | It is connected. | | `battery` | `float?` | Its battery, 0 to 1, when it reports one. | | `icon` | `text` | An icon name for its kind. | | `connecting` | `bool` | A `connect()` or `pair()` of it runs (pairing, trusting, connecting), until it ends; of several calls running, the one asked last shows. | | `disconnecting` | `bool` | A `disconnect()` of it runs, until it ends; of several calls running, the one asked last shows. | | Method | | | --- | --- | | `action connect()` | Connects to it; a failure is `bluetooth.failed`. Asked again while a connect or pair of it runs (a double click), it is dropped. | | `action disconnect()` | Disconnects from it; a failure is `bluetooth.failed`. Asked again while one runs, it is dropped. | | `action pair()` | Pairs with it, trusts it (so it may reconnect on its own) and connects to it: how a `nearby` device is added. One already paired is trusted and connected. Strand asks for no PIN or passkey: BlueZ pairs through the system's pairing agent if one runs, else without one (most headphones, mice, keyboards and phones). A device that insists on a PIN fails. A pairing not done within a minute is cancelled and fails. Asked again while one runs (a double click), it is dropped. A failure is `bluetooth.failed`. | | `action forget()` | Forgets it: unpaired and removed from the adapter, so it leaves `devices`. Asked again while one runs, it is dropped. A failure is `bluetooth.failed`. | ### `Canvas` What `canvas { draw: (c) => … }` draws on, with the paint API the renderer uses. A live handle, not data: it compares, but `persist` and settings files cannot store it. | Field | Type | | | --- | --- | --- | | `width` | `float` | The canvas width. | | `height` | `float` | The canvas height. | | Method | | | --- | --- | | `fn fill(paint: paint, evenodd: bool = false)` | Fills the shapes drawn since the last paint with `paint`, or the whole canvas when there are none. `evenodd: true` fills by the even-odd rule, so a shape drawn inside another cuts a hole in it (a ring is two circles); unset, overlapping shapes all fill. | | `fn stroke(paint: paint, width: length = 1)` | Strokes the shapes drawn since the last paint with `paint`, `width` wide. | | `fn line(x1: float, y1: float, x2: float, y2: float)` | A line from (`x1`, `y1`) to (`x2`, `y2`). | | `fn rect(x: float, y: float, w: float, h: float)` | A rectangle at (`x`, `y`), `w` wide and `h` high. | | `fn circle(x: float, y: float, r: float)` | A circle centred at (`x`, `y`) with radius `r`. | | `fn text(t: text, x: float, y: float)` | Text `t` at (`x`, `y`). | | `fn move(x: float, y: float)` | Starts a path of your own at (`x`, `y`): `c.move(0, 40); c.to(20, 10); c.to(40, 30); c.stroke($accent, 2)` draws a polyline. | | `fn to(x: float, y: float)` | A line from the path's current point to (`x`, `y`). With no path open (at the start, after `close`, a paint, a `rect` or a `circle`), it starts one at (`x`, `y`). | | `fn arc(x: float, y: float, r: float, from: angle, to: angle)` | An arc of the circle centred at (`x`, `y`) with radius `r`, from angle `from` to angle `to`: `0deg` points right and angles grow clockwise, as `rotate` turns, so `to` past `from` turns clockwise (at most one whole turn). It joins the open path with a line to its start, or starts one there: `c.move(20, 20); c.arc(20, 20, 16, from: -90deg, to: 30deg); c.close(); c.fill($accent)` is a pie slice. An `atan2` angle runs from -180deg to 180deg: an arc from `135deg` to one turns back past 180deg, so add `360deg` to an angle below the start (`to: a < 135deg ? a + 360deg : a`). | | `fn close()` | Closes the open path with a line back to where it started. | ### `ClipboardEntry` One entry of the clipboard's history: a text or an image copied, newest first, keyed by `id`. | Field | Type | | | --- | --- | --- | | `id` | `int` | Its identity, kept while it is in the history (across restarts). | | `text` | `text` | Its text, or "" for an image. | | `image` | `text?` | An image's file, which `image` draws (`image e.image ?? ""`), or null for a text. | | `size` | `int` | Its size in bytes, as copied. | | `time` | `Date` | When it was last copied. | | Method | | | --- | --- | | `action copy()` | Makes it the selection again; it moves to the front of the history: `on click { e.copy() }`. | | `action remove()` | Removes it from the history, its image file with it. | ### `Connection` A connection saved in NetworkManager (a Wi-Fi network joined before, a wired profile, a VPN, a WireGuard tunnel): an item of `network.connections`, keyed by its UUID. Keyed by `id`. | Field | Type | | | --- | --- | --- | | `id` | `text` | Its UUID, which stays when it is renamed: the key. | | `name` | `text` | Its name, as the network settings show it (`Home`, `Work VPN`). | | `kind` | `ConnectionKind` | What kind of network it is: `if c.kind == vpn { … }`. | | `active` | `bool` | It is up (activated; not while it is still being brought up). | | Method | | | --- | --- | | `action up()` | Brings it up (NetworkManager's `ActivateConnection`, on the device NetworkManager picks): a VPN toggle's `on`. It is followed until it is up; a failure, including one NetworkManager reports later (a secret missing, a timeout), is `network.failed(name, error)`. Over iwd it fails. | | `action down()` | Takes it down (`DeactivateConnection`). When it is not up, or over iwd, it fails, as `network.failed(name, error)`. | ### `Date` A date and time of day, such as `clock.today`. | Field | Type | | | --- | --- | --- | | `year` | `int` | The year. | | `month` | `int` | The month, 1 to 12. | | `day` | `int` | The day of the month, 1 to 31. | | `weekday` | `int` | The day of the week. | | `hour` | `int?` | The hour, 0 to 23; null for a date without a time of day (`clock.today`, a calendar's days). `clock.now` and a notification's `time` have one: `n.time.format("%H:%M")`. | | `minute` | `int?` | The minute, 0 to 59; null when `hour` is. | | Method | | | --- | --- | | `fn month_start() -> Date` | The first day of its month: `clock.today.month_start()`. | | `fn add(days: int = 0, months: int = 0, years: int = 0) -> Date` | Moves by days, months and years, keeping its time of day: `month.add(months: -1)`. | | `fn format(pattern: text) -> text` | Formats with a strftime pattern: `month.format("%B %Y")`. | ### `Day` A cell of a month grid, from `calendar.days(month)`. Keyed by `date`. | Field | Type | | | --- | --- | --- | | `date` | `Date` | The day's date. | | `in_month` | `bool` | It falls in the month asked for; the grid is padded with the days of the months around it. | | `today` | `bool` | It is today. | | `weekend` | `bool` | It falls on a weekend. | ### `Drop` What another program drops on an element (design.md, "Drag and drop"): files, an app or text, as `on drop(d: Drop, at: int)`. `kind` says which; the other fields are empty. A drag inside the shell delivers the `drag:` source's own value instead (`on drop(p: Pin, at: int)`). | Field | Type | | | --- | --- | --- | | `kind` | `DropKind` | What was dropped: `files`, `app` or `text`. | | `files` | `[path]` | The dropped files. | | `app` | `App?` | The dropped app. | | `text` | `text` | The dropped text. | ### `File` A file a `from dir` service lists: `service walls from dir "~/Pictures/walls" { files: [File] }`, then `for f in walls.files key f.path { image f.path }`. Keyed by `path`. | Field | Type | | | --- | --- | --- | | `path` | `text` | Its full path. | | `name` | `text` | Its name, without the directory. | | `modified` | `Date` | When it last changed, to the minute. | | `size` | `int` | Its size in bytes. | ### `Filesystem` A mounted filesystem, keyed by `path`. | Field | Type | | | --- | --- | --- | | `path` | `text` | Where it is mounted: `/`, `/home`. | | `device` | `text` | The device it is on: `/dev/nvme0n1p2` (the mount's source). | | `usage` | `float` | The fraction in use, 0 to 1, as `df` counts it (used over used plus free). | | `used` | `float` | Bytes in use. | | `total` | `float` | Bytes in all. | | `free` | `float` | Bytes free to a user (space reserved for root left out). | ### `Hit` A launcher search result: the app, its score and the matched ranges. Keyed by `app.id`. | Field | Type | | | --- | --- | --- | | `app` | `App` | The app found. | | `score` | `float` | Its score: fuzzy match plus frecency (higher first). | | `ranges` | `[Range]` | The matched character ranges of its name: `marks: h.ranges`. Empty when the query matched its keywords or generic name instead. | ### `IdleTimeout` A timeout `idle.after` was asked about, and whether the seat has been idle that long. Keyed by `timeout`. | Field | Type | | | --- | --- | --- | | `timeout` | `duration` | How long without input it waits for. | | `idle` | `bool` | The seat has had no input for at least `timeout`. | ### `Key` A key press: `on key(k: Key)`. | Field | Type | | | --- | --- | --- | | `name` | `text` | The key's name, such as `Escape`, `Return` or `a`. | | `text` | `text` | The text the key types; empty for keys that type none. | | `ctrl` | `bool` | Ctrl is held. | | `shift` | `bool` | Shift is held. | | `alt` | `bool` | Alt is held. | | `logo` | `bool` | The logo (Super) key is held. | ### `Node` A node of the tree: `self`, or an element given an `id:`. A live handle, not data: it compares, but `persist` and settings files cannot store it. | Field | Type | | | --- | --- | --- | | `hover` | `bool` | The pointer is over it (latched while dragging). | | `pressed` | `bool` | A pointer button is held down on it. | | `focused` | `bool` | It has keyboard focus. On a `window` (its `id:`), the compositor has it focused (its `activated` state): `w.focused`. | | `selected` | `bool` | It is the selected item of a `list`; arrow keys move the selection. | | `width` | `float` | Its laid-out width. `when self.width < 300 { … }` is a container query, with 4 px hysteresis so it cannot flicker. | | `height` | `float` | Its laid-out height. | | `open` | `bool` | A `popup` (or another surface) is open: from the moment its `open` turns true (or the pointer opens a `trigger: hover` one) until it turns false, as its exit pose starts. False on other elements. `when cal.open { bg: $surface.hi }` styles an anchor while its popup shows. | | `box` | `Rect` | Its laid-out box, in its surface's logical pixels from the top-left corner of the surface's box (paint offsets, transforms and poses not applied). A popup's own box is where it sits in the surface it opens from; a bar's, panel's or OSD's is at 0, 0. It updates a frame after layout moves it, and springs like any value: `x: item.box.x - row.box.x ~ $motion.spatial`. It may drive anything that does not lay it out again, through any chain of bindings: paint props (`x`, `y`, `opacity`, colours, transforms; a surface's effects), the size of a `place: absolute` element it is not inside, or what is on another surface that reads nothing back; a load error shows the loop otherwise. A loop through another node's `width` or `height` with no `box` on it is a warning, `check::size_loop`. | | `pointer` | `Point?` | The pointer over it, in its laid-out box (0, 0 is its top-left corner as layout put it; its paint offsets and transforms, and its ancestors', are undone, as for a slider), once a frame while it moves or the node moves under it; null while the pointer is not over it. While a left-button press that began on it is held it stays set, wherever the pointer goes (outside the box too), in the frame the node had when the press began: what the drag moves (its `x`, its layout, its surface's margins) does not move the frame, so `on change self.pointer { if self.pressed { px = px0 + (self.pointer?.x ?? grab.x) - grab.x } }` (`grab` a `Point` kept when the press began) follows the pointer without feeding back. An `on change` of only nodes' input state (`pointer`, `hover`, `pressed`, `focused`, `selected`) is an input handler, never throttled. | | `maximized` | `bool` | A `window` the compositor maximised. False on other elements. | | `fullscreen` | `bool` | A `window` the compositor made fullscreen. False on other elements. | | `tiled` | `bool` | A `window` the compositor tiles against another window or a screen edge on any side: drawn square there, with no shadow. False on other elements. | | `decorated` | `bool` | A `window` whose title bar and borders the compositor draws (`decorations: server` where it offers them). While false the config draws its own: `if !w.decorated { TitleBar }`. False on other elements. | | `blank` | `bool` | A `thumbnail` with nothing to show, which draws nothing: its window went, its screen could not be captured, or inside a `lock` its monitor has no still from before the lock (it was plugged in while locked). `when still.blank { … }` styles that monitor's lock its own way. It turns true a frame after the thumbnail is first drawn, and false when a frame comes. False on other elements. | | Method | | | --- | --- | | `action move()` | `w.move()` moves a `window` with the pointer, from a handler of the press that is still held (`on change bar.pressed { if bar.pressed { w.move() } }`): a title bar the config draws. | | `action resize(edge: Anchor)` | `w.resize(bottom_right)` resizes a `window` with the pointer from that edge or corner, from a handler of the press that is still held. | | `action minimize()` | Minimises a `window`. | | `action maximize()` | `w.maximize()` maximises a `window`, or restores it when it is maximised. | | `action fullscreen()` | `w.fullscreen()` makes a `window` fullscreen, or restores it when it is fullscreen. | | `action close()` | Closes a `window` as the compositor's close button does: its `open: <-> x` is written false. | ### `Notification` A desktop notification, keyed by `id`. | Field | Type | | | --- | --- | --- | | `id` | `int` | Its identity. | | `app` | `NotificationApp` | The app that sent it. | | `summary` | `text` | Its summary line. | | `body` | `text` | Its body, which may hold basic markup (`markup: basic`). | | `image` | `text?` | An image it carries, if any: a path. | | `urgency` | `Urgency` | `low`, `normal` or `critical`. | | `timeout` | `duration?` | How long the sender asked it to show; null leaves it to the shell: `after n.timeout ?? 6s { n.expire() }`. | | `persistent` | `bool` | The sender asked it never to expire (an `expire_timeout` of 0): a shell keeps its popup with `after n.timeout ?? 6s while !n.persistent { n.expire() }`. | | `time` | `Date` | When it arrived. | | `actions` | `[NotificationAction]` | The buttons it offers; none on one remembered from an earlier run (`notifications.remember`), whose sender cannot hear them. | | `progress` | `float?` | How far along what it reports is, 0 to 1 (the sender's `value` hint, a percentage), null when it says none: `meter n.progress ?? 0`. | | `category` | `text?` | The kind of event it reports (the `category` hint: `email.arrived`, `transfer.complete`), null when the sender gives none. | | `desktop_entry` | `text?` | The desktop entry of the app that sent it (the `desktop-entry` hint), null when the sender gives none. | | `can_reply` | `bool` | The sender takes a typed reply (an `inline-reply` action, which is not among `actions`): `if n.can_reply { input … }`. Strand offers senders inline replies only while the config names `can_reply` or `reply`, so a sender keeps its own Reply button otherwise. False on one remembered from an earlier run. | | Method | | | --- | --- | | `action expire()` | Ends its popup, as a timeout does; it stays in `all`, still open (its actions still reach the sender) until dismissed. | | `action dismiss()` | Closes it: `on secondary { n.dismiss() }`. | | `action activate()` | Invokes its default action: `on click { n.activate() }`. | | `action reply(text: text)` | `n.reply(text)` sends `text` to the sender as its inline reply (to that sender alone, never to other programs on the bus) and closes it (unless it is resident); nothing when it takes no reply. | ### `NotificationAction` A button a notification offers. Keyed by `id`. | Field | Type | | | --- | --- | --- | | `id` | `text` | Its identity: the key the sender gave it. | | `label` | `text` | Its label: `button a.label`. | | `notification` | `int` | The notification it belongs to. | | Method | | | --- | --- | | `action invoke()` | Invokes it: `on click { a.invoke() }`. The sender is told; the notification closes unless it is resident. | ### `NotificationApp` The app that sent a notification. | Field | Type | | | --- | --- | --- | | `name` | `text` | The app's name. | | `icon` | `text` | The app's icon: an icon name or a path. | ### `Output` A monitor as the compositor's output configuration sees it, on or off, keyed by `name`: what a display settings page shows and changes with `outputs.apply`. | Field | Type | | | --- | --- | --- | | `name` | `text` | Its connector name, such as `DP-1`: the same as `Screen.name` while it is on. | | `description` | `text` | A readable description of it. | | `make` | `text` | Its maker, or "" when the compositor does not say. | | `model` | `text` | Its model, or "" when the compositor does not say. | | `serial` | `text` | Its serial number, or "" when the compositor does not say. | | `enabled` | `bool` | It is on: shown, and part of the layout. | | `mode` | `OutputMode?` | The mode it shows, or null while it is off. | | `preferred` | `OutputMode?` | The mode the monitor prefers, if it names one. | | `modes` | `[OutputMode]` | Every mode it can show, each once, in the compositor's order (plain data: `for m in o.modes key join("x", m.width, m.height, m.refresh)`). | | `x` | `int` | Its left edge in the layout, in logical pixels (its last place while it is off). | | `y` | `int` | Its top edge in the layout, in logical pixels. | | `scale` | `float` | Its scale factor (`1.5`). | | `transform` | `OutputTransform` | Its rotation (counter-clockwise, as `wl_output` counts) and flip. | | `adaptive_sync` | `bool` | Adaptive sync (variable refresh rate) is on. | | `physical_width` | `int` | Its physical width in millimetres, or 0 when unknown. | | `physical_height` | `int` | Its physical height in millimetres, or 0 when unknown. | ### `OutputChange` One output's part of a configuration for `outputs.apply` and `outputs.test`, named by `name`: a null field keeps what the output has, so `OutputChange(name: o.name, scale: 1.5)` changes only its scale. | Field | Type | | | --- | --- | --- | | `name` | `text` | The output it changes (`Output.name`). | | `enabled` | `bool?` | Turns it on or off. | | `mode` | `OutputMode?` | The mode to show: one of its `modes`, or a size and rate (the listed mode of that size nearest the rate within 0.5 Hz, else a custom mode; a rate of 0 takes the fastest listed one). | | `x` | `int?` | Its left edge in the layout, in logical pixels. | | `y` | `int?` | Its top edge in the layout, in logical pixels. | | `scale` | `float?` | Its scale factor, from 1/256 (the protocol's step) up. | | `transform` | `OutputTransform?` | Its rotation and flip. | | `adaptive_sync` | `bool?` | Adaptive sync on or off. | ### `OutputMode` A mode an output can show: its size in hardware pixels and its refresh rate. An output's `modes` list them; `OutputMode(width: 2560, height: 1440, refresh: 144)` names one for `OutputChange.mode`. | Field | Type | | | --- | --- | --- | | `width` | `int` | Its width in hardware pixels (before scale and rotation). | | `height` | `int` | Its height in hardware pixels. | | `refresh` | `float` | Its refresh rate in Hz (`59.951`), or 0 when it has no fixed rate. | ### `Player` One MPRIS player: an item of `media.players`, with the fields and actions `media` has for the active one. Keyed by `id`. | Field | Type | | | --- | --- | --- | | `id` | `text` | Its bus name, such as `org.mpris.MediaPlayer2.spotify`: the key. | | `name` | `text` | Its name, such as `Spotify` or `mpv` (its MPRIS `Identity`, else its bus name's last part). | | `active` | `bool` | `media.*` follows it now. | | `playing` | `bool` | It is playing. | | `title` | `text?` | The track's title. | | `artist` | `text?` | The track's artist. | | `album` | `text?` | The track's album. | | `art` | `text?` | The album art, as `media.art` shows it. | | `position` | `float rw` | Fraction of the track played, 0 to 1. Writable: seeks this player, as writing `media.position` seeks the active one. | | `elapsed` | `duration` | Time played. | | `length` | `duration?` | The track's length, when known. | | `can_next` | `bool` | It can skip to the next track. | | `can_previous` | `bool` | It can go back to the previous track. | | `can_seek` | `bool` | Writing `position` seeks it. | | Method | | | --- | --- | | `action play_pause()` | Plays or pauses it. | | `action next()` | Skips it to the next track. | | `action previous()` | Takes it back to the previous track. | | `action select()` | Makes it the player `media.*` follows, until it leaves or another player starts playing: `on click { p.select() }`. | ### `Point` A point (`self.pointer`), logical pixels. | Field | Type | | | --- | --- | --- | | `x` | `float` | From the left. | | `y` | `float` | From the top. | ### `PolkitRequest` A program asking polkit for something that needs a password first (`pkexec`, a settings page changing the system): what the dialog shows. `polkit.request` is the one being answered. | Field | Type | | | --- | --- | --- | | `action_id` | `text` | The polkit action asked for (`org.freedesktop.policykit.exec`). | | `message` | `text` | What polkit says it is for, in your language ("Authentication is needed to run `/usr/bin/bash' as the super user"). | | `command` | `text?` | The command it would run, with its arguments, as the program asked (`pkexec`'s: "/usr/bin/sh -c rm -rf /tmp/x"), or null for a request that runs none (a settings page's). Show it: `message` names only the program, so it is what tells one `pkexec sh -c …` from another. | | `icon` | `text` | The icon the program named for it (a theme icon name), or "". | | `user` | `text` | The user whose password is asked for: you, or an administrator when the action needs one. Another of `users` is picked with `polkit.choose(user)`. | | `users` | `[text]` | Every user polkit would take a password from for it (the administrators, or only you), each once; `user` is one of them. | | `prompt` | `text` | What the password check asks ("Password: "), as PAM says it; "" while it asks nothing (an answer being checked, a fingerprint reader waiting). | | `echo` | `bool` | The answer to `prompt` may be shown as it is typed (PAM asks for a name, not a secret). | | `info` | `text` | What PAM last told the user, as it said it ("Place your finger on the fingerprint reader"), or "". | ### `PowerDevice` A power source UPower reports besides the combined battery: a laptop's own batteries, a mouse's, a headset's. Keyed by `id`. | Field | Type | | | --- | --- | --- | | `id` | `text` | Its UPower object path. | | `name` | `text` | Its model name, or its kernel name when it has none. | | `kind` | `text` | What it is: `battery`, `ups`, `mouse`, `keyboard`, `headset`, `phone`, `tablet`, `gaming_input` or `other`. | | `percent` | `float` | Charge, 0 to 1. | | `charging` | `bool` | It is charging. | | `icon` | `text` | An icon name for its charge and state. | ### `Range` A span of characters: from `start` up to, not including, `end`. | Field | Type | | | --- | --- | --- | | `start` | `int` | The first character. | | `end` | `int` | Just past the last character. | ### `Rect` A box in logical pixels: a laid-out node's (`id.box`), or a window's place on its screen (`win.rect`). | Field | Type | | | --- | --- | --- | | `x` | `float` | Its left edge. | | `y` | `float` | Its top edge. | | `width` | `float` | Its width. | | `height` | `float` | Its height. | ### `Screen` A monitor. `id` is its identity (make, model and description, with ` #2` for a second identical one): what a `bar` instance is keyed and pinned by, kept across a replug to another port. `name` is the connector (`DP-1`). Keyed by `id`. | Field | Type | | | --- | --- | --- | | `id` | `text` | Its identity: make, model and description, with ` #2` for a second identical one. Kept across a replug to another port. | | `name` | `text` | Its connector name, such as `DP-1`. | | `make` | `text` | Its maker. | | `model` | `text` | Its model. | | `description` | `text` | A readable description of it. | | `scale` | `float` | Its scale factor. | | `width` | `float` | Its width in logical pixels. | | `height` | `float` | Its height in logical pixels. | | `focused` | `bool` | It has keyboard focus. | ### `TrayItem` An item in the system tray (StatusNotifierItem), keyed by `id`. | Field | Type | | | --- | --- | --- | | `id` | `text` | Its identity: its bus name and object path. | | `title` | `text` | Its title. | | `icon` | `text` | Its icon: `image item.icon`. An icon name, or the path of a picture (an icon the app ships, or pixels it sent). | | `tooltip` | `text?` | Its tooltip, if it has one. | | `status` | `text` | `Active`, `Passive` or `NeedsAttention`. | | `menu` | `TrayMenu` | Its menu. | | Method | | | --- | --- | | `action activate()` | Its primary action: `on click { item.activate() }`. An item that is only a menu (`ItemIsMenu`: most libappindicator apps) opens its menu instead (`menu.opened`), as the StatusNotifierItem spec asks. The app is told where: the bottom-left corner of the element pressed, in its screen's logical pixels (where an app showing a menu of its own puts it). | | `action secondary()` | Its secondary action, told where as `activate()` is. | | `action scroll(dy: float)` | Scrolls on it by `dy` wheel notches (one click is 1, a touchpad sends fractions; positive is down, as `on scroll(dy)` gives it). The app gets 120 per notch, positive up, as KDE's tray sends it. | ### `TrayMenu` A tray item's menu (DBusMenu). | Field | Type | | | --- | --- | --- | | `item` | `text` | The tray item it belongs to. | | `items` | `[TrayMenuItem]` | Its entries, as the app last laid them out; empty when the item has no menu. | | `opened` | `bool` | It is open: true from `open()` until `close()` (or the item going away). A popup shows it with `open: item.menu.opened`, and calls `item.menu.close()` when it closes. One flag per item, not per screen: a bar on every screen keeps which screen opened it (`on secondary { item.menu.open(); menu_screen = screen }`, `open: item.menu.opened && menu_screen == screen`). | | Method | | | --- | --- | | `action open()` | Opens it: `on secondary { item.menu.open() }`. The app is told it opens (and may update its entries first), and `opened` becomes true; a `popup { open: item.menu.opened … }` drawing `items` is what shows a DBusMenu, and nothing shows without one. An item without a DBusMenu shows its own menu. An item that is only a menu (`ItemIsMenu`, or one refusing `Activate`) opens it on `item.activate()` too. | | `action close()` | The popup closed: the app is told, and `opened` becomes false. | ### `TrayMenuItem` An entry of a tray item's menu (DBusMenu), keyed by `id`. | Field | Type | | | --- | --- | --- | | `id` | `int` | Its id in its menu. | | `item` | `text` | The tray item whose menu holds it. | | `label` | `text` | Its label, without mnemonic underscores. | | `enabled` | `bool` | It can be chosen. | | `kind` | `text` | `standard` or `separator`. | | `toggle` | `text` | `none`, `checkmark` or `radio`. | | `checked` | `bool` | Its check mark or radio button is on. | | `icon` | `text?` | Its icon: an icon name, or the path of the picture the app sent. | | `shortcut` | `text?` | Its keyboard shortcut, if it has one (`Control+S`). | | `children` | `[TrayMenuItem]` | Its submenu's entries; empty when it has none. | | Method | | | --- | --- | | `action activate()` | Chooses it: the app is told it was clicked. | | `action open()` | Opens its submenu: the app is told (and may update the entries first). | ### `VideoStream` A program taking video over PipeWire. Keyed by `id`. | Field | Type | | | --- | --- | --- | | `id` | `int` | Its PipeWire id (the program's stream). | | `app` | `text` | The program's name, such as `Firefox`. | | `source` | `VideoSource` | Where the video comes from: `camera` when any source it takes is a camera, else `screencast` (a screen or window cast). | ### `Window` A toplevel window, from `ext-foreign-toplevel-list`, with what the compositor's IPC adds. Keyed by `id`. | Field | Type | | | --- | --- | --- | | `id` | `text` | Its identity: the compositor's id when its IPC is used, else the protocol's identifier. | | `title` | `text` | Its title: `windows.focused?.title`. | | `app_id` | `text` | The app's id, such as `firefox`. | | `icon` | `text` | An icon name for its app: its desktop entry's icon, else its app id (lowercased when only that is in the icon theme). | | `workspace` | `int?` | The workspace it is on, when known. | | `focused` | `bool` | It has focus. | | `minimized` | `bool` | It is minimised. | | `maximized` | `bool` | It is maximised. | | `fullscreen` | `bool` | It is fullscreen. | | `urgent` | `bool` | It asks for attention. | | `app` | `App?` | The installed app it belongs to, found from its app id (the desktop entry's id, `StartupWMClass`, a reverse-DNS id's last part, its name): `win.app?.categories`. Null when no entry matches. | | `rect` | `Rect?` | Its place and size on its screen, logical pixels from the screen's top-left corner, from the compositor's IPC: a tiled niri window (which scrolls with its column) and the standard protocols have none. Hyprland tells no move of a floating window, so there it is as of the window's last other change. | | Method | | | --- | --- | | `action focus()` | Raises and focuses it. | | `action close()` | Asks it to close. | | `action minimize()` | Minimises it. | | `action maximize()` | `win.maximize()` maximises it, or restores it when it is maximised. | | `action fullscreen()` | `win.fullscreen()` makes it fullscreen, or restores it when it is fullscreen. | | `action move_to(ws: Workspace)` | `win.move_to(ws)` moves it to workspace `ws`, the focus staying where it is (on the compositors' IPC: Hyprland, sway, niri). | ### `Workspace` A workspace, from `ext-workspace-v1`, keyed by `id`. | Field | Type | | | --- | --- | --- | | `id` | `int` | Its identity. | | `name` | `text` | Its name. | | `focused` | `bool` | It is the focused workspace (at most one is; with the standard protocols alone and several screens, none is). | | `active` | `bool` | It is shown on its screen (one per screen). | | `occupied` | `bool` | It holds windows. | | `urgent` | `bool` | A window on it asks for attention. | | `screen` | `text` | The name of the screen it is on. | | `windows` | `[Window]` | Its windows, keyed by `id`. | | Method | | | --- | --- | | `action focus()` | Switches to it: `on click { ws.focus() }`. | ## Enums A variant is written bare where its enum is expected (`edge: top`). | Enum | Variants | | --- | --- | | Align | `start`, `center`, `end`, `stretch`, `baseline` | | Anchor | `center`, `top`, `bottom`, `left`, `right`, `top_left`, `top_right`, `bottom_left`, `bottom_right` | | Axis | `vertical`, `horizontal`, `both`. Which way a `scroll` scrolls. | | Blend | `normal`, `screen`, `add`, `multiply`, `overlay`, `difference` | | BlurFallback | `tint`, `none` | | Cap | `butt`, `round`, `square` | | ConnectionKind | `wifi`, `ethernet`, `vpn`, `wireguard`, `other`. What kind of network a connection is: a Wi-Fi network, a wired one, a VPN, a WireGuard tunnel, or another kind (a bridge, a modem, a hotspot shared over Bluetooth). | | CornerStyle | `round`, `squircle` | | CursorShape | `default`, `pointer`, `text`, `crosshair`, `grab`, `grabbing`, `move`, `not_allowed`, `wait`, `progress`, `help`, `context_menu`, `cell`, `copy`, `alias`, `no_drop`, `all_scroll`, `zoom_in`, `zoom_out`, `col_resize`, `row_resize`, `ew_resize`, `ns_resize`, `nesw_resize`, `nwse_resize`, `n_resize`, `e_resize`, `s_resize`, `w_resize`, `ne_resize`, `nw_resize`, `se_resize`, `sw_resize` | | Curve | `linear`, `ease_in`, `ease_out`, `ease_in_out`, `in_back`, `out_back`, `out_elastic`, `out_bounce`, `standard`, `emphasized` | | Decorations | `server`, `none`. Who draws a `window`'s title bar and borders: `server`, the compositor where it offers to, or `none`, the config. | | DropKind | `files`, `app`, `text` | | Edge | `top`, `bottom`, `left`, `right` | | EffectKind | `lightning`, `sparks`, `shimmer`, `ripple`, `aurora` | | Ellipsis | `none`, `start`, `middle`, `end` | | Fit | `cover`, `contain`, `fill`, `none` | | GraphStyle | `line`, `steps`, `bars` | | HitKeyword | `none` | | InputKind | `text`, `password`. What an `input` holds: `type: password` masks it (docs/grammar.md). | | Instant | `instant` | | Justify | `start`, `center`, `end`, `space_between`, `space_around`, `space_evenly` | | Keyboard | `none`, `on_demand`, `exclusive` | | Layer | `background`, `bottom`, `top`, `overlay` | | Markup | `none`, `basic` | | OutputTransform | `normal`, `rotate_90`, `rotate_180`, `rotate_270`, `flipped`, `flipped_90`, `flipped_180`, `flipped_270`. An output's rotation and flip, as `wl_output` (and niri) name them: counter-clockwise, so sway's clockwise `transform 90` is `rotate_270`; `flipped_90` is flipped about the vertical axis, then rotated 90 degrees. | | Place | `flow`, `absolute` | | PopupAnchor | `pointer`. A popup opened where the press that opened it went down. | | PopupTrigger | `open`, `hover` | | PoseName | `none`, `fade`, `slidefade` | | RadiusKeyword | `full` | | Screens | `all`, `focused` | | Shape | `rect`, `circle`, `pill`, `cookie`, `clover`, `burst`, `flower`, `gem`, `sunny`, `triangle`, `pentagon`, `hexagon`, `heart` | | SizeKeyword | `auto` | | SpectrumStyle | `bars`, `mirror`, `wave`, `line` | | StreamDirection | `output`, `input`, `monitor`. Whether a program's audio stream plays (`output`), records a microphone or another source (`input`), or records what a sink plays (`monitor`: a visualiser, a recorder of the desktop's sound). | | SwapKind | `none`, `disc`, `dissolve`, `pixelate` | | Urgency | `low`, `normal`, `critical` | | Variant | `tonal_spot`, `vibrant`, `expressive`, `neutral`, `monochrome`, `fidelity`, `content`, `rainbow`, `fruit_salad` | | VideoSource | `camera`, `screencast`. Where a program's video comes from: a camera, or a screen (or window) cast. | | WifiSecurity | `open`, `enhanced_open`, `wep`, `wpa`, `wpa2`, `wpa3`, `enterprise`. How a Wi-Fi network is secured. `enhanced_open` is OWE (open, but encrypted); `enterprise` is 802.1X. | ## Aliases | Alias | Is | | --- | --- | | AppId | `text` | ## Opaque types Values with no structure a program can see, made by functions and read by props. Easing, Filter, HitArea, Mask, Palette, Pose, Spring, Sprite, SwapTransition, TokenSet --- # Functions Builtin functions and values, every name in scope in every file, and the methods of the language's own types. A function marked `lift` passes `null` through: given `null` for a parameter that takes none, it gives `null`. An `action` changes the world and is called only from a handler. ## Functions | Function | | | --- | --- | | `fn atan2(y: float, x: float) -> angle` | The angle from the origin to the point (`x`, `y`), from -180deg to 180deg, 0deg pointing right and clockwise positive (y grows down): the knob angle of a pointer, `atan2(p.y - cy, p.x - cx)`. An arc drawn from a fixed angle to it turns the other way once the pointer passes 180deg: add `360deg` to an angle below the arc's start. | | `fn bezier(x1: float, y1: float, x2: float, y2: float) -> Easing` | A cubic bézier curve: `~ bezier(0.05, 0.9, 0.1, 1.05)`. | | `fn bloom(radius: length, strength: float = 1) -> Filter` | Bloom: bright parts bleed light `radius` around them, `strength` times as bright as they are (1: as bright), `filter: bloom(12)`, `filter: bloom(12, 1.5)`. Without a GPU it draws as a glow. | | `fn blur(radius: length) -> Filter` | Blurs: `backdrop: blur(16)` over your own content, `filter: blur(30)`. | | `fn brightness(amount: float) -> Filter` | A colour filter: `filter: brightness(0.9)`. | | `fn bytes(n: float) -> text lift` | A count of bytes as text in binary units: `bytes(1536)` is `1.5 KiB`, `bytes(disk.used)` `19.4 GiB` (one decimal below 100, none from 100; `B`, `KiB`, `MiB`, `GiB`, `TiB`, `PiB`, the unit chosen after rounding, so `bytes(1023.6)` is `1 KiB`). Null in, null out. | | `fn chromatic(offset: length) -> Filter` | Chromatic aberration: red shifted `offset` right and blue `offset` left of green, `filter: chromatic(2)`. Without a GPU the node draws unfiltered. | | `fn clamp(x: float, lo: float, hi: float) -> float` | `x` kept between `lo` and `hi`. | | `fn conic(from: angle = 0, ...stops: color) -> paint` | A conic gradient: `conic(from: t * 60deg, $accent, $secondary, $accent)`. | | `fn contrast(amount: float) -> Filter` | A colour filter changing contrast. | | `fn cos(a: angle) -> float` | The cosine of an angle: `x: r * cos(a)` places a point on a circle, 0deg pointing right and angles turning clockwise, as `c.arc` reads them. | | `fn crt() -> Filter` | A CRT look: a curved screen with rounded corners, scanlines, a fringe of colour and a darker rim, `filter: crt()`. No knobs. Without a GPU the node draws unfiltered. | | `fn dot(size: length) -> Sprite` | A round particle sprite: `sprite: dot(3)`. | | `fn dur(duration: duration, clock: bool = false) -> text lift` | A duration as text: `dur(battery.time_left)` is `45 s`, `12 min` or `2 h 5 min`; with `clock: true` it is a clock reading, `m:ss` or `h:mm:ss` (`dur(media.length, clock: true)` is `3:45`). Null in, null out. | | `fn ease(curve: Curve, duration: duration) -> Easing` | A named curve over a duration: `~ ease(out_back, 300ms)`. | | `fn exp(x: float) -> float` | e to the power `x`. | | `fn fade(edge: Edge, size: length) -> Mask` | A mask fading toward an edge: `mask: fade(bottom, 24)`. | | `fn glass(refraction: length = 12) -> Filter` | Liquid glass over what is behind it: lightly frosted, bent up to `refraction` at its rim (red and blue apart), a fresnel rim and a highlight under the pointer, `backdrop: glass()`, `backdrop: glass(20)`. Without a GPU it draws as a blur and a tint. | | `fn grayscale(amount: float) -> Filter` | A colour filter: `filter: grayscale(1)`. | | `fn grow(by: length) -> HitArea` | A larger hit area for a small target: `hit: grow(6)`. | | `fn hue(angle: angle) -> Filter` | A colour filter turning hues: `filter: hue(30deg)`. | | `fn import(source: text) -> Palette` | A palette from another scheme: `import("catppuccin:mocha")`. It must fill the whole palette schema. | | `fn invert(amount: float) -> Filter` | A colour filter inverting colours. | | `fn join(sep: text, ...parts: any) -> text` | Joins the parts that are not null. | | `fn linear(angle: angle, ...stops: color) -> paint` | A linear gradient: `linear(45deg, $accent, $tertiary)`. Banding is removed by dithering. | | `fn ln(x: float) -> float` | The natural logarithm; 0 and negative numbers have none (an error value). | | `fn material(seed: color, variant: Variant = tonal_spot, dark: bool = false, contrast: float = 0) -> Palette` | A Material 3 palette from a seed colour, or from a wallpaper image (quantised off-thread and cached; the old palette holds until the new one is ready, so that form is `Async`). | | `fn material(image: path, variant: Variant = tonal_spot, dark: bool = false, contrast: float = 0) -> Async` | A Material 3 palette from a seed colour, or from a wallpaper image (quantised off-thread and cached; the old palette holds until the new one is ready, so that form is `Async`). | | `fn max(a: float, b: float) -> float` | The larger of two numbers. | | `fn min(a: float, b: float) -> float` | The smaller of two numbers. | | `fn noise(x: float) -> float` | Smooth noise at `x`; a time signal when `x` follows `t`. | | `fn num(x: float, digits: int = 0, pad: int = 0) -> text lift` | A number as text with `digits` decimals and its whole part padded with zeros to `pad` digits: `num(3.14159, digits: 2)` is `3.14`, `num(7, pad: 2)` is `07`. It rounds half away from zero on the decimals the number is written with (`num(1.005, digits: 2)` is `1.01`). Null in, null out. | | `fn number(t: text) -> float? lift` | The number a text spells, or null when it spells none: `number(" 42 ")` is 42, `number("4 GB")` null. Leading and trailing white space is ignored; `1e3` and `-0.5` are numbers. Null in, null out. | | `fn oklch(from: color, l: float = l, c: float = c, h: angle = h, alpha: float = alpha) -> color` | `oklch(from $surface, l: l + 0.12)`: `l`, `c`, `h` and `alpha` name the source colour's channels inside the arguments. | | `fn oklch(l: float, c: float, h: angle, alpha: float = 1) -> color` | `oklch(from $surface, l: l + 0.12)`: `l`, `c`, `h` and `alpha` name the source colour's channels inside the arguments. | | `fn pct(fraction: float) -> text lift` | A fraction as a percentage: `pct(battery.percent)`. Null in, null out. | | `fn popin(scale: float) -> Pose` | A pose scaling in from `scale`: `enter: popin(0.8)`. | | `fn pow(x: float, y: float) -> float` | `x` to the power `y`: `pow(2, 10)` is 1024. | | `action propagate()` | Handlers only. | | `fn radial(...stops: color) -> paint` | A radial gradient, or with `at` and `size` a radial reveal mask. | | `fn radial(at: Anchor, size: length) -> Mask` | A radial gradient, or with `at` and `size` a radial reveal mask. | | `fn rgb(r: float, g: float, b: float, alpha: float = 1) -> color` | A colour from red, green and blue channels. | | `fn saturate(amount: float) -> Filter` | A colour filter: `filter: saturate(1.3)`. | | `fn shape(shape: Shape) -> Mask` | A mask of a shape from the shape library: `mask: shape(cookie)`. | | `fn sin(a: angle) -> float` | The sine of an angle (a plain number is degrees, so `pi` in it is a warning: a half turn is `180deg`): `r * sin(a)`. Of a time signal it follows time, as arithmetic does: `x: 20 * sin(t * 90deg)`. | | `fn sleep(duration: duration) -> Async<()>` | Waits for `duration` inside a handler. | | `fn slide(edge: Edge) -> Pose` | A pose sliding in from an edge: `enter: slide(top)`. | | `fn spring(stiffness: float, damping: float) -> Spring` | A spring for `~` and the motion tokens: `spring(3000, 0.75)`. | | `fn sqrt(x: float) -> float` | The square root; a negative number has none (an error value). | | `fn tan(a: angle) -> float` | The tangent of an angle. | | `fn tint(color: color) -> Filter` | A colour filter tinting toward a colour: `filter: tint($accent)`. | | `fn wave(period: duration, phase: float = 0) -> float` | A time signal that swings once per `period`: `10 * wave(2s)`. Only the node reading it repaints, and only while visible. | | `fn wipe(edge: Edge) -> SwapTransition` | A transition wiping from an edge: `transition: wipe(left)`. | | `fn wobble(amplitude: length) -> Filter` | A raster wobble: rows swing up to `amplitude` sideways in a wave 40 px long that travels down every 2 s, `filter: wobble(4)`. Without a GPU the node draws unfiltered. | ## Values | Value | Type | | | --- | --- | --- | | `pi` | `float` | The ratio of a circle's circumference to its diameter, 3.14159… | | `t` | `float` | Time signal: seconds, for `rotate: t * 20deg`. | ## Methods of builtin types ### `color` | Method | | | --- | --- | | `fn alpha(alpha: float) -> color` | The colour with opacity `alpha`: `$fg.alpha(0.65)`. | | `fn mix(other: color, amount: float) -> color` | Mixes in `amount` of `other`: `$surface.mix($fg, 8%)`. | | `fn lighten(amount: float) -> color` | A lighter colour. | | `fn darken(amount: float) -> color` | A darker colour. | ### `float` | Method | | | --- | --- | | `fn abs() -> float` | The absolute value. | | `fn round() -> int` | The nearest whole number. | | `fn floor() -> int` | The whole number at or below. | | `fn ceil() -> int` | The whole number at or above. | | `fn min(other: float) -> float` | The smaller of this and `other`. | | `fn max(other: float) -> float` | The larger of this and `other`. | | `fn clamp(lo: float, hi: float) -> float` | This kept between `lo` and `hi`. | ### `text` | Member | | | --- | --- | | `len: int` | How many characters. | | `fn upper() -> text` | The text in upper case. | | `fn lower() -> text` | The text in lower case. | | `fn trim() -> text` | The text without leading and trailing white space. | | `fn contains(part: text) -> bool` | It holds `part`. | | `fn starts_with(prefix: text) -> bool` | It starts with `prefix`. | | `fn ends_with(suffix: text) -> bool` | It ends with `suffix`. | | `fn split(sep: text) -> [text]` | The pieces between each `sep`. | | `fn replace(from: text, to: text) -> text` | Every `from` replaced with `to`. | | `fn slice(from: int, to: int? = null) -> text` | The characters from `from` up to (not including) `to`, counted from 0; without `to`, to the end. A negative index counts from the end (`name.slice(-3)` is the last three), and indices past either end stop there: `"strand".slice(1, 3)` is `tr`. | | `fn index_of(part: text) -> int?` | The character index of the first `part` in it (0 for the first character, as `slice` counts), or null when it holds none. | ### `[T]` A list of `T` items. `remove_key`, `move` and `update` are a keyed list's (`[T] key field`, or a keyed record's), `K` its key's type. | Member | | | --- | --- | | `len: int` | How many items. | | `first: T?` | The first item; null if empty. | | `last: T?` | The last item; null if empty. | | `fn filter(keep: fn(T) -> bool) -> [T]` | The items `keep` is true for. | | `fn map(f: fn(T) -> any) -> [any]` | Each item through `f`. | | `fn sort_by(by: fn(T) -> any) -> [T]` | The items ordered by `by`. | | `fn take(count: int) -> [T]` | The first `count` items. | | `fn skip(count: int) -> [T]` | All but the first `count` items. | | `fn reverse() -> [T]` | The items in reverse order. | | `fn distinct() -> [T]` | The items without repeats: the first of each equal item, in order. | | `fn sum(of: fn(T) -> any) -> any` | The items added up (`xs.sum()`), or what `of` gives for each (`ws.sum(w => w.width)`); 0 for no items. Numbers of one unit. | | `fn reduce(init: U, f: fn(U, T) -> U) -> U` | `init` carried through every item in order by `f`, which takes what it gave last and the item: `xs.reduce(0, (sum, x) => sum + x)`, `init` for no items. Its type is `init`'s. | | `fn join(sep: text) -> text` | The text items joined with `sep`. | | `fn contains(item: T) -> bool` | Whether `item` is in the list. | | `fn any(test: fn(T) -> bool) -> bool` | Whether `test` is true for some item. | | `fn all(test: fn(T) -> bool) -> bool` | Whether `test` is true for every item. | | `fn count(test: fn(T) -> bool) -> int` | How many items `test` is true for. | | `fn find(test: fn(T) -> bool) -> T?` | The first item `test` is true for, or null. | | `fn index_of(test: fn(T) -> bool) -> int?` | The index of the first item `test` is true for (0 for the first item, as `xs[i]` reads it), or null. | | `fn push(item: T)` | Adds `item` at the end (handlers only). | | `fn insert(at: int, item: T)` | Inserts `item` at `at` (handlers only). | | `fn remove(at: int)` | Removes the item at `at` (handlers only). | | `fn clear()` | Removes every item (handlers only). | | `fn remove_key(key: K)` | Removes the item with `key` (keyed lists, handlers only). | | `fn move(key: K, to: int)` | Moves the item with `key` to `to` (keyed lists, handlers only). | | `fn update(key: K, f: fn(T) -> T)` | Replaces the item with `key` by `f` of it (keyed lists, handlers only). | --- # Tokens A token is read as `$path` (spec.md, section 9). The palette's colour roles come from the theme's palette, and every theme fills all of them; the base tokens are valued by the theme's token sets. A theme may add tokens of its own. ## Palette roles | Token | | | --- | --- | | `$accent` | The accent colour (Material 3 `primary`). | | `$accent_container` | A softer accent fill (`primary_container`). | | `$accent_fixed` | A fill that keeps its tone in light and dark (`primary_fixed`). | | `$accent_fixed_dim` | A dimmer `accent_fixed` (`primary_fixed_dim`). | | `$bg` | The background (`background`). | | `$blue` | A blue that stays blue: a hue role. | | `$bright_blue` | A brighter `blue`. | | `$bright_cyan` | A brighter `cyan`. | | `$bright_green` | A brighter `green`. | | `$bright_magenta` | A brighter `magenta`. | | `$bright_red` | A brighter `red` (lighter in dark palettes, darker in light). | | `$bright_yellow` | A brighter `yellow`. | | `$cyan` | A cyan that stays cyan: a hue role. | | `$error` | The error colour (`error`). | | `$error_container` | A softer error fill (`error_container`). | | `$fg` | Text and icons on surfaces (`on_surface`). | | `$fg_variant` | Secondary text and icons on surfaces (`on_surface_variant`). | | `$green` | A green that stays green (success): a hue role. | | `$inverse_accent` | The accent on `inverse_surface` (`inverse_primary`). | | `$inverse_fg` | Text and icons on `inverse_surface` (`inverse_on_surface`). | | `$inverse_surface` | The inverse surface (`inverse_surface`). | | `$magenta` | A magenta that stays magenta: a hue role. | | `$on_accent` | Text and icons on `accent` (`on_primary`). | | `$on_accent_container` | Text and icons on `accent_container` (`on_primary_container`). | | `$on_accent_fixed` | Text and icons on `accent_fixed` (`on_primary_fixed`). | | `$on_accent_fixed_variant` | Lower-emphasis text on `accent_fixed` (`on_primary_fixed_variant`). | | `$on_bg` | Text and icons on `bg` (`on_background`). | | `$on_error` | Text and icons on `error` (`on_error`). | | `$on_error_container` | Text and icons on `error_container` (`on_error_container`). | | `$on_secondary` | Text and icons on `secondary` (`on_secondary`). | | `$on_secondary_container` | Text and icons on `secondary_container` (`on_secondary_container`). | | `$on_secondary_fixed` | Text and icons on `secondary_fixed` (`on_secondary_fixed`). | | `$on_secondary_fixed_variant` | Lower-emphasis text on `secondary_fixed` (`on_secondary_fixed_variant`). | | `$on_tertiary` | Text and icons on `tertiary` (`on_tertiary`). | | `$on_tertiary_container` | Text and icons on `tertiary_container` (`on_tertiary_container`). | | `$on_tertiary_fixed` | Text and icons on `tertiary_fixed` (`on_tertiary_fixed`). | | `$on_tertiary_fixed_variant` | Lower-emphasis text on `tertiary_fixed` (`on_tertiary_fixed_variant`). | | `$orange` | An orange that stays orange: a hue role. | | `$outline` | Outlines (`outline`). | | `$outline_variant` | Subtle outlines and dividers (`outline_variant`). | | `$red` | A red that stays red (terminal red, errors): a hue role. | | `$scrim` | The scrim colour (`scrim`). | | `$secondary` | The secondary colour (`secondary`). | | `$secondary_container` | A softer secondary fill (`secondary_container`). | | `$secondary_fixed` | A fill that keeps its tone in light and dark (`secondary_fixed`). | | `$secondary_fixed_dim` | A dimmer `secondary_fixed` (`secondary_fixed_dim`). | | `$shadow` | The shadow colour (`shadow`). | | `$surface` | The surface colour (`surface`). | | `$surface_bright` | The brightest surface (`surface_bright`). | | `$surface_container` | The container surface (`surface_container`). | | `$surface_dim` | The dimmest surface (`surface_dim`). | | `$surface_high` | A high container surface (`surface_container_high`). | | `$surface_highest` | The highest container surface (`surface_container_highest`). | | `$surface_low` | A low container surface (`surface_container_low`). | | `$surface_lowest` | The lowest container surface (`surface_container_lowest`). | | `$surface_tint` | The surface tint (`surface_tint`). | | `$surface_variant` | A variant surface (`surface_variant`). | | `$tertiary` | The tertiary colour (`tertiary`). | | `$tertiary_container` | A softer tertiary fill (`tertiary_container`). | | `$tertiary_fixed` | A fill that keeps its tone in light and dark (`tertiary_fixed`). | | `$tertiary_fixed_dim` | A dimmer `tertiary_fixed` (`tertiary_fixed_dim`). | | `$yellow` | A yellow that stays yellow (warnings): a hue role. | ## Base tokens | Token | Type | | | --- | --- | --- | | `$accent.container` | `color` | A soft accent fill, such as a selected row: `$accent.alpha(0.22)`. | | `$accent.hover` | `color` | The accent under the pointer. | | `$border` | `color` | Hairline borders: `oklch(from $surface, l: l + 0.12)`. | | `$elevation.lg` | `shadow` | Shadows by height: `md`, `lg`, `xl`. | | `$elevation.md` | `shadow` | Shadows by height: `md`, `lg`, `xl`. | | `$elevation.xl` | `shadow` | Shadows by height: `md`, `lg`, `xl`. | | `$fg.faint` | `color` | Faint text, such as days outside the month: `$fg.alpha(0.35)`. | | `$fg.muted` | `color` | Secondary text: `$fg.alpha(0.65)`. | | `$font.caption` | `font` | Fonts: `ui` for text, `title`, `caption` and `mono`. | | `$font.mono` | `font` | Fonts: `ui` for text, `title`, `caption` and `mono`. | | `$font.title` | `font` | Fonts: `ui` for text, `title`, `caption` and `mono`. | | `$font.ui` | `font` | Fonts: `ui` for text, `title`, `caption` and `mono`. | | `$motion.bouncy` | `Spring` | Springs: `spatial` for movement and size, `effects` for colour and opacity, `bouncy` for play. | | `$motion.effects` | `Spring` | Springs: `spatial` for movement and size, `effects` for colour and opacity, `bouncy` for play. | | `$motion.spatial` | `Spring` | Springs: `spatial` for movement and size, `effects` for colour and opacity, `bouncy` for play. | | `$radius.full` | `length` | Corner radii, smallest to largest, and `full` for pills. | | `$radius.lg` | `length` | Corner radii, smallest to largest, and `full` for pills. | | `$radius.md` | `length` | Corner radii, smallest to largest, and `full` for pills. | | `$radius.sm` | `length` | Corner radii, smallest to largest, and `full` for pills. | | `$radius.xl` | `length` | Corner radii, smallest to largest, and `full` for pills. | | `$space.1` | `length` | The spacing scale, smallest to largest: `$space.1` to `$space.4`. | | `$space.2` | `length` | The spacing scale, smallest to largest: `$space.1` to `$space.4`. | | `$space.3` | `length` | The spacing scale, smallest to largest: `$space.1` to `$space.4`. | | `$space.4` | `length` | The spacing scale, smallest to largest: `$space.1` to `$space.4`. | | `$surface.hi` | `color` | A raised surface, such as under the pointer: `$surface.mix($fg, 8%)`. | --- # Troubleshooting Each section starts from what you see and says what to do. When a problem is not here, run the shell with more logging (see [Logs](#where-the-logs-go)) and save a [glitch report](/tools/reporting.html.md). ## The shell does not start `strand run` prints why it stopped and exits with an error status. - **"cannot connect to the Wayland compositor".** Strand connects to the compositor that `WAYLAND_DISPLAY` names. Run it from inside your Wayland session, or from the compositor's startup commands. - **"the compositor does not offer zwlr\_layer\_shell\_v1"** (or `wl_compositor`, or `wl_shm`). Your compositor cannot show a shell: Strand needs layer shell. [Install](/start/install.html.md#what-the-compositor-needs) lists what it needs and the compositors Strand is tested on. - **"… does not exist".** The config directory is not there. The message names the `strand new` command that makes it, with a starter bar; or give `strand run` the directory you meant. A path that is a file is "… is not a directory". - **"no config directory (set XDG\_CONFIG\_HOME or HOME)".** Neither variable names a directory to look in. Set one, or give the directory. ## A config that does not check A mistake in a file is found before it runs. `strand check` prints each problem with its file, its line and, when it can, a fix. This one is computed by the same check when this site is built: ```strand bar Top { edge: top; heigth: 32 text clock.format("%H:%M") } ``` While the shell runs, a saved file that does not check is held back: its last good version keeps running, `strand watch` lists it under "held back", and the overlay below shows the problem. Fix it and save again. ## The error overlay When a save leaves problems that stand for a quarter of a second, an overlay opens listing every one: the problems first, then notices, such as a state value a reload kept although its default changed. A save that is fixed within that quarter second, as format-on-save does, never opens it. Fixing a problem takes it off the list, and the overlay closes when nothing is left on it. - **Click a row** to open its file at that line. Strand runs `$STRAND_EDITOR` when it is set, as a template with `{file}`, `{line}` and `{col}` in it (`foot -e nvim +{line} {file}`). Else it runs `$VISUAL` or `$EDITOR` as ` + `. A terminal editor (`vi`, `vim`, `nvim`, `nano`, `micro`, `kak`, `hx`, `helix`, `ne`, `joe`, `mg`) runs inside `xdg-terminal-exec` when it is installed, else `$TERMINAL -e`. With no terminal to run it in, or no editor set, the click runs `xdg-open` on the file. - **The close button** dismisses the overlay and clears its notices. It stays away until the list changes. - **A notice with `[reset]`** resets that state cell to its default when clicked. ## Part of the shell stopped responding A runtime error, such as a handler that fails, freezes only the component it happened in, and outlines that component in red. An error outside any component freezes nothing. The rest of the shell keeps running. `strand watch` prints the error as a `fault:` line with where it happened. The next reload of a fixed config takes the outline away. ## A surface does not appear - **Check the config first.** `strand check` should be clean: a file held back is still running its old version. - **A `window` or a popup needs `xdg_wm_base`**, and a `lock` needs `ext_session_lock_manager_v1`. Without them they are not shown. `strand report` lists every global your compositor offers. A missing `xdg_wm_base` is logged as a warning for windows; for popups, run with `STRAND_LOG=debug` to see it. ## The lock screen warns or will not lock - **"no `strand-auth` helper is installed".** The lock checks passwords in a separate helper, `strand-auth`, and Strand finds none. For a config with a `lock`, `strand run` says so at start, or when a reload adds the `lock`, and `strand check` reports an error, since such a lock could not be unlocked but from a TTY. Both name the directory to put the helper in, beside `strand`; [Install](/start/install.html.md#build-it) shows how to build it. - **"the compositor does not offer ext-session-lock".** A `lock` cannot lock the session on this compositor. ## Blur shows a tint instead `blur` needs the compositor to blur what is behind the surface, through `ext-background-effect-v1`. Without it `blur` draws its tint fallback, and Strand says why once per run, in the log and to `strand watch`, naming the first surface that asked for blur. `blur_fallback: none` turns the tint off. On Hyprland, `strand compositor-rules` prints layer rules for your config; paste them into your Hyprland config, and Hyprland blurs Strand's surfaces itself. ## The GPU is not used Strand draws on the CPU by default and hands a surface to the GPU while it animates a large area. It asks for a GPU device only where a DRM render node (`/dev/dri/renderD*`) exists, and it needs a Vulkan driver for it; a software driver such as lavapipe counts as none. With no device, everything still draws on the CPU, but a `shader` node draws nothing; [the inspector](/tools/inspector.html.md) says why on that node's pane. `strand report` shows the GPU on its `gpu:` line: `unused`, `starting`, `up` with the adapter, or `unavailable` with the reason, and how many devices were lost. After three lost devices in one run the GPU stays off until Strand restarts, and the reason says so. A build without the GPU backend gives "built without the GPU backend". ## Commands say "no running strand" `strand watch`, `strand reload`, `strand set` and the other commands talk to the shell through a socket, by default `$XDG_RUNTIME_DIR/strand-.sock`. "no running strand on …; start one with `strand run`" means nothing answers there: the shell is not running, or the command runs with another `WAYLAND_DISPLAY`, `XDG_RUNTIME_DIR` or `STRAND_SOCKET` than the shell has. A second `strand run` on the same display runs without a socket and logs "no IPC socket (strand reload, strand watch): … another strand answers on …". The commands reach the first shell. ## Where the logs go The shell logs to its standard error: the terminal you started it in, or wherever your compositor sends its programs' output. `STRAND_LOG` sets the level (`error`, `warn`, `info`, `debug`, `trace`; `warn` by default): ```sh STRAND_LOG=debug strand run 2> strand.log ``` [Running a shell](/start/running.html.md#logs) says what each level adds. ## Start over: `strand reload --hard` When the shell's state has gone wrong and a normal save does not bring it back, `strand reload --hard` drops every state that is not `persist` and makes every surface again, without restarting the shell. --- # `node` ```strand group node ``` What every element accepts. Every visual prop springs; `x`, `y`, `scale`, `rotate` and `opacity` are paint-only and never relayout. | Prop | Type | | | --- | --- | --- | | `id` | `Node` | Names the element, so others read its state (`results.selected`) and keys route to it (`nav: results`). | | `width` | `length \| auto` | Its width: logical pixels, or `40%` of the parent; `auto` (as unset) sizes it by what it holds, and takes a transition there (`width: auto ~ instant`). | | `height` | `length \| auto` | Its height: logical pixels, or a percentage of the parent; `auto` as for `width`. | | `min_width` | `length` | The smallest width it takes. | | `min_height` | `length` | The smallest height it takes. | | `max_width` | `length` | The largest width it takes. | | `max_height` | `length` | The largest height it takes: `max_height: 420`. | | `size` | `length \| auto` | Its width and height at once: `size: 8`; `auto` as for `width`. | | `grow` | `float` | Its share of the space left in its row or column: `grow: 1`. | | `shrink` | `float` | How much it gives up when its row or column is too small: `shrink: 0` keeps its size (default 1). | | `justify` | `Justify` | How its children share the main axis: `justify: space_between`. | | `pad` | `insets` | Space inside its edge: one to four values (`pad: 0, $space.3`). | | `margin` | `insets` | Space outside its edge: one to four values. | | `gap` | `length` | Space between its children: `gap: $space.2`. | | `align` | `Align` | Alignment of its children on the cross axis, or of a text's lines. | | `place` | `Place` | `absolute` takes it out of the flow, placed by `x` and `y`. | | `stagger` | `duration` | Delays each child's `enter` this much after the one before. | | `x` | `length` | Horizontal offset; paint-only. | | `y` | `length` | Vertical offset; paint-only. | | `scale` | `float` | Scale about its centre; paint-only. | | `rotate` | `angle` | Rotation about its centre; paint-only: `rotate: t * 20deg`. | | `opacity` | `float` | Opacity, 0 to 1; paint-only. | | `bg` | `paint` | Background paint: a colour or a gradient. | | `color` | `color` | Inherited. Text and icon colour, inherited by every descendant. | | `font` | `font` | Inherited. Font, inherited by every descendant: `"Inter" 13px 500`, `$font.ui`. | | `weight` | `int` | Inherited. Font weight, inherited by every descendant: `weight: 600`. | | `radius` | `corners \| full` | Corner radii: one value, four (`14, 14, 0, 0`), or `full` for a pill. | | `corners` | `CornerStyle` | `squircle` for smooth corners. | | `border` | `length, paint` | A border of a width and a paint: `border: 1, $border`. A gradient's angle may animate; only the ring repaints. | | `shadow` | `shadow` | Drop shadows: `0 2px 8px $shadow.alpha(0.25)`, comma-separated. | | `inner_shadow` | `shadow` | A shadow inside its edge. | | `blur` | `length` | Blur behind it, done by the compositor: `blur: 24`. | | `blur_fallback` | `BlurFallback` | What to draw when the compositor cannot blur. | | `backdrop` | `Filter` | A filter over your own content behind it: `backdrop: blur(16)`. | | `clip` | `bool` | Clips its children to its rounded shape. | | `hit` | `HitArea \| none` | Its hit area: `hit: grow(6)` enlarges it for small targets; `hit: none` takes no input in its own box (the pointer passes through to what is below), while its children take theirs. On a `trigger: hover` popup that draws nothing, a top-level element with `hit: none` gives the popup's input region its children's boxes instead of its own. | | `cursor` | `CursorShape` | The pointer's shape while it is over it: `cursor: pointer` on a link, `grab` on a handle (`when pressed { cursor: grabbing }`), `ew_resize` on a grip. The innermost node under the pointer with one decides, and while a press is held the one it went down on does; an `input` shows `text` unless it says otherwise, anything else the default arrow. Set through `wp_cursor_shape_v1`, or the cursor theme where the compositor lacks it. | | `tooltip` | `text` | A tooltip shown on hover. | | `filter` | `Filter \| [Filter]` | Colour filters on its subtree: `filter: grayscale(1)`, or a list. | | `blend` | `Blend` | How it blends with what is below it. | | `mask` | `Mask` | Masks its subtree: `fade(bottom, 24)`, `radial(…)`, `shape(cookie)`. | | `glow` | `length, color` | A glow of a radius and a colour: `glow: 12, $accent.alpha(0.6)`. | | `rim` | `Edge, color` | A lit edge: `rim: top, $fg.alpha(0.15)`. | | `grain` | `float` | Film grain: `grain: 0.04`. | | `morph` | `text` | A shared-element name: nodes with the same `morph` morph into each other across surfaces (`morph: "media"`). | | `jelly` | `float` | Squash and stretch while dragged: `jelly: 0.4`. | | `parallax` | `length` | Moves with the pointer, up to this far: `parallax: 6px`. | | `tilt` | `angle` | Tilts toward the pointer, up to this angle: `tilt: 8deg`. | | `shape` | `Shape` | A shape from the shape library; a change morphs by spring. | | `stroke` | `length, paint` | An outline of a width and a paint, with styles: `stroke: 3, $accent { trim: 0, progress; cap: round }`. | | `stroke.trim` | `float, float` | The part drawn: from a start to an end fraction. | | `stroke.wave` | `float, length` | A wavy stroke: its amplitude and wavelength. | | `stroke.cap` | `Cap` | The line caps. | | `stroke.dash` | `length, length` | Dash and gap lengths. | | `enter` | `Pose \| PoseName` | The pose it enters from: `enter { opacity: 0 }`, `popin(0.8)`, `slide(top)`, `fade` or `slidefade`. `exit` mirrors it unless given. | | `exit` | `Pose \| PoseName` | The pose it leaves by; the subtree unmounts once it has played. | | `transition` | `SwapTransition \| SwapKind` | How its content swaps on `if`, `pages` and image changes: `wipe(left)`, `disc`, `dissolve` or `pixelate`; `none` for no mask. | | `drag` | `any` | Makes it a drag source carrying this value: `drag: pin`. | | Event | | | --- | --- | | `on click` | A primary click. | | `on secondary` | A secondary (right) click. | | `on middle` | A middle click. | | `on scroll(dy: float, dx: float)` | A scroll: `on scroll(dy) { … }`. | | `on drop(value: any, at: int)` | Something was dropped on it: a `drag:` source's value or a `Drop` from another program, and the index it landed at. | | `on key(key: Key)` | A key press while it has focus. | | `on activate` | It was chosen: clicked, Enter while selected in a `list`, or Enter in an `input` with no `nav:` list. | --- # `surface` ```strand group surface: node ``` What every surface accepts. Also every prop and event of [`node`](/reference/elements/node.html.md). | Prop | Type | | | --- | --- | --- | | `screens` | `Screens \| [text] \| text` | The monitors it is on: `all`, `focused`, or output names. | | `layer` | `Layer` | Its layer-shell layer. | | `open` | `<-> bool` | Whether it is shown. Bound two-way (`open: <-> open`), Escape, click-away and focus loss write false; on a `window` only the compositor's close and `w.close()` do. | | `keyboard` | `Keyboard` | Keyboard focus: `none`, `on_demand` or `exclusive`. | | `anchor` | `Anchor` | Where it sits on the screen: `anchor: top_right`. | | `attach` | `Edge` | Grows out of an edge with concave fillets: `attach: top`. | | `scrim` | `color` | A colour dimming what is behind it, on a `popup` or `panel`: `scrim: $shadow.alpha(0.3)`. A single-pixel buffer, so it costs nothing; a gradient has no single-pixel form. | | `inhibit_idle` | `bool` | While it is shown, the compositor does not go idle (no screensaver, dimming or lock by timeout): `bar Top { inhibit_idle: caffeine }`. | | Event | | | --- | --- | | `on show` | It was shown: `on show { query = "" }`. | | `on hide` | It was hidden. | --- # `arc` ```strand element arc: node ``` An arc or ring gauge: `arc { value: cpu.usage; sweep: 270deg; stroke: 4, $accent }`. Its line is its `stroke:`: the width (4 px unset), the value's paint (else `color`, else `$accent`) and the styles (`cap`, `dash`). Its box is square, `size:` (24 px unset), drawn on its smaller side; `width` and `height` size the box too, never the line (one `width` beside a `size:` and no `stroke:` is warned, its fix `stroke`). It takes no children. Also every prop and event of [`node`](/reference/elements/node.html.md). | Prop | Type | | | --- | --- | --- | | `value` | `float` | How full it is, in `range` (0 to 1 when unset); a value outside it is drawn at its nearer end. | | `range` | `(float \| auto, float \| auto) \| auto` | The values its ends stand for, as a `meter`'s: `range: 0, 100`, `range: auto` (0 to 1 when unset). | | `sweep` | `angle` | The angle of the whole arc. | | `track` | `paint` | The paint of the unfilled part (unset, the line's colour, else `color`, at 15%). | --- # `bar` ```strand element bar: surface ``` A bar, on every monitor with `screen` in scope. It is a surface, written at the top level of a file. Also every prop and event of [`surface`](/reference/elements/surface.html.md). | Prop | Type | | | --- | --- | --- | | `edge` | `Edge` | The screen edge it sits on. | | `exclusive` | `bool \| length` | Space it keeps from windows: `true` (the default) its thickness, a length in px that much (beyond its margin), `false` or `0` none (it overlaps them). | | In scope | Type | | | --- | --- | --- | | `screen` | `Screen` | The monitor this instance is on: one instance per monitor. | --- # `box` ```strand element box: node ``` A plain box to style. Also every prop and event of [`node`](/reference/elements/node.html.md). --- # `button` ```strand element button(text -> text): node ``` A button with a label: `button "‹" { on click { … } }`. Also every prop and event of [`node`](/reference/elements/node.html.md). --- # `canvas` ```strand element canvas: node ``` Draws with code: `canvas { draw: (c) => … }`. It takes no children. Also every prop and event of [`node`](/reference/elements/node.html.md). | Prop | Type | | | --- | --- | --- | | `draw` | `fn(Canvas)` | Draws on the canvas. It runs again when what it reads changes, not every frame, so `t`, `wave(…)` and `noise` of `t` are refused in it (`check::time_in_canvas`): animate the canvas's own props (`rotate: t * 90deg`) or draw every frame with a `shader`. | --- # `center` ```strand element center: node ``` The centre part of a `split`, truly centred. It may appear only directly inside `split`. Also every prop and event of [`node`](/reference/elements/node.html.md). --- # `col` ```strand element col: node ``` Lays out its children in a column. Also every prop and event of [`node`](/reference/elements/node.html.md). | Prop | Type | | | --- | --- | --- | | `wrap` | `bool` | Runs its children on in another column when one is full, which needs a bounded height (`gap` stands between columns too; `align` and `justify` place them in each). | --- # `effect` ```strand element effect(EffectKind -> style): node ``` A built-in effect: `effect sparks { … }`. It takes no children. Also every prop and event of [`node`](/reference/elements/node.html.md). | Prop | Type | | | --- | --- | --- | | `speed` | `float` | How fast it runs; 1 is normal. | --- # `end` ```strand element end: node ``` The end part of a `split`. It may appear only directly inside `split`. Also every prop and event of [`node`](/reference/elements/node.html.md). --- # `file` ```strand element file(text -> path) ``` Keeps a file in step with the theme: `file "~/.cache/strand/colors.css" { template: "colors.css.tmpl"; run: ["reload-colors"] }`. Allowed at the top level as well as in a tree, where `when` can change its props; it draws nothing. Written atomically when its text would change, and caught up at boot. It takes no children. | Prop | Type | | | --- | --- | --- | | `template` | `text` | A template file (relative to the config), its holes `{{ expr }}` over the theme. | | `text` | `any` | The content as one template expression, in place of `template:`. | | `run` | `[text]` | A command run 200 ms after the file was last written; it needs `permit exec`. | --- # `graph` ```strand element graph(float | [float] -> value): node ``` A sparkline with its own history: `graph cpu.usage { history: 60s }`. Only the new column repaints. Of a value that exists at file scope (a service field, or an expression over service fields, file-level `let`s, `state`s and settings, and constants) it shows the last `history` as soon as it mounts, with time asleep as a gap; of one tied to a list item, a component's parameter or `let`, an element's state or the animation clock, or with a `history` that is not a constant (a literal, or a file-level `let` of one), what came since it mounted. A list is several series in one graph, each with its own history, drawn over one another against one `range`, the first at the back: `graph cpu.cores`, `graph [cpu.usage, memory.usage] { color: $accent, $tertiary }`. Its columns are whole periods of the boot clock, the same columns whenever its frames come, and its value does not spring: `smooth` eases it. It takes no children. Also every prop and event of [`node`](/reference/elements/node.html.md). | Prop | Type | | | --- | --- | --- | | `history` | `duration` | How much history it keeps and shows (60 s when unset). | | `range` | `(float \| auto, float \| auto) \| auto` | The values its bottom and top stand for: `range: 0, 100`; `auto` (or `auto` for one end, `range: 0, auto`) fits what it shows, on a 1, 2, 5 ladder, growing at once and narrowing only once the values shown fit four fifths of a smaller step (0 to 1 when unset). A value outside a fixed range is drawn at its nearer end. | | `style` | `GraphStyle` | `line` (a line through its values), `steps` (each value held flat across its column, snapped to whole pixels, with square risers) or `bars` (a bar per value, `line_width` wide and `gap` apart). | | `color` | `color \| [color]` | The line's colour (inherited when unset, as every node's), or a list, one per series (taken in turn when there are more series than colours; one colour serves them all). | | `fill` | `paint \| [paint]` | The paint under the line, laid over the graph's box (a gradient fades as written: `linear(180deg, $accent.alpha(0.4), $accent.alpha(0))`), or a list, one per series; under `style: bars`, the bars' own paint. | | `line_width` | `length` | The line's width (1.5 px when unset); under `style: bars`, each bar's (2 px when unset). | | `gap` | `length` | Under `style: bars`, the space between bars (1 px when unset). | | `smooth` | `float` | Smoothing: 0 (unset) follows the value; towards 1 each column moves less of the way to it. | --- # `grid` ```strand element grid: node ``` Lays out its children in a grid. Also every prop and event of [`node`](/reference/elements/node.html.md). | Prop | Type | | | --- | --- | --- | | `columns` | `int` | How many columns: `columns: 7`. | --- # `icon` ```strand element icon(text -> source): node ``` An icon by name: `icon "wifi"` looks in the config's `icons/` folder (`wifi.svg` or `.png`, then `wifi-symbolic`), then the icon theme. A `-symbolic` icon is drawn in `color`. It takes no children. Also every prop and event of [`node`](/reference/elements/node.html.md). --- # `image` ```strand element image(text? -> source): node ``` A picture by file path (`image "art.png"`, relative to the config directory) or by icon name, as `icon` finds it. A null source shows nothing. It takes no children. Also every prop and event of [`node`](/reference/elements/node.html.md). | Prop | Type | | | --- | --- | --- | | `fit` | `Fit` | How the image fills its box: `cover`, `contain`, `fill` or `none`. | --- # `input` ```strand element input: node ``` A text field: `input { text: <-> query }`. Focused, it copies its selection with Ctrl+C or Ctrl+Insert, cuts it with Ctrl+X or Shift+Delete and pastes at the caret with Ctrl+V or Shift+Insert, through the compositor's clipboard (a `password` copies nothing; a paste becomes one line). It takes no children. Also every prop and event of [`node`](/reference/elements/node.html.md). | Prop | Type | | | --- | --- | --- | | `text` | `<-> text` | Its text, bound two-way. | | `placeholder` | `text` | Shown while it is empty. | | `type` | `InputKind` | `password` masks it, and the text it binds is a password: `check::secret_flow` warns where that reaches anything that shows, keeps or sends it, other than `auth.submit`, `polkit.respond`, `greetd.submit` and a network's `connect_with` (its length and whether it is empty are free to show). | | `focus` | `bool` | Takes keyboard focus when shown, and again whenever it turns true while its surface has the keyboard (`focus: mode == search`). Tab and Shift+Tab move focus between a surface's inputs and lists, unless an `on key` around the focused one hears keys: then Tab is that handler's. | | `nav` | `Node` | Routes arrow keys to a `list`: `nav: results`. | --- # `letters` ```strand element letters: node ``` Animates each letter of the `text` it is written in: `text "hi" { letters { y: 2 * wave(1s, phase: index * 0.1) } }`. Its props apply to each drawn letter in turn; `index` is the letter's position. It takes no children; may appear only directly inside `text`. Also every prop and event of [`node`](/reference/elements/node.html.md). | In scope | Type | | | --- | --- | --- | | `index` | `int` | The letter's position, from 0. | | `count` | `int` | How many letters there are. | --- # `list` ```strand element list: node ``` Virtualised; arrow keys move the selection. Also every prop and event of [`node`](/reference/elements/node.html.md). | Prop | Type | | | --- | --- | --- | | `focus` | `bool` | Takes keyboard focus when shown, and whenever it turns true (as an `input`'s); then arrows move its selection, Return activates the selected row, and Right opens a popup the row holds (a menu's submenu). | | `snap` | `bool` | A wheel step or a touchpad's lift settles with a row's start at the view's start, as a `scroll`'s `snap`, rows not mounted yet included. | --- # `lock` ```strand element lock: surface ``` The lock screen, on `ext-session-lock`: on every monitor its `screens:` picks (every one by default), one instance a monitor sharing the lock's `state`s, timers and `on change` handlers. It is exempt from reload and fails closed. It is a surface, written at the top level of a file. Also every prop and event of [`surface`](/reference/elements/surface.html.md). | In scope | Type | | | --- | --- | --- | | `screen` | `Screen` | The monitor this instance is on; with `screens: focused` (or a pick no plugged monitor matches), the focused one, else the first, changing in place as focus moves. | --- # `lottie` ```strand element lottie(path -> source): node ``` A Lottie animation: `lottie "loader.json" { speed: 1 }`. It takes no children. Also every prop and event of [`node`](/reference/elements/node.html.md). | Prop | Type | | | --- | --- | --- | | `speed` | `float` | Playback speed; 1 is normal. | --- # `merge` ```strand element merge(length -> value): node ``` Goo merge: children melt together within the given distance. Also every prop and event of [`node`](/reference/elements/node.html.md). --- # `meter` ```strand element meter(float -> value): node ``` A bar filled to its value: `meter level`, `meter memory.used { range: 0, memory.total }`. Laid out taller than wide (`width: 6; height: 80`), it fills upward from the bottom, without its `wave`. It takes no children. Also every prop and event of [`node`](/reference/elements/node.html.md). | Prop | Type | | | --- | --- | --- | | `value` | `float` | How full it is, in `range` (0 to 1 when unset); a value outside it is drawn at its nearer end. | | `range` | `(float \| auto, float \| auto) \| auto` | The values its ends stand for: `range: 0, 100`; `auto` (or `auto` for one end, `range: 0, auto`) follows the values it showed in the last minute, on a 1, 2, 5 ladder (0 to 1 when unset); it moves when the meter draws, so it narrows at the first change after a burst is a minute old. | | `track` | `paint` | The paint of the unfilled part. | | `wave` | `float` | A wavy fill's amplitude; 0 is flat: `wave: media.playing ? 3 : 0`. | --- # `osd` ```strand element osd: surface ``` An on-screen display: on the focused monitor, in the overlay layer and click-through; with `screens: all` (or names) on each of those, with `screen` in scope. It is a surface, written at the top level of a file. Also every prop and event of [`surface`](/reference/elements/surface.html.md). | In scope | Type | | | --- | --- | --- | | `screen` | `Screen` | With `screens:` other than `focused`: the monitor this instance is on. | --- # `page` ```strand element page(any -> name): node ``` A page of `pages`, shown when its value is `current`. It may appear only directly inside `pages`; mounts its children only while shown. Also every prop and event of [`node`](/reference/elements/node.html.md). --- # `pages` ```strand element pages: node ``` Shows one `page` at a time, with directional transitions. Hidden pages unmount, so what they read stops. Also every prop and event of [`node`](/reference/elements/node.html.md). | Prop | Type | | | --- | --- | --- | | `current` | `<-> any` | The value of the page shown, bound two-way. | --- # `panel` ```strand element panel: surface ``` A layer-shell panel: launchers, notification stacks, dashboards. With `screens: all` (or names) it is on each of those monitors, one instance per monitor with `screen` in scope, as a bar is. It is a surface, written at the top level of a file. Also every prop and event of [`surface`](/reference/elements/surface.html.md). | Prop | Type | | | --- | --- | --- | | `exclusive` | `bool \| length` | Space it keeps from windows along the edge it is attached or anchored to (one edge: a corner or the centre keeps none, a warning): `true` its thickness, a length in px that much (beyond its margin), `false` or `0` none (the default; it overlaps them). | | In scope | Type | | | --- | --- | --- | | `screen` | `Screen` | With `screens:` other than `focused`: the monitor this instance is on. | --- # `particles` ```strand element particles: node ``` Particles: `particles { rate: 20; life: 1.2s; sprite: dot(3) }`. It takes no children. Also every prop and event of [`node`](/reference/elements/node.html.md). | Prop | Type | | | --- | --- | --- | | `rate` | `float` | Particles per second. | | `life` | `duration` | How long each particle lives. | | `sprite` | `Sprite` | What each particle looks like: `dot(3)`. | | `glow` | `length` | The particles' glow radius. | | `speed` | `float` | How fast the particles move. | --- # `popup` ```strand element popup: surface ``` Anchored to its parent element; nests (tray menus). It mounts its children only while shown. Also every prop and event of [`surface`](/reference/elements/surface.html.md). | Prop | Type | | | --- | --- | --- | | `trigger` | `PopupTrigger` | What opens it: `open` (the default), its `open:` prop; `hover`, the pointer. A `trigger: hover` popup opens while the pointer is on the element it is anchored to (after `delay`), stays open while the pointer is on that element or on the popup (popups opened from it too), and 200 ms more to cross the gap, or while a drag that started in it goes on. It never takes the keyboard, and opening one closes the other `trigger: hover` popup open from the same surface. Its `open:` (never two-way) opens it from code and holds it open while true; then the pointer holds it as usual. | | `delay` | `duration` | How long the pointer rests on the element before a `trigger: hover` popup opens. At once by default. | | `grace` | `duration` | How long a `trigger: hover` popup stays open after the pointer leaves both it and its element, to cross the gap. 200 ms by default. | | `anchor` | `Anchor \| PopupAnchor` | Where it opens: below the element it is anchored to (the default; a submenu beside its row, on the side `left`, `right`, `top` or `bottom` names), or `pointer`, where the press on its element that opened it went down: a context menu at the click, down and to the right of it, kept inside the screen. Opened with no press (a key, a timer, a hover) it opens at its element; a later press on its element that leaves it open moves it there. | | Event | | | --- | --- | | `on dismiss` | Escape or a click away closed it. | --- # `row` ```strand element row: node ``` Lays out its children in a row. Also every prop and event of [`node`](/reference/elements/node.html.md). | Prop | Type | | | --- | --- | --- | | `wrap` | `bool` | Runs its children on in another row when one is full (`gap` stands between rows too; `align` and `justify` place them in each). | --- # `scroll` ```strand element scroll: node ``` Scrolls its content. Also every prop and event of [`node`](/reference/elements/node.html.md). | Prop | Type | | | --- | --- | --- | | `axis` | `Axis` | Which way it scrolls: `vertical` (the default), `horizontal` (its children in a row; a wheel without a sideways motion scrolls it when no scroll around it can move along y) or `both`. A touchpad gesture keeps the axis its first motion took until the fingers lift. | | `snap` | `bool` | A wheel step or a touchpad's lift settles with a child's start at the view's start (the end of the content too): one wheel notch moves to the next child, and a fling lands on the child nearest where it would have stopped. | --- # `segmented` ```strand element segmented: node ``` One choice of several: `segmented { options: Look; value: <-> theme.look }`. It takes no children. Also every prop and event of [`node`](/reference/elements/node.html.md). | Prop | Type | | | --- | --- | --- | | `options` | `any` | The choices: an enum or a list. | | `value` | `<-> any` | The chosen one, bound two-way. | --- # `shader` ```strand element shader(path -> source): node ``` Your own WGSL shader: `shader "aurora.wgsl" { u_speed: 0.4 }`. Props named `u_…` are its uniforms; it is checked with naga and hot-reloaded. It takes no children; accepts any prop starting with `u_` (a shader uniform). Also every prop and event of [`node`](/reference/elements/node.html.md). --- # `slider` ```strand element slider: node ``` A slider: `slider { value: <-> audio.sink.volume }`. Dragged, it writes values in its `range`: `slider { value: <-> percent; range: 0, 100 }`. Laid out taller than wide (`width: 24; height: 160`), it runs upward: its value grows from the bottom (its written `width` and `height` decide, else its laid-out box; one growing from a written 0, `width: 0; grow: 1`, runs along that axis; a drag keeps the way it began). It takes no children. Also every prop and event of [`node`](/reference/elements/node.html.md). | Prop | Type | | | --- | --- | --- | | `value` | `<-> float` | Its value, in `range` (0 to 1 when unset), bound two-way; a value outside it is drawn at its nearer end. | | `range` | `float, float` | The values its ends stand for: `range: 0, 100` (0 to 1 when unset). A drag writes values between them. | | `track` | `paint` | The paint of the unfilled track. | | `step` | `float` | A drag moves in whole steps of this, in `range`'s units from its low end (`range: 0, 100; step: 5`), and writes the decimal they spell (`step: 0.1` writes `0.9`, never `0.90000004`); unset, it moves freely. | --- # `spacer` ```strand element spacer: node ``` Takes up the space left. It takes no children. Also every prop and event of [`node`](/reference/elements/node.html.md). --- # `spectrum` ```strand element spectrum(AudioDevice -> source): node ``` An audio spectrum: `spectrum audio.sink { bars: 48; style: mirror }`. It stops while the audio is silent. It takes no children. Also every prop and event of [`node`](/reference/elements/node.html.md). | Prop | Type | | | --- | --- | --- | | `bars` | `int` | How many bars. | | `smooth` | `float` | Smoothing, 0 to 1. | | `style` | `SpectrumStyle` | `bars`, `mirror`, `wave` or `line`. | --- # `split` ```strand element split: node ``` Start, centre and end, with a truly centred middle. Also every prop and event of [`node`](/reference/elements/node.html.md). --- # `stack` ```strand element stack: node ``` Stacks its children on top of each other. Also every prop and event of [`node`](/reference/elements/node.html.md). --- # `start` ```strand element start: node ``` The start part of a `split`. It may appear only directly inside `split`. Also every prop and event of [`node`](/reference/elements/node.html.md). --- # `svg` ```strand element svg(path -> source): node ``` An SVG whose layers bind by id: `svg "icon.svg" { #needle { … } }`. A `#id { … }` block takes `rotate`, `scale`, `x`, `y`, `opacity` and `fill` for that layer. It takes no children; holds `#name { … }` selectors. Also every prop and event of [`node`](/reference/elements/node.html.md). | Prop | Type | | | --- | --- | --- | | `fit` | `Fit` | How the drawing fills its box. | | `fill` | `paint` | One colour every shape takes (in a `#id { … }` block: that layer's). | --- # `text` ```strand element text(text -> text): node ``` Text: `text clock.format("%a %d %H:%M")`. It takes no children but [`letters`](/reference/elements/letters.html.md). Also every prop and event of [`node`](/reference/elements/node.html.md). | Prop | Type | | | --- | --- | --- | | `ellipsis` | `Ellipsis` | Where overflowing text is cut with `…`: `ellipsis: end`. | | `max_lines` | `int` | At most this many lines. | | `marks` | `[Range]` | Character ranges to highlight: `marks: h.ranges`. | | `mark_color` | `paint` | The paint of `marks`. | | `markup` | `Markup` | `basic` renders simple markup. | | `roll` | `bool` | Rolls the digits when the number changes: `roll: true`. | | `text_stroke` | `length, paint` | An outline around the letters: `text_stroke: 1, $bg`. | | `fill` | `paint` | The paint of the letters: `fill: linear(…)`. | --- # `thumbnail` ```strand element thumbnail(Window | Screen -> source): node ``` A live thumbnail of a window (`thumbnail w`), or a still of a screen, taken each time it is shown (`thumbnail screen`). The still is taken before the surface showing it appears, so on its own screen it shows what was there, not that surface; one shown later in a surface already on screen is taken then, and holds that surface. Inside a `lock` it is the still taken just before the lock covered the screen; a monitor plugged in while locked (a replug included) has none, and draws nothing, so the lock's own `bg` shows there. With nothing to show it is `blank` (`thumbnail screen { id: still }`, then `when still.blank { … }`). It takes no children. Also every prop and event of [`node`](/reference/elements/node.html.md). | Prop | Type | | | --- | --- | --- | | `fit` | `Fit` | How the thumbnail fills its box. | --- # `tooltip` ```strand element tooltip: node ``` A tooltip with content of its own. It mounts its children only while shown. Also every prop and event of [`node`](/reference/elements/node.html.md). --- # `window` ```strand element window: surface ``` A window the compositor manages (`xdg_toplevel`): it tiles, floats, moves, minimises and shows in task bars as an app's window does (a settings window, a picker). The compositor decides its size: its `width` and `height` (its content's while unset) are the size it asks for, and its `min_*`/`max_*` the sizes it takes. Its compositor's close writes its `open: <-> x` false. Its own `focused` is true while the compositor has it focused, and `maximized`, `fullscreen`, `tiled` and `decorated` follow the compositor (`id: w` and `w.maximized`); `w.move()`, `w.resize(edge)`, `w.minimize()`, `w.maximize()`, `w.fullscreen()` and `w.close()` ask it. The compositor places, stacks and focuses it, so the `surface` props that place a layer surface (`screens`, `layer`, `anchor`, `keyboard`, `attach`, `scrim`, and `margin` on it) are load errors (`check::window_prop`), and `screen` is not in scope in it. Escape and focus loss leave it open; only the compositor's close and `w.close()` write its `open` false. It is a surface, written at the top level of a file. Also every prop and event of [`surface`](/reference/elements/surface.html.md). | Prop | Type | | | --- | --- | --- | | `title` | `text` | The title the compositor shows: `title: "Settings"`. Its name while unset. | | `app_id` | `text` | The app id compositor rules match: `strand-` while unset. | | `decorations` | `Decorations` | Who draws its title bar and borders: `server` (the default), the compositor where it offers to (`w.decorated`), or `none`, the config. | --- # `apps` Installed apps, from the desktop entries in every `applications/` directory (`$XDG_DATA_HOME`, then `$XDG_DATA_DIRS`; `NoDisplay`, `Hidden`, `OnlyShowIn`/`NotShowIn` and `TryExec` honoured), watched live. | Field | Type | | | --- | --- | --- | | `all` | `[App]` | Every installed app, keyed by `id`, by name. | | Method | | | --- | --- | | `fn search(query: text) -> Async<[Hit]>` | Fuzzy match of `query` (nucleo) against the apps' names, then their keywords and generic names, plus frecency (how often and how lately each was launched); best first. An empty query lists every app, the most used first. Runs only when asked: `let hits = apps.search(query)` keeps its last result while searching. | --- # `audio` Audio devices and the programs' streams, from PipeWire, and levels for a visualiser of one's own (`audio.levels`). | Field | Type | | | --- | --- | --- | | `sink` | `AudioDevice` | The default output: `audio.sink.volume`, `audio.sink.muted`. | | `source` | `AudioDevice` | The default input. | | `sinks` | `[AudioDevice]` | Every output, keyed by `id`. | | `sources` | `[AudioDevice]` | Every input, keyed by `id`. | | `streams` | `[AudioStream]` | The programs' streams, keyed by `id`: what plays (a per-app mixer: `for st in audio.streams.filter(s => s.direction == output) key st.id { slider { value: <-> st.volume } }`) and what records (a microphone indicator: `audio.streams.any(s => s.direction == input)`). Followed only while a reader reads it. Peak meters (pavucontrol's, Strand's own) are left out. | | `meters` | `[AudioMeter]` | The meters `audio.levels` runs, keyed by `device`: each one's latest spectrum, 64 bands from low to high pitch, each 0 to 1 (none while its device is quiet). Read through `audio.levels`, which asks for the meter; reading this field runs none. | | Method | | | --- | --- | | `fn levels(device: AudioDevice, bars: int = 16) -> [float]` | `device`'s sound now, as `bars` levels from low to high pitch, each 0 to 1 (zeros while it is quiet), for a visualiser of one's own (`let v = audio.levels(audio.sink, 32)`, drawn by a `canvas`); `audio.levels(audio.source, 1)[0]` is a microphone's level. Its meter runs while a reader of `audio` is visible and something evaluates `levels` for that device; one no evaluation has asked for within a second stops. `bars` is at most 512. | --- # `auth` The lock screen's password check: a PAM helper of its own (`strand-auth`, a separate process) checks each password with the `strand` PAM service, or `login` when `/etc/pam.d/strand` is missing. Only a success unlocks the session lock; anything else leaves it locked. While a lock is shown PAM's conversation runs, so a module that needs no password (a fingerprint reader) can unlock it, and PAM's texts and prompts show as PAM says them. | Field | Type | | | --- | --- | --- | | `busy` | `bool` | A password is being checked: PAM has it, or it waits for PAM to ask (typed while a fingerprint reader waits). | | `failed` | `bool` | The last password was refused, or could not be checked. | | `message` | `text` | What PAM last told the user, as it said it ("Place your finger on the fingerprint reader", "The account is locked"); "" once PAM next asks for a password, and while no lock is shown. Show it as it is: `if auth.message != "" { text auth.message }`. | | `prompt` | `text` | The hidden prompt PAM waits on ("Password: "), or "" while it waits on none (a fingerprint reader, a password being checked). | | Method | | | --- | --- | | `action submit(password: text)` | Checks `password`: `input { type: password; text: <-> secret; focus: true; on activate { if secret != "" { auth.submit(secret) }; secret = "" } }` (Return in the field activates it; an empty field is not sent, since with pam_faillock each empty Return pressed to wake the screen would count as a failed login). Every password, empty or not, goes to PAM: it answers the prompt PAM waits on, or the next one it asks. Ignored while one is being checked. | --- # `battery` The battery, from UPower: its combined display device, followed live, and every power source it reports. | Field | Type | | | --- | --- | --- | | `present` | `bool` | A battery is present: `if battery.present { Battery }`. | | `percent` | `float` | Charge, 0 to 1: `pct(battery.percent)`. | | `charging` | `bool` | It is charging. | | `time_left` | `duration?` | Time until empty, or until full while charging; null when unknown. | | `icon` | `text` | An icon name for its charge and state. | | `power` | `float` | Power draw in watts. | | `devices` | `[PowerDevice]` | Every power source but line power, keyed by `id`: laptop batteries and the batteries of peripherals. | | Method | | | --- | --- | | `fn history(span: duration, points: int = 60) -> Async<[float]>` | The battery's charge over the last `span`, as UPower recorded it (`GetHistory`, which UPower keeps across reboots): `points` charges, 0 to 1, evenly spaced from `span` ago to now, oldest first, so a chart shows the hours before the shell started: `let past = battery.history(10800s) ?? []`. Each is the charge last recorded at or before its time; the times before UPower's first record in the span are left out, so the list may be shorter (it still ends now). Of the laptop's first battery that keeps a history; an error when none does. Asked only when called, never polled. `points` is at most 2000. | --- # `bluetooth` Bluetooth, from BlueZ, followed live: the first adapter, the devices paired with it, and the devices in range while a pairing page reads them. | Field | Type | | | --- | --- | --- | | `powered` | `bool rw` | The adapter is on. Writable. | | `devices` | `[BluetoothDevice]` | Paired devices, keyed by `address`. | | `available` | `bool` | BlueZ answers and has an adapter: false without them (or without a system bus), so a menu can hide what cannot work. | | `nearby` | `[BluetoothDevice]` | Unpaired devices in range that have a name, keyed by `address`, found by discovery. Discovery runs only while a visible reader reads them (a pairing page that is open). | | `scanning` | `bool` | The adapter is discovering devices, for `nearby` or for another program. | | Event | | | --- | --- | | `on bluetooth.failed(address: text, error: text)` | Connecting, disconnecting, pairing or forgetting a device failed (out of range, turned off, timed out, or the service not running): `on bluetooth.failed(address, error) { … }`. | --- # `brightness` The screen backlight: read from `/sys/class/backlight` (watched, so a brightness key shows at once) and set through logind's `SetBrightness` (no root or udev rules). | Field | Type | | | --- | --- | --- | | `level` | `float rw` | Backlight level, 0 to 1. Writable. | | `available` | `bool` | A backlight was found. | --- # `calendar` Month grids for calendars. | Method | | | --- | --- | | `fn days(month: Date) -> [Day]` | The days of the weeks that cover `month`, padded with days of the months around it: `for d in calendar.days(month)`. | --- # `clipboard` The clipboard, from the compositor's data control (`ext-data-control-v1`, else `wlr-data-control`: Hyprland, sway, niri, labwc): the selection, a value copied from a handler, and the history. | Field | Type | | | --- | --- | --- | | `available` | `bool` | The compositor offers data control: false without it, when `history` stays as it was and `copy` fails with `clipboard.failed` (an `input` still copies and pastes). | | `text` | `text` | The selection's text, or "" when it holds none (empty, an image, or a password manager's secret, which is never read). | | `holds_image` | `bool` | The selection is an image; once recorded, its file is `history[0].image`. | | `history` | `[ClipboardEntry]` | What was copied, newest first, each once: texts and images, the newest 100 within 64 MiB, kept across restarts. Recorded while a program reads it anywhere (see spec.md 8.1); a secret never enters it. | | Method | | | --- | --- | | `action copy(value: text)` | Copies `value` as text: it becomes the selection, which other programs paste (`on click { clipboard.copy(ip) }`). | | `action clear()` | Empties the history and deletes its file; the selection stays. | | Event | | | --- | --- | | `on clipboard.failed(action: text, error: text)` | An action failed (no data control, an image file gone): `on clipboard.failed(action, error) { … }`. | --- # `clock` The date and time. Reading it wakes the shell only as often as what is read changes. | Field | Type | | | --- | --- | --- | | `today` | `Date` | Today's date: `clock.today.month_start()`. | | `now` | `Date` | The current date and time, to the minute. | | Method | | | --- | --- | | `fn format(pattern: text) -> text` | Wakes only as often as the pattern changes (once a minute for %H:%M). | --- # `cpu` Processor load, sampled once a second while a reader is visible. While none is but a `graph` keeps history of it, it samples at the coarser of that and a twentieth of the graph's `history` (at least 2 s); otherwise not at all. | Field | Type | | | --- | --- | --- | | `usage` | `float` | Load over every core, 0 to 1: `graph cpu.usage`. | | `cores` | `[float]` | The load of each core, 0 to 1. | | `temperature` | `float?` | The processor's temperature in degrees Celsius, when a sensor reports one. | --- # `disk` Disk usage of the mounted filesystems (`statvfs`), read every 10 s while a reader is visible. While none is but a `graph` keeps history of it, it reads at the coarser of that and a twentieth of the graph's `history`; otherwise not at all. | Field | Type | | | --- | --- | --- | | `usage` | `float` | The root filesystem's fraction in use, 0 to 1: `pct(disk.usage)`. | | `used` | `float` | The root filesystem's bytes in use. | | `total` | `float` | The root filesystem's size in bytes. | | `free` | `float` | The root filesystem's bytes free to a user. | | `all` | `[Filesystem]` | Every mounted filesystem on a local disk, `/` first, keyed by `path`. | --- # `greetd` A login screen for greetd: when Strand runs as greetd's greeter, its socket (`$GREETD_SOCK`) logs a user in and starts their session. greetd's PAM conversation shows as it goes (`prompt`, `message`), and each answer is wiped once sent. Once a session starts, Strand exits so greetd can run it. | Field | Type | | | --- | --- | --- | | `available` | `bool` | greetd answers: Strand runs as its greeter. False otherwise, when every action fails with `error`. | | `user` | `text` | The user being logged in (`greetd.login(user)`), or "". | | `prompt` | `text` | What greetd asks ("Password: "), as PAM says it; "" while it asks nothing. | | `echo` | `bool` | The answer to `prompt` may be shown as it is typed (a visible question, such as a name), not a secret one. | | `message` | `text` | What PAM last told the user ("Place your finger on the fingerprint reader", "You are required to change your password"), or "". | | `busy` | `bool` | greetd is busy with a request: checking an answer, starting the session. | | `failed` | `bool` | The last answer was refused (greetd then asks the same user again), or a login or session could not start; `error` says why. It stays until the next answer or login. | | `error` | `text` | Why the last request failed, as greetd says it, or "". | | Method | | | --- | --- | | `action login(user: text)` | Starts logging `user` in (a login already under way is dropped): greetd then asks its questions through `prompt`. | | `action submit(answer: text)` | Answers `prompt` with `answer`: `input { type: password; text: <-> pw; on activate { greetd.submit(pw); pw = "" } }`. One typed after `login(user)` before greetd asks answers its first question when that one is secret (not `echo`), and is dropped when it is visible; one typed while an answer is checked, or after a text PAM showed, is dropped. Pass a password to nothing else: the config is trusted code, and a declared action's arguments can be read by every user in the process list. Only a greetd that root (or Strand's own user) serves is answered. | | `action start(command: [text])` | Starts `command` as the user's session (`["sway"]`) once the login succeeds: called with the last answer, it waits for greetd to accept it; a login that fails drops it. Strand then exits. Each word is one word, as given (`["sh", "-c", "sway > log"]` for a shell line); greetd runs it after the user's `~/.profile`. | | `action cancel()` | Drops the login under way. | --- # `idle` Whether the user is away, from the compositor's idle notifications (ext-idle-notify-v1). What inhibits idling (a video playing, a surface's `inhibit_idle: true`) holds them back. | Field | Type | | | --- | --- | --- | | `available` | `bool` | The compositor tells how long the user has been away (ext-idle-notify-v1); while false, `idle.after` is false. | | `timeouts` | `[IdleTimeout]` | The timeouts `idle.after` was asked about, each with whether the seat has been idle that long, by timeout. | | Method | | | --- | --- | | `fn after(timeout: duration) -> bool` | True once the seat has had no input for `timeout`, false again at the next input: `panel Saver { open: idle.after(300s) }`. The compositor keeps a timer for each timeout only while something reads it; one no longer read goes a second later. | --- # `keyboard` The keyboard's lock keys, layouts and modifiers. While a Strand surface has keyboard focus (a lock screen, a launcher, a window switcher) they are that keyboard's own, exact as keys are pressed; otherwise the lock keys come from the kernel's keyboard lights, read while a visible reader shows them, the layouts from the compositor (Hyprland, sway, niri), and no modifier is held. | Field | Type | | | --- | --- | --- | | `caps_lock` | `bool` | Caps Lock is on: `if keyboard.caps_lock { text "Caps Lock is on" }`. False when nothing reports it. | | `num_lock` | `bool` | Num Lock is on. | | `layout` | `text?` | The active layout's name as the keymap names it (`English (US)`), null when unknown: `text keyboard.layout ?? ""`. | | `layouts` | `[text]` | The layouts it switches between, by index, as the keymap names them (`["English (US)", "German"]`; empty when unknown): `keyboard.set_layout(i)` takes an index into it. | | `alt` | `bool` | Alt is held, while a Strand surface has keyboard focus (false otherwise: no compositor tells): an alt-tab switcher commits when it turns false. | | `ctrl` | `bool` | Control is held, while a Strand surface has keyboard focus. | | `shift` | `bool` | Shift is held, while a Strand surface has keyboard focus. | | `logo` | `bool` | The logo (Super) key is held, while a Strand surface has keyboard focus. | | Method | | | --- | --- | | `action next_layout()` | Switches every keyboard to its next layout (Hyprland, sway, niri). | | `action set_layout(index: int)` | `keyboard.set_layout(i)` switches every keyboard to layout `i` of `layouts`. | --- # `media` The active media player, over MPRIS: every `org.mpris.MediaPlayer2.*` on the session bus, the one playing (the latest to start) chosen, else the latest to pause, unless one was picked with `p.select()`, which holds until that player leaves or another starts playing; `players` lists them all. Its position is carried forward from the player's last report at its rate, never asked for again (no polling), and only while a visible reader shows it. | Field | Type | | | --- | --- | --- | | `playing` | `bool` | Something is playing. | | `title` | `text?` | The track's title. | | `artist` | `text?` | The track's artist. | | `album` | `text?` | The track's album. | | `art` | `text?` | The album art: `image media.art { fit: cover }`. Local art (a `file://` URL or a path) as the player sent it; remote (`https://`, `http://`) art fetched and shown as a file once it is in. Null when the player sent none, while remote art loads, or when it failed, so `media.art ?? "audio-x-generic"` falls back. | | `position` | `float rw` | Fraction of the track played, 0 to 1. Writable: a scrubber's `slider { value: <-> media.position }` seeks the player (MPRIS `SetPosition`, or `Seek` when the track has no id). A player that cannot seek, or a track of unknown length, keeps its place. | | `elapsed` | `duration` | Time played. | | `length` | `duration?` | The track's length, when known. | | `player` | `text?` | The active player's name, such as `Firefox` or `mpv`; null when no player runs. | | `can_next` | `bool` | The active player can skip to the next track (MPRIS `CanGoNext`): `opacity: media.can_next ? 1 : 0.4`. False when no player runs or it takes no control (`CanControl` false). | | `can_previous` | `bool` | The active player can go back to the previous track (`CanGoPrevious`, as `can_next`). | | `can_seek` | `bool` | Writing `position` seeks: the active player can seek (`CanSeek`, as `can_next`) and the track's length is known. | | `players` | `[Player]` | Every player running, keyed by its bus name (`for p in media.players key p.id { … }`): a switcher, a card per player. The one `media.*` follows is `active`; `p.select()` picks another. Read only while a visible reader reads it; its playing players' times tick once a second then. | | Method | | | --- | --- | | `action play_pause()` | Plays or pauses. | | `action next()` | Skips to the next track. | | `action previous()` | Goes back to the previous track. | --- # `memory` Memory use, sampled once a second while a reader is visible. While none is but a `graph` keeps history of it, it samples at the coarser of that and a twentieth of the graph's `history` (at least 2 s); otherwise not at all. | Field | Type | | | --- | --- | --- | | `usage` | `float` | The fraction in use, 0 to 1. | | `used` | `float` | Memory in use, in bytes (installed memory less what is available). | | `total` | `float` | Memory installed, in bytes. | | `available` | `float` | Memory available to start new programs without swapping, in bytes (the kernel's estimate, `MemAvailable`): free memory plus the cache it can drop. | | `free` | `float` | Memory holding nothing at all, in bytes (`MemFree`): far less than `available`, since the kernel fills spare memory with cache. | | `cached` | `float` | The page cache and buffers, in bytes (`Cached` plus `Buffers`): files kept in memory, most of it given back when programs need it. | | `swap_used` | `float` | Swap in use, in bytes (`SwapTotal` less `SwapFree`). | | `swap_total` | `float` | Swap configured, in bytes; 0 without swap. | --- # `network` The network, followed live: from NetworkManager, or from iwd (`net.connman.iwd`) where NetworkManager does not run. Either one starting or stopping switches to the other. `network.disconnect()` leaves the Wi-Fi network joined, and `network.scan()` scans for networks now. | Field | Type | | | --- | --- | --- | | `connected` | `bool` | A network connection is up. Over iwd, which knows only Wi-Fi: the Wi-Fi network is joined. | | `ssid` | `text?` | The Wi-Fi network joined, if any. | | `strength` | `float` | The Wi-Fi signal strength, 0 to 1. | | `wifi` | `bool rw` | The Wi-Fi radio is on. Writable. | | `access_points` | `[AccessPoint]` | The Wi-Fi networks in range, keyed by `ssid`. Scanned only while a visible reader reads them (a network menu that is open). | | `available` | `bool` | NetworkManager or iwd answers: false without either (or without a system bus), so a menu can hide what cannot work. | | `connecting` | `text?` | The Wi-Fi network being joined, by name: from the moment `ap.connect()` or `ap.connect_with()` is asked, or the daemon starts joining one on its own, until it is joined or fails; null otherwise (`if network.connecting == ap.ssid { … }`). | | `disconnecting` | `text?` | The Wi-Fi network being left (`network.disconnect()`, or the daemon leaving it), until it is left; null otherwise. | | `frequency` | `int?` | The joined Wi-Fi network's frequency in MHz (2412, 5180, 5955): below 3000 is the 2.4 GHz band, 5150 to 5895 the 5 GHz band, from 5925 the 6 GHz band. Null while no Wi-Fi network is joined, or when iwd does not say. | | `security` | `WifiSecurity?` | How the joined Wi-Fi network is secured; null while none is joined. | | `address` | `text?` | The connection's IP address, as text without a prefix: its IPv4 address, else its global IPv6 address. Null while not connected. Over NetworkManager this is the default-route connection's interface (wired too); over iwd it is the Wi-Fi interface. Read from the kernel, and followed only while a visible reader reads it. | | `rx` | `float` | Bytes a second received through the machine's network hardware (every interface backed by a device: Ethernet, Wi-Fi, a modem; not loopback, bridges, VPN tunnels or containers' links), from the kernel's counters whichever daemon runs: `graph network.rx { range: auto }`. Sampled once a second while a visible reader reads it or `tx`; while none does but a `graph` keeps history of the service, at the coarser of that and a twentieth of the graph's `history` (at least 2 s); otherwise not at all. 0 until the first second has passed. | | `tx` | `float` | Bytes a second sent, as `rx` counts them. | | `scanning` | `bool` | A Wi-Fi scan runs: one `scan()` asked, or the scan a network menu asks when it opens; over iwd, iwd's own scans too. | | `kind` | `ConnectionKind?` | The kind of the connection carrying the traffic, so a bar picks its icon: `match network.kind { wifi => …, ethernet => …, vpn => …, wireguard => …, _ => … }` (the `_` arm for null and `other`). A VPN or WireGuard tunnel holding the default route counts first, since traffic goes through it; else the connection that holds it (NetworkManager's primary connection). Null while not connected. Over iwd, `wifi` while a network is joined. | | `connections` | `[Connection]` | The connections saved in NetworkManager, keyed by `id`, by name: a VPN toggle (`for c in network.connections.filter(c => c.kind == vpn) key c.id { … }`), a wired profile, Wi-Fi networks joined before. Read, and followed as they are added, changed or removed, only while a visible reader reads them. NetworkManager's loopback profile is left out. Over iwd, which knows only Wi-Fi networks, it is empty. | | Method | | | --- | --- | | `action disconnect()` | Leaves the Wi-Fi network joined, or stops the one being joined; the daemon then joins nothing on its own until a network is joined again. A join it stops is no failure; if the disconnect itself fails, that join goes on and is reported as any join is. A `disconnect()` while one is in flight (a double click) is dropped. Without a network joined or being joined it fails. A failure is `network.failed`. | | `action scan()` | Scans for Wi-Fi networks now (a refresh button): `scanning` while it runs, `access_points` read again when it ends. A scan the daemon is already running, or has just run, is not asked again and is no failure. With the radio off, or a Wi-Fi device that cannot scan (its hardware switch off, not ready), it fails, as `network.failed("", error)`. | | Event | | | --- | --- | | `on network.failed(ssid: text, error: text)` | Joining a network failed (no password and no secret agent to ask for one, a wrong one, out of range, timed out, or the service not running): `on network.failed(ssid, error) { … }`. `ssid` is the network the action was about (a connection's `name` for its `up()` and `down()`), "" for one that names none (`scan()`, or a `disconnect()` the service could not take). | --- # `notifications` The notification server: the shell owns the freedesktop notification name, and says so clearly if another daemon (dunst, mako) has it. | Field | Type | | | --- | --- | --- | | `popups` | `[Notification]` | The notifications to show now; each leaves when it expires or is dismissed. | | `all` | `[Notification]` | Every notification kept, popups included: the newest 1,000 (older ones close as expired), remembered across restarts while `remember` is on. | | `count` | `int` | How many notifications are kept. | | `dnd` | `bool rw` | Do not disturb: new notifications that are not critical skip the popups (they are kept in `all`, and `received` still fires). Writable. | | `remember` | `bool rw` | Notifications kept in `all` are remembered across restarts (under `$XDG_STATE_HOME/strand`, readable only by you); on by default. Those from an earlier run come back in `all` (never as popups, and without `received`) with no buttons, and `activate()` only dismisses them, since their senders cannot hear it. A sender's `transient` notifications are never remembered. Writable: false forgets every remembered notification at once, and stays false across restarts. | | Method | | | --- | --- | | `action clear()` | Dismisses every notification. | | Event | | | --- | --- | | `on notifications.received(n: Notification)` | A notification arrived. Events are lossless queues: `on notifications.received(n) { … }`. | --- # `outputs` The monitors' configuration, from the compositor's `zwlr_output_manager_v1` (wlr-output-management: sway, Hyprland, niri, labwc): every output, on or off, with its modes, place, scale and rotation, changed together with `outputs.apply` and checked first with `outputs.test`. | Field | Type | | | --- | --- | --- | | `all` | `[Output]` | Every output, on or off, keyed by `name`. | | `available` | `bool` | The compositor offers output configuration: false without it, when `apply` and `test` fail with `outputs.failed`. | | Method | | | --- | --- | | `action apply(changes: [OutputChange])` | Applies `changes` together, as one configuration: each names an output and what to change on it, and outputs not named keep theirs (`on click { outputs.apply([OutputChange(name: "DP-1", scale: 1.5)]) }`). `outputs.succeeded("apply")` or `outputs.failed("apply", error)` follows. | | `action test(changes: [OutputChange])` | Asks the compositor whether `changes` would apply, changing nothing: `outputs.succeeded("test")` or `outputs.failed("test", error)` follows. A tested configuration may still fail to apply. | | Event | | | --- | --- | | `on outputs.succeeded(action: text)` | The compositor took a configuration: `on outputs.succeeded(action) { … }`, `action` as written (`"apply"` or `"test"`). | | `on outputs.failed(action: text, error: text)` | A configuration failed: the compositor refused it (a mode the monitor cannot show), it named no output or an impossible value, or the compositor offers no output configuration: `on outputs.failed(action, error) { … }`. | --- # `polkit` Strand's own polkit authentication agent: the password dialog when a program asks for something that needs one. While a config reads `polkit`, Strand registers as this session's agent with polkit (unless another agent already is, which it leaves be), and each request polkit sends becomes `request` until it is answered, cancelled, or withdrawn by the program. The password goes to polkit's own helper (`polkit-agent-helper-1`), never checked by Strand, and is wiped once sent. A panel shows it: `panel Polkit { open: polkit.request != null; keyboard: exclusive … }`. | Field | Type | | | --- | --- | --- | | `request` | `PolkitRequest?` | The request being answered, or null when none waits. | | `waiting` | `int` | How many more requests wait behind `request`, each shown in turn. | | `busy` | `bool` | An answer is being checked. | | `failed` | `bool` | The last answer was refused: `request` stays, and its prompt asks again. | | `available` | `bool` | Strand is this session's polkit agent. False while another agent is (a desktop's own, `polkit-gnome`), and when it cannot be one: polkit's helper (`polkit-agent-helper-1`, setuid root, or polkit's own socket for it) is not installed, polkit is not running, or Strand runs in no login session; `error` says which. | | `error` | `text` | Why Strand is not the agent ("another polkit authentication agent already serves this session…"), or why the shown request's check could not run or ended early; "" otherwise. | | Method | | | --- | --- | | `action respond(answer: text)` | Answers `request.prompt` with `answer` (the password): `input { type: password; text: <-> pw; on activate { polkit.respond(pw); pw = "" } }`. Ignored while one is being checked, and when the request shown has not asked yet (it went, or another user was chosen, since the answer was typed: an answer is never given to a request it was not typed for). After a refusal an answer typed before the check asks again waits for that question, and only a secret one (not `echo`) takes it. Pass a password to nothing else: the config is trusted code, and a declared action's arguments can be read by every user in the process list. | | `action cancel()` | Dismisses `request`: the program is told it was refused, and the next request, if any, shows. | | `action choose(user: text)` | Asks for `user`'s password instead (one of `request.users`). | --- # `screens` Monitors. | Field | Type | | | --- | --- | --- | | `all` | `[Screen]` | Every monitor, keyed by `name`. | | `focused` | `Screen?` | The monitor with keyboard focus. | --- # `session` The login session and the machine's power, from logind (`org.freedesktop.login1`): a power menu. Each `can_*` says whether logind allows it, so a menu greys out what it does not; an action that fails (polkit refused it, no login session, no logind) is `session.failed`. | Field | Type | | | --- | --- | --- | | `can_suspend` | `bool` | Suspending is allowed (logind's `CanSuspend` is `yes`, or `challenge`: polkit asks for a password first). | | `can_hibernate` | `bool` | Hibernating is allowed (`CanHibernate`, as `can_suspend`). | | `can_reboot` | `bool` | Rebooting is allowed (`CanReboot`, as `can_suspend`). | | `can_power_off` | `bool` | Powering off is allowed (`CanPowerOff`, as `can_suspend`). | | `available` | `bool` | logind answers: false without it (or without a system bus), when every action fails with `session.failed`. | | Method | | | --- | --- | | `action suspend()` | Suspends the machine. | | `action hibernate()` | Hibernates the machine. | | `action reboot()` | Reboots the machine. | | `action power_off()` | Powers the machine off. | | `action lock()` | Asks logind to lock this session: the locker that follows logind puts its lock up, and `lock_requested` fires, so a shell's own `lock` can follow too. | | `action log_out()` | Ends this login session (logind's `TerminateSession`): every app in it is closed. | | Event | | | --- | --- | | `on session.lock_requested()` | logind asks this session's locker to lock it (`session.lock()`, `loginctl lock-session`, an idle daemon): `on session.lock_requested { locked = true }` puts a shell's own `lock` up. | | `on session.failed(action: text, error: text)` | An action failed (polkit refused it, no login session to lock or end, logind or the system bus missing): `on session.failed(action, error) { … }`, `action` as written (`"reboot"`). A power action waiting on polkit's password dialog has not failed: it fails only when logind refuses it. | --- # `system` The desktop's appearance settings, from the XDG desktop portal and followed live, and the machine's host name, kernel and uptime. | Field | Type | | | --- | --- | --- | | `dark` | `bool` | The desktop prefers a dark style. | | `accent` | `color?` | The desktop's accent colour, if it sets one. | | `contrast` | `float` | The contrast preference, as `material(contrast:)` takes it: 0 for normal, 1 for high. | | `reduced_motion` | `bool` | Motion should be reduced: springs snap, and loops, time signals and effects stop. | | `hostname` | `text` | The machine's host name. | | `kernel` | `text` | The running kernel's release, as `uname -r` gives it: `6.11.2-arch1-1`. | | `uptime` | `duration` | Time since boot (suspend included), to the minute: `dur(system.uptime)` reads `3 h 12 min`. It changes once a minute, and only while a visible reader shows it. | | `replay` | `bool rw` | The glitch-report recording is on: Strand keeps the last minute or so of what drove the shell in memory for `strand report`. Writable; false empties it at once, and the choice is kept across restarts. | --- # `tray` The system tray: a StatusNotifierItem host, with a watcher of its own when the session has none, and each item's DBusMenu. | Field | Type | | | --- | --- | --- | | `items` | `[TrayItem]` | The tray's items, keyed by `id`: `for item in tray.items`. | --- # `video` The programs taking video, from PipeWire: a camera or screen-cast indicator. | Field | Type | | | --- | --- | --- | | `streams` | `[VideoStream]` | The programs taking video now, keyed by `id`: a camera indicator (`video.streams.any(s => s.source == camera)`) and a screen-cast one (`… == screencast`), with the programs' names. A program that opens a camera without PipeWire is not seen. | --- # `windows` Toplevel windows, from `ext-foreign-toplevel-list`. | Field | Type | | | --- | --- | --- | | `focused` | `Window?` | The focused window, if any: `windows.focused?.title`. | | `all` | `[Window]` | Every window, keyed by `id`. | --- # `wm` The compositor. | Field | Type | | | --- | --- | --- | | `name` | `text` | Its name, such as `Hyprland`, `niri` or `sway` (the desktop's name when only the standard protocols serve it). | | `mode` | `text?` | The compositor's binding mode (a Hyprland submap, a sway mode), null in its default one and on compositors without modes: `if wm.mode != null { text wm.mode ?? "" }`. | | Event | | | --- | --- | | `on wm.config_reloaded(failed: bool?)` | The compositor reloaded its config; `failed` says whether the new config failed to load, when the compositor tells (niri). | --- # `workspaces` Workspaces, from `ext-workspace-v1`; Hyprland, niri and sway IPC fill in what the protocol does not cover yet. | Field | Type | | | --- | --- | --- | | `all` | `[Workspace]` | Every workspace, keyed by `id`. | | `focused` | `Workspace?` | The focused workspace, if any. | | `configured` | `[Workspace]` | The workspaces the compositor's config places on a screen that do not exist yet (Hyprland's numbered workspace rules, sway's `workspace … output …`), keyed by `id`: empty and inactive, made by `ws.focus()`. `workspaces.slots(screen)` puts them in place. | | Method | | | --- | --- | | `fn on(screen: Screen) -> [Workspace]` | The workspaces on `screen`: `for ws in workspaces.on(screen)`. | | `fn slots(screen: Screen) -> [Workspace]` | A bar's slots on `screen`: its workspaces and the configured ones not made yet, by their number (Hyprland's id, else the number their name starts with), then the rest: `for ws in workspaces.slots(screen) { Dot ws }`, each switched to with `ws.focus()`. | | `action focus(target: int \| text)` | Switches to a workspace by number (`int`) or by name (`text`), making it where the compositor makes workspaces it is switched to (Hyprland, sway): `on click { workspaces.focus(4) }`. A number is the same on every compositor: Hyprland's workspace 4, sway's `workspace number 4`, niri's fourth workspace of the focused screen. A workspace record is switched to with `ws.focus()`, not by its `id`. |