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
| Code | Does |
|---|---|
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
| Code | Does |
|---|---|
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
| Code | Does |
|---|---|
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
| Code | Means |
|---|---|
100.0 | exactly 100 pixels |
Parent | as big as the parent allows. Several Parent children share the free space evenly. |
Full | a whole monitor side. Only for LayerWindow sizes and .radius(Full). |
Row, Column, Stack
| Code | Does |
|---|---|
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
| Code | Does |
|---|---|
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
| Code | Called 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
| Code | Does |
|---|---|
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
| Code | Does |
|---|---|
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
| Code | Does |
|---|---|
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
| Code | Does |
|---|---|
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
| Code | Does |
|---|---|
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
| Code | Does |
|---|---|
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
| Code | Does |
|---|---|
impl Service for T { fn new() -> Self { ... } } | the only required function |
fn interval() -> Duration | how often update runs, 1 second by default |
fn update(&mut self) -> bool | polls. 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
| Service | Reads | Controls |
|---|---|---|
Battery | present, percent, charging, full | |
Cpu | percent | |
Memory | percent, used_kib, total_kib | |
Brightness | present, percent | set |
Audio | volume, muted, microphone_volume, microphone_muted | set_volume, toggle_mute, set_microphone_volume, toggle_microphone_mute |
Network | connected, link, ssid, strength, wifi_enabled, connecting, access_points | scan, connect, disconnect, set_wifi |
Bluetooth | available, powered, scanning, devices | set_powered, start_scan, stop_scan, pair, connect, disconnect, forget |
Media | players, active, title, artist, art_url, playing, position, length | play_pause, next, previous |
Notifications | list, running | click, invoke, dismiss, clear |
Workspaces | list | focus |
Apps | list | launch on each DesktopApp |
Palette | colors, dominant, accent, background, foreground, on_accent, light | open |
Lock | checking, failed | start, unlock |
Commands, files, IPC, D-Bus
Commands and Files · IPC · D-Bus
| Code | Does |
|---|---|
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
| Command | Does |
|---|---|
amane startup | creates ~/.config/amane/src/main.rs |
amane dev | rebuilds and restarts on every save |
amane compile | builds the optimized shell |
amane run | builds if needed, then starts it |
amane ipc call <name> [args...] | calls an IPC handler |
AMANE_FRAMES=1 | prints 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:
| Library | Debian / Ubuntu | Arch |
|---|---|---|
| Wayland | libwayland-dev | wayland |
| xkbcommon | libxkbcommon-dev | libxkbcommon |
| fontconfig | libfontconfig1-dev | fontconfig |
| Vulkan loader | libvulkan1 | vulkan-icd-loader |
| PulseAudio client | libpulse-dev | libpulse |
| PAM | libpam0g-dev | pam |
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 pullin the clone, then runcargo install --path cliagain.
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.mainbuilds anApp, gives it one window, and runs it.run()never returns while the shell is open..window(view)passes the functionviewitself, without calling it. Amane calls it whenever the window needs to be drawn.viewdescribes 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
Rectanglethat fills it (Parentmeans “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
| Command | What it does |
|---|---|
amane startup | Creates ~/.config/amane/src/main.rs from a template. Never overwrites an existing file. |
amane dev | Builds your shell, starts it, and rebuilds and restarts it on every save. |
amane compile | Builds the optimized shell without starting it. |
amane run | Builds 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.rsis the entry point. You can add more files next to it and load them withmod, 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 yourmain.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 dev | amane run, amane compile | |
|---|---|---|
| Your code | not optimized, builds in seconds | optimized |
| Amane and its dependencies | optimized | optimized |
| Use it for | writing your shell | daily 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:
- system data, through Built-in Services
- running commands and reading their output, through Commands and Files
- file watching, D-Bus, and IPC
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:
- Amane calls
view. viewreads the battery (80%) and builds a new window with aTextthat says 80%.- Amane draws it, then throws the widgets away.
- The battery drops to 79%.
- Amane calls
viewagain. It reads 79% and builds a newTextthat 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
ScrollAreaandTextInputto keep their scroll position and text between frames. You give each one a name, likeScrollArea::new("apps", ...). - a
staticyou manage yourself, like athread_local!or aMutex. 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:
widthandheightcome first, and are required. A number is a size in pixels.Fullstretches the window across the whole monitor.anchor_verticalandanchor_horizontalpick the edges it sticks to.layerpicks how high in the stack it is.spacepicks 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)
| Layer | Sits | Use it for |
|---|---|---|
Background | under everything | wallpapers |
Bottom | under normal windows | desktop widgets |
Top | above normal windows | bars, docks |
Overlay | above everything, even fullscreen windows | popups, launchers, OSDs |
The default is Overlay. Set Top for a bar, or it covers fullscreen videos.
Reserved space
.space(Zone::Reserve)
| Zone | Effect |
|---|---|
Reserve | Other windows are kept out of the strip this window covers. Use it for bars. |
Respect | Reserves nothing, and moves out of the space other windows reserve. This is the default. |
Ignore | Reserves 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)
| Keyboard | Effect |
|---|---|
None | Never gets keys. This is the default. |
OnDemand | Gets keys after you click it, like a normal window. Use it for a search box in a panel. |
Exclusive | Takes 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:
- It measures. The fixed children need 100 + 100 = 200 pixels. The rest of the row’s width is free.
- It places. Each child is put next to the one before it. The free space is split evenly between the
Parentchildren. 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
| Widget | Places children |
|---|---|
Row | side by side, left to right |
Column | on top of each other, top to bottom |
Stack | all 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 becomesParent-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)
| Justify | Effect |
|---|---|
Start | packed at the start. This is the default. |
Center | packed in the middle |
End | packed at the end |
SpaceBetween | first at the start, last at the end, equal space between |
SpaceAround | equal space around each child |
SpaceEvenly | equal 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)takesStart,Center, orEndfor 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
- The first time anything calls
Clock::read(), Amane creates the clock withnew()and keeps it for as long as the shell runs. There’s only ever oneClock. - Amane also starts a background thread just for the clock. Every
interval(), that thread callsupdate(). update()returns whether anything the windows show has changed. When it returnstrue, Amane redraws every window whose view read the clock.viewcallsClock::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
intervalandupdate. - 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:
| Method | Called | Gets |
|---|---|---|
on_click | when a button is pressed and released over the same rectangle | which Button: Left, Right, or Middle |
on_hover | when the pointer comes in, and when it goes out | true when it comes in, false when it goes out |
on_scroll | for each wheel step or touchpad movement | a Scroll with x and y |
on_move | on every movement while the pointer is over it | a Point |
on_drag | on a left press, and on every movement until release | a 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, likeCharacter('A')Enter,Escape,Tab,Backspace,SpaceUp,Down,Left,Right,Home,EndOther: 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, andEndgo to the input only. Up,Down,Tab, andOtherskip the input and go to the window’son_key. That’s how arrow keys can move through a list while you type in a search box.Escapetakes 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.
to(400.0)sets a new target and notes the time. Because this happens insidewrite(), the window redraws.- The view calls
value(). The animation has just started, so it returns about 100. - Calling
value()on an unfinished animation also tells Amane “I’m still moving”. After drawing, Amane asks the compositor for the next frame. - On the next frame, the view runs again, and
value()returns a little more. - 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)
durationdefaults to 200 milliseconds.easingdecides how the speed changes along the way:
| Easing | Motion |
|---|---|
Out | starts fast, slows down as it arrives. This is the default, and feels right for most UI. |
InOut | starts slow, speeds up, slows down again. Good for big movements. |
Linear | the 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:
| Fill | Example |
|---|---|
| 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)
opacityfades the rectangle and everything inside it, from 0 (invisible) to 1.Shadow::dropcasts a shadow behind the rectangle.Shadow::innershades along the inside edge, as if it were pressed in.blursoftens it, andoffsetmoves it.blurblurs 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"))
childputs one widget inside. To hold several, give it aRow,Column, orStack.paddingandalign_childplace the child (Layout).cliphides 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:
| Rules | Height |
|---|---|
| neither | one line |
wrap with max_lines(n) | n lines |
wrap alone | Parent, 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, andEndedit the text. Entercallson_submit.Escapetakes focus away, and then also reaches the window’son_key.Up,Down, andTabskip the input and go straight to the window’son_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
ScrollAreaisParent-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
Canvasis 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, andxandygrow right and down. shapes!makes the list of shapes, likechildren!does for widgets. Later shapes are drawn over earlier ones, so the blue arc sits on top of the gray track.- An
Arcwith 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, andopacitycome from theShapetrait, soShapehas to be in youruseline, or Rust can’t find these methods.
Shapes
| Shape | Builder | Defaults |
|---|---|---|
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, .close | empty |
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
| Fit | Effect |
|---|---|
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
.svgextension. - 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:
- The first time a view asks for a file, the rectangle is drawn without the image, and decoding starts.
- When decoding finishes, the windows waiting for that image redraw, now with the image.
- 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:
| Input | Type | Meaning |
|---|---|---|
uv | vec2<f32> | where the pixel is, from (0, 0) at the top left to (1, 1) at the bottom right |
size | vec2<f32> | the rectangle’s size in pixels |
time | f32 | seconds since the first shader was drawn |
values | array<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 withamane dev, where you can see that output. - A shader file is read once. Editing it while the shell runs doesn’t change it.
amane devdoesn’t watch shader files either, unless they’re insidesrc/, 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)),
)
}
titledefaults to"amane".sizeis the size it opens at, 640 by 480 by default. The compositor may give it another size, and a tiling compositor usually does.resizablelets the user resize it. It’sfalseby default.on_keyworks the same as on a layer window. A normal window gets keyboard focus like any app, so it needs noKeyboardsetting.
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::locktakes a view that’s shown on every monitor while the session is locked. Likewindow_per_monitor, it gets the&Monitorit’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 theloginservice, 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
LockService tells the view what’s happening, throughchecking()andfailed().
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
| Function | Meaning |
|---|---|
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 withloginctl terminate-session <id>(loginctl list-sessionsshows 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.
| Function | Gives |
|---|---|
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.
| Function | Gives |
|---|---|
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.
| Function | Gives |
|---|---|
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).
| Function | Gives |
|---|---|
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
| Function | Gives 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
| Function | Gives |
|---|---|
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:
| Function | Gives |
|---|---|
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
| Function | Does |
|---|---|
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
| Function | Gives |
|---|---|
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:
| Function | Gives |
|---|---|
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
| Function | Does |
|---|---|
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:
- the one that’s playing,
- or else the one shown last,
- or else the first one.
| Function | Gives 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:
| Function | Gives |
|---|---|
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
| Function | Does |
|---|---|
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:
| Function | Gives |
|---|---|
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:
| Function | Gives 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 pickscountcolors 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.openchanges the Palette, so it’s called onwrite(), and every window that reads the Palette redraws. That’s the one place it’s fine to callwrite()outside an input handler: inmain, before the shell starts, or in an IPC handler.- The Palette checks the file twice a second. When the image changes, or you
opena 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
| Function | Gives 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.
| Function | Does | Waits? |
|---|---|---|
amane::spawn(command) | starts a command and moves on | no |
amane::output(command) | runs a command and returns what it printed | yes, until it exits |
amane::lines(command) | runs a long-running command and gives each line it prints | yes, for each line |
amane::watch_file(path) | gives one item every time a file changes | yes, 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(¬e.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 byamane 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:
| Variant | Read 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::Nothing | the 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 returnsfalseif 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, andmethod.reply(...)answers it.bus.emit(path, interface, name, &arguments![...])sends a signal of your own.
Troubleshooting
The shell won’t start
| Message | Cause | Fix |
|---|---|---|
failed to bind wlr-layer-shell | your compositor doesn’t support layer windows | use a compositor that does, like niri, Hyprland, Sway, or river |
failed to listen: amane is already running | another Amane shell is running in this session | stop it first, see IPC |
failed to run: no window set | App was given no windows | add at least one .window, .window_per_monitor, .normal_window, or .lock |
failed to read shader | a shader path is wrong | use an absolute path, see Shaders |
The window looks wrong
| Problem | Fix |
|---|---|
| Normal windows cover the bar | add .space(Zone::Reserve) to the bar (Layer Windows) |
| The bar covers fullscreen videos | add .layer(Layer::Top). The default is Overlay, which is above everything. |
| The window is in the middle of the screen | add anchor_vertical or anchor_horizontal. Both default to Middle. |
| A color shows bright pink | that hex string is mistyped. Amane shows pink instead of crashing. |
| An image doesn’t show | check the path is absolute and the file is PNG, JPEG, or SVG (Images) |
| Text runs past its box | add .elide() or .wrap() (Text) |
Shape methods like .stroke don’t exist | add Shape to your use line (Canvas and Shapes) |
Input doesn’t work
| Problem | Fix |
|---|---|
| Typing does nothing | give the window .keyboard(Keyboard::OnDemand) or Keyboard::Exclusive (Layer Windows) |
on_key never runs | same as above. Keys only reach a window that has keyboard focus. |
| Clicks go through the window | check for .click_through() or an .input_region(...) that leaves that part out |
| Clicks don’t go through an empty part of the window | give 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 aread()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, andsleepall 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 wordtime(Shaders). - A Service that wakes windows every poll. Its
update()returnstrueeven 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.