Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Introduction

Amane is a Rust library for building your own Wayland desktop shell: bars, panels, launchers, notification popups, and lock screens. You write the whole shell as one Rust program. It connects straight to the compositor and draws on the GPU, with no browser engine, no QML, and no GTK underneath.

Here’s a complete bar:

use amane::{App, Color, Full, Layer, LayerWindow, Parent, Rectangle, Text, Vertical};

fn main() {
    App::new().window(view).run();
}

fn view() -> LayerWindow {
    LayerWindow::new()
        .width(Full)
        .height(30.0)
        .anchor_vertical(Vertical::Top)
        .layer(Layer::Top)
        .child(
            Rectangle::new()
                .width(Parent)
                .height(Parent)
                .fill(Color::BLUE)
                .child(Text::new("Amane bar").size(20.0).color(Color::WHITE)),
        )
}

That’s a 30-pixel blue strip across the top of the screen with some white text in it.

What you need

  • A Wayland compositor that supports wlr-layer-shell, like niri, Hyprland, Sway, or river. GNOME doesn’t support it.
  • A GPU with Vulkan drivers.
  • Some Rust. You don’t need to know it deeply. If you can read a function, a struct, and a closure (|x| ...), you can follow this book. Where Amane uses something less common, the page explains it.

How this book is laid out

  • Getting Started installs Amane and gets a bar on your screen.
  • Core Concepts explains the ideas everything else is built on: views, windows, layout, state, input, and animation. Read these in order.
  • Widgets covers each building block in detail.
  • More Windows covers multiple monitors, normal windows, and the lock screen.
  • Built-in Services covers the system data Amane can read for you: battery, audio, network, media players, notifications, and more.
  • Talking to the System covers running commands, watching files, IPC, and D-Bus.

The Core Concepts pages build on each other, and each one starts from a problem a shell has and works up to how Amane solves it. The other pages are for looking things up: each starts with a short example, followed by sections named after what you might want to do.

Every code block starts with its use line, so you always know where a name comes from. Unless a page says otherwise, every name comes from amane.

If you want to know how Amane works inside, read ARCHITECTURE.md in the repository instead.

Cheat Sheet

Every builder method and function on one page. Each row links to the page that explains it.

All names come from amane. To bring in everything at once:

use amane::*;
fn main() {}

App

Your First Shell · Multiple Windows and Monitors

CodeDoes
App::new()starts building the shell
.window(view)adds a layer window, fn() -> LayerWindow
.window_per_monitor(view)adds a layer window on every monitor, fn(&Monitor) -> LayerWindow
.normal_window("name", view)adds a normal app window, fn() -> Window
.lock(view)sets the lock screen, fn(&Monitor) -> LayerWindow
.ipc("name", handler)handles amane ipc call name, fn(&[String]) -> String
.font("Inter")sets the default font family
.run()starts the shell. Never returns.

LayerWindow

Layer Windows

CodeDoes
LayerWindow::new().width(w).height(h)required first. A number in pixels, or Full.
.anchor_vertical(Vertical::Top)Top, Middle (default), Bottom
.anchor_horizontal(Horizontal::Left)Left, Middle (default), Right
.margin(Margin { top, right, bottom, left })gap from anchored edges, in pixels (i32)
.layer(Layer::Top)Background, Bottom, Top, Overlay (default)
.space(Zone::Reserve)Reserve, Respect (default), Ignore
.keyboard(Keyboard::OnDemand)None (default), OnDemand, Exclusive
.on_key(handler)handles keys, Fn(Key)
.visible(bool)hides or shows the window, which keeps running
.input_region(vec![InputArea { x, y, width, height }])only these areas take the mouse
.click_through()no part takes the mouse
.namespace("bar")name for compositor rules, read once when the window opens
.child(widget)the window’s content

Window

Multiple Windows and Monitors

CodeDoes
Window::new()a normal app window
.title("Settings")window title, "amane" by default
.size(480.0, 320.0)size it opens at, 640 by 480 by default
.resizable(true)lets the user resize it, false by default
.on_key(handler)handles keys, Fn(Key)
.child(widget)the window’s content
open_window("name", view)opens a normal window later. Does nothing if that name is open.
close_window("name")closes it
window_size()the real size of the window being drawn, (f32, f32)

Sizes

Layout

CodeMeans
100.0exactly 100 pixels
Parentas big as the parent allows. Several Parent children share the free space evenly.
Fulla whole monitor side. Only for LayerWindow sizes and .radius(Full).

Row, Column, Stack

Layout

CodeDoes
Row::new(children![a, b])side by side
Column::new(children![a, b])top to bottom
Stack::new(children![a, b])on top of each other, later ones drawn over earlier ones
.width(size), .height(size)optional. Measured from the children by default.
.gap(12.0)space between children (Row, Column)
.justify(SpaceBetween)along the row: Start, Center, End, SpaceBetween, SpaceAround, SpaceEvenly
.align(Center)across the row: Start, Center, End
children![...]boxes a list of mixed widgets
Vec<Box<dyn Widget>>a list built in a loop, with Box::new(widget)

Rectangle

Rectangle

CodeDoes
Rectangle::new().width(w).height(h)required first. A number or Parent.
.fill("#1e1e2e")a hex color: #rgb, #rrggbb, #rrggbbaa
.fill(Color::rgb(30, 30, 46))a color. Also Color::rgba, Color::BLACK, WHITE, RED, GREEN, BLUE, TRANSPARENT.
.fill(Gradient::linear(90.0, [(0.0, "#a"), (1.0, "#b")]))a linear gradient. The angle works like CSS.
.fill(Gradient::radial([(0.0, "#a"), (1.0, "#b")]))a radial gradient
.fill(Image::cover(path))an image. Also contain and stretch.
.fill(Mask)cuts a hole in the rectangle around it
.radius(12.0)rounded corners
.radius(Full)a pill or circle
.border(2.0, "#cdd6f4")an outline along the inside edge
.opacity(0.5)fades it and its child
.shadow(Shadow::drop("#000").blur(12.0).offset(0.0, 4.0).opacity(0.3))a drop shadow
.shadow(Shadow::inner("#000").blur(8.0))an inner shadow
.blur(20.0)blurs what your window drew behind it
.padding(8.0)space inside the edges. Or Padding { top, right, bottom, left }.
.align_child(Center, Center)places the child: horizontal, then vertical
.clip()cuts the child off at the edges and corners
.child(widget)one child
.rotate(30.0)degrees, clockwise, around the center
.scale(0.5)around the center
.translate(20.0, -10.0)moves it without changing the layout
.shader(path)a WGSL or GLSL shader over the fill
.shader_values(vec![[a, b, c, d]])up to 16 rows of numbers for the shader

Input

Input · all on Rectangle

CodeCalled with
.on_click(|button| ...)Button::Left, Right, Middle
.on_hover(|inside| ...)true on enter, false on leave
.on_scroll(|scroll| ...)Scroll { x, y } in lines, positive y is down
.on_move(|point| ...)Point { x, y } from the top-left corner
.on_drag(|point| ...)Point, from a left press until release
.cursor(Pointer)Default, Pointer, Text, Grab, Grabbing, Move, NotAllowed, Wait, Crosshair, Resize...

Text

Text

CodeDoes
Text::new("hello")takes &str, String, or format!(...)
.size(20.0)16 by default
.color(Color::WHITE)takes a Color. Wrap hex in Color::from("#...").
.font("JetBrains Mono")a font family
.weight(Weight::Bold)or a number like 300
.elide()one line, cut off with “…”
.wrap()as many lines as needed
.max_lines(2)with wrap
.tight()measures the letters’ shapes, for icon fonts
.height_in(width)the height of wrapped text at that width

TextInput

Text Input

CodeDoes
TextInput::new("id")the name its text is kept under
.placeholder("search")shown while empty
.password()shows dots
.focused()takes keys without a click
.on_change(|text| ...)after every change
.on_submit(|text| ...)on Enter
.size(18.0), .color(c), .width(w)look and size
TextInput::set_text("id", "...")replaces its text

ScrollArea

Scroll Area

CodeDoes
ScrollArea::new("id", child)scrolls child vertically. The name keeps its position.
.width(size), .height(size)Parent by default

Canvas and shapes

Canvas and Shapes · needs Shape in the use line

CodeDoes
Canvas::new().width(w).height(h).shapes(shapes![...])draws shapes in its own coordinates
Circle::new().center(x, y).radius(r)middle of the canvas, as big as fits, by default
Arc::new().center(x, y).radius(r).start(deg).sweep(deg)0 is up, clockwise
Line::new().from(x, y).to(x, y)a straight line
Path::new().move_to(x, y).line_to(x, y)also quad_to, cubic_to, arc, close
.fill(color)paints the inside
.stroke(4.0, color)draws the outline
.cap(Cap::Round)Butt (default), Round, Square
.opacity(0.5)fades the shape

Image

Images

CodeDoes
Image::cover(path)fills, cuts off the edges
Image::contain(path)shows all of it
Image::stretch(path)fills, squashed to fit
.thumbnail(w, h)keeps a small copy
.blurred(radius)blurs it once, when loaded
Image::loaded(path)true once it’s ready to draw

Animation

Animation

CodeDoes
Animation::new(0.0)an animated f32. Animation<Color> for colors.
.duration(Duration::from_millis(400))200 ms by default
.easing(Easing::InOut)Out (default), InOut, Linear
animation.to(target)starts moving from wherever it is now
animation.value()the current value. Keeps frames coming while it moves.
amane::request_frame()asks for one more frame

Services

State and Services

CodeDoes
impl Service for T { fn new() -> Self { ... } }the only required function
fn interval() -> Durationhow often update runs, 1 second by default
fn update(&mut self) -> boolpolls. Return true when something changed.
fn listen()replaces polling with your own loop
T::read()reads it. In a view, also subscribes the window.
T::write()changes it, and redraws every window that reads it. Never in a view.

Built-in Services

Built-in Services

ServiceReadsControls
Batterypresent, percent, charging, full
Cpupercent
Memorypercent, used_kib, total_kib
Brightnesspresent, percentset
Audiovolume, muted, microphone_volume, microphone_mutedset_volume, toggle_mute, set_microphone_volume, toggle_microphone_mute
Networkconnected, link, ssid, strength, wifi_enabled, connecting, access_pointsscan, connect, disconnect, set_wifi
Bluetoothavailable, powered, scanning, devicesset_powered, start_scan, stop_scan, pair, connect, disconnect, forget
Mediaplayers, active, title, artist, art_url, playing, position, lengthplay_pause, next, previous
Notificationslist, runningclick, invoke, dismiss, clear
Workspaceslistfocus
Appslistlaunch on each DesktopApp
Palettecolors, dominant, accent, background, foreground, on_accent, lightopen
Lockchecking, failedstart, unlock

Commands, files, IPC, D-Bus

Commands and Files · IPC · D-Bus

CodeDoes
amane::spawn("cmd")starts a command, doesn’t wait
amane::output("cmd")runs a command and returns its output. Waits.
amane::lines("cmd")each line a long-running command prints
amane::watch_file(path)one item per change to a file
Bus::system(), Bus::session()a D-Bus connection
bus.property(dest, path, iface, name)reads a property
bus.call(dest, path, iface, method, &arguments![...])calls a method
bus.signals(iface, name)each matching signal

Command line

The amane Command

CommandDoes
amane startupcreates ~/.config/amane/src/main.rs
amane devrebuilds and restarts on every save
amane compilebuilds the optimized shell
amane runbuilds if needed, then starts it
amane ipc call <name> [args...]calls an IPC handler
AMANE_FRAMES=1prints a line per frame, for finding slow or endless redraws

Installation

Amane comes as one command, amane. The command carries a full copy of the library inside it. It builds your shell from your config folder, so you never set up a Cargo project yourself.

NixOS

The repository is a flake. Its package wraps amane with everything it needs at build time (cargo, rustc, pkg-config, and the system libraries), so there’s nothing else to install.

To try it without installing:

nix run github:MystiaFin/amane -- startup

To install it for your user:

nix profile install github:MystiaFin/amane

To install it system-wide, add it to your flake inputs:

{
  inputs = {
    nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
    amane.url = "github:MystiaFin/amane";
  };

  outputs = { nixpkgs, amane, ... }: {
    nixosConfigurations.your-host = nixpkgs.lib.nixosSystem {
      system = "x86_64-linux";
      modules = [
        ./configuration.nix
        {
          environment.systemPackages = [ amane.packages.x86_64-linux.default ];
        }
      ];
    };
  };
}

Replace your-host with your host’s name, then rebuild.

Other distributions

You need a Rust toolchain, pkg-config, and these libraries with their development headers:

LibraryDebian / UbuntuArch
Waylandlibwayland-devwayland
xkbcommonlibxkbcommon-devlibxkbcommon
fontconfiglibfontconfig1-devfontconfig
Vulkan loaderlibvulkan1vulkan-icd-loader
PulseAudio clientlibpulse-devlibpulse
PAMlibpam0g-devpam

Then build and install the command:

git clone https://github.com/MystiaFin/amane
cd amane
cargo install --path cli

cargo install puts amane in ~/.cargo/bin. Make sure that folder is on your PATH.

The amane command runs cargo every time it builds your shell, so keep the Rust toolchain installed.

Updating

The library is copied into the amane command when the command is built. To get a newer Amane, update the command itself:

  • NixOS: nix profile upgrade amane, or update the flake input and rebuild.
  • Other distributions: git pull in the clone, then run cargo install --path cli again.

The next amane run or amane dev rebuilds your shell with the new library.

Your First Shell

This page gets a bar on your screen, then changes it while it runs.

1. Create the config

amane startup

This creates ~/.config/amane/src/main.rs (or $XDG_CONFIG_HOME/amane/src/main.rs) with a small bar in it. If the file already exists, startup stops and leaves it alone.

2. Start it in dev mode

amane dev

The first build takes a few minutes, because it compiles Amane and all its dependencies. After that, the bar appears at the top of your screen.

Leave amane dev running. Every time you save main.rs, it rebuilds and restarts the bar. If a build fails, the old bar stays on screen while you fix the error.

Only one Amane shell can run at a time. If your shell is already running from amane run, stop it before starting amane dev.

3. Read the file

Open ~/.config/amane/src/main.rs:

use amane::{App, Color, Full, Layer, LayerWindow, Parent, Rectangle, Text, Vertical};

fn main() {
    App::new().window(view).run();
}

fn view() -> LayerWindow {
    LayerWindow::new()
        .width(Full)
        .height(30.0)
        .anchor_vertical(Vertical::Top)
        .layer(Layer::Top)
        .child(
            Rectangle::new()
                .width(Parent)
                .height(Parent)
                .fill(Color::BLUE)
                .child(Text::new("hello from amane").size(20.0).color(Color::WHITE)),
        )
}

Top to bottom:

  • use amane::{...} brings in every name the file uses.
  • main builds an App, gives it one window, and runs it. run() never returns while the shell is open.
  • .window(view) passes the function view itself, without calling it. Amane calls it whenever the window needs to be drawn.
  • view describes the window:
    • .width(Full) makes it as wide as the monitor.
    • .height(30.0) makes it 30 pixels tall.
    • .anchor_vertical(Vertical::Top) sticks it to the top edge.
    • .layer(Layer::Top) puts it above normal windows.
  • Inside the window is a Rectangle that fills it (Parent means “as big as my parent”) and is painted blue.
  • Inside the rectangle is a Text.

4. Change it

With amane dev still running, change the color and the text:

use amane::{App, Color, Full, Layer, LayerWindow, Parent, Rectangle, Text, Vertical};

fn main() {
    App::new().window(view).run();
}

fn view() -> LayerWindow {
    LayerWindow::new()
        .width(Full)
        .height(30.0)
        .anchor_vertical(Vertical::Top)
        .layer(Layer::Top)
        .child(
            Rectangle::new()
                .width(Parent)
                .height(Parent)
                .fill("#1e1e2e")
                .child(Text::new("my shell").size(16.0).color(Color::from("#cdd6f4"))),
        )
}

Save, and the bar restarts with the new look.

.fill takes a hex string directly. .color on Text takes a Color, so a hex string goes through Color::from.

5. Run it for real

amane dev builds quickly, but the program it builds isn’t fully optimized. When you’re happy with your shell, use:

amane run

This builds an optimized shell and starts it. To start your shell when you log in, run amane run from your compositor’s startup config. For niri:

spawn-at-startup "amane" "run"

When nothing has changed since the last build, amane run starts right away.

Next

Views explains what view really is and why it’s a function. Read it before writing anything bigger.

The amane Command

CommandWhat it does
amane startupCreates ~/.config/amane/src/main.rs from a template. Never overwrites an existing file.
amane devBuilds your shell, starts it, and rebuilds and restarts it on every save.
amane compileBuilds the optimized shell without starting it.
amane runBuilds the optimized shell if anything changed, then starts it.
amane ipc call <name> [arguments...]Calls a handler in the running shell. See IPC.

Where things live

  • Your shell: ~/.config/amane/src/. main.rs is the entry point. You can add more files next to it and load them with mod, like in any Rust program.
  • Build files: ~/.cache/amane/.
    • library/ holds the copy of Amane unpacked from the command.
    • project/ holds the Cargo project generated around your main.rs.
    • project/target/ holds the build output.

Both paths follow XDG_CONFIG_HOME and XDG_CACHE_HOME when they’re set.

You never edit anything in the cache folder. Deleting it is safe. The next build makes it again, from scratch.

Splitting your shell into files

main.rs can load other files the usual Rust way:

~/.config/amane/src/
├── main.rs
├── bar.rs
└── clock.rs
// main.rs
mod bar;
mod clock;

use amane::App;

fn main() {
    App::new().window(bar::view).run();
}

amane dev watches the whole src/ folder, so saving any of these files triggers a rebuild.

Dev builds and release builds

amane devamane run, amane compile
Your codenot optimized, builds in secondsoptimized
Amane and its dependenciesoptimizedoptimized
Use it forwriting your shelldaily use

Amane is optimized even in dev builds, because unoptimized drawing is too slow for smooth animation. Only your own code is left unoptimized, which keeps rebuilds after a save quick.

Dependencies

Your shell’s Cargo.toml is generated for you on every build, and it only depends on amane. That means you can’t add other crates to your shell yet.

Most of what a shell needs is in the library already:

For anything else, like the current time, run a command with amane::output (for example date +%H:%M).

Views

A bar shows data that changes: the time, the battery, the volume. Most UI toolkits build the widgets once, and you write code to update each widget when its data changes. Amane works differently. A view is a function that describes what a window shows, and Amane calls it again whenever that should change, so there’s no update code to write.

This page explains when a view runs, and the one rule that follows from that.

A view with live data

use amane::{App, Battery, Full, LayerWindow, Service, Text};

fn main() {
    App::new().window(view).run();
}

fn view() -> LayerWindow {
    let battery = Battery::read();

    LayerWindow::new()
        .width(Full)
        .height(30.0)
        .child(Text::new(format!("battery {}%", battery.percent())))
}

When the battery goes from 80% to 79%, the text changes on its own.

When a view runs

view is called again every time the window needs to change:

  1. Amane calls view.
  2. view reads the battery (80%) and builds a new window with a Text that says 80%.
  3. Amane draws it, then throws the widgets away.
  4. The battery drops to 79%.
  5. Amane calls view again. It reads 79% and builds a new Text that says 79%.

The first draw and every update go through the same function, so the screen always matches the data.

Amane also knows which windows to call again. Battery::read() records that this view read the battery. When the battery changes, only the windows whose view read it are drawn again. A window that never reads the battery is left alone. State and Services explains this in detail.

Keeping state outside the view

The widgets are thrown away after every frame. So:

Nothing stored in a widget survives to the next frame.

Anything that has to last, like a counter, whether a menu is open, or text the user typed, has to live outside the view. Amane gives you three places for it:

  • a Service, for your own state (State and Services)
  • a built-in name-keyed store, used by ScrollArea and TextInput to keep their scroll position and text between frames. You give each one a name, like ScrollArea::new("apps", ...).
  • a static you manage yourself, like a thread_local! or a Mutex. The window you click or type in always redraws after the event, so a static changed by its own input handler shows up. But a static changed anywhere else, like from a thread or another window’s handler, redraws nothing. A Service redraws every window that reads it, wherever the change comes from.

What a view should and shouldn’t do

A view runs often, sometimes 60 times a second during an animation, on the same thread that draws every window. So a view should:

  • read data (SomeService::read()) and build widgets
  • not wait on anything: no running commands, no reading files, no network calls, no sleep

Slow work goes in a Service’s own thread instead. The view then reads the result.

Splitting a view into functions

App::window takes fn() -> LayerWindow: a function that takes nothing and returns a window. It can’t be a closure that captures variables. That’s on purpose: since a view can’t capture anything, everything it shows has to come from somewhere Amane can watch.

You can split a view into helper functions freely:

use amane::{App, Color, Full, LayerWindow, Parent, Rectangle, Row, Text, children};

fn main() {
    App::new().window(view).run();
}

fn view() -> LayerWindow {
    LayerWindow::new()
        .width(Full)
        .height(30.0)
        .child(Row::new(children![label("left"), label("right")]))
}

fn label(content: &str) -> Rectangle {
    Rectangle::new()
        .width(Parent)
        .height(Parent)
        .fill(Color::BLACK)
        .child(Text::new(content).color(Color::WHITE))
}

Helpers can take arguments and return any widget type. Only the top-level view has a fixed signature.

Terms

  • View: a function that builds a window’s widgets from the current data.
  • Frame: one run of the view, plus drawing the result.
  • Immediate mode: this style of UI, where the widgets are built again for every frame instead of being kept and updated.

Layer Windows

A LayerWindow is a window that’s part of the desktop rather than an app: a bar, a dock, a popup, a wallpaper. A normal app window goes wherever the compositor puts it, and other windows cover it. A layer window sticks to a screen edge, sits on a layer you choose, and can keep other windows out of its way.

This page covers each of those settings.

A bar

use amane::{App, Color, Full, Layer, LayerWindow, Parent, Rectangle, Vertical, Zone};

fn main() {
    App::new().window(view).run();
}

fn view() -> LayerWindow {
    LayerWindow::new()
        .width(Full)
        .height(32.0)
        .anchor_vertical(Vertical::Top)
        .layer(Layer::Top)
        .space(Zone::Reserve)
        .child(Rectangle::new().width(Parent).height(Parent).fill(Color::BLACK))
}

This is a real bar: full width, 32 pixels tall, stuck to the top, above normal windows, and other windows are kept out of the 32 pixels it covers.

How window settings reach the compositor

The compositor (niri, Hyprland, Sway) owns the screen. A LayerWindow is a request to it: “put this on the top edge, at this size, on this layer”. Each method sets one part of that request:

  • width and height come first, and are required. A number is a size in pixels. Full stretches the window across the whole monitor.
  • anchor_vertical and anchor_horizontal pick the edges it sticks to.
  • layer picks how high in the stack it is.
  • space picks whether other windows have to stay out of its way.

The view runs again on every change, and Amane compares the new settings with the old ones. When one changed, Amane sends only that change to the compositor. So you can move, resize, or hide a window just by returning different settings from the view.

Size

LayerWindow::new().width(300.0).height(200.0)   // 300 by 200 pixels
LayerWindow::new().width(Full).height(30.0)     // full width, 30 pixels tall
LayerWindow::new().width(48.0).height(Full)     // full height, for a side bar

width must come before height, and both must come before anything else. If you forget one, the code doesn’t compile, so you can’t open a window without a size.

A compositor may give a window a different size than it asked for. Use window_size() inside a view to read the real one (Multiple Windows and Monitors).

Position

.anchor_vertical(Vertical::Top)          // Top, Middle, or Bottom
.anchor_horizontal(Horizontal::Right)    // Left, Middle, or Right

Both default to Middle, so a window with no anchors sits in the center of the screen.

A Full size anchors both opposite edges on its own. A Full width is stuck to the left and the right edge, whatever anchor_horizontal says.

To keep a gap from the edges, use a margin. The numbers are pixels:

.margin(Margin { top: 10, right: 10, bottom: 0, left: 0 })

A margin only pushes away from an edge the window is anchored to. A negative margin pushes the window past the edge, partly off screen, which is useful for sliding a panel in and out (Animation).

Layer

.layer(Layer::Top)
LayerSitsUse it for
Backgroundunder everythingwallpapers
Bottomunder normal windowsdesktop widgets
Topabove normal windowsbars, docks
Overlayabove everything, even fullscreen windowspopups, launchers, OSDs

The default is Overlay. Set Top for a bar, or it covers fullscreen videos.

Reserved space

.space(Zone::Reserve)
ZoneEffect
ReserveOther windows are kept out of the strip this window covers. Use it for bars.
RespectReserves nothing, and moves out of the space other windows reserve. This is the default.
IgnoreReserves nothing, and covers the space other windows reserve too. Use it for full-screen overlays.

Reserve reserves the window’s height, or its width for a window anchored to the left or right edge with a fixed width.

Keyboard

A layer window gets no keys by default. To type into it, ask for keyboard focus:

.keyboard(Keyboard::OnDemand)
KeyboardEffect
NoneNever gets keys. This is the default.
OnDemandGets keys after you click it, like a normal window. Use it for a search box in a panel.
ExclusiveTakes all keys as long as it’s open. Use it for launchers and menus.

Input covers what to do with the keys.

Hiding and showing

.visible(panel.open)

A hidden window stays alive. Its view keeps running, and it shows again when the view returns visible(true). This is how you make a panel you toggle with a keybind (IPC).

Clicks that pass through

By default, the whole window takes the mouse. To let clicks reach the windows underneath:

.click_through()                     // no part of the window takes the mouse
.input_region(vec![InputArea { x: 0, y: 0, width: 100, height: 30 }])   // only this part does

InputArea is in pixels, measured from the window’s top-left corner. You can pass several.

Namespace

.namespace("bar")

The namespace is a name the compositor can match window rules on, like niri’s layer-rule. It defaults to "amane". It’s only read when the window opens, so changing it later does nothing.

Terms

  • Layer shell: the Wayland protocol (wlr-layer-shell) that layer windows use. Your compositor must support it.
  • Anchor: an edge a window is stuck to.
  • Exclusive zone: the compositor’s name for reserved space.

Layout

A bar usually has a few things on the left, a clock in the middle, and a few things on the right. When the bar’s width changes, the middle should stretch, and the sides should stay the same size.

This page covers how widgets get their size and position: Size, Row, Column, Stack, and the spacing and alignment options.

Fixed and stretching widgets

use amane::{App, Color, Full, LayerWindow, Parent, Rectangle, Row, children};

fn main() {
    App::new().window(view).run();
}

fn view() -> LayerWindow {
    LayerWindow::new()
        .width(Full)
        .height(30.0)
        .child(Row::new(children![
            Rectangle::new().width(100.0).height(Parent).fill(Color::RED),
            Rectangle::new().width(Parent).height(Parent).fill(Color::GREEN),
            Rectangle::new().width(100.0).height(Parent).fill(Color::BLUE),
        ]))
}

Red is always 100 pixels, blue is always 100 pixels, and green takes whatever is left.

How a row places its children

Every widget answers one question for each direction: how big do you want to be? The answer is a Size, and there are two kinds:

  • a number, like 100.0: exactly that many pixels.
  • Parent: “as big as my parent lets me”.

The Row works out positions in two steps:

  1. It measures. The fixed children need 100 + 100 = 200 pixels. The rest of the row’s width is free.
  2. It places. Each child is put next to the one before it. The free space is split evenly between the Parent children. Here there’s only one, so green gets all of it.

When the bar gets wider, only the free space changes, so only green grows.

Lists of widgets

Row::new takes a list of widgets of different types: rectangles, text, other rows. Rust lists can only hold one type, so each widget has to be put in a Box<dyn Widget>, which means “any widget”. The children! macro does that boxing for you:

Row::new(children![
    Rectangle::new().width(100.0).height(30.0),
    Text::new("hello"),
])

When you build the list in a loop, box each widget yourself:

use amane::{App, Column, LayerWindow, Text, Widget};

fn main() {
    App::new().window(view).run();
}

fn view() -> LayerWindow {
    let mut rows: Vec<Box<dyn Widget>> = Vec::new();

    for number in 1..=5 {
        rows.push(Box::new(Text::new(format!("row {number}"))));
    }

    LayerWindow::new().width(200.0).height(200.0).child(Column::new(rows))
}

Row, Column, and Stack

WidgetPlaces children
Rowside by side, left to right
Columnon top of each other, top to bottom
Stackall in the same spot, later children drawn over earlier ones

Row and Column work the same way, just in different directions. Everything on this page about Row also applies to Column, with width and height swapped.

Stack is for layering, like a badge on top of a card. Each child starts at the stack’s top-left corner. Move one with translate (Rectangle):

use amane::{App, Center, Color, Full, LayerWindow, Rectangle, Stack, Text, children};

fn main() {
    App::new().window(view).run();
}

fn view() -> LayerWindow {
    let card = Rectangle::new()
        .width(200.0)
        .height(120.0)
        .radius(16.0)
        .fill("#1e1e2e");

    let badge = Rectangle::new()
        .width(28.0)
        .height(28.0)
        .radius(Full)
        .fill("#f38ba8")
        .translate(186.0, -14.0)
        .align_child(Center, Center)
        .child(Text::new("3").size(14.0).color(Color::BLACK));

    LayerWindow::new()
        .width(260.0)
        .height(180.0)
        .child(Stack::new(children![card, badge]))
}

How big a row is

If you don’t give a Row a size, it measures its children:

  • Its width is all its children’s widths added up, plus the gaps.
  • Its height is its tallest child’s height.
  • If any child is Parent-sized in a direction, the row becomes Parent-sized in that direction too.

To set a size yourself, use .width(...) and .height(...). Both are optional on Row, Column, and Stack.

Gaps

Row::new(children![a, b, c]).gap(12.0)

gap puts 12 pixels between each pair of children. There’s no gap before the first child or after the last.

Spreading children along a row

When the children don’t fill the row, justify decides where the leftover space goes:

Row::new(children![a, b, c]).width(Parent).justify(SpaceBetween)
JustifyEffect
Startpacked at the start. This is the default.
Centerpacked in the middle
Endpacked at the end
SpaceBetweenfirst at the start, last at the end, equal space between
SpaceAroundequal space around each child
SpaceEvenlyequal space between every child and the edges

justify only matters when there’s space left over. A Parent-sized child takes all of it, so there’s nothing to spread.

Lining children up across a row

align decides where each child sits in the other direction. For a Row, that’s vertically:

Row::new(children![a, b, c]).height(40.0).align(Center)

It takes Start, Center, or End. The default is Start: the top of a row, or the left of a column.

Padding and placing a single child

A Rectangle holds one child. padding keeps space between its edges and the child, and align_child places the child inside that space:

use amane::{App, Center, Color, End, LayerWindow, Padding, Parent, Rectangle};

fn main() {
    App::new().window(view).run();
}

fn view() -> LayerWindow {
    LayerWindow::new().width(300.0).height(100.0).child(
        Rectangle::new()
            .width(Parent)
            .height(Parent)
            .fill(Color::BLACK)
            .padding(Padding { top: 8.0, right: 16.0, bottom: 8.0, left: 16.0 })
            .align_child(End, Center)
            .child(Rectangle::new().width(40.0).height(40.0).fill(Color::RED)),
    )
}
  • padding(16.0) puts the same space on every side. Padding { ... } sets each side.
  • align_child(horizontal, vertical) takes Start, Center, or End for each direction. The default is the top-left corner.

Example: a three-part bar

Left, center, and right sections, with the center always in the middle of the bar:

use amane::{
    Align, App, Center, Color, End, Full, LayerWindow, Parent, Rectangle, Row, Start, Text,
    children,
};

fn main() {
    App::new().window(view).run();
}

fn view() -> LayerWindow {
    LayerWindow::new()
        .width(Full)
        .height(30.0)
        .child(Row::new(children![
            section(Text::new("workspaces").color(Color::WHITE), Start),
            section(Text::new("12:00").color(Color::WHITE), Center),
            section(Text::new("battery").color(Color::WHITE), End),
        ]))
}

fn section(content: Text, align: impl Into<Align>) -> Rectangle {
    Rectangle::new()
        .width(Parent)
        .height(Parent)
        .fill("#1e1e2e")
        .align_child(align, Center)
        .child(content)
}

All three sections are Parent-sized, so each gets exactly a third of the bar. The middle third is always centered, however long the left and right content is.

Terms

  • Size: a fixed number of pixels, or Parent.
  • Justify: placement along the direction a row or column grows.
  • Align: placement across that direction.
  • Main axis / cross axis: the usual names for “along” and “across”.

State and Services

A view is rebuilt on every frame (Views), so it can’t hold data. A Service is where data lives instead. It keeps a value between frames, updates it in the background, and redraws the windows that show it when it changes.

This page shows how to read a Service, how to write your own, and the rules for using them.

A clock

use std::time::Duration;

use amane::{App, Full, LayerWindow, Service, Text};

struct Clock {
    time: String,
}

impl Service for Clock {
    fn new() -> Self {
        Self { time: amane::output("date +%H:%M:%S") }
    }

    fn interval() -> Duration {
        Duration::from_secs(1)
    }

    fn update(&mut self) -> bool {
        let time = amane::output("date +%H:%M:%S");

        let changed = time != self.time;

        self.time = time;

        changed
    }
}

fn main() {
    App::new().window(view).run();
}

fn view() -> LayerWindow {
    let clock = Clock::read();

    LayerWindow::new()
        .width(Full)
        .height(30.0)
        .child(Text::new(&clock.time))
}

std::time::Duration comes from Rust’s standard library. amane::output runs a shell command and returns what it printed (Commands and Files).

How a Service updates

  1. The first time anything calls Clock::read(), Amane creates the clock with new() and keeps it for as long as the shell runs. There’s only ever one Clock.
  2. Amane also starts a background thread just for the clock. Every interval(), that thread calls update().
  3. update() returns whether anything the windows show has changed. When it returns true, Amane redraws every window whose view read the clock.
  4. view calls Clock::read(), which gives it the current value and records that this window depends on the clock.

The clock’s thread does the waiting, so the bar never freezes, even if date were slow.

Reading a Service

let clock = Clock::read();

read() works from anywhere: views, input handlers, other Services. In a view it also subscribes the window to that Service, which is what makes it redraw on changes.

The value it returns locks the Service for reading. Keep it for as long as you need it, then let it go. In a view, that’s usually until the end of the function.

Writing to a Service

Counter::write().count += 1;

write() gives you the Service to change. When the value it returns goes away (at the end of the statement here), Amane marks the Service as changed and redraws every window that reads it. You don’t call anything to redraw.

Write from input handlers (Input), from IPC handlers (IPC), or from the Service’s own thread.

Never call write() inside a view. The view may still be holding a read() of the same Service, and a write waits for every read to finish, so the shell freezes forever. A view only reads.

State that only changes through input

Many Services don’t poll anything. A menu’s open state, a counter, or a selected tab only change when you click. Give those a long interval, and leave out update:

use std::time::Duration;

use amane::{App, Button, LayerWindow, Parent, Rectangle, Service, Text};

struct Counter {
    count: i32,
}

impl Service for Counter {
    fn new() -> Self {
        Self { count: 0 }
    }

    // nothing changes on its own, only through clicks
    fn interval() -> Duration {
        Duration::from_secs(3600)
    }
}

fn main() {
    App::new().window(view).run();
}

fn view() -> LayerWindow {
    let counter = Counter::read();

    LayerWindow::new().width(200.0).height(40.0).child(
        Rectangle::new()
            .width(Parent)
            .height(Parent)
            .fill("#1e1e2e")
            .on_click(clicked)
            .child(Text::new(format!("clicked {} times", counter.count))),
    )
}

fn clicked(_: Button) {
    Counter::write().count += 1;
}

update defaults to “nothing changed”, so polling it costs nothing.

Listening instead of polling

Some data announces its own changes: a command that streams events, a file being saved, a D-Bus signal. Polling those wastes time and adds delay. Replace listen instead:

use amane::{App, Full, LayerWindow, Service, Text};

struct Niri {
    event: String,
}

impl Service for Niri {
    fn new() -> Self {
        Self { event: String::from("waiting for niri") }
    }

    fn listen() {
        for line in amane::lines("niri msg event-stream") {
            Self::write().event = line;
        }
    }
}

fn main() {
    App::new().window(view).run();
}

fn view() -> LayerWindow {
    let niri = Niri::read();

    LayerWindow::new().width(Full).height(30.0).child(Text::new(&niri.event))
}

listen runs on the Service’s own thread, so it can wait as long as it likes. The default listen is the polling loop from How a Service updates. Replacing it means interval and update aren’t used anymore.

If listen panics, for example because a program it talks to went away, Amane prints a message and starts it again after 5 seconds. If listen returns, the Service keeps its last value and stops updating.

Several Services in one view

A view can read as many Services as it likes:

let battery = Battery::read();
let audio = Audio::read();
let clock = Clock::read();

The window redraws when any of them changes. A window that reads none of them is never redrawn because of them. That’s how a shell with many windows stays cheap: each window only wakes up for its own data.

Built-in Services

Amane comes with Services for common system data: Battery, Audio, Network, Media, Notifications, Workspaces, and more. You use them exactly like your own, with read(), and they also have functions to control them, like Audio::set_volume(50). See Built-in Services.

Terms

  • Service: one global value per type, with its own background thread. Reading it in a view subscribes the window to it.
  • Poll: asking for new data on a timer, with interval and update.
  • Listen: waiting for data to arrive, by replacing listen.

Input

A bar needs buttons: click a workspace to switch to it, scroll over the volume to change it, click the clock to open a calendar.

This page covers mouse and keyboard input: clicks, hover, scrolling, dragging, the pointer’s look, and keys.

Handling a click

use amane::{App, Button, LayerWindow, Parent, Rectangle, Text};

fn main() {
    App::new().window(view).run();
}

fn view() -> LayerWindow {
    LayerWindow::new().width(200.0).height(40.0).child(
        Rectangle::new()
            .width(Parent)
            .height(Parent)
            .fill("#1e1e2e")
            .on_click(clicked)
            .child(Text::new("click me")),
    )
}

fn clicked(button: Button) {
    println!("clicked with {button:?}");
}

How input reaches a handler

Input handlers are set on a Rectangle. Each frame, Amane remembers where every rectangle with a handler ended up. When you click, it finds the rectangle under the pointer and calls its handler.

If rectangles overlap, the one drawn last gets the event. That’s the innermost and topmost one: a button inside a panel gets the click, not the panel.

The handler runs after the frame is gone, so it can’t change widgets directly. It changes state instead, usually by writing to a Service, and the view shows the change on the next frame:

fn clicked(_: Button) {
    Counter::write().count += 1;
}

Mouse handlers

All of these are methods on Rectangle:

MethodCalledGets
on_clickwhen a button is pressed and released over the same rectanglewhich Button: Left, Right, or Middle
on_hoverwhen the pointer comes in, and when it goes outtrue when it comes in, false when it goes out
on_scrollfor each wheel step or touchpad movementa Scroll with x and y
on_moveon every movement while the pointer is over ita Point
on_dragon a left press, and on every movement until releasea Point

A Point has x and y, measured in pixels from the rectangle’s top-left corner. During on_drag, the point keeps coming even after the pointer leaves the rectangle, so it can be negative or bigger than the rectangle. Clamp it.

A Scroll is measured in lines, so a mouse wheel and a touchpad move things by the same amount. Positive y means scrolling down, and positive x means scrolling right.

Passing values into handlers

A handler can be a function, like clicked above, or a closure. A closure can carry values from the view into the handler:

use amane::{App, Column, LayerWindow, Parent, Rectangle, Text, Widget};

fn main() {
    App::new().window(view).run();
}

fn view() -> LayerWindow {
    let mut rows: Vec<Box<dyn Widget>> = Vec::new();

    for number in 1..=3 {
        let row = Rectangle::new()
            .width(Parent)
            .height(30.0)
            .on_click(move |_| println!("clicked row {number}"))
            .child(Text::new(format!("row {number}")));

        rows.push(Box::new(row));
    }

    LayerWindow::new().width(200.0).height(90.0).child(Column::new(rows))
}

move copies number into the closure, so each row remembers its own number. |_| ignores the Button argument.

A closure can’t borrow from a Service’s read(), because the handler lives longer than the view. Copy out what you need first:

let id = workspace.id();                                   // a copy, not a borrow
let name = String::from(device.name());                    // an owned copy of the text

Rectangle::new()
    .width(30.0)
    .height(Parent)
    .on_click(move |_| Workspaces::focus(id))

Hover effects

A hover effect needs state, because the view has to know whether the pointer is inside:

use std::time::Duration;

use amane::{App, Color, LayerWindow, Parent, Rectangle, Service};

struct Hover {
    inside: bool,
}

impl Service for Hover {
    fn new() -> Self {
        Self { inside: false }
    }

    fn interval() -> Duration {
        Duration::from_secs(3600)
    }
}

fn main() {
    App::new().window(view).run();
}

fn view() -> LayerWindow {
    let hover = Hover::read();

    let fill = if hover.inside { Color::from("#45475a") } else { Color::from("#1e1e2e") };

    LayerWindow::new().width(200.0).height(40.0).child(
        Rectangle::new()
            .width(Parent)
            .height(Parent)
            .fill(fill)
            .on_hover(|inside| Hover::write().inside = inside),
    )
}

Changing the pointer

Rectangle::new()
    .width(100.0)
    .height(30.0)
    .cursor(Pointer)
    .on_click(clicked)

While the pointer is over the rectangle, it takes that look. The names are values you import from amane: Default, Pointer (a hand), Text, Grab, Grabbing, Move, NotAllowed, Wait, Crosshair, and the resize arrows ResizeTop, ResizeBottom, ResizeLeft, ResizeRight, ResizeTopLeft, ResizeTopRight, ResizeBottomLeft, ResizeBottomRight, ResizeHorizontal, and ResizeVertical.

Text the cursor and Text the widget share a name, and both come from amane. Rust tells them apart by how they’re used. If you’d rather be explicit, write Cursor::Text.

A TextInput shows the text cursor on its own.

Transformed rectangles

Input follows a rectangle’s transform. A rotated button only reacts inside its rotated shape, not inside the box it took up before rotating (Rectangle).

Keyboard input

Keys go to a window, not a widget. First the window has to ask for keyboard focus (Layer Windows), then it handles keys with on_key:

use std::process;

use amane::{App, Key, Keyboard, LayerWindow, Text};

fn main() {
    App::new().window(view).run();
}

fn view() -> LayerWindow {
    LayerWindow::new()
        .width(300.0)
        .height(40.0)
        .keyboard(Keyboard::Exclusive)
        .on_key(key_pressed)
        .child(Text::new("press escape to quit"))
}

fn key_pressed(key: Key) {
    if key == Key::Escape {
        process::exit(0);
    }
}

std::process is Rust’s standard library.

Key is one of:

  • Character(char): a key that types something, already shifted, like Character('A')
  • Enter, Escape, Tab, Backspace, Space
  • Up, Down, Left, Right, Home, End
  • Other: any key Amane has no name for yet

When a text input has focus

A focused TextInput takes keys before the window does, because typing should type:

  • Letters, Space, Backspace, Enter, Left, Right, Home, and End go to the input only.
  • Up, Down, Tab, and Other skip the input and go to the window’s on_key. That’s how arrow keys can move through a list while you type in a search box.
  • Escape takes focus away from the input, and then also goes to the window, which may want to close.

Terms

  • Handler: a function Amane calls when an event happens.
  • Target: the area a rectangle with handlers took up in the last frame.
  • Keyboard focus: the window the compositor sends keys to.

Animation

When a panel jumps from 100 pixels tall to 400 in one frame, it looks broken. It should grow over a fraction of a second. But a view only describes one frame, and it only runs when something changes. Animations solve both.

This page covers smooth motion: sliding panels, fading popups, and colors that blend into each other.

Growing a rectangle

use std::time::Duration;

use amane::{Animation, App, Button, LayerWindow, Parent, Rectangle, Service};

struct Panel {
    height: Animation,
}

impl Service for Panel {
    fn new() -> Self {
        Self { height: Animation::new(100.0) }
    }

    fn interval() -> Duration {
        Duration::from_secs(3600)
    }
}

fn main() {
    App::new().window(view).run();
}

fn view() -> LayerWindow {
    let panel = Panel::read();

    LayerWindow::new().width(300.0).height(400.0).child(
        Rectangle::new()
            .width(Parent)
            .height(panel.height.value())
            .fill("#89b4fa")
            .on_click(clicked),
    )
}

fn clicked(_: Button) {
    let mut panel = Panel::write();

    let target = if panel.height.value() < 250.0 { 400.0 } else { 100.0 };

    panel.height.to(target);
}

Click the rectangle and it grows smoothly to 400 pixels. Click again and it shrinks back.

How an animation keeps drawing

An Animation doesn’t store a changing number. It stores where it started, where it’s going, and when it started. value() works out the current number from the clock.

  1. to(400.0) sets a new target and notes the time. Because this happens inside write(), the window redraws.
  2. The view calls value(). The animation has just started, so it returns about 100.
  3. Calling value() on an unfinished animation also tells Amane “I’m still moving”. After drawing, Amane asks the compositor for the next frame.
  4. On the next frame, the view runs again, and value() returns a little more.
  5. Once the animation arrives, value() stops asking for frames, and the window rests.

So an animation only costs frames while it’s moving. A shell full of finished animations draws nothing.

Speed and easing

Animation::new(0.0)
    .duration(Duration::from_millis(400))
    .easing(Easing::InOut)
  • duration defaults to 200 milliseconds.
  • easing decides how the speed changes along the way:
EasingMotion
Outstarts fast, slows down as it arrives. This is the default, and feels right for most UI.
InOutstarts slow, speeds up, slows down again. Good for big movements.
Linearthe same speed the whole way

Changing direction halfway

If you call to while an animation is still moving, it starts again from wherever it is right now. It never jumps. So you can click a toggle quickly several times, and the panel just turns around smoothly.

Calling to with the target it already has does nothing.

Animating colors

Animation works with f32 (the default) and with Color:

struct Tab {
    fill: Animation<Color>,
}

// in new()
fill: Animation::new(Color::from("#1e1e2e")),

// in a hover handler
Tab::write().fill.to(Color::from("#45475a"));

// in the view
.fill(tab.fill.value())

To animate your own type, implement the Blend trait for it. blend(from, to, amount) returns the value amount of the way from from to to, where amount goes from 0 to 1.

Animating windows

Anything the view returns can come from an animation, including the window’s own size and margin. A panel that slides in from the top edge:

use std::time::Duration;

use amane::{
    Animation, App, Horizontal, Layer, LayerWindow, Margin, Parent, Rectangle, Service, Vertical,
};

struct Panel {
    open: bool,
    slide: Animation,
}

impl Service for Panel {
    fn new() -> Self {
        Self { open: true, slide: Animation::new(10.0).duration(Duration::from_millis(300)) }
    }

    fn interval() -> Duration {
        Duration::from_secs(3600)
    }
}

fn main() {
    App::new().ipc("toggle", toggle).window(view).run();
}

fn view() -> LayerWindow {
    let panel = Panel::read();

    let margin = Margin { top: panel.slide.value() as i32, right: 10, bottom: 0, left: 0 };

    LayerWindow::new()
        .width(300.0)
        .height(100.0)
        .anchor_vertical(Vertical::Top)
        .anchor_horizontal(Horizontal::Right)
        .layer(Layer::Top)
        .margin(margin)
        .child(Rectangle::new().width(Parent).height(Parent).fill("#89b4fa"))
}

fn toggle(_: &[String]) -> String {
    let mut panel = Panel::write();

    panel.open = !panel.open;

    let target = if panel.open { 10.0 } else { -110.0 };

    panel.slide.to(target);

    String::from("ok")
}

amane ipc call toggle slides the panel up past the screen edge (a negative margin), and back down the next time (IPC).

Motion you control yourself

Some motion never arrives, like a spinner or a pulsing dot. For that, compute the position from the time yourself, and call request_frame() to ask for another frame:

use std::sync::LazyLock;
use std::time::Instant;

use amane::{App, Full, LayerWindow, Rectangle};

static START: LazyLock<Instant> = LazyLock::new(Instant::now);

fn main() {
    App::new().window(view).run();
}

fn view() -> LayerWindow {
    let seconds = START.elapsed().as_secs_f32();

    let x = 170.0 + (seconds * 3.0).sin() * 150.0;

    // it never arrives, so it always wants the next frame
    amane::request_frame();

    LayerWindow::new().width(400.0).height(60.0).child(
        Rectangle::new()
            .width(60.0)
            .height(60.0)
            .radius(Full)
            .fill("#89b4fa")
            .translate(x, 0.0),
    )
}

LazyLock and Instant come from Rust’s standard library.

Stop calling request_frame() once the motion is done, so the window can rest. A view that always calls it redraws at the monitor’s refresh rate forever.

Terms

  • Easing: the curve that maps time passed to distance traveled.
  • Frame callback: the compositor’s signal that it’s ready for the next frame. Amane waits for it, so animations run at the monitor’s refresh rate.

Rectangle

Rectangle is the widget you’ll use most. It’s a box that can be painted, rounded, outlined, shadowed, and transformed, and it can hold one child. Buttons, cards, backgrounds, and icons are all rectangles.

A rounded rectangle

use amane::{App, LayerWindow, Rectangle};

fn main() {
    App::new().window(view).run();
}

fn view() -> LayerWindow {
    LayerWindow::new()
        .width(200.0)
        .height(100.0)
        .child(Rectangle::new().width(160.0).height(60.0).radius(12.0).fill("#89b4fa"))
}

width and height are required, and must come first, in that order. They take a number of pixels or Parent (Layout). Everything else is optional.

Fill

fill takes any of these:

FillExample
a hex string.fill("#1e1e2e"), also "#rgb" and "#rrggbbaa"
a Color.fill(Color::BLUE), .fill(Color::rgb(30, 30, 46)), .fill(Color::rgba(0, 0, 0, 128))
a Gradient.fill(Gradient::linear(90.0, [(0.0, "#89b4fa"), (1.0, "#f5c2e7")]))
an Image.fill(Image::cover("/path/to/wallpaper.png")), see Images
Mask.fill(Mask), a hole, see Masks

A rectangle with no fill is invisible, which is useful for spacing and for invisible click areas.

A mistyped hex string, like "#12345", shows bright pink instead of crashing your shell, so you can spot it.

Color has the constants BLACK, WHITE, RED, GREEN, BLUE, and TRANSPARENT. Color::rgb and Color::rgba are const, so you can keep your theme as constants:

const BACKGROUND: Color = Color::rgb(0x1e, 0x1e, 0x2e);
const FOREGROUND: Color = Color::rgb(0xcd, 0xd6, 0xf4);

Gradients

Gradient::linear(90.0, [(0.0, "#89b4fa"), (1.0, "#f5c2e7")])
Gradient::radial([(0.0, "#f9e2af"), (0.5, "#fab387"), (1.0, "#1e1e2e")])

Each stop is a position from 0 to 1 and a color. A linear gradient also takes an angle in degrees, the same way CSS does: 0.0 goes from bottom to top, 90.0 from left to right, and 180.0 from top to bottom. A radial gradient goes from the center out to the edges.

Corners and borders

.radius(12.0)            // rounded corners, 12 pixels
.radius(Full)            // a pill, or a circle if the rectangle is square
.border(2.0, "#cdd6f4")  // a 2 pixel outline along the inside edge

Full makes the radius half of the shorter side, so it stays a perfect pill however the rectangle is sized.

Opacity, shadow, and blur

.opacity(0.5)
.shadow(Shadow::drop("#000000").opacity(0.3).blur(12.0).offset(0.0, 4.0))
.shadow(Shadow::inner("#000000").opacity(0.3).blur(8.0))
.blur(20.0)
  • opacity fades the rectangle and everything inside it, from 0 (invisible) to 1.
  • Shadow::drop casts a shadow behind the rectangle. Shadow::inner shades along the inside edge, as if it were pressed in. blur softens it, and offset moves it.
  • blur blurs whatever was drawn behind the rectangle, inside its shape, for a frosted-glass look. It can only blur what your own window drew underneath. It can’t see other apps’ windows.

Masks

A rectangle filled with Mask paints nothing. Instead, it cuts its shape out of the nearest rectangle around it:

use amane::{App, Center, Full, LayerWindow, Mask, Parent, Rectangle};

fn main() {
    App::new().window(view).run();
}

fn view() -> LayerWindow {
    LayerWindow::new().width(200.0).height(120.0).child(
        Rectangle::new()
            .width(Parent)
            .height(Parent)
            .radius(24.0)
            .fill("#1e1e2e")
            .align_child(Center, Center)
            .child(Rectangle::new().width(60.0).height(60.0).radius(Full).fill(Mask)),
    )
}

The window gets a round hole in it, and you see your wallpaper through it. A mask can have a border, to outline the hole.

Holding a child

Rectangle::new()
    .width(Parent)
    .height(Parent)
    .padding(8.0)
    .align_child(Center, Center)
    .clip()
    .child(Text::new("hello"))
  • child puts one widget inside. To hold several, give it a Row, Column, or Stack.
  • padding and align_child place the child (Layout).
  • clip hides any part of the child that sticks out, following the rounded corners. Use it for scrolling lists and progress bars.

Transforms

.rotate(30.0)          // degrees, clockwise
.scale(0.6)            // 1.0 keeps the size
.translate(20.0, -30.0)

Rotation and scale happen around the rectangle’s center. Transforms move the drawing and the click area, but not the layout: the rectangle still takes up its original space in its row or column, and its neighbors don’t move. That makes them good for animation and for overlapping things in a Stack.

The child moves with its rectangle.

Input

on_click, on_hover, on_scroll, on_move, on_drag, and cursor are covered in Input.

Shaders

.shader("path/to/file.wgsl") draws a GPU shader over the fill. See Shaders.

Example: a button

Putting it together:

use amane::{App, Center, Color, LayerWindow, Pointer, Rectangle, Shadow, Text};

fn main() {
    App::new().window(view).run();
}

fn view() -> LayerWindow {
    LayerWindow::new().width(200.0).height(80.0).child(button("Save"))
}

fn button(label: &str) -> Rectangle {
    Rectangle::new()
        .width(120.0)
        .height(36.0)
        .radius(8.0)
        .fill("#89b4fa")
        .shadow(Shadow::drop("#000000").opacity(0.25).blur(8.0).offset(0.0, 2.0))
        .align_child(Center, Center)
        .cursor(Pointer)
        .on_click(|_| println!("saved"))
        .child(Text::new(label).color(Color::from("#1e1e2e")))
}

Text

Text draws one or more lines of text. This page covers size, color, fonts, weight, and what happens when text doesn’t fit.

Drawing text

use amane::{App, Color, LayerWindow, Text};

fn main() {
    App::new().window(view).run();
}

fn view() -> LayerWindow {
    LayerWindow::new()
        .width(300.0)
        .height(40.0)
        .child(Text::new("hello").size(20.0).color(Color::WHITE))
}

Text::new takes a &str, a String, or anything else that turns into a String, so format! works directly:

Text::new(format!("battery {}%", battery.percent()))

The defaults are size 16, black, regular weight, and your system’s sans-serif font.

Color

Text::new("hello").color(Color::WHITE)
Text::new("hello").color(Color::from("#cdd6f4"))

color takes a Color. Unlike Rectangle::fill, it doesn’t take a hex string directly, so wrap hex strings in Color::from.

Fonts

Fonts are found through fontconfig, so any font installed on your system works, by its family name:

App::new().font("Inter").window(view).run()   // the default for all text
Text::new("12:00").font("JetBrains Mono")      // just this text

If a letter isn’t in the font, like Japanese text in a Latin font, Amane looks for it in Noto Sans, then in Noto Sans CJK JP. A letter no installed font has is skipped.

Weight

Text::new("bold").weight(Weight::Bold)
Text::new("light").weight(300)

Weight has Thin, ExtraLight, Light, Regular, Medium, SemiBold, Bold, ExtraBold, and Black. A number from 100 to 900 picks the closest one, like in CSS. The font has to have that weight installed. Most font families come in several separate files, one per weight.

Long text: eliding and wrapping

By default, text is one line, and it’s exactly as wide as its letters. In a Row, it takes the space it needs, and Parent-sized neighbors get the rest.

When the text is longer than the room it’s given, it runs past the edge. Two options change that:

Text::new(long).elide()                     // one line, cut off with "…"
Text::new(long).wrap()                      // as many lines as it needs
Text::new(long).wrap().max_lines(2).elide() // up to two lines, then "…"

With wrap or elide, the text’s width becomes Parent: it takes all the width it’s given, and fits itself into it. So put it somewhere with a set width, like a fixed-width rectangle or a Parent-sized section of a row.

Height works like this:

RulesHeight
neitherone line
wrap with max_lines(n)n lines
wrap aloneParent, because the number of lines depends on the width, which isn’t known until layout is done

When you need the height of wrapped text yourself, for example to size a notification card around it, ask for it with a width:

let body = Text::new(notification.body()).wrap();

let height = body.height_in(360.0);

Icon fonts

Text is normally measured by the space its letters take when typed, including some room on each side. For icon fonts, that extra room makes icons look a little off-center. tight measures the letters’ actual shapes instead:

Text::new("\u{f240}").font("Symbols Nerd Font").size(18.0).tight()

Example

use amane::{App, Column, LayerWindow, Text, Weight, children};

fn main() {
    App::new().window(view).run();
}

fn view() -> LayerWindow {
    let long = "the quick brown fox jumps over the lazy dog, then keeps running far past the edge";

    LayerWindow::new()
        .width(260.0)
        .height(400.0)
        .child(Column::new(children![
            Text::new("regular"),
            Text::new("bold").weight(Weight::Bold),
            Text::new("light, picked by number").weight(300),
            Text::new(long).elide(),
            Text::new(long).wrap().max_lines(2).elide(),
            Text::new(long).wrap(),
        ]))
}

Text Input

TextInput is a one-line box you can type in: a search field, a password field, a command prompt.

A text field

use amane::{App, Keyboard, LayerWindow, Rectangle, TextInput};

fn main() {
    App::new().window(view).run();
}

fn view() -> LayerWindow {
    LayerWindow::new()
        .width(300.0)
        .height(40.0)
        .keyboard(Keyboard::OnDemand)
        .child(
            Rectangle::new()
                .width(300.0)
                .height(40.0)
                .fill("#ffffff")
                .padding(8.0)
                .child(
                    TextInput::new("name")
                        .placeholder("your name")
                        .on_submit(|text| println!("hello, {text}")),
                ),
        )
}

Click the box, type, and press Enter.

Focus and stored text

  • The window asks for keyboard focus with .keyboard(Keyboard::OnDemand). Without it, the compositor never sends the window any keys, and the input can’t be typed in (Layer Windows).
  • Clicking the input gives it focus. From then on, typed keys go to it.
  • The view is rebuilt on every frame, so the input can’t keep its own text. Amane keeps it for you, under the name you passed to TextInput::new, here "name". Each input needs its own name.

Options

TextInput::new("search")
    .size(18.0)                       // text size, 16 by default
    .color(Color::WHITE)              // text color, black by default
    .width(240.0)                     // Parent by default
    .placeholder("search apps")       // shown while it's empty
    .password()                       // shows dots instead of the text
    .focused()                        // takes the keys as soon as it's drawn, no click needed
    .on_change(|text| ...)            // runs after every change, with the whole new text
    .on_submit(|text| ...)            // runs when Enter is pressed

focused is what you want for a launcher: open it, start typing. Pair it with Keyboard::Exclusive on the window.

Text that’s longer than the input is cut off at its edge.

Reading what was typed

The input owns its text. To use the text anywhere else, copy it into a Service in on_change:

use std::time::Duration;

use amane::{App, Column, Keyboard, LayerWindow, Service, Text, TextInput, children};

struct Search {
    query: String,
}

impl Service for Search {
    fn new() -> Self {
        Self { query: String::new() }
    }

    fn interval() -> Duration {
        Duration::from_secs(3600)
    }
}

fn main() {
    App::new().window(view).run();
}

fn view() -> LayerWindow {
    let search = Search::read();

    LayerWindow::new()
        .width(300.0)
        .height(80.0)
        .keyboard(Keyboard::Exclusive)
        .child(Column::new(children![
            TextInput::new("search")
                .focused()
                .on_change(|text| Search::write().query = text),
            Text::new(format!("searching for: {}", search.query)),
        ]))
}

Setting the text

TextInput::set_text replaces what an input holds, by name, and puts the cursor at the end:

TextInput::set_text("search", "");          // clear it, for example after Enter
TextInput::set_text("search", "firefox");   // fill it in

Enter doesn’t clear the input on its own. Call set_text with an empty string in on_submit if you want that.

Keys

While an input has focus:

  • Letters, Space, Backspace, Left, Right, Home, and End edit the text.
  • Enter calls on_submit.
  • Escape takes focus away, and then also reaches the window’s on_key.
  • Up, Down, and Tab skip the input and go straight to the window’s on_key, so a list under a search box can be moved through with the arrow keys.

Holding a key down doesn’t repeat it yet.

Password fields

TextInput::new("password")
    .placeholder("password")
    .password()
    .on_submit(|password| Lock::unlock(&password))

password only changes what’s drawn. The text passed to on_change and on_submit is the real text. See Lock Screen for a full example.

Scroll Area

ScrollArea shows part of a widget that’s too tall for its space, and scrolls it with the mouse wheel or touchpad.

A scrolling list

use amane::{App, Column, LayerWindow, Rectangle, ScrollArea, Text, Widget};

fn main() {
    App::new().window(view).run();
}

fn view() -> LayerWindow {
    let mut rows: Vec<Box<dyn Widget>> = Vec::new();

    for number in 1..=50 {
        rows.push(Box::new(Text::new(format!("item {number}"))));
    }

    LayerWindow::new().width(300.0).height(400.0).child(
        Rectangle::new()
            .width(300.0)
            .height(400.0)
            .fill("#ffffff")
            .child(ScrollArea::new("items", Column::new(rows))),
    )
}

How scrolling works

  • A ScrollArea is Parent-sized by default, so it takes all the room its parent gives it. Here, that’s 300 by 400 pixels.
  • Its child, the column, is as tall as its 50 rows. Only the part that fits inside the area is shown.
  • The view is rebuilt on every frame, so the scroll area can’t remember how far it’s scrolled. Amane keeps the position for you under the name you pass, here "items". Give each scroll area its own name. Two areas with the same name scroll together.

Options

ScrollArea::new("apps", Column::new(rows))
    .width(300.0)
    .height(Parent)

width and height default to Parent.

Scrolling is vertical only.

Rounded corners

Put the scroll area inside a rounded rectangle with clip, so rows scrolling past the corners are cut off along the curve:

Rectangle::new()
    .width(300.0)
    .height(400.0)
    .radius(24.0)
    .fill(Color::WHITE)
    .clip()
    .child(ScrollArea::new("items", Column::new(rows)))

Rows that take input

Rows inside a scroll area take clicks like anywhere else. Rows scrolled out of view can’t be clicked.

A row with its own on_scroll, like a volume slider, takes the scroll while the pointer is over it, and the area doesn’t move.

Canvas and Shapes

Canvas draws shapes that rectangles can’t: progress rings, gauges, graphs, and custom icons.

A progress ring

A progress ring at 70%:

use amane::{App, Arc, Canvas, Cap, LayerWindow, Shape, shapes};

fn main() {
    App::new().window(view).run();
}

fn view() -> LayerWindow {
    LayerWindow::new().width(120.0).height(120.0).child(ring(0.7))
}

fn ring(value: f32) -> Canvas {
    Canvas::new().width(120.0).height(120.0).shapes(shapes![
        Arc::new().stroke(10.0, "#45475a"),
        Arc::new().sweep(360.0 * value).stroke(10.0, "#89b4fa").cap(Cap::Round),
    ])
}

Coordinates and drawing order

  • A Canvas is a widget with a size, like a rectangle. Inside it, shapes are placed in the canvas’s own coordinates: (0, 0) is its top-left corner, and x and y grow right and down.
  • shapes! makes the list of shapes, like children! does for widgets. Later shapes are drawn over earlier ones, so the blue arc sits on top of the gray track.
  • An Arc with no center and no radius sits in the middle of the canvas, as big as fits, and pulls in by half its line width so the line stays inside.
  • stroke, fill, cap, and opacity come from the Shape trait, so Shape has to be in your use line, or Rust can’t find these methods.

Shapes

ShapeBuilderDefaults
Circle.center(x, y), .radius(r)middle of the canvas, as big as fits
Arc.center(x, y), .radius(r), .start(degrees), .sweep(degrees)middle of the canvas, as big as fits, a full turn
Line.from(x, y), .to(x, y)from (0, 0) to (0, 0)
Path.move_to, .line_to, .quad_to, .cubic_to, .arc, .closeempty

Arc angles are in degrees. 0 points straight up, and angles grow clockwise. A negative sweep goes counterclockwise. Path::arc uses the same angles.

Fill, stroke, and caps

Every shape gets the same four methods:

.fill("#89b4fa")          // paints the inside
.stroke(4.0, "#cdd6f4")   // draws the outline, 4 pixels thick
.cap(Cap::Round)          // how open line ends look: Butt (the default), Round, or Square
.opacity(0.5)

A new shape draws nothing until it gets a fill or a stroke. It can have both.

Paths and graphs

A Path is a pen you move around. Each call adds to the same path:

Path::new()
    .move_to(10.0, 50.0)                      // lift the pen and put it here
    .line_to(50.0, 10.0)                      // a straight line
    .quad_to(70.0, 0.0, 90.0, 10.0)           // a curve with one handle
    .cubic_to(100.0, 30.0, 100.0, 70.0, 90.0, 90.0)  // a curve with two handles
    .close()                                  // a straight line back to the last move_to
    .stroke(2.0, "#cdd6f4")

Paths are good for graphs. Build them in a loop:

use amane::{App, Canvas, Cap, LayerWindow, Path, Shape, shapes};

fn main() {
    App::new().window(view).run();
}

fn view() -> LayerWindow {
    let history = [0.2, 0.5, 0.4, 0.8, 0.6, 0.9, 0.3];

    LayerWindow::new().width(200.0).height(60.0).child(graph(&history))
}

fn graph(history: &[f32]) -> Canvas {
    let width = 200.0;
    let height = 60.0;

    let step = width / (history.len() - 1) as f32;

    let mut line = Path::new().move_to(0.0, height * (1.0 - history[0]));

    for (index, value) in history.iter().enumerate().skip(1) {
        line = line.line_to(index as f32 * step, height * (1.0 - value));
    }

    Canvas::new()
        .width(width)
        .height(height)
        .shapes(shapes![line.stroke(2.0, "#a6e3a1").cap(Cap::Round)])
}

y grows downward, so a value of 1 is drawn at y = 0, the top.

Example: a gauge

Half a circle, with a needle:

use amane::{App, Arc, Canvas, Cap, Circle, LayerWindow, Line, Shape, shapes};

fn main() {
    App::new().window(view).run();
}

fn view() -> LayerWindow {
    LayerWindow::new().width(120.0).height(80.0).child(gauge(0.4))
}

fn gauge(value: f32) -> Canvas {
    let angle = f32::to_radians(-90.0 + 180.0 * value);

    let needle_x = 60.0 + f32::sin(angle) * 36.0;
    let needle_y = 70.0 - f32::cos(angle) * 36.0;

    Canvas::new().width(120.0).height(80.0).shapes(shapes![
        Arc::new()
            .center(60.0, 70.0)
            .radius(48.0)
            .start(-90.0)
            .sweep(180.0)
            .stroke(8.0, "#45475a")
            .cap(Cap::Round),
        Arc::new()
            .center(60.0, 70.0)
            .radius(48.0)
            .start(-90.0)
            .sweep(180.0 * value)
            .stroke(8.0, "#f9e2af")
            .cap(Cap::Round),
        Line::new().from(60.0, 70.0).to(needle_x, needle_y).stroke(3.0, "#cdd6f4").cap(Cap::Round),
        Circle::new().center(60.0, 70.0).radius(6.0).fill("#cdd6f4"),
    ])
}

When to use a canvas

  • For boxes, pills, circles, and anything with a shadow, use Rectangle. It’s simpler, and it can hold children and take input.
  • For arcs, lines, curves, and graphs, use Canvas.

A canvas can’t hold children or take input on its own. To make one clickable, put it inside a rectangle that has the handler.

Images

Images are drawn as a rectangle’s fill. This page covers fitting them, loading them, and keeping big ones cheap.

An image fill

use amane::{App, Image, LayerWindow, Rectangle};

fn main() {
    App::new().window(view).run();
}

fn view() -> LayerWindow {
    LayerWindow::new().width(300.0).height(200.0).child(
        Rectangle::new()
            .width(300.0)
            .height(200.0)
            .radius(16.0)
            .fill(Image::cover("/usr/share/backgrounds/default.png")),
    )
}

The image fills the rectangle, follows its rounded corners, and works with border, shadow, and opacity like any other fill.

Fitting an image

FitEffect
Image::cover(path)fills the whole rectangle, cutting off what spills past the edges
Image::contain(path)shows the whole image, leaving the rest of the rectangle empty
Image::stretch(path)fills the rectangle exactly, squashing the image to match

cover is right for wallpapers and album art. contain is right for icons.

Formats and paths

  • PNG and JPEG are recognized by their contents, whatever the file name says. SVG is recognized by the .svg extension.
  • Use absolute paths. A relative path is relative to the folder the shell was started from, which depends on how it was started. ~ isn’t expanded.

How images load

Decoding a big image takes long enough to drop frames, so Amane decodes on a separate thread:

  1. The first time a view asks for a file, the rectangle is drawn without the image, and decoding starts.
  2. When decoding finishes, the windows waiting for that image redraw, now with the image.
  3. After that, the decoded image is kept in memory, keyed by its path, and appears instantly.

A file that can’t be read or decoded never appears, and doesn’t crash the shell.

Because images are kept by path, changing a file on disk doesn’t change what’s shown. To show a new wallpaper, use a new path.

To show something else while an image loads, ask whether it’s ready:

// players give a file:// link, and Image wants a plain path
let art = media.art_url().strip_prefix("file://").unwrap_or("");

let cover = if Image::loaded(art) {
    Rectangle::new().width(64.0).height(64.0).fill(Image::cover(art))
} else {
    Rectangle::new().width(64.0).height(64.0).fill("#313244")
};

Image::loaded also starts the decode if nothing asked for it yet.

Thumbnails

A 4K wallpaper shown as a 200-pixel preview still keeps all 8 million pixels in memory. thumbnail keeps a small copy instead:

Image::cover(path).thumbnail(200, 120)

The copy is just big enough to cover 200 by 120 pixels. The full-size image is dropped right after shrinking. Use it for wallpaper pickers, icon grids, and anything else that shows many images at once.

Blurred backgrounds

Image::cover(wallpaper).thumbnail(64, 36).blurred(4)

blurred blurs the decoded copy once, when it’s loaded, so it costs nothing per frame. Blurring a tiny thumbnail and stretching it to full screen is a cheap way to get a smooth, frosted background, for example for a lock screen.

The radius is counted in the copy’s own pixels, so with a small thumbnail, small numbers already blur a lot.

App icons

The Apps Service finds each program’s icon file for you:

let icon = Rectangle::new().width(24.0).height(24.0);

let icon = match app.icon_path() {
    Some(path) => icon.fill(Image::contain(path)),
    None => icon,
};

Shaders

A shader is a small program that runs on the GPU and works out the color of every pixel in a rectangle. Use one for effects no built-in fill can do: animated backgrounds, glows, noise, and custom gradients.

An animated shader

~/.config/amane/waves.wgsl:

@fragment
fn main(@location(0) uv: vec2<f32>) -> @location(0) vec4<f32> {
    let wave = sin(uv.x * 12.0 + time * 2.0) * 0.5 + 0.5;

    return vec4<f32>(uv.x, wave, 1.0 - uv.y, 1.0);
}

main.rs:

use amane::{App, LayerWindow, Rectangle};

fn main() {
    App::new().window(view).run();
}

fn view() -> LayerWindow {
    LayerWindow::new().width(200.0).height(200.0).child(
        Rectangle::new()
            .width(200.0)
            .height(200.0)
            .radius(24.0)
            .shader("/home/you/.config/amane/waves.wgsl"),
    )
}

The rectangle shows moving colored waves, cut to its rounded corners.

Shader inputs

The GPU runs main once for every pixel of the rectangle, all at the same time. Each run gets that pixel’s position and returns its color. Amane gives every shader these inputs:

InputTypeMeaning
uvvec2<f32>where the pixel is, from (0, 0) at the top left to (1, 1) at the bottom right
sizevec2<f32>the rectangle’s size in pixels
timef32seconds since the first shader was drawn
valuesarray<vec4<f32>, 16>numbers you pass from Rust, see Passing values from Rust

The color you return is red, green, blue, and alpha, each from 0 to 1.

The shader is drawn over the rectangle’s fill, inside its shape. The rectangle’s radius, border, opacity, and transforms all still apply.

Animated shaders and frames

Amane checks whether your shader’s source mentions the word time. If it does, the window draws a new frame continuously while the shader is on screen, so the animation runs. If it doesn’t, the shader is drawn once, and only redrawn when something else changes.

Any time counts, even in a comment. Leave the word out of shaders that don’t animate, so they don’t keep your GPU busy.

Passing values from Rust

shader_values passes up to 16 rows of 4 numbers from your view to the shader, as values. Rows you don’t pass are 0.

Rectangle::new()
    .width(420.0)
    .height(200.0)
    .shader("/home/you/.config/amane/spots.wgsl")
    .shader_values(vec![[2.0, 0.0, 0.0, 0.0], [60.0, 100.0, 30.0, 0.0], [200.0, 80.0, 50.0, 0.0]])
// values[0].x is how many spots, then one row per spot: x, y, radius
@fragment
fn main(@location(0) uv: vec2<f32>) -> @location(0) vec4<f32> {
    let point = uv * size;

    let count = u32(values[0].x);

    var light = 0.0;

    for (var index = 1u; index <= count; index++) {
        let spot = values[index];

        let distance = length(point - spot.xy);

        light = max(light, 1.0 - smoothstep(spot.z - 1.0, spot.z + 1.0, distance));
    }

    return vec4<f32>(0.54, 0.71, 0.98, light);
}

The values come from the view, so they can come from a Service or an Animation, like an audio level or a hover position. More than 16 rows panics.

GLSL

Files ending in .glsl or .frag are read as GLSL. Amane adds the #version line and the inputs for you. Write the result to color:

void main() {
    vec2 pixel = uv * size;
    vec2 center = size / 2.0;

    float rings = sin(distance(pixel, center) * 0.15 - time * 3.0) * 0.5 + 0.5;

    color = vec4(rings, 0.3, 1.0 - rings, 1.0);
}

values is available in GLSL too, as vec4 values[16].

Any other extension is read as WGSL.

Limitations

  • The entry point must be called main.
  • A missing shader file stops the shell. A shader is loaded when it’s first drawn, and if the file can’t be read, the shell exits with an error. A shader that doesn’t compile doesn’t stop the shell. It prints amane: gpu error: ... to the terminal instead, and the effect won’t draw correctly. Test new shaders with amane dev, where you can see that output.
  • A shader file is read once. Editing it while the shell runs doesn’t change it. amane dev doesn’t watch shader files either, unless they’re inside src/, so restart the shell to see changes.
  • Use absolute paths, for the same reason as images.

Multiple Windows and Monitors

A shell is rarely one window. This page covers several layer windows, one window per monitor, normal app-style windows, and opening and closing windows while the shell runs.

Several windows

Call .window once for each window:

use amane::{App, Full, Horizontal, LayerWindow, Parent, Rectangle, Vertical};

fn main() {
    App::new().window(bar).window(corner).run();
}

fn bar() -> LayerWindow {
    LayerWindow::new()
        .width(Full)
        .height(30.0)
        .anchor_vertical(Vertical::Top)
        .child(Rectangle::new().width(Parent).height(Parent).fill("#1e1e2e"))
}

fn corner() -> LayerWindow {
    LayerWindow::new()
        .width(200.0)
        .height(60.0)
        .anchor_vertical(Vertical::Bottom)
        .anchor_horizontal(Horizontal::Right)
        .child(Rectangle::new().width(Parent).height(Parent).fill("#313244"))
}

Each window has its own view, and each one only redraws for the Services its own view reads. Windows added with .window go on whichever monitor the compositor picks, usually the focused one.

One window per monitor

A bar should be on every monitor, including ones you plug in later:

use amane::{App, Color, Full, Layer, LayerWindow, Monitor, Parent, Rectangle, Text, Vertical};

fn main() {
    App::new().window_per_monitor(bar).run();
}

fn bar(monitor: &Monitor) -> LayerWindow {
    let label = format!("{} {}x{}", monitor.name, monitor.width, monitor.height);

    LayerWindow::new()
        .width(Full)
        .height(30.0)
        .anchor_vertical(Vertical::Top)
        .layer(Layer::Top)
        .child(
            Rectangle::new()
                .width(Parent)
                .height(Parent)
                .fill(Color::BLUE)
                .child(Text::new(label).color(Color::WHITE)),
        )
}

The view takes a &Monitor, with the monitor’s name (like "DP-1"), width, and height. Amane opens one window for each monitor when it starts, opens a new one when a monitor is plugged in, and closes it when the monitor is unplugged.

Use the monitor to show different things on different screens:

if monitor.name == "eDP-1" {
    // the laptop screen
}

Normal windows

A normal window is a regular app window, with a title bar, that the compositor places and tiles like any other app. Use one for a settings panel, or anything you’d want to move around:

use amane::{App, Center, Color, Parent, Rectangle, Text, Window};

fn main() {
    App::new().normal_window("settings", settings).run();
}

fn settings() -> Window {
    Window::new()
        .title("amane settings")
        .size(480.0, 320.0)
        .resizable(true)
        .child(
            Rectangle::new()
                .width(Parent)
                .height(Parent)
                .fill("#313244")
                .align_child(Center, Center)
                .child(Text::new("settings go here").color(Color::WHITE)),
        )
}
  • title defaults to "amane".
  • size is the size it opens at, 640 by 480 by default. The compositor may give it another size, and a tiling compositor usually does.
  • resizable lets the user resize it. It’s false by default.
  • on_key works the same as on a layer window. A normal window gets keyboard focus like any app, so it needs no Keyboard setting.

The name, "settings" here, tells windows apart. It’s used to close the window later.

Opening and closing windows later

Windows given to App open when the shell starts. To open one later, for example from a button, call open_window with a name and a view:

use amane::{App, Full, LayerWindow, Parent, Pointer, Rectangle, Window, close_window, open_window};

fn main() {
    App::new().window(bar).run();
}

fn bar() -> LayerWindow {
    LayerWindow::new().width(Full).height(30.0).child(
        Rectangle::new()
            .width(Parent)
            .height(Parent)
            .fill("#1e1e2e")
            .cursor(Pointer)
            .on_click(|_| open_window("settings", settings)),
    )
}

fn settings() -> Window {
    Window::new().title("settings").child(
        Rectangle::new()
            .width(120.0)
            .height(40.0)
            .fill("#f38ba8")
            .on_click(|_| close_window("settings")),
    )
}
  • open_window(name, view) opens a normal window. If a window with that name is already open, it does nothing, so clicking the button twice doesn’t open two.
  • close_window(name) closes it. Closing a window that isn’t open does nothing.
  • The user can also close a normal window from its title bar or with their compositor’s keybind.

Both work from anywhere: input handlers, IPC handlers, or a Service’s thread.

open_window only opens normal windows. To show and hide a layer window, like a popup or a launcher, keep it open and use .visible(...) (Layer Windows).

The real window size

A compositor can give a window another size than it asked for. Inside a view, window_size() gives the size the window really has, in pixels:

let (width, height) = window_size();

It’s (0.0, 0.0) until the compositor has said, which can happen for the very first frame.

Lock Screen

Amane can lock your session with a lock screen you design yourself, and check the password through PAM, like your login screen does.

Your compositor must support the ext-session-lock protocol. niri, Hyprland, and Sway do.

A lock screen

use amane::{
    App, Center, Color, Column, LayerWindow, Lock, Monitor, Parent, Rectangle, Service, Text,
    TextInput, children,
};

fn main() {
    App::new().lock(view).run();
}

fn view(_: &Monitor) -> LayerWindow {
    let lock = Lock::read();

    let status = if lock.checking() {
        "checking..."
    } else if lock.failed() {
        "wrong password"
    } else {
        "locked"
    };

    // the compositor sizes lock screens to the monitor, so this size is never used
    LayerWindow::new().width(1.0).height(1.0).child(
        Rectangle::new()
            .width(Parent)
            .height(Parent)
            .fill("#1e1e2e")
            .align_child(Center, Center)
            .child(
                Column::new(children![
                    Text::new(status).size(24.0).color(Color::WHITE),
                    Rectangle::new()
                        .width(300.0)
                        .height(40.0)
                        .fill(Color::WHITE)
                        .padding(8.0)
                        .child(
                            TextInput::new("password")
                                .placeholder("password")
                                .password()
                                .focused()
                                .on_submit(|password| Lock::unlock(&password)),
                        ),
                ])
                .gap(12.0),
            ),
    )
}

This sets up the lock screen, but doesn’t lock anything yet. Locking the session shows how.

How locking works

  • App::lock takes a view that’s shown on every monitor while the session is locked. Like window_per_monitor, it gets the &Monitor it’s on.
  • While the session is locked, the compositor shows only the lock screen, sends it all keys, and keeps every other window hidden. The lock screen’s size, anchors, and layer are ignored. It always covers the whole monitor.
  • Lock::unlock(password) checks the password through PAM (with the login service, as your user). That can take a few seconds, so it runs on its own thread. If the password is right, the session unlocks. If not, the screen stays locked.
  • The Lock Service tells the view what’s happening, through checking() and failed().

Locking the session

Lock::start() locks the session. Call it from anywhere: a button, an IPC handler, or a Service’s thread. Calling it while already locked does nothing.

The usual setup is an IPC handler, so a keybind or an idle daemon can lock:

fn main() {
    App::new()
        .ipc("lock", |_| {
            Lock::start();

            String::from("locked")
        })
        .window(bar)
        .lock(lock_screen)
        .run();
}

Then lock from a keybind, or from swayidle or hypridle:

amane ipc call lock

For niri:

binds {
    Mod+Alt+L { spawn "amane" "ipc" "call" "lock"; }
}

Lock reference

FunctionMeaning
Lock::read().checking()a password is being checked right now
Lock::read().failed()the last password was wrong
Lock::start()locks the session
Lock::unlock(password)checks a password, and unlocks if it’s right

Only one password is checked at a time. Calling unlock while a check is running does nothing. Each new lock starts clean, without the last lock’s failed state.

Testing safely

A locked session only opens with the right password. If your lock screen has a bug, like an input that doesn’t take keys, you can’t get back in from that session.

Killing the shell doesn’t help either. If the program that locked the session exits without unlocking, the compositor keeps the session locked. That’s on purpose: otherwise, crashing the lock screen would be a way past it.

Before using a new lock screen for real:

  • Save your work in other apps.
  • Switch to another TTY (Ctrl+Alt+F3), log in there, and keep it open. If you get stuck, switch to it and end the locked session with loginctl terminate-session <id> (loginctl list-sessions shows the id). This closes every app in that session.
  • Try a wrong password first, then the right one.

A blurred wallpaper background

Rectangle::new()
    .width(Parent)
    .height(Parent)
    .fill(Image::cover("/home/you/Pictures/wall.jpg").thumbnail(64, 36).blurred(4))

See Images for why the small thumbnail makes this cheap.

System: Battery, CPU, Memory, Brightness

Amane comes with Services for the system data most bars show. You use them like your own Services (State and Services): read() in a view, and the window redraws when the value changes. Each one starts the first time you read it.

All four at once

use amane::{App, Battery, Brightness, Cpu, Full, LayerWindow, Memory, Service, Text};

fn main() {
    App::new().window(view).run();
}

fn view() -> LayerWindow {
    let battery = Battery::read();
    let cpu = Cpu::read();
    let memory = Memory::read();
    let brightness = Brightness::read();

    let label = format!(
        "battery {}%   cpu {}%   memory {}%   brightness {}%",
        battery.percent(),
        cpu.percent(),
        memory.percent(),
        brightness.percent()
    );

    LayerWindow::new().width(Full).height(30.0).child(Text::new(label))
}

Service has to be in the use line, because read() comes from the Service trait.

Battery

Read from the kernel (/sys/class/power_supply), every 5 seconds.

FunctionGives
present()false on a machine without a battery
percent()charge, 0 to 100
charging()true while charging
full()plugged in and not charging, usually because it’s full

On a desktop, present() is false and everything else reads 0. Hide the battery widget there:

if battery.present() {
    // show the battery
}

CPU

Read from the kernel (/proc/stat), every 2 seconds.

FunctionGives
percent()usage across all cores, 0 to 100

CPU usage only exists as a difference between two readings. The very first reading is the average since the machine booted, and from 2 seconds on it shows current usage.

Memory

Read from the kernel (/proc/meminfo), every 2 seconds.

FunctionGives
percent()memory in use, 0 to 100
used_kib()memory in use, in kibibytes
total_kib()all memory, in kibibytes

“In use” means what programs hold. Cache the kernel can free right away doesn’t count, the same way free reports it.

Brightness

Reads the screen backlight from the kernel (/sys/class/backlight).

FunctionGives
present()false on a monitor without a backlight, like most desktop monitors
percent()brightness, 0 to 100
Brightness::set(percent)sets the brightness

set goes through logind, which lets the user sitting at the machine change the backlight without root and without a password. It never goes below 1%, because a black screen is hard to undo when you can’t see it.

Scroll over a widget to change the brightness:

use amane::{App, Brightness, LayerWindow, Parent, Rectangle, Scroll, Service, Text};

fn main() {
    App::new().window(view).run();
}

fn view() -> LayerWindow {
    let brightness = Brightness::read();

    LayerWindow::new().width(200.0).height(30.0).child(
        Rectangle::new()
            .width(Parent)
            .height(Parent)
            .on_scroll(scrolled)
            .child(Text::new(format!("brightness {}%", brightness.percent()))),
    )
}

// each wheel step moves the brightness by 5
fn scrolled(scroll: Scroll) {
    let current = i32::from(Brightness::read().percent());

    let step = if scroll.y < 0.0 { 5 } else { -5 };

    let changed = (current + step).clamp(1, 100);

    Brightness::set(changed as u8);
}

set runs on a background thread, so a slow answer never freezes the shell.

Audio

Audio reads and controls the volume of your default speaker and microphone. It talks to PulseAudio, which also covers PipeWire through pipewire-pulse, the default on most distributions.

It doesn’t poll. The sound server announces every change, so the bar updates as soon as you press a volume key.

A volume widget

Scroll to change the volume, click to mute:

use amane::{App, Audio, Button, LayerWindow, Parent, Rectangle, Scroll, Service, Text};

fn main() {
    App::new().window(view).run();
}

fn view() -> LayerWindow {
    let audio = Audio::read();

    let label = if audio.muted() {
        String::from("muted")
    } else {
        format!("volume {}%", audio.volume())
    };

    LayerWindow::new().width(200.0).height(30.0).child(
        Rectangle::new()
            .width(Parent)
            .height(Parent)
            .on_click(clicked)
            .on_scroll(scrolled)
            .child(Text::new(label)),
    )
}

fn clicked(_: Button) {
    Audio::toggle_mute();
}

// each wheel step moves the volume by 5
fn scrolled(scroll: Scroll) {
    let volume = i32::from(Audio::read().volume());

    let step = if scroll.y < 0.0 { 5 } else { -5 };

    let changed = (volume + step).clamp(0, 100);

    Audio::set_volume(changed as u8);
}

Reference

FunctionGives or does
volume()speaker volume, 0 to 100
muted()true when the speaker is muted
microphone_volume()microphone volume, 0 to 100
microphone_muted()true when the microphone is muted
Audio::set_volume(volume)sets the speaker volume, 0 to 100
Audio::toggle_mute()mutes or unmutes the speaker
Audio::set_microphone_volume(volume)sets the microphone volume, 0 to 100
Audio::toggle_microphone_mute()mutes or unmutes the microphone

“Speaker” and “microphone” mean your default output and input devices. When you switch the default, for example by plugging in headphones, Audio follows.

The control functions run on a background thread, so a slow sound server never freezes the shell. The new value shows up when the sound server announces it, usually right away.

Network

Network shows the current connection and lists Wi-Fi networks, and it can join, leave, and scan. It talks to NetworkManager over D-Bus, so NetworkManager has to be running. Without it, everything reads as offline.

It polls once a second, because Wi-Fi strength changes all the time without anything announcing it. A poll that finds nothing new doesn’t redraw anything.

A connection label

use amane::{App, Full, LayerWindow, Link, Network, Service, Text};

fn main() {
    App::new().window(view).run();
}

fn view() -> LayerWindow {
    let network = Network::read();

    let label = match network.link() {
        Link::Offline => String::from("offline"),
        Link::Wired => String::from("wired"),
        Link::Wifi => format!("{}  {}%", network.ssid(), network.strength()),
        Link::Other => String::from("connected"),
    };

    LayerWindow::new().width(Full).height(30.0).child(Text::new(label))
}

Link::Other covers connections that aren’t wired or Wi-Fi, like a VPN or a phone’s USB tethering.

Reading the connection

FunctionGives
connected()true when there’s any connection
link()Link::Offline, Wired, Wifi, or Other
ssid()the Wi-Fi network’s name, empty on a wired link
strength()Wi-Fi signal, 0 to 100, and 0 on a wired link
wifi_enabled()false when the Wi-Fi radio is off
connecting()true while joining a network
access_points()the Wi-Fi networks in range, empty with no Wi-Fi device or with the radio off

Each AccessPoint in access_points() has:

FunctionGives
ssid()the network’s name
strength()signal, 0 to 100
secured()true when it needs a password
active()true for the network you’re on
saved()true when NetworkManager already has a profile for it

Joining and leaving networks

FunctionDoes
Network::scan()looks for networks. New ones show up in access_points() a few seconds later.
Network::connect(ssid, password)joins a network
Network::disconnect()leaves the current Wi-Fi network
Network::set_wifi(enabled)turns the Wi-Fi radio on or off

All of them run on a background thread and return right away. The result shows up in the next poll.

connect takes the password as an Option:

  • For a saved network, pass None. NetworkManager already has the password.
  • For a new secured network, pass Some(password). NetworkManager saves a new profile with it, so next time it’s saved.
  • For a new open network, pass None.

A network list

use amane::{App, Column, LayerWindow, Network, Parent, Rectangle, Service, Text, Widget};

fn main() {
    App::new().window(view).run();
}

fn view() -> LayerWindow {
    let network = Network::read();

    let mut rows: Vec<Box<dyn Widget>> = Vec::new();

    for access_point in network.access_points() {
        let mark = if access_point.active() { "* " } else { "" };

        let label = format!("{mark}{}  {}%", access_point.ssid(), access_point.strength());

        let ssid = String::from(access_point.ssid());

        rows.push(Box::new(
            Rectangle::new()
                .width(Parent)
                .height(30.0)
                .on_click(move |_| Network::connect(&ssid, None))
                .child(Text::new(label)),
        ));
    }

    LayerWindow::new().width(300.0).height(400.0).child(Column::new(rows).width(Parent))
}

ssid is copied into a String before the closure, because the closure lives longer than network (Input).

For new secured networks, pair this with a password TextInput (Text Input).

Bluetooth

Bluetooth shows the adapter and its devices, and can power it, scan, pair, connect, and forget devices. It talks to BlueZ over D-Bus, so the bluetooth service has to be running.

It polls every 2 seconds.

A device list

use amane::{App, Bluetooth, BluetoothDevice, Column, LayerWindow, Parent, Rectangle, Service, Text, Widget};

fn main() {
    Bluetooth::start_scan();

    App::new().window(view).run();
}

fn view() -> LayerWindow {
    let bluetooth = Bluetooth::read();

    let mut rows: Vec<Box<dyn Widget>> = Vec::new();

    for device in bluetooth.devices() {
        rows.push(Box::new(device_row(device)));
    }

    LayerWindow::new().width(360.0).height(400.0).child(Column::new(rows))
}

fn device_row(device: &BluetoothDevice) -> Rectangle {
    let state = if device.connected() {
        "connected"
    } else if device.paired() {
        "paired"
    } else {
        "new"
    };

    let path = String::from(device.path());
    let connected = device.connected();
    let paired = device.paired();

    Rectangle::new()
        .width(Parent)
        .height(30.0)
        .on_click(move |_| {
            if connected {
                Bluetooth::disconnect(&path);
            } else if paired {
                Bluetooth::connect(&path);
            } else {
                Bluetooth::pair(&path);
            }
        })
        .child(Text::new(format!("{}  {state}", device.name())))
}

Reading Bluetooth state

FunctionGives
available()false when there’s no adapter, or BlueZ isn’t running
powered()true when the adapter is on
scanning()true while looking for new devices
devices()known and discovered devices: connected first, then paired, then by name

Each BluetoothDevice has:

FunctionGives
name()its name, like “WH-1000XM4”
address()its hardware address
path()its BlueZ path, which the control functions take
icon()BlueZ’s icon name for its type, like "audio-headphones"
paired()true once paired
connected()true while connected
battery()its battery, 0 to 100, for devices that report one, otherwise None

Controlling devices

FunctionDoes
Bluetooth::set_powered(on)turns the adapter on or off
Bluetooth::start_scan()starts looking for new devices. They show up in devices() as they’re found.
Bluetooth::stop_scan()stops looking
Bluetooth::pair(path)pairs with a device, then connects, like other Bluetooth menus do
Bluetooth::connect(path)connects a paired device
Bluetooth::disconnect(path)disconnects a device
Bluetooth::forget(path)unpairs and removes a device. It comes back only after a new scan.

All of them run on a background thread and return right away. The result shows up in the next poll.

pair only works for devices that pair without a code, like headphones, mice, and most speakers. Devices that ask you to confirm or type a code, like phones and some keyboards, can’t be paired from Amane yet. Pair those once with bluetoothctl, and then connect works.

Media Players

Media shows what’s playing and controls playback, in any player that supports MPRIS: Spotify, mpv, Firefox and Chromium tabs, VLC, and most others.

It polls once a second, because players don’t announce their position while a track plays.

A now-playing widget

use amane::{App, Button, Full, LayerWindow, Media, Parent, Rectangle, Row, Service, Text, children};

fn main() {
    App::new().window(view).run();
}

fn view() -> LayerWindow {
    let media = Media::read();

    let symbol = if media.playing() { "pause" } else { "play" };

    let seconds = media.position().as_secs();
    let total = media.length().as_secs();

    let label = format!(
        "{} - {}  {}:{:02} / {}:{:02}",
        media.artist(),
        media.title(),
        seconds / 60,
        seconds % 60,
        total / 60,
        total % 60,
    );

    LayerWindow::new().width(Full).height(30.0).child(Row::new(children![
        button("prev", |_| Media::previous()),
        button(symbol, |_| Media::play_pause()),
        button("next", |_| Media::next()),
        Rectangle::new().width(Parent).height(Parent).child(Text::new(label)),
    ]))
}

fn button(label: &str, clicked: fn(Button)) -> Rectangle {
    Rectangle::new()
        .width(60.0)
        .height(Parent)
        .on_click(clicked)
        .child(Text::new(label))
}

The active player

With several players open, the functions on Media itself show and control one of them, the active player:

  1. the one that’s playing,
  2. or else the one shown last,
  3. or else the first one.
FunctionGives or does
title()the track’s title, empty when no player is open
artist()the track’s artist
art_url()a link to the cover art, usually file://... or https://...
playing()true while playing
position()how far into the track, as a Duration
length()the track’s length, as a Duration
Media::play_pause()toggles playback
Media::next()skips to the next track
Media::previous()goes back to the previous track

Duration is std::time::Duration. Use .as_secs() to get whole seconds.

To show the cover art, strip the file:// from art_url() and use it as an image (Images). https:// links can’t be shown.

Every player

players() lists every open player, in the same order on every poll, and active() gives the active one, or None when no player is open. Each MediaPlayer has the same functions as above, plus identity() (its display name, like “Spotify”), name() (its D-Bus name), and its own play_pause(), next(), and previous():

use amane::{App, Column, Full, LayerWindow, Media, MediaPlayer, Parent, Rectangle, Service, Text, Widget};

fn main() {
    App::new().window(view).run();
}

fn view() -> LayerWindow {
    let media = Media::read();

    let mut rows: Vec<Box<dyn Widget>> = Vec::new();

    for player in media.players() {
        rows.push(Box::new(player_row(player)));
    }

    LayerWindow::new().width(Full).height(90.0).child(Column::new(rows))
}

fn player_row(player: &MediaPlayer) -> Rectangle {
    let label = format!("{}: {} - {}", player.identity(), player.artist(), player.title());

    // the closure keeps its own copy, so it keeps controlling this player
    let target = player.clone();

    Rectangle::new()
        .width(Parent)
        .height(30.0)
        .on_click(move |_| target.play_pause())
        .child(Text::new(label))
}

The control functions run on a background thread and return right away.

Notifications

Notifications makes your shell the notification daemon. Programs send their notifications to Amane, the same way they’d send them to mako or dunst, and you decide how to show them.

Before you start

Only one notification daemon can run at a time. Stop the one you have (mako, dunst, swaync, or a notification feature in another bar) before starting your shell. If another daemon is running, running() returns false and Amane receives nothing.

Amane starts being the daemon the first time something reads Notifications, so read it in a view that’s open when the shell starts.

A notification list

use amane::{
    App, Column, Horizontal, Layer, LayerWindow, Notification, Notifications, Parent, Rectangle,
    Service, Text, Urgency, Vertical, Widget,
};

fn main() {
    App::new().window(view).run();
}

fn view() -> LayerWindow {
    let notifications = Notifications::read();

    let mut cards: Vec<Box<dyn Widget>> = Vec::new();

    for notification in notifications.list() {
        cards.push(Box::new(card(notification)));
    }

    LayerWindow::new()
        .width(400.0)
        .height(600.0)
        .anchor_vertical(Vertical::Top)
        .anchor_horizontal(Horizontal::Right)
        .layer(Layer::Overlay)
        .visible(!notifications.list().is_empty())
        .child(Column::new(cards).gap(8.0))
}

// click a notification to run its default action, or to dismiss it
fn card(notification: &Notification) -> Rectangle {
    let fill = match notification.urgency() {
        Urgency::Critical => "#f38ba8",
        _ => "#313244",
    };

    let id = notification.id();

    let label = format!("{}: {}", notification.summary(), notification.body());

    Rectangle::new()
        .width(Parent)
        .height(60.0)
        .radius(12.0)
        .fill(fill)
        .padding(12.0)
        .on_click(move |_| Notifications::click(id))
        .child(Text::new(label).elide())
}

The window hides itself while there are no notifications, so its empty space doesn’t block clicks on the windows underneath.

Try it with:

notify-send "hello" "from amane"

Reading notifications

list() gives every notification, oldest first. Each Notification has:

FunctionGives
id()its number, which the control functions take
app_name()the sending program’s name
summary()the one-line title
body()the text, which may be empty and may hold simple markup like <b>
icon()an icon name like "firefox", a file path, or empty
image()a picture for this notification, like an avatar: a file path, a file:// link, or empty
urgency()Urgency::Low, Normal, or Critical
actions()the buttons the sender asked for, in order
has_default_action()whether clicking the notification itself does something
received()when it arrived, or when the sender last replaced it, as a SystemTime

Each Action has key(), to pass to invoke, and label(), the text to show on the button.

Clicking, dismissing, and actions

FunctionDoes
Notifications::click(id)runs the default action, or dismisses it when there’s none. Use it when the notification itself is clicked.
Notifications::invoke(id, key)runs one of its actions. Use it for action buttons.
Notifications::dismiss(id)closes it
Notifications::clear()closes all of them

After an action runs, the notification closes, unless the sender asked for it to stay.

Action buttons

for action in notification.actions() {
    let id = notification.id();
    let key = String::from(action.key());

    buttons.push(Box::new(
        Rectangle::new()
            .width(100.0)
            .height(30.0)
            .on_click(move |_| Notifications::invoke(id, &key))
            .child(Text::new(action.label())),
    ));
}

Try it with:

notify-send -A yes=Yes -A no=No "question" "pick one"

Timeouts

Notifications stay until they’re dismissed. Amane ignores the timeout the sender asks for.

To hide popups after a few seconds, compare received() with the current time in a Service that polls every second, and only show the recent ones in your popup window. Keep the full list for a notification center.

Workspaces

Workspaces lists your compositor’s workspaces and can switch between them. It supports niri, Hyprland and Sway. On other compositors, the list stays empty.

It doesn’t poll. It follows the compositor’s event stream, so the bar updates the moment you switch.

Workspace buttons

use amane::{App, Color, Full, LayerWindow, Parent, Rectangle, Row, Service, Text, Widget, Workspace, Workspaces};

fn main() {
    App::new().window(view).run();
}

fn view() -> LayerWindow {
    let workspaces = Workspaces::read();

    let mut buttons: Vec<Box<dyn Widget>> = Vec::new();

    for workspace in workspaces.list() {
        buttons.push(Box::new(button(workspace)));
    }

    LayerWindow::new().width(Full).height(30.0).child(Row::new(buttons))
}

fn button(workspace: &Workspace) -> Rectangle {
    let fill = if workspace.focused() {
        "#89b4fa"
    } else if workspace.urgent() {
        "#f38ba8"
    } else {
        "#313244"
    };

    let id = workspace.id();

    Rectangle::new()
        .width(30.0)
        .height(Parent)
        .fill(fill)
        .on_click(move |_| Workspaces::focus(id))
        .child(Text::new(workspace.index().to_string()).color(Color::WHITE))
}

Reference

list() gives every workspace, sorted by monitor, then by position on it. Each Workspace has:

FunctionGives
id()the compositor’s id for it, which Workspaces::focus takes; an i64, because named Hyprland workspaces have negative ids
index()its position on its monitor, starting at 1; on Hyprland and Sway, the workspace’s number (0 for a workspace with only a name)
name()its name, or None for workspaces you never named
output()the monitor it’s on, like "DP-1", or None
active()true when it’s the one shown on its monitor, even when another monitor has focus
focused()true for the one workspace that has focus overall
urgent()true when a window on it asks for attention; always false on Hyprland
windows()how many windows are on it

Workspaces::focus(id) switches to a workspace. It runs on a background thread and returns right away.

Per-monitor bars

With window_per_monitor, show each bar only its own monitor’s workspaces by comparing output() with the monitor’s name (Multiple Windows and Monitors):

fn bar(monitor: &Monitor) -> LayerWindow {
    let workspaces = Workspaces::read();

    for workspace in workspaces.list() {
        if workspace.output() != Some(monitor.name.as_str()) {
            continue;
        }

        // add a button
    }

    // ...
}

Apps

Apps lists the programs installed on your system, from their .desktop files, with their names and icons. Use it to build an app launcher.

A launcher

use amane::{App, Apps, Color, Column, Image, LayerWindow, Pointer, Rectangle, Row, ScrollArea, Service, Text, Widget, children};

fn main() {
    App::new().window(view).run();
}

fn view() -> LayerWindow {
    let apps = Apps::read();

    let mut rows: Vec<Box<dyn Widget>> = Vec::new();

    for app in apps.list() {
        let launched = app.clone();

        let icon = Rectangle::new().width(24.0).height(24.0);

        let icon = match app.icon_path() {
            Some(path) => icon.fill(Image::contain(path)),
            None => icon,
        };

        let row = Rectangle::new()
            .width(300.0)
            .height(32.0)
            .fill("#1e1e2e")
            .cursor(Pointer)
            .on_click(move |_| launched.launch())
            .child(Row::new(children![
                icon,
                Text::new(app.name()).size(16.0).color(Color::WHITE),
            ]));

        rows.push(Box::new(row));
    }

    LayerWindow::new()
        .width(300.0)
        .height(500.0)
        .child(ScrollArea::new("apps", Column::new(rows)))
}

App (the shell) and Apps (the Service) are different things. app in the loop is one DesktopApp.

Reference

list() gives every program, sorted by name, without the ones marked hidden or not to be shown in menus. Each DesktopApp has:

FunctionGives or does
name()its name, like “Firefox”
description()its comment, or a generic name like “Web Browser”, or None
exec()its command line, without the %f-style placeholders
icon()its icon’s name in the icon theme, like "firefox", or None
icon_path()the icon’s image file, a PNG when the theme has one, or None
launch()starts the program, without waiting for it

First load and rescans

Finding icons means walking every icon theme on your system, which takes a few seconds. So list() is empty at first, and fills in once the first scan is done. The window redraws by itself when it does.

After that, the list is scanned again every 30 seconds, so programs you install while the shell runs show up.

Searching

Filter the list with a TextInput (Text Input):

let query = search.query.to_lowercase();

for app in apps.list() {
    if !app.name().to_lowercase().contains(&query) {
        continue;
    }

    // add a row
}

Palette

Palette takes the main colors out of an image, usually your wallpaper, so your shell’s colors can follow it.

A themed bar

use amane::{App, Full, LayerWindow, Palette, Parent, Rectangle, Service, Text};

fn main() {
    Palette::write().open("/home/you/Pictures/wall.jpg", 16);

    App::new().window(view).run();
}

fn view() -> LayerWindow {
    let palette = Palette::read();

    LayerWindow::new().width(Full).height(30.0).child(
        Rectangle::new()
            .width(Parent)
            .height(Parent)
            .fill(palette.background())
            .child(Text::new("themed").color(palette.foreground())),
    )
}

How colors are picked and updated

  • open(path, count) reads the image and picks count colors from it. It reads the image right away, so the very first frame already has the colors. 16 is enough for a whole shell’s theme.
  • open changes the Palette, so it’s called on write(), and every window that reads the Palette redraws. That’s the one place it’s fine to call write() outside an input handler: in main, before the shell starts, or in an IPC handler.
  • The Palette checks the file twice a second. When the image changes, or you open a new path, the colors are picked again on a background thread, and the windows redraw.

So a wallpaper script that overwrites the same file re-themes your shell on its own.

Reference

FunctionGives or does
open(path, count)reads an image and picks count colors. Call it on Palette::write().
colors()every color picked, most common first
dominant()the color the image shows most
accent()the most vivid color, for highlights and the focused workspace
background()the darkest color, so light text always reads on it
foreground()a color readable on background(), tinted by the image when possible
on_accent()white or black, whichever reads better on accent()
light()true when the image is light overall

Before an image is opened, the Palette holds a set of default colors.

Changing the wallpaper from a keybind

fn main() {
    App::new()
        .ipc("wallpaper", |arguments| {
            let Some(path) = arguments.first() else {
                return String::from("usage: wallpaper <path>");
            };

            Palette::write().open(path, 16);

            format!("palette from {path}")
        })
        .window(bar)
        .run();
}
amane ipc call wallpaper ~/Pictures/new.jpg

Here ~ works, because your shell (fish, bash, zsh) expands it before amane sees it.

open reads the image on the spot, and IPC handlers run on the thread that draws, so the shell pauses for a moment on a very large image. If that bothers you, have your wallpaper script copy the new image over one fixed path instead, and open that path once in main. The Palette notices the change on its own and reads it on a background thread.

Commands and Files

This page covers running other programs and watching files: the glue for anything Amane doesn’t read for you.

FunctionDoesWaits?
amane::spawn(command)starts a command and moves onno
amane::output(command)runs a command and returns what it printedyes, until it exits
amane::lines(command)runs a long-running command and gives each line it printsyes, for each line
amane::watch_file(path)gives one item every time a file changesyes, for each change

All commands run through sh -c, so pipes, globs, ~, and && work like in a terminal.

Starting programs

.on_click(|_| amane::spawn("firefox"))
.on_click(|_| amane::spawn("niri msg action focus-workspace-down"))
.on_click(|_| amane::spawn("notify-send amane 'the bar was clicked'"))

spawn returns right away, so it’s safe in input handlers.

Reading a command’s output

output waits for the command to finish, so never call it in a view, where it would freeze every window. Call it in a Service, in new() or update(), which run on the Service’s own thread:

use std::time::Duration;

use amane::{App, Full, LayerWindow, Service, Text};

struct Kernel {
    version: String,
}

impl Service for Kernel {
    fn new() -> Self {
        Self { version: amane::output("uname -r") }
    }

    // never changes while the shell runs
    fn interval() -> Duration {
        Duration::from_secs(3600)
    }
}

fn main() {
    App::new().window(view).run();
}

fn view() -> LayerWindow {
    let kernel = Kernel::read();

    LayerWindow::new().width(Full).height(30.0).child(Text::new(&kernel.version))
}

output trims the trailing newline. A command that fails or prints nothing gives an empty string.

Following a command’s output

Some commands print a line every time something happens: niri msg event-stream, pactl subscribe, udevadm monitor. lines gives each line as it comes, in a Service’s listen:

use amane::{App, Full, LayerWindow, Service, Text};

struct Events {
    last: String,
}

impl Service for Events {
    fn new() -> Self {
        Self { last: String::from("waiting") }
    }

    fn listen() {
        for line in amane::lines("niri msg event-stream") {
            Self::write().last = line;
        }
    }
}

fn main() {
    App::new().window(view).run();
}

fn view() -> LayerWindow {
    let events = Events::read();

    LayerWindow::new().width(Full).height(30.0).child(Text::new(&events.last))
}

The loop ends when the command exits. If the loop stops early, for example because listen panicked, the command is killed, so nothing is left running.

Watching a file

use std::fs;

use amane::{App, Full, LayerWindow, Service, Text};

const NOTE: &str = "/tmp/amane-note";

struct Note {
    text: String,
}

impl Service for Note {
    fn new() -> Self {
        Self { text: read_note() }
    }

    fn listen() {
        for _ in amane::watch_file(NOTE) {
            Self::write().text = read_note();
        }
    }
}

fn read_note() -> String {
    fs::read_to_string(NOTE).unwrap_or_default().trim_end().to_string()
}

fn main() {
    App::new().window(view).run();
}

fn view() -> LayerWindow {
    let note = Note::read();

    LayerWindow::new().width(Full).height(30.0).child(Text::new(&note.text))
}

std::fs is Rust’s standard library.

Try echo hello > /tmp/amane-note while the bar runs.

watch_file gives an item each time the file is written, created, or replaced. Editors usually save by writing a new file and renaming it over the old one, which would end a watch on the file itself, so Amane watches the folder and picks out your file’s changes. The file doesn’t have to exist yet when you start watching, but its folder does.

Reading the file is up to you, in the loop, as above.

IPC

IPC lets other programs talk to your running shell. Its main use is keybinds: your compositor runs amane ipc call toggle-launcher, and your shell opens the launcher.

Toggling a window from the command line

use std::time::Duration;

use amane::{App, LayerWindow, Parent, Rectangle, Service};

struct Launcher {
    open: bool,
}

impl Service for Launcher {
    fn new() -> Self {
        Self { open: false }
    }

    fn interval() -> Duration {
        Duration::from_secs(3600)
    }
}

fn main() {
    App::new().ipc("toggle-launcher", toggle).window(view).run();
}

fn view() -> LayerWindow {
    let launcher = Launcher::read();

    LayerWindow::new()
        .width(400.0)
        .height(300.0)
        .visible(launcher.open)
        .child(Rectangle::new().width(Parent).height(Parent).fill("#1e1e2e"))
}

fn toggle(_: &[String]) -> String {
    let mut launcher = Launcher::write();

    launcher.open = !launcher.open;

    if launcher.open {
        String::from("opened")
    } else {
        String::from("closed")
    }
}

From a terminal:

amane ipc call toggle-launcher

It prints opened, and the launcher appears. Run it again to close it.

How a call reaches your shell

  • App::ipc(name, handler) registers a handler under a name. Call it once per name.
  • Your shell listens on a socket, $XDG_RUNTIME_DIR/amane.sock.
  • amane ipc call <name> [arguments...] connects to that socket, sends the name and arguments, and prints the reply.
  • The handler gets the arguments and returns a String. Whatever it returns is printed by amane ipc call.
  • The handler writes to a Service, so every window that reads it redraws (State and Services).

The handler runs on the same thread that draws, so keep it quick. For slow work, start a thread or use amane::spawn.

Passing arguments

Everything after the name is passed to the handler as a list of strings:

fn main() {
    App::new().ipc("say", say).window(view).run();
}

fn say(arguments: &[String]) -> String {
    let words = arguments.join(" ");

    Message::write().text = words.clone();

    format!("showing: {words}")
}
amane ipc call say hello world

The handler gets ["hello", "world"]. Arguments can’t contain newlines.

Calling a name that has no handler answers no handler named <name>.

Binding calls to keys

niri:

binds {
    Mod+Space { spawn "amane" "ipc" "call" "toggle-launcher"; }
}

Hyprland:

bind = SUPER, SPACE, exec, amane ipc call toggle-launcher

Sway:

bindsym $mod+space exec amane ipc call toggle-launcher

One shell at a time

There’s one socket per session, so only one Amane shell can run at a time. Starting a second one stops it right away with failed to listen: amane is already running. amane dev restarts its own shell without hitting this, but it can’t stop a shell started some other way. Stop your everyday shell (the one from amane run) before running amane dev.

If amane ipc call says amane is not running, no shell is running in this session.

D-Bus

D-Bus is how most Linux system services talk: NetworkManager, UPower, BlueZ, logind, media players, notification daemons. Amane’s built-in Services use it, and you can use the same small wrapper for anything they don’t cover.

The two buses

let bus = Bus::system();    // shared by every user: NetworkManager, UPower, logind, BlueZ
let bus = Bus::session();   // your own programs: media players, notifications, the tray

There’s one connection per bus for the whole shell, shared by every thread. If a bus can’t be reached, every call on it answers with nothing instead of failing. A bus is only tried once, so a bus that starts after your shell needs a shell restart.

Reading a property

use std::time::Duration;

use amane::{App, Bus, Full, LayerWindow, Service, Text};

struct Wifi {
    enabled: bool,
}

impl Service for Wifi {
    fn new() -> Self {
        Self { enabled: read_enabled() }
    }

    fn interval() -> Duration {
        Duration::from_secs(5)
    }

    fn update(&mut self) -> bool {
        let enabled = read_enabled();

        let changed = enabled != self.enabled;

        self.enabled = enabled;

        changed
    }
}

fn read_enabled() -> bool {
    let bus = Bus::system();

    let enabled = bus.property(
        "org.freedesktop.NetworkManager",    // who to ask
        "/org/freedesktop/NetworkManager",   // which object
        "org.freedesktop.NetworkManager",    // which interface
        "WirelessEnabled",                   // which property
    );

    enabled.bool()
}

fn main() {
    App::new().window(view).run();
}

fn view() -> LayerWindow {
    let wifi = Wifi::read();

    let label = if wifi.enabled { "wifi on" } else { "wifi off" };

    LayerWindow::new().width(Full).height(30.0).child(Text::new(label))
}

To find names to use, explore the bus with a tool like busctl, qdbusviewer, or D-Spy.

D-Bus calls wait for an answer, so make them in a Service’s thread, not in a view.

Values

Every answer is a Value:

VariantRead it with
Value::Bool.bool()
Value::Number.number(), as an f64, whatever number type D-Bus used
Value::Text.text()
Value::List.list()
Value::Map.get("key")
Value::Nothingthe call failed, or there was no answer

Each reader gives an empty answer for the wrong kind: false, 0.0, "", an empty list, or Value::Nothing. So a call to a program that isn’t running gives Value::Nothing, and reading it shows nothing instead of crashing. Readers chain:

let title = metadata.get("xesam:title").text();
let first_artist = metadata.get("xesam:artist").list().first().map(|artist| artist.text());

Calling methods

let answer = bus.call(destination, path, interface, method, &arguments![...]);

arguments! turns plain Rust values into D-Bus arguments: bool, i32, u32, i64, f64, &str, String, and Vec<String>. For other D-Bus types, build an Argument directly, like Argument::Path(String::from("/org/...")) for an object path, or Argument::Variant(...).

Pause Spotify through MPRIS:

let bus = Bus::session();

bus.call(
    "org.mpris.MediaPlayer2.spotify",
    "/org/mpris/MediaPlayer2",
    "org.mpris.MediaPlayer2.Player",
    "Pause",
    &[],
);

Most methods answer with one value, which comes back on its own. A method that answers with several values gives a Value::List.

To set a property:

bus.set_property(destination, path, interface, "Powered", Argument::from(true));

Listening for signals

Programs announce changes with signals. signals waits for each one, so call it in a Service’s listen:

fn listen() {
    let bus = Bus::system();

    for signal in bus.signals("org.freedesktop.DBus.Properties", "PropertiesChanged") {
        if signal.path() != "/org/freedesktop/NetworkManager" {
            continue;
        }

        let enabled = read_enabled();

        Self::write().enabled = enabled;
    }
}

Each Signal has sender(), path(), and arguments(). sender() is the sender’s unique name, like :1.42, not a well-known name like org.mpris.MediaPlayer2.mpv.

Answering calls

Your shell can offer its own D-Bus interface, so other programs can call it:

use amane::{App, Bus, LayerWindow, Service, Text, arguments};

struct Server;

impl Service for Server {
    fn new() -> Self {
        Server
    }

    fn listen() {
        let bus = Bus::session();

        // watch first, then take the name, so the first calls aren't missed
        let methods = bus.methods("/org/example/Shell", "org.example.Shell");

        if !bus.own("org.example.Shell") {
            return;
        }

        for method in methods {
            if method.name() == "Ping" {
                method.reply(&arguments!["pong"]);
            }
        }
    }
}

fn main() {
    App::new().window(view).run();
}

fn view() -> LayerWindow {
    // reading the Service once starts its thread
    let _server = Server::read();

    LayerWindow::new().width(200.0).height(30.0).child(Text::new("serving"))
}

Test it with:

busctl --user call org.example.Shell /org/example/Shell org.example.Shell Ping
  • own(name) takes a well-known name, and returns false if another program already has it. Amane never takes a name away from another program.
  • methods(path, interface) waits for each call to that object and interface.
  • method.arguments() gives the call’s arguments, and method.reply(...) answers it.
  • bus.emit(path, interface, name, &arguments![...]) sends a signal of your own.

Troubleshooting

The shell won’t start

MessageCauseFix
failed to bind wlr-layer-shellyour compositor doesn’t support layer windowsuse a compositor that does, like niri, Hyprland, Sway, or river
failed to listen: amane is already runninganother Amane shell is running in this sessionstop it first, see IPC
failed to run: no window setApp was given no windowsadd at least one .window, .window_per_monitor, .normal_window, or .lock
failed to read shadera shader path is wronguse an absolute path, see Shaders

The window looks wrong

ProblemFix
Normal windows cover the baradd .space(Zone::Reserve) to the bar (Layer Windows)
The bar covers fullscreen videosadd .layer(Layer::Top). The default is Overlay, which is above everything.
The window is in the middle of the screenadd anchor_vertical or anchor_horizontal. Both default to Middle.
A color shows bright pinkthat hex string is mistyped. Amane shows pink instead of crashing.
An image doesn’t showcheck the path is absolute and the file is PNG, JPEG, or SVG (Images)
Text runs past its boxadd .elide() or .wrap() (Text)
Shape methods like .stroke don’t existadd Shape to your use line (Canvas and Shapes)

Input doesn’t work

ProblemFix
Typing does nothinggive the window .keyboard(Keyboard::OnDemand) or Keyboard::Exclusive (Layer Windows)
on_key never runssame as above. Keys only reach a window that has keyboard focus.
Clicks go through the windowcheck for .click_through() or an .input_region(...) that leaves that part out
Clicks don’t go through an empty part of the windowgive the window an .input_region(...), or hide it with .visible(false) while it’s empty

The shell freezes

  • A view calls write(). A view may still hold a read() of the same Service, and the write waits for it forever. Only read in views (State and Services).
  • A view waits on something. amane::output, file reads, D-Bus calls, and sleep all block the thread that draws every window. Move them into a Service.

The shell uses too much CPU

Run your shell with AMANE_FRAMES=1 to print a line for every frame:

AMANE_FRAMES=1 ~/.cache/amane/project/target/release/amane-shell

Each line shows which window drew, the time since its last frame, and how long the view, the drawing, and the GPU took. It also prints which Service woke the windows. Look for:

  • Frames that never stop. Something keeps asking for frames: a view that always calls request_frame(), or a shader whose source contains the word time (Shaders).
  • A Service that wakes windows every poll. Its update() returns true even when nothing changed. Compare the old and new values, and return whether they differ.
  • A slow view. Something in the view is doing real work. Move it into a Service.

The lock screen won’t unlock

See Testing safely for how to get back into a locked session.