Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

sottomano logo

Sottomano

Sottomano is Italian for “at hand”: avere tutto sottomano, to have everything within reach. It is my desktop shell: a top panel, a bottom panel, an optional workspace sidebar and notification toasts, in one binary.

Why

After twenty years of GNOME and KDE Plasma I wanted a desktop that never asks for attention but answers when I look, with the widgets I kept missing there. Sottomano is written in Rust, so readings arrive as streams from compositor IPC, D-Bus, PipeWire and native libraries instead of polled commands, and each one explains itself in a hover card whose top line says what a click does.

Previous work:

  • niri-waybar-windowlist: a hard fork of a Waybar window list module, where styling meant fighting GTK CSS.
  • Odin: two Qt Quick panels on Quickshell, with a subprocess spawned and polled for every reading.

Tour

Weather: Conditions

Hovering the info icon of the temperature opens the forecast for the coming hours.

Hovering the temperature in the weather group

Tailscale: Exit node

The tailnet: how each peer is reached, when the key expires, and which exit node carries the traffic, next to the switch that offers this machine as one.

Opening the Tailscale group from the Network menu and hovering an exit node

Dictation: Speech model

The speech model the dictation daemon loaded.

Opening the dictation group from the Media menu and hovering the speech model

Toast: Next to its window

When the sending window is visible, the toast sits in its top corner.

A build toast placed in the corner of the terminal that sent it

The other groups, each in its menu next to pinned in the bar, are in the guide. The recordings use fixtures, so the numbers are made up.

Built on

  • iced, through a fork that carries a small rendering patch.
  • Wayland layer surfaces from iced_exwlshell in waycrate’s exwlshelleventloop, through a fork with the patches the shell needs until they land upstream.
  • niri-ipc and swayipc-types for compositor IPC, zbus for D-Bus services, PipeWire for audio, and a CLI only where nothing else is available.

Get started

  • Try it from a checkout: cargo r inside nix develop.
  • Install it with the home-manager and NixOS modules: installation.
  • Configure it with one TOML file whose complete default the shell prints: configuration.
  • Work on it: development and style.

Installation

Sottomano runs on niri, sway and Hyprland, and finds the running one from NIRI_SOCKET, HYPRLAND_INSTANCE_SIGNATURE or SWAYSOCK. Each compositor’s page covers starting the session and including the window styling rules sottomano writes, and lists what that compositor does not offer. For installing the compositor itself, follow the links on its page.

The pages under this one are organized by package manager:

  • Nix: the flake’s package, the home-manager module for the bar, the NixOS module for the system pieces some widgets read, and the nix profile route for other distributions.
  • From source: building with cargo, for which each distribution’s page lists its build headers.
  • Ubuntu and Fedora: each distribution’s build headers and the sudo sottomano setup <distro> command that sets up the rest.
  • System setup without NixOS: the distro-neutral equivalent of the NixOS module’s system pieces, sudo sottomano setup linux and what each step does.
  • Session startup: the systemd user service that starts the bar with the graphical session, outside home-manager.

Try it from a checkout

On any distribution with Nix and flakes enabled:

git clone https://codeberg.org/wvhulle/sottomano
cd sottomano
nix develop
cargo r

Or run the packaged binary without cloning:

nix run git+https://codeberg.org/wvhulle/sottomano

sottomano --help lists the subcommands that describe the setup without starting the shell, such as default-config and secrets.

Nix

The flake provides a package, a home-manager module for the bar and a NixOS module for the system pieces some widgets read. NixOS is the tested setup; the nix profile route below works on any distribution.

On NixOS

Add the flake as an input and import both modules. The NixOS module enables the system services and kernel modules. The home-manager module installs the bar, writes its config and starts it with the graphical session.

{
  inputs = {
    nixpkgs.url = "github:nixos/nixpkgs/nixpkgs-unstable";
    home-manager = {
      url = "github:nix-community/home-manager";
      inputs.nixpkgs.follows = "nixpkgs";
    };
    sottomano = {
      url = "git+https://codeberg.org/wvhulle/sottomano";
      inputs.nixpkgs.follows = "nixpkgs";
    };
  }

  outputs =
    { nixpkgs, home-manager, sottomano, ... }:
    {
      nixosConfigurations.laptop = nixpkgs.lib.nixosSystem {
        system = "x86_64-linux";
        modules = [
          ./configuration.nix
          sottomano.nixosModules.default
          home-manager.nixosModules.home-manager
          {
            programs.sottomano.enable = true;
            home-manager.users.alice = {
              imports = [ sottomano.homeModules.default ];
              services.sottomano.enable = true;
            };
          }
        ];
      };
    };
}

sottomano.homeModules.default also imports niri-hm’s module. When home-manager runs niri (services.niri.enable) or sway (wayland.windowManager.sway.enable), the module includes the window styling rules sottomano writes for that compositor. Hyprland’s Lua config loads them itself, as its page shows.

System module

programs.sottomano.enable turns on fwupd, UPower and power-profiles-daemon (at mkDefault, so a host using tlp can turn the last one off) and loads drivetemp for SATA drive temperatures. The other options:

{
  programs.sottomano = {
    enable = true;
    journalReaders = [ "alice" ];
    nspawnContainerControllers = [ "alice" ];
    autoBrightness.enable = true;
    cpuPowerReading.enable = true;
    cacheCounters.enable = true;
    x3dPreferenceSwitching.enable = true;
  };
}
  • journalReaders adds users to systemd-journal so the systemd and journal groups can read the system journal. Members of wheel already can.
  • nspawnContainerControllers lets users start, stop and restart systemd-nspawn and NixOS containers from the nspawn containers group: a polkit rule allows exactly those verbs on container@*.service and systemd-nspawn@*.service, and nothing else.
  • autoBrightness runs clight for webcam-based brightness, which drives the monitor group’s auto toggle and backlight slider.
  • cpuPowerReading (on by default) makes the RAPL energy counters world-readable for the CPU power reading. That is the PLATYPUS power side channel, so turn it off on machines shared with untrusted users.
  • cacheCounters (on by default with AMD microcode) loads amd_uncore and sets kernel.perf_event_paranoid = 0 for per-chiplet L3 hit rates.
  • x3dPreferenceSwitching (on by default) lets wheel switch the X3D cache or frequency preference from the bar. It matches no device on CPUs without the amd_x3d_vcache driver.

Home-manager module

{ config, ... }:
{
  services.sottomano = {
    enable = true;
    umbra.enable = false;
    windowAgents.kitty.enable = true;
    secretFiles = {
      caldav-password = config.age.secrets.caldav-password.path;
      gh-auth-token = config.age.secrets.gh-auth-token.path;
    };
  };
}
  • settings takes the keys of the full default as Nix attributes and is written verbatim to ~/.config/sottomano.toml.
  • secretFiles.<name> symlinks each secret into ~/.config/sottomano/ from wherever it lives (agenix, sops-nix), so it never enters the Nix store.
  • umbra.enable (default true) registers dark and light palettes with umbra and requires its home-manager module. Set it to false for a static palette.
  • windowAgents.kitty.enable turns on kitty’s socket-only remote control so the window list can tell kitty OS windows apart.
  • virtualMachines.qmpSocket.enable (default true) sets QEMU_OPTS so every NixOS run-*-vm script you start opens a QMP socket in its own temporary directory, which the virtual machines group uses to show a paused VM and power it off. An existing QEMU_OPTS wins.
  • compositors (default [ "niri" "sway" "hyprland" ]) lists the compositors compiled into the bar. Listing only yours gives a smaller build; the bar still finds the running one at startup, and names the missing entry if it runs under a compositor left out of the list.
  • systemdUnit (default true) adds a user service bound to graphical-session.target. Disable it to start sottomano from the compositor’s own startup commands instead.

The home-manager modules also work with standalone home-manager on other distributions.

On other distributions

nix profile builds the same package as on NixOS:

sh <(curl -L https://nixos.org/nix/install) --daemon
mkdir -p ~/.config/nix
echo "experimental-features = nix-command flakes" >> ~/.config/nix/nix.conf
nix profile install git+https://codeberg.org/wvhulle/sottomano

A Nix-built binary looks for GPU drivers under /run/opengl-driver, which only NixOS provides. If it fails to open its surfaces, start it through nixGL or build it from source instead. The system pieces some widgets read need a manual system setup.

From source

Sottomano builds with cargo on a nightly toolchain, which rustup picks up from rust-toolchain.toml in the checkout. The build headers it needs come from the distribution’s package manager: Ubuntu or Fedora.

git clone https://codeberg.org/wvhulle/sottomano && cd sottomano
eval "$(cargo xtask fetch-icon-svgs)"
cargo install --locked --path sottomano-shell

cargo xtask fetch-icon-svgs clones the three icon sets the icon crates embed SVGs from — phosphor, simple-icons and weather-icons — into ~/.cache/sottomano/icon-sets/, each at the revision the flake pins, and prints the PHOSPHOR_SVG, SIMPLE_ICONS_SVG and WEATHER_ICONS_SVG exports the build reads (a Nix build does this itself). The phosphor and simple-icons revisions come from flake.nix; the weather-icons version comes from the nixpkgs the flake pins, so that one needs nix eval --inputs-from ..

To compile in only your compositor, add --no-default-features --features niri,libvirt-domains (or sway, hyprland) to cargo install.

The widgets call distribution tools and D-Bus services when present and hide what they cannot read; sudo sottomano setup <distro> installs them, as the distribution pages show. The system pieces some widgets read need the same command’s system setup steps.

Ubuntu

The Ubuntu-specific parts of installing sottomano: the build headers for cargo and the one command that sets up everything else the widgets read. The bar itself builds from source or comes from Nix, and starts with the session as the session page shows.

Build headers

Before the first cargo build, which needs the headers sottomano links against:

sudo apt install build-essential pkg-config clang libclang-dev \
  libwayland-dev libxkbcommon-dev libgl-dev libvulkan-dev \
  libudev-dev libsystemd-dev libpipewire-0.3-dev

System setup

sottomano setup writes the system files and runs the system commands the widgets read: the modules and udev rules, the runtime tools the widgets call, and the journal group. Review everything it does first:

sottomano setup ubuntu --print

then run it:

sudo sottomano setup ubuntu

It is idempotent: files whose contents already match are left alone. Steps can be left out with --skip (rapl, amd-uncore, journal, runtime-tools), repeatable or comma-separated. Tailscale comes from its own repository, and VPNs show up when NetworkManager runs them through a VPN plugin, such as network-manager-openconnect or network-manager-openvpn.

Ubuntu uses the sudo group where other distributions use wheel; what each system piece is for is on the system setup page.

Fedora

The Fedora-specific parts of installing sottomano: the build headers for cargo and the one command that sets up everything else the widgets read. The bar itself builds from source or comes from Nix, and starts with the session as the session page shows.

Build headers

Before the first cargo build, which needs the headers sottomano links against:

sudo dnf install gcc pkgconf-pkg-config clang clang-devel \
  wayland-devel libxkbcommon-devel mesa-libGL-devel vulkan-loader-devel \
  systemd-devel pipewire-devel

System setup

sottomano setup writes the system files and runs the system commands the widgets read: the modules and udev rules, the runtime tools the widgets call, and the journal group. Review everything it does first:

sottomano setup fedora --print

then run it:

sudo sottomano setup fedora

It is idempotent: files whose contents already match are left alone. Steps can be left out with --skip (rapl, amd-uncore, journal, runtime-tools), repeatable or comma-separated.

Fedora ships tuned-ppd, which serves the power-profiles-daemon D-Bus API the power group reads. Tailscale comes from its own repository, and VPNs show up when NetworkManager runs them through a VPN plugin, such as NetworkManager-openconnect or NetworkManager-openvpn. Fedora uses the wheel group; what each system piece is for is on the system setup page.

System setup without NixOS

The NixOS module enables the system pieces some widgets read. Without it, sottomano setup sets them up by hand:

sudo sottomano setup linux

sottomano setup linux --print shows every file it writes and every command it runs first. On Ubuntu and Fedora the distribution subcommands also install the runtime tools. What each piece is for:

  • A drivetemp module load at boot gives the SATA drive temperatures.
  • A udev rule makes the RAPL energy counters world-readable for the CPU power reading, and hands the amd_x3d_mode sysfs file to the administrators group (wheel or sudo, auto-detected) so the bar can switch the X3D cache or frequency preference. The RAPL rule can be left out with --skip rapl; it is the PLATYPUS power side channel, so skip it on machines shared with untrusted users.
  • The amd_uncore module load and kernel.perf_event_paranoid = 0 (together the amd-uncore step) give the per-chiplet L3 hit rates; --skip amd-uncore leaves them out.
  • Adding your user to systemd-journal (--skip journal leaves it out) lets the journal group read the system journal.

The runtime tools and D-Bus services the widgets call come from each distribution’s package manager — NetworkManager and its VPN plugins, ModemManager, bluez, PipeWire with wireplumber, fwupd, UPower and a power-profiles-daemon implementation (Fedora’s tuned-ppd serves the same D-Bus API). Tailscale comes from its own repository.

Configuration

There is one TOML file. The complete default below is generated from the code, so it cannot drift from what the shell actually reads.

The config file

The file is $XDG_CONFIG_HOME/sottomano.toml, usually ~/.config/sottomano.toml. Every key is optional, so the file only needs what differs from the defaults. sottomano default-config prints the full default, and its output is a working starting point for the file.

The running shell watches the file and applies every saved change in place. Unknown keys are rejected. An invalid file raises a toast naming the problem, and the previous configuration stays in use.

With home-manager, services.sottomano.settings takes the same keys as Nix attributes, and the module writes them to that file verbatim.

Compositors

The same file works on niri, sway and Hyprland. The gamepad’s compositor_actions name compositor-neutral actions; on a compositor without an equivalent, such as toggle_overview on sway, pressing the button shows a toast saying so. The window styling group keeps its settings in the state database and writes them as rules for the running compositor, which its page explains how to include: niri, sway, Hyprland.

Full default

Every key with its default, as sottomano default-config prints it.

[theme.palette]
background = "#0b0e14"
text = "#e6e1cf"
primary = "#95e6cb"
success = "#aad94c"
warning = "#ffb454"
danger = "#f07178"
background_weak = "#202229"
background_strong = "#3e4b59"
secondary = "#bfbdb6"

[theme.font]
text_size = "14px"

[theme.spacing]
item_gap = "4px"
surface_inset = "10px"
group_gap = "14px"

[desktop_panels]
height = "24px"

[desktop_panels.slot_drag]
ghost_scale = 1.08
spring_load_delay = "600ms"
focus_indicator_duration = "160ms"

[group_menus]
alert_promotion_severity = "warning"

[widget_group_layout]
expansion_duration = "90ms"

[group_items]
slider_expansion_duration = "90ms"

[hover_cards]
copy_flash_duration = "700ms"

[hover_cards.loading]
debug_delay = "0s"

[hover_cards.loading.placeholder]
effect = "pulse"
period = "1s 400ms"

[hover_cards.loading.intro]
effect = "fade"
duration = "500ms"

[hover_cards.loading.replace]
effect = "decode"
duration = "350ms"
glyph_change = "55ms"

[overlay_surfaces]
surface_morph_duration = "160ms"
typing_idle_delay = "750ms"
typing_background_opacity = 0.3

[widget_groups.window]
audio_indicator = "shown"
progress = "shown"
count_badge = "shown"
nixpkgs_branch = "nixpkgs-unstable"
maximum_window_title_width = "200px"

[widget_groups.window.agents]
kitty = "enabled"
wezterm = "enabled"
pty_shells = "enabled"

[widget_groups.workspaces]
preview_scale = 0.19

[widget_groups.tray.items.Signal_status_icon_1]
icon = "shown"
left_click = "focus_app_window"
middle_click = "secondary_activate_item"
right_click = "ignore"
window_app_id = "signal"

[widget_groups.tray.items.spotify-client]
icon = "shown"
left_click = "focus_app_window"
middle_click = "secondary_activate_item"
right_click = "ignore"
window_app_id = "spotify"

[widget_groups.tray.items.steam]
icon = "shown"
left_click = "focus_app_window"
middle_click = "secondary_activate_item"
right_click = "ignore"
window_app_id = "steam"

[widget_groups.compute.temperature_sensors]

[widget_groups.memory_board.growth_trend]
trend_window = "2m"
warning_when_full_within = "5m"
critical_when_full_within = "1m"

[widget_groups.physical_drive.temperature_sensors]

[widget_groups.systemd_manager]
slow_job = "10s"
slow_boot_unit = "5s"
slow_shutdown_stop = "5s"
slower_boot_warning_percent = 25
slower_boot_critical_percent = 60

[widget_groups.claude_usage]
monthly_budget_usd = 200

[widget_groups.copilot_usage]
monthly_budget_usd = 40

[widget_groups.openrouter_credit]
monthly_budget_usd = 20
daily_warning_usd = 5
max_blended_usd_per_million = 4.0

[widget_groups.session_focus]
idle_detection_delay = "30s"
shortest_counted_focus = "1s"
idle_after = "3m"
session_warning = "45m"
session_critical = "1h 30m"
warning_snooze = "15m"
switches_per_hour_warning = 100
switches_per_hour_critical = 300
switch_heat_span = "30m"
switch_heat_warning = 20
switch_heat_critical = 32

[widget_groups.session_focus.app_daily_budget_minutes]

[widget_groups.session_focus.project_daily_budget_minutes]

[widget_groups.weather.rain_alerts]
lead_time = "15m"
minimum_millimeters_per_hour = 0.5
heavy_millimeters_per_hour = 4.0

[widget_groups.reachability]
latency_target = "1.1.1.1"
metered_cycle_start_day = 1

[widget_groups.gamepad.navigation]
stick = "left"
up = "focus_workspace_up"
down = "focus_workspace_down"
left = "focus_left"
right = "focus_right"

[widget_groups.gamepad.pointer]
stick = "right"
pixels_per_second = 1600
left_button = "right_trigger"
right_button = "right_stick"

[widget_groups.gamepad.typed_keys]
south = "Return"
east = "BackSpace"
north = "shift+Tab"
west = "Escape"
dpad_up = "Up"
dpad_down = "Down"
dpad_left = "Left"
dpad_right = "Right"
back = "Tab"

[widget_groups.gamepad.compositor_actions]
left_shoulder = "move_left"
right_shoulder = "move_right"
start = "maximize"
guide = "toggle_overview"
left_stick = "focus_previous_window"

[widget_groups.gamepad.commands]

[widget_groups.gamepad.push_to_talk]
button = "left_trigger"

[widget_groups.caldav]
server_url = ""
user = ""
upcoming_days = 7
change_poll_interval = "1m"
push_safety_recheck_interval = "15m"
subscription_download_interval = "15m"

[widget_groups.media.lyrics]
lrclib_lookup = "enabled"

[notification_toasts.placement]
fallback_corner = "bottom-right"

[notification_toasts.lifetime]
low_severity_timeout = "8s"
normal_severity_timeout = "12s"
critical_severity_timeout = "0s"

[notification_toasts.card]
appear = "90ms"
dismiss_fade = "140ms"
expire_fade = "220ms"
motion = "160ms"

[calendar]
other_time_zones = ["Asia/Shanghai"]

[calendar.working_hours]
start = "09:00:00"
end = "17:00:00"

[launched_programs]
terminal_command_prefix = ["kitty", "-e"]
color_temperature_service = "screen-temperature.service"

Tray clicks

A left click on a tray icon brings the app’s window to the front. The window is found through the process that owns the tray icon on D-Bus, or through window_app_id for apps whose window belongs to another process. When no single window matches, the click activates the item instead. The shell first gets an XDG activation token from the compositor and passes it to the app, so the app may raise the window it opens. Tray menu clicks pass a token the same way. The hover hint shows which of the two a click will do.

X11 apps run through xwayland-satellite, so the compositor reports the satellite as the owner of their windows. They need window_app_id, as the default rule for Steam has. Apps whose tray item cannot take an activation token, such as libappindicator items and X11 apps, may still fail to come to the front from their own menu items. That limit is in the app.

To keep an app’s own activate behaviour, set left_click = "activate_item" for its item, keyed by the item’s StatusNotifierItem id.

Application menu

The application menu is off by default; the Behaviour menu has the switch. When it is on, the top panel gets an Application menu with the focused window’s menu bar: each top-level menu is a group, its entries are rows, and entries of submenus appear as Parent › Entry. Single entries and whole menus can be pinned to the panel like any other group.

Qt apps export their menu bar over D-Bus only when a global menu registrar is running when they start, so while the switch is on the shell claims the com.canonical.AppMenu.Registrar name. Qt apps started after that hide the menu bar inside their window; apps started before keep theirs until they are restarted. GTK apps that publish a menu bar show it in both places.

X11 apps are matched to their exact window: through the registrar, through the menu properties KDE apps set on their window, or through the ones GTK apps and LibreOffice set. Native Wayland apps are matched through the process that owns the window, so when one process has several menu bars, the most recent one is shown and its hover card says so. Browsers and Electron apps running natively on Wayland export no menu, and apps built on libadwaita have no menu bar.

Secrets

A few widgets need an API key or password. Each one is optional, and the widgets that need a missing one stay hidden. sottomano secrets lists the secret files in ~/.config/sottomano/, what each one enables and whether it is present.

With home-manager, services.sottomano.secretFiles.<name> symlinks each one from wherever the secret actually lives, so nothing lands in the Nix store.

Session startup

Outside home-manager, write the user service the module would generate to ~/.config/systemd/user/sottomano.service:

[Unit]
Description=sottomano bar
PartOf=graphical-session.target
After=graphical-session.target

[Service]
ExecStart=%h/.cargo/bin/sottomano
Restart=on-failure
RestartSec=2
Slice=session.slice
CPUWeight=500

[Install]
WantedBy=graphical-session.target

Use %h/.nix-profile/bin/sottomano for a Nix profile install, then enable it:

systemctl --user enable --now sottomano.service

session.slice and the raised weight keep the bar responsive when apps load the machine. The service starts with graphical-session.target. niri starts that target itself; sway and Hyprland need a line in their config, as their pages show.

Next: configuration.

Compositors

Sottomano runs on niri, sway and Hyprland, and finds the running one from NIRI_SOCKET, HYPRLAND_INSTANCE_SIGNATURE or SWAYSOCK. The same configuration works on each compositor; the pages below cover what each one offers the bar, starting the session and including the window styling rules sottomano writes.

  • niri: everything the bar offers works, including the overview and column moves.
  • sway and Hyprland: what they do not offer.

niri

Sottomano talks to niri through niri-ipc 26.4, so use niri 26.04 or newer. Everything the bar offers works on niri, including the overview, column moves and the workspace order restored from niri’s config. For installing niri itself, follow its instructions.

Start with the session

niri’s niri-session starts graphical-session.target, which starts the sottomano user service from the session page.

The bar’s frames only reach the screen through niri, so give niri at least the bar’s CPU weight in ~/.config/systemd/user/niri.service.d/sottomano-priority.conf, which takes effect at the next login:

[Service]
CPUWeight=1000

The home-manager module writes this file when services.niri.enable is set.

Window styling rules

The window styling group writes ~/.config/sottomano/niri.kdl: blur for the panel and toasts, shadows behind the panels, window corner radius, gaps, and the switch-heat borders. Include it in ~/.config/niri/config.kdl:

include "/home/alice/.config/sottomano/niri.kdl" optional=true

The home-manager module adds this include when services.niri.enable is set. niri reloads the file after each change.

sway

Sottomano follows sway through its IPC socket (SWAYSOCK). It is tested with sway 1.12. For installing sway itself, follow its instructions.

Start with the session

sway does not start graphical-session.target by itself, which the sottomano user service from the session page waits for. Start a target bound to it at the end of your sway config, after exporting the session environment to systemd:

exec dbus-update-activation-environment --systemd WAYLAND_DISPLAY SWAYSOCK XDG_CURRENT_DESKTOP && systemctl --user start sway-session.target

with ~/.config/systemd/user/sway-session.target:

[Unit]
BindsTo=graphical-session.target
Wants=graphical-session.target
After=graphical-session-pre.target

Window styling rules

The window styling group writes ~/.config/sottomano/sway.conf with the window gaps. Include it in your sway config:

include ~/.config/sottomano/sway.conf

The home-manager module adds this include when wayland.windowManager.sway.enable is set. Sottomano asks sway to reload after each change.

What sway does not offer

  • Stock sway has no rounded window corners, blur or per-window border colours, so the window styling group only shows the gaps slider.
  • There is no overview: pressing the active workspace does nothing.
  • Workspaces cannot be reordered: dragging one in the bar reports that the compositor has no equivalent. Dragging a window onto another workspace moves it, but windows have no columns to reorder within a workspace.
  • Toggling the overview, maximizing, centring and focusing the previous window from a gamepad report that sway has no equivalent.

Apps launched from the bar start in their own transient user service through systemd-run --user.

Hyprland

Sottomano follows Hyprland through its request and event sockets (HYPRLAND_INSTANCE_SIGNATURE). It sends its requests as Lua dispatchers (hl.dsp) and writes a Lua rule file, so it needs Hyprland 0.56 or newer with a Lua config (hyprland.lua). It is tested with Hyprland 0.56.2. For installing Hyprland itself, follow its installation guide.

Start with the session

Hyprland does not start graphical-session.target by itself, which the sottomano user service from the session page waits for. Start a target bound to it from your Lua config, after exporting the session environment to systemd:

hl.on("hyprland.start", function()
    hl.exec_cmd("dbus-update-activation-environment --systemd WAYLAND_DISPLAY HYPRLAND_INSTANCE_SIGNATURE XDG_CURRENT_DESKTOP && systemctl --user start hyprland-session.target")
end)

with ~/.config/systemd/user/hyprland-session.target:

[Unit]
BindsTo=graphical-session.target
Wants=graphical-session.target
After=graphical-session-pre.target

Window styling rules

The window styling group writes ~/.config/sottomano/hyprland.lua: gaps, rounding, blur for the panel and toasts, and the switch-heat border colours. Load it at the end of your hyprland.lua:

local sottomano_rules = os.getenv("HOME") .. "/.config/sottomano/hyprland.lua"
local sottomano_rules_file = io.open(sottomano_rules)
if sottomano_rules_file then
    sottomano_rules_file:close()
    dofile(sottomano_rules)
end

The home-manager module does not add this, because home-manager’s Hyprland module writes a hyprland.conf. Sottomano asks Hyprland to reload after each change.

What Hyprland does not offer

  • There is no overview: pressing the active workspace does nothing, and toggling the overview from a gamepad reports that Hyprland has no equivalent.
  • Workspaces cannot be reordered: dragging one in the bar reports that the compositor has no equivalent. Dragging a window onto another workspace moves it, but windows have no columns to reorder within a workspace.

Apps launched from the bar start in their own transient user service through systemd-run --user.

Guide

Every group that has a recording and is not already in the introduction, rendered from fixtures, so the numbers are made up. The shell also has groups for the clock, disks, GPU, network interfaces, power, the system tray, systemd units and a few more, which look and behave the same way.

Widgets that look alike share one recording, with the others listed in a table below it. The compute page shows a group in its menu next to the same group pinned in the bar. The looks have a page of their own.

Group menus

Most groups live in menus behind a single icon on a panel. Pressing the icon opens the menu: one row per group, with settings that share a unit lined up in a table above them. Hovering a row opens the group’s items in a panel beside it, pressing it opens them at once, and hovering an item’s info icon there shows its card. A menu stays open until a press anywhere outside it, on any monitor, a press on its icon or Escape; while it is open, hovering or pressing another menu icon switches to that menu. Menus open downward from the top panel and upward from the bottom panel.

Opening the Media menu, pressing its rows and hovering the microphone Opening the Appearance menu with its settings lined up in a table

The pin on a row puts that group back on the panel, next to its menu icon; a pin on one of the group’s items puts only that item there. The unpin button in its card returns it to the menu. Pins are remembered across restarts. A group whose alert reaches group_menus.alert_promotion_severity also shows on the panel until the alert clears.

Pinning the memory group from the Hardware menu, which moves it onto the panel

The Menus group in the Behaviour menu changes how every menu opens. Menus can open from their icon on hover instead of on press, and such a menu closes when the pointer leaves it. In an accordion, pressing a row opens the group’s items below it, and several groups can stay open.

Opening two groups of the Hardware menu as an accordion
MenuPanelGroups
Hardwaretop, leftpower, compute, memory and board, GPUs, storage, drives
Networktop, leftWi-Fi adapters, mobile modems, ethernet ports, Bluetooth, reachability, VPN
Application menutop, leftthe focused window’s menus, one group per top-level menu; off until switched on in Behaviour
Agendatop, middlecalendar, weather, quiet schedule, focus session
Displaytop, rightmonitors, dark mode, colour temperature, wallpaper
Mediatop, rightmedia players, audio, dictation, game controllers
Appearancebottom, leftpanel, sidebar, overlay, window and menu settings with a unit
Behaviourbottom, leftthe remaining panel, sidebar, overlay, window and menu settings, the application menu switch, looks
Applicationsbottom, middlefrequently launched apps, one group per category, launcher options
Virtualizationbottom, rightvirtual machines, microVMs, Incus instances, nspawn containers, LXC containers, Podman containers, Docker containers, Waydroid, Flatpak apps, memory limits
Servicesbottom, rightlocal models, CI runs, AI usage, systemd units, journal, upgrades
Sessionbottom, rightidle timeouts, login session

Top panel

The Hardware, Network and Application menus on the left; the clock, Agenda menu and notifications in the middle; the Display and Media menus and the power menu on the right.

Bottom panel

The Appearance and Behaviour menus and the workspaces on the left; the windows in the middle; the Virtualization, Services and Session menus and the system tray on the right.

  • Windows and workspaces: previews, the window list and the process inspector.
  • Idle and session: idle timeouts and the login session.
  • AI usage: subscription windows, billing block and spend.
  • Network: WireGuard tunnels with a switch to connect or disconnect them, and who may reach this machine over Tailscale.
  • Virtualization and CI runs: running QEMU, libvirt and microVM guests, Incus instances, systemd-nspawn and LXC containers, the containers in each Podman and Docker engine, Waydroid and Flatpak sandboxes, memory caps on slices and services, and the runs of local CI runners.
  • Appearance: the looks and how to switch them.

Notification toasts

Compute

The machine’s processor: load, clock, temperature, power and the cache layout of each chiplet.

In its menuPinned in the bar
Opening the compute group from the Hardware menu The compute group pinned in the panel, then expanded

Compute: CPU

Pressing the compute row in the Hardware menu opens load, temperature, power and model.

Opening the compute group from the Hardware menu from the CPU reading

Compute: Model

The CPU model with its vendor logo, microarchitecture and caches.

Hovering the CPU model in the compute group

Compute: Other readings

These open from the same row and look like the readings above.

ReadingWhat it shows
ClockEach thread’s clock per chiplet, with the chiplet the scheduler prefers starred.
TemperatureThe hottest sensor, and the chip it is read from.
Cache preferenceOn dual-chiplet X3D parts, which chiplet the scheduler fills first; a click flips it.

Memory and board

Memory use and the hardware it sits on.

Memory and board: Memory

Memory use, the processes holding the most and the installed modules.

Opening the memory and board group from the Hardware menu from the memory reading

Memory and board: Other readings

This card looks like the CPU model card.

ReadingWhat it shows
BoardThe board with its maker’s logo, model and firmware.

Weather

Conditions where you are, from the location fix to rain and pollen.

Weather: Rain

Rain in the coming hours on one timeline, split by hour. Each stretch of time comes from the most trusted source that covers it: radar nowcasts (Buienradar, every five minutes) rank above forecast models (Open-Meteo, every quarter-hour), and the model takes over where the radar ends or has no coverage. Within the radar’s range the model only adds the precipitation type, and only where both see rain.

Hovering the rain item in the weather group

Weather: Other readings

These open from the weather group like the rain reading above.

ReadingWhat it shows
LocationHow rough the location fix is and which source produced it.
WindWind speed, gusts and direction, with the Beaufort force in the panel.
PollenToday’s pollen per allergen.

Audio and media

What is playing, and the speaker and microphone it plays through.

Media: Track

The playing track from any MPRIS player, with where to find it.

Hovering the playing track

Audio: Volume

Clicking the speaker opens a slider in place; dragging sets the level.

Opening the volume slider and dragging it

Audio: Other readings

The microphone sits next to the speaker in the audio group.

ReadingWhat it shows
MicrophoneThe microphone with its live level meter, straight from PipeWire.

More of the top panel

The smaller top-panel groups: tunnels, dictation, the agenda and game controllers.

Agenda: Events and tasks

Upcoming events and open tasks over CalDAV, overdue tasks in red.

Opening the Agenda menu and hovering its events and tasks

Game controller: Bindings

The controller drawn with every bound button labelled in place.

Hovering the game controller to see its bindings drawn on the controller

Other readings

The introduction shows the Tailscale and dictation groups; these open from the same groups.

ReadingWhat it shows
WireGuard: TunnelsEach WireGuard tunnel by its connection name, client or server, with each peer’s last handshake and traffic.
Dictation: PipelineWhat the dictation daemon is doing right now and how fast the last pass decoded.

Wallpaper

The place in the current wallpaper and what Wikipedia says about it.

Wallpaper: Article

The Wikipedia summary for that place.

Hovering the Wikipedia article in the wallpaper group

Wallpaper: Other readings

This opens from the same group as the article.

ReadingWhat it shows
PlaceThe place in the current wallpaper, when it carries a location.

Windows and workspaces

Workspace previews, the window list and what runs behind each window.

Workspaces: Previews

Hovering a workspace shows its windows.

Hovering a workspace in the bottom panel

Applications: Frequent apps

The rocket before the window list opens the installed applications. The first group ranks apps by launches on the current workspace, or across all workspaces on an unnamed one. An app with an open window has a button on the right of its row that focuses its most recently used window.

Frequently launched apps with a hover card and open window buttons

Applications: Categories

Every other app is listed under its freedesktop category. A list grows up to the height of the screen and scrolls when it does not fit, fading at an edge with rows beyond it.

A scrolled application list fading at both edges

Windows: Directory groups

Neighbouring terminals in one directory share a heading, with the selected window in the accent colour.

Terminals grouped under their working directory in the bottom panel

Windows: Process inspector

The process tree behind a window.

Hovering the process inspector of a window

Windows and workspaces: Other views

These look like the views above.

ViewWhat it shows
WindowA window’s title, app id, window id and when it last had focus.
Flat rowsWith grouping turned off in the panel settings, each window shows its own directory.
Workspace sidebarThe same workspace previews in a vertical sidebar.

Idle and session

When the screen turns off and locks, and what the session runs on.

Idle timeouts: Screen off

Dragging a timeout changes it; the card says when it fires and through which mechanism.

Opening the idle timeouts from the Session menu and hovering the screen-off timer

Idle timeouts: Other timers

This timer works like the screen-off timer above.

TimerWhat it shows
LockThe lock timer, next to the screen-off timer.

Login session: Kernel and compositor

The kernel and the compositor, each with a logo and a wiki link.

Opening the login session group from the Session menu and hovering the kernel and compositor

AI usage

Subscription windows, the current billing block and spend against the monthly budget.

AI usage: Usage windows

How much of each subscription window is used, and where it is heading by reset.

Opening the AI usage group from the Services menu and hovering the Claude usage windows

AI usage: Other readings

These open from the same group as the usage windows.

ReadingWhat it shows
Billing blockThe current five-hour block: spend so far, burn rate and where it will end.
PeriodsSpend today, this week and this month against the monthly budget.

Network

Network: WireGuard tunnels

Every WireGuard interface on this computer, one row each, with a switch that connects or disconnects the tunnel. A server tunnel counts its connected clients in a badge. Its card shows the listen port, how many clients are connected and, per peer, the endpoint, the time since the last handshake and the data received and sent. Peer statistics need CAP_NET_ADMIN; without it the card says so.

Opening the WireGuard group from the Network menu and hovering a server tunnel

Network: Tailscale access

The Tailscale group has a switch for Tailscale itself, this machine’s tags, the exit nodes to route through and a switch for reaching the LAN while exiting. The “Reachable from” row reads the Tailscale packet filter: its card lists the machines that may reach this one and on which ports, and the machines allowed to route through it.

Opening the Tailscale group from the Network menu and hovering who can reach this machine

Virtualization and CI runs

The Virtualization menu holds everything that runs a guest on this computer: virtual machines, microVMs, Incus instances, nspawn, LXC, Podman and Docker containers, Waydroid, Flatpak sandboxes, and the memory caps that keep them from crowding out the desktop. Groups for kinds of guests this computer does not run stay hidden.

Virtualization: Virtual machines

Every running QEMU virtual machine on this computer, one row each, with its CPU and memory use: the ones you start yourself, NixOS tests running inside a nix build, and libvirt domains. A paused or shutting-down machine says so in its row. The card lists its cores, memory, disks, forwarded ports and, for NixOS, the system it boots.

Pressing a machine that has its own window focuses that window. Right-pressing powers it off the way the power button would, when the machine has a QMP socket this user can open, or when it is a libvirt domain.

Opening the virtual machines group from the Virtualization menu and hovering a machine started from a terminal

Virtualization: MicroVMs

Every guest running under a lightweight hypervisor on this computer, one row each, with its memory use: Cloud Hypervisor, Firecracker, crosvm and kvmtool, as started by microvm.nix or by hand. A microvm.nix guest is named after its microvm@ service and its memory is that service’s; any other guest is named after its --id or --name, or its hypervisor and process. The card lists the hypervisor, guest memory and cores, uptime, service, kernel and API socket.

Right-pressing a Cloud Hypervisor or Firecracker guest with an API socket this user can open presses its power button.

Virtualization: Incus instances

Every Incus or LXD instance on this computer, containers and virtual machines alike, across all projects, one row each. A running instance shows its address, or its memory use when it has none; a stopped one says so. The card lists its kind, status, daemon, project, memory use, addresses and image. Pressing a stopped instance starts it and right-pressing a running one stops it. Reading the daemon needs your user in the incus-admin (or lxd) group; without it the daemon gets one row that says access is denied.

Virtualization: nspawn containers

Every systemd-nspawn container on this computer, one row each, including NixOS containers: the running ones machined knows about and the stopped ones that only have a container@ or systemd-nspawn@ service. A running container shows its address in its row; the others say whether they are stopped, starting or stopping. The card lists its service, addresses, uptime, memory use, leader process and root directory.

Pressing the switch on a row starts or stops the container’s service. Containers are system services, so this needs your user in the system module’s nspawnContainerControllers (or another polkit rule that lets it manage these units); otherwise the switch falls back to the state it had.

Opening the nspawn containers group from the Virtualization menu and hovering a running NixOS container

Virtualization: LXC containers

Every plain LXC container on this computer, one row each: the running ones, found by their control group, and the stopped ones configured under /var/lib/lxc. A running container shows its memory use. The card lists its state, memory use, configured addresses and config file. Waydroid’s container is left out here because it has its own group.

Virtualization: Podman containers

Every Podman engine a systemd service on this computer serves, one row each, named after its owner, with the number of containers running in it. An engine whose socket this user may not open shows a warning in its row, and its card says why. The card lists the running containers with their image, CPU and memory use, and the engine’s unit and socket.

Opening the Podman containers group from the Virtualization menu

Virtualization: Docker containers

The Docker engines on this computer, one row each: the system engine at /run/docker.sock and a rootless engine in your runtime directory. An engine that is not installed or not running has no row. Each row shows how many containers run in it, and its card lists them with their image, CPU and memory use. A Woodpecker step is shown by its step name.

Virtualization: Waydroid

The Android container Waydroid runs, as one row while its container or session is up, with the session’s state. The card lists whether the container runs, its memory use, the session’s state and user, and the screen density. Right-pressing it asks Waydroid to stop the container.

Virtualization: Flatpak apps

Every Flatpak app you are running, one row per sandbox, with its memory use. The card lists the app, memory use, uptime, runtime, branch, instance number and sandbox process.

Virtualization: Memory limits

Every systemd slice, service or container on this computer that has a memory cap, one row each, with its memory use against the cap, plus the machine slice where containers and libvirt machines run, capped or not. A row warns when its use passes the mark where the kernel starts throttling it, or when its processes spent more than a tenth of the last ten seconds waiting for memory. It turns red when the kernel killed one of its processes since Sottomano started.

The card lists the throttle mark, the hard cap, swap in use against its own cap, the time spent waiting for memory, how often the cgroup was throttled or hit the cap, how many processes were killed, and the cgroup path. Pressing a row opens systemd-cgtop in a terminal, sorted by memory, for that cgroup.

Opening the memory limits group from the Virtualization menu and hovering the CI runners slice

Services: CI runs

The runs that the Woodpecker and Forgejo runners on this computer are executing, one row each, with its repository and run and how long it has been running. The card shows the step that is running, its image, CPU and memory use, the time until it times out and a link to its log.

Opening the CI runs group from the Services menu and hovering a Woodpecker run

Appearance

The Looks group in the Behaviour menu holds ready-made combinations of colour scheme, panel opacity, blur, shadow and outline. Picking one switches the desktop’s dark or light mode and saves the new look, so the panel, sidebar and overlay settings can fine-tune it afterwards.

  • Frosted dark: dark colours, translucent panels frosted with blur.
  • Clear dark: dark colours, see-through panels without outline, blur or shadow, and a lightly tinted, frosted and outlined sidebar.
  • Bright office: light colours and nearly opaque panels without blur.

Hover cards stay blurred in every look. The recordings are rendered offscreen, where there is nothing to blur, so blur does not show in them.

Looks: Switching

Pressing a look applies it at once; its icon lights up while it is in use.

Opening the Behaviour menu and switching from clear dark to frosted dark

Notification toasts

The shell is the notification daemon.

Toast: Arrival

A toast arrives, holds while hovered and expires on its own.

A toast arriving, holding while hovered and expiring

Toast: Progress

A download that reports progress draws a progress bar.

A download toast with a rising progress bar

Toast: Actions

Actions the sender offers appear as buttons.

Hovering the actions of an invitation toast and accepting it

Development

Setup and workflow for working on sottomano: building and checking it, debugging, CI, the VM tests and the tour recordings. How the crates fit together is in Architecture; the rules the code follows are in Style.

Work inside the Nix devshell (nix develop).

Building and checking

  • cargo r for a debug run.
  • cargo clippy --workspace --all-targets before committing. The workspace warns on clippy pedantic plus absolute_paths.
  • treefmt formats Rust (rustfmt, nightly for the options in .rustfmt.toml) and Nix (nixfmt), as configured in treefmt.toml.
  • cargo xtask update-config-docs regenerates the full default config in docs/configuration.md; with --check it only reports whether it is stale.

Version control is jj, not the git CLI.

State database schema

Every table in ~/.local/state/sottomano/state.db is defined in db-migrations/. A schema or data change is a new file named NNNN_what_changes.sql with the next number, registered in sottomano-desktop-sources/src/sqlite_state_store/versioned_schema_migrations.rs; a test fails when a file is missing there or out of order. Never edit a file that has shipped: the database records the last applied number in PRAGMA user_version and runs each newer file once, inside a transaction.

Debugging with comet

cargo r --features devtools attaches iced’s devtools to the running shell, and --features time-travel adds rewinding. The metrics show in comet, iced’s debugger window. The bar is a layer surface that never takes keyboard focus, so devtools’ F12 shortcut does not reach it. Start comet yourself:

cargo install --locked --git https://github.com/iced-rs/comet.git \
  --rev 3f75f3240edc1719df584810337bc7df010327d8
iced_comet &
cargo r --features devtools

The revision is the one the iced fork’s devtools pins; bump it when the fork moves. The shell’s beacon keeps retrying its connection, so comet and the shell can start in either order. Comet then shows each message with its update, view, layout and draw timings, plus task and subscription counts; under time-travel it can rewind the shell to an earlier message.

CI

Woodpecker CI (.woodpecker/) builds the flake checks (tests, clippy and format) on pull requests and on main. It also checks pull request commits against Conventional Commits (locally: nix run --inputs-from . nixpkgs#convco -- check main..HEAD). After checks pass on main, the recordings pipeline re-renders the tour, force-pushes it as the single commit of the orphan recordings branch and opens a pull request when the embed widths changed. The config docs pipeline does the same for the full default config in docs/configuration.md when the config crate changes.

flowchart LR
  pull_request_event(["pull request"])
  main_push_event(["push or manual on main"])

  subgraph checks_pipeline ["checks pipeline"]
    pull_request_checks["tests, clippy"]
    main_checks["tests, clippy,<br>then push to the binary cache"]
    format_check["format<br>treefmt: rustfmt, nixfmt"]
    conventional_commits["conventional commits<br>convco check"]
  end

  subgraph recordings_pipeline ["recordings pipeline"]
    render_recordings["render the tour<br>nix build .#readme-recordings<br>cargo xtask update-recording-embeds"]
    push_orphan_recordings["replace the single commit<br>of the recordings branch"]
    push_recordings_branch["force-push resized embeds<br>to Codeberg"]
    open_recordings_pull_request["open, update or close<br>the pull request"]
    render_recordings -->|"recordings/*.webp"| push_orphan_recordings
    render_recordings -->|resized embeds| push_recordings_branch
    push_recordings_branch --> open_recordings_pull_request
  end

  subgraph config_docs_pipeline ["config docs pipeline"]
    update_config_docs["regenerate the default config<br>cargo xtask update-config-docs"]
    push_config_docs_branch["force-push to Codeberg"]
    open_config_docs_pull_request["open, update or close<br>the pull request"]
    update_config_docs --> push_config_docs_branch
    push_config_docs_branch --> open_config_docs_pull_request
  end

  main_branch[("main")]

  pull_request_event --> pull_request_checks & format_check & conventional_commits
  main_push_event --> main_checks & format_check
  checks_pipeline -->|"after checks"| recordings_pipeline
  checks_pipeline -->|"after checks"| config_docs_pipeline
  open_recordings_pull_request -->|recordings PR| main_branch
  open_config_docs_pull_request -->|config docs PR| main_branch

The recordings pipeline skips its own commits. The config docs pipeline only runs when the config crate, xtask/ or docs/configuration.md change, and skips its own commits.

VM tests

vm-tests/ boots a NixOS VM per compositor (niri, sway, Hyprland) and scenario, runs sottomano in a real session there and checks it through the compositor’s IPC and OCR of the screen. Each check is a flake check named vm-<compositor>-<scenario>:

  • panel-mapped: the bar starts and maps its panel.
  • styling-rules: the bar writes the running compositor’s rule file and the compositor accepts it.
  • workspace-follow: named windows on two workspaces show in the bar after each workspace switch.
nix build .#checks.x86_64-linux.vm-sway-workspace-follow
nix run .#checks.x86_64-linux.vm-sway-workspace-follow.driverInteractive

The VMs need KVM and render in software. The niri in the VMs is patched to accept a software renderer. The bar there uses a large font and a high-contrast palette, so OCR reads it. A failing check prints the compositor’s windows and workspaces and the user journal.

The Woodpecker pipelines skip the VM tests. A Forgejo Actions workflow (.forgejo/workflows/vm-tests.yaml) runs them on pushes to main on a runner labelled nix with KVM.

Tour recordings

The recordings in the guide and the README are rendered offscreen by sottomano-widget-showcase into the untracked root recordings/. nix build .#readme-recordings renders them all the way CI does. Its source is only the crates the showcase links, the workspace manifests and .cargo/; sottomano-shell and xtask contribute their Cargo.toml (which takes part in workspace feature unification) and empty targets. Changes to the shell, xtask, the docs, the showcase’s .ice tests or nix code that does not define the recordings keep the cached recordings.

cargo run -p sottomano-widget-showcase
cargo run -p sottomano-widget-showcase -- --jobs 2
cargo run -p sottomano-widget-showcase -- <demo-name>
cargo run -p sottomano-widget-showcase -- --window

Rendering every demo runs one child process per demo, --jobs at a time (4 by default). A demo buffers its frames until it encodes them, so lower --jobs when memory is short.

flowchart LR
  subgraph widget_showcase [sottomano-widget-showcase]
    group_fixtures["group fixtures<br>on a fixed scripted clock"]
    demo_scripts["demo scripts<br>hover, press, drag, rest, …"]
    scripted_program["scripted program"]
    animation_recorder["recorder<br>iced_test emulator, frame clock,<br>content crop, lavapipe"]
    interactive_tester["interactive tester<br>--window"]
    ice_scenarios[".ice scenarios"]
    group_fixtures --> demo_scripts --> scripted_program --> animation_recorder
    group_fixtures --> interactive_tester
    group_fixtures --> ice_scenarios
  end

  embedding_update["cargo xtask update-recording-embeds"]

  recorded_webp[/"recordings/&lt;name&gt;.webp"/]
  showcase_document["docs/guide/*.md<br>embeds the rest, widths rewritten"]
  readme_document["README.md<br>widths rewritten for the recordings it embeds"]

  animation_recorder -->|lossless animated webp| recorded_webp
  recorded_webp -->|pixel width / 2| embedding_update
  embedding_update -->|"&lt;img src width&gt;"| showcase_document
  embedding_update -->|"&lt;img src width&gt;"| readme_document

Scripting a demo

Each demo shows one widget, is named <group>_<widget> and sits under its heading on a page of docs/guide/, unless the README shows it. The name also selects the iced preset that boots its fixture for the recorder, the .ice scenarios and the windowed tester alike.

A demo names a fixture and a script of steps: hovering, pressing or dragging named targets for panel widgets, and hovering or pressing visible text for toasts, which a toast fixture serves when the demo starts. The recorder finds each target on screen, plays the steps through iced_test’s emulator and writes recordings/<name>.webp, a lossless animated WebP cropped in both directions to what the demo shows, plus the panel gap as a margin. A group whose handle previews one of its items is opened by pressing that item’s target. A handle without a preview is opened through the target for the group’s face.

To add a demo, write a fixture for the group (fed its readings through its own messages), add the group to the showcase’s groups if it is not there yet, and add the demo to the list of demos. A target that is not on screen fails the run and lists the targets that are.

A demo can put several fixtures in one menu. The settings groups are shown with their messages dropped, because those groups save to SQLite on update. Pressing the pin of a menu row only changes the showcase’s in-memory state.

Reproducibility

Rendering twice gives identical files, which nix build .#readme-recordings --rebuild verifies after a first build. Time is data: every preset boots with a clock whose wall time is the fixed showcase epoch, and the recorder advances that clock one frame interval per captured frame and sends it as a message. The frame also rewrites the redraw instant iced hands to widgets, so animations run from the same scripted time. Live readings, toast expiry and the hover card hide timer fire when their deadline passes on that clock, and the scripted program has no subscriptions.

Keep that property when writing fixtures and widgets: take the clock as an argument, never read the system time (the widgets crate’s clippy config forbids it, also inside macros such as vec!), derive a fixture’s sample index from the clock, and return deadlines instead of starting timers. iced’s own text input blinks its cursor from the real clock, so no demo types into one.

Windowed tester

--window opens the presets in a real window with the iced tester attached, which switches between presets and records interactions as .ice scenarios. Scenarios saved in the showcase crate’s tests run with cargo test (and so in CI); expect sees plain text widgets, not rich text such as toast bodies.

Embedding recordings

The recordings are not part of main: CI publishes them as the single commit of the orphan recordings branch, so the history of main stays free of media. That branch serves the recordings when the markdown is read on Codeberg; the documentation site serves its own copy (see below). Each recording appears either in the root README.md (a few highlights) or on a page of docs/guide/ (the rest), as <img src="/recordings/<name>.webp" width="<w>">. <w> is the recording’s cropped width in logical pixels, so all demos show at the same scale. After rendering, cargo xtask update-recording-embeds rewrites <w> from the files in recordings/; it fails with the expected tag only when a recording is embedded nowhere. CI runs it right after the render.

Rendering every widget takes long, so each visually distinct kind of widget has one demo, and widgets that look alike are listed in a table on the guide page below it. The compute page opens with a two-column table: the group’s menu demo next to its pinned demo, which boots with that group pinned. Both images carry alt before width="100%", so the width rewrite leaves them alone and each fills half the page. The canvas is transparent, so clear and translucent panels show the page behind them.

Documentation site

docs/ is an mdBook published at sottomano.work; its first page includes the root README.md. The mermaid diagrams render through the mdbook-mermaid preprocessor, whose install subcommand adds the bundled mermaid.min.js and mermaid-init.js to the book and the [preprocessor.mermaid] entry to book.toml (the nix build runs it on a copy; run it once yourself at the repo root before mdbook serve, which serves the book from there too). mdbook serve previews it locally, and nix build .#documentation-site builds what the server hosts. The site updates when the nuremberg host in nixos-config is rebuilt with a newer sottomano input.

The build rewrites the Codeberg recording and logo URLs and the absolute / links to site paths, and links recordings/ to the output of .#readme-recordings, so the pages and their recordings come from the same commit. It then checks every link offline with lychee, heading anchors included, and fails on a missing page, anchor or recording. Building the site therefore needs .#readme-recordings: from the binary cache, or rendered on the spot.

Upstream docs

Architecture

How sottomano’s crates fit together and how the running shell works, one diagram per flow. The workflow for building, debugging and testing it is in Development.

Workspace

The repository is a cargo workspace:

  • sottomano-toml-config/ holds the TOML config types and their loading.
  • sottomano-desktop-sources/ holds the data sources the shell reads, as streams, together with the bus proxies and connection handles they share.
  • sottomano-desktop-sinks/ holds what the shell writes: commands, settings and signals sent to desktop services, built on the handles from the sources crate.
  • corredo/ holds generic iced widgets that know nothing about the shell.
  • sottomano-widgets/ holds the panels, group menus, widget groups, group items and hover cards, one module per level of the hierarchy in CONTRIBUTING.md. Each widget group restores and persists its own state.
  • sottomano-shell/ composes them into the running shell (binary sottomano) and defines the message type they produce.
  • sottomano-widget-showcase/ plays widget fixtures for the tour recordings, the .ice scenarios and the windowed tester.

Startup

The shell loads the config and starts its daemon. A data source becomes a subscription that yields a message while a reading it backs is on screen or can alert, decided the same way for pinned groups and groups in a menu. Windows and workspaces come from whichever compositor runs (niri, sway or Hyprland), detected from its socket variable; each compositor sits behind a cargo feature of the same name, all on by default.

flowchart TB
  config_file[("$XDG_CONFIG_HOME/sottomano.toml")]
  config_loading["config loading"]
  config_file --> config_loading

  subgraph sources_crate [sottomano-desktop-sources]
    compositor_events["compositor IPC<br>niri, sway or Hyprland events"]
    file_watches["inotify<br>config file, desktop entries"]
  end

  subgraph shell_crate [sottomano-shell]
    shell_startup["shell startup"]
    message_subscriptions["message subscriptions"]
    shell_update["shell update"]
  end

  config_loading --> shell_startup
  shell_startup -->|startup load tasks| shell_update
  compositor_events & file_watches --> message_subscriptions
  message_subscriptions --> shell_update

Readings to messages

Every live reading arrives as a message from a subscription: D-Bus services, the notification server, compositor events, Wayland outputs and file watches. Snapshot sources instead yield whole widgets at once, for the tour recordings and the windowed tester.

flowchart TB
  subgraph sources_crate [sottomano-desktop-sources]
    desktop_bus["D-Bus (zbus)<br>power, login session, media, …"]
    notification_server["freedesktop<br>notification server"]
    compositor_events["compositor IPC<br>niri, sway or Hyprland events"]
    wayland_outputs["Wayland outputs"]
    file_watches["inotify<br>config file, desktop entries"]
    snapshot_sources["snapshot sources"]
  end

  message_subscriptions["message subscriptions"]
  snapshot_subscription["snapshot subscription"]
  shell_message["shell messages"]

  desktop_bus & notification_server & compositor_events & wayland_outputs & file_watches --> message_subscriptions
  snapshot_sources -->|snapshots| snapshot_subscription
  snapshot_subscription --> shell_message
  shell_message --> message_subscriptions

Messages to screen and back

The update step applies each message, persisting state where needed, and the view renders the layer-shell surfaces. Each widget group updates and views its own items, restoring and persisting its state in the per-group SQLite database.

flowchart TB
  shell_update["shell update"]
  shell_view["shell view"]
  app_launch_store["app launch history"]

  subgraph widgets_crate [sottomano-widgets]
    widget_groups["widget groups<br>update, view"]
    group_stores["per-group SQLite state<br>dark mode schedule, idle timeouts,<br>focus spans, shell settings, …"]
    widget_groups <-->|restore / persist| group_stores
  end

  layer_surfaces["layer-shell surfaces<br>bars, panel, sidebar, toasts, hover cards"]
  state_database[("$XDG_STATE_HOME/sottomano/state.db")]

  shell_update <-->|group messages and tasks| widget_groups
  shell_update -->|state| shell_view
  shell_view --> layer_surfaces
  group_stores & app_launch_store <--> state_database
  shell_update <-->|restore / persist| app_launch_store

Writes to the desktop

The write tasks the widget groups produce go through the sinks crate: service commands and notification signals, and the compositor requests that rewrite the running compositor’s window styling rule file, which each compositor’s page includes in its config.

flowchart TB
  widget_groups["widget groups"]

  subgraph sinks_crate [sottomano-desktop-sinks]
    service_commands["service commands<br>volume, power, network, tray, …"]
    notification_signals["notification<br>signals"]
    compositor_requests["compositor requests<br>and window styling rules"]
  end

  compositor_rules_file[("$XDG_CONFIG_HOME/sottomano/<br>niri.kdl, sway.conf or hyprland.lua")]

  widget_groups -->|write tasks| service_commands & notification_signals & compositor_requests
  compositor_requests -->|rewrite| compositor_rules_file

The CI pipelines and the tour recordings each have a diagram of their own in Development.

Style

The rules the code in sottomano follows. Setup, building, CI and the tour recordings are in Development.

Vocabulary

The shell is one hierarchy: a panel has zones, a zone holds group menus and widget groups, a menu holds groups, a group holds items, and an item explains itself with a hover card. Each level has one module in the widgets crate and one type prefix, named after the level (panel, group menu, widget group, group item, hover card), so a name says where it sits.

One further module holds only iced primitives and helpers that belong to no level. A module used by one group lives in that group. Pins and layouts describe things in the hierarchy rather than adding levels, and panels declare what they contain: a menu or group never says where it sits. Side surfaces of a cascading menu are satellites, and “panel” always means the bar along a screen edge.

Conventions

  • No comments, doc-comments or docstrings. Intent lives in names and call flow; code that cannot be made self-evident that way is restructured, not annotated.
  • The sources crate is grouped by domain. Each directory covers what one widget domain reads and carries two or more keywords; each file is named after its subject and the mechanism behind it. One directory holds the D-Bus, HTTP, shell, inotify, sysfs and journal access that names no daemon and implements no snapshot source. Consumers spell out the full module path rather than aliasing it back to a short name.
  • A source that only works with one vendor’s driver carries that vendor in its file name. Generic sources read the kernel’s shared frameworks (cpufreq, hwmon, thermal, powercap) and compose the vendor files rather than branching on vendor inline. No per-model constants: a limit or cause is shown only when the kernel publishes it.
  • Strict config/state separation: no field appears in both the TOML config and the runtime state that widget groups persist to SQLite. The modules persisting that state sit next to each group and, like sources, are named after their mechanism and subject.
  • No macro_rules! deduplication. Use traits, methods and marker types.
  • No global config statics. Config is threaded through as instance data.
  • Widgets over free helper functions: style functions, builder widgets and methods on the owning type.
  • Widget state lives in its widget and routes through its group’s action type — no application-level carve-outs, no shadow copies of a cell’s state.
  • Names describe structure, not appearance.
  • README.md holds the pitch, a short tour linking to docs/guide/ and pointers into docs/. It is also the first page of the documentation site, so its links and images use absolute URLs. Installation, configuration, development and the full tour live in docs/. No user instructions beyond those: the UX should be intuitive. Where help or explanation is needed, keep it next to the widget in question, as descriptive labels, hover cards and visual indicators, rather than as prose in the docs.
  • Docs name no variables, types, functions or modules, because those go out of date quickly. Describe the behaviour, and name only what a reader types or configures, such as commands, flags and config keys.
  • docs/development.md and this file are the only contributor docs. Sub-crates have no CONTRIBUTING.md.
  • No config keys, defaults or TOML snippets in README.md. Users get a complete default config from sottomano default-config and start from there, so every option must show up in that output with its default. The “Full default” section of docs/configuration.md is that output, regenerated by cargo xtask update-config-docs (and by CI on main); never edit it by hand.