Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Introduction

Note: The content of this developer guide has been entirely generated by AI, based on analysis of the ashell source code, GitHub issues, pull requests, and project discussions. It may contain inaccuracies or become outdated as the project evolves. Contributions to improve and maintain this documentation are welcome.

ashell is a ready-to-go Wayland status bar for Hyprland and Niri window managers. It is written in Rust using the iced GUI framework and provides a modern, feature-rich taskbar experience for Wayland-based Linux desktops.

Who This Book Is For

This developer guide is written for:

  • New contributors who want to understand the codebase and start contributing.
  • Existing developers who need a reference for architecture decisions, patterns, and subsystems.
  • Anyone interested in learning how a real-world iced + Wayland application is built.

This is not a user guide. For end-user documentation (installation, configuration, usage), visit the ashell website.

How to Navigate This Book

If you’re new to the project, read the sections in order:

  1. Getting Started — Set up your development environment and build the project.
  2. Architecture — Understand the high-level design, the Elm architecture, and data flow.
  3. Core Systems — Learn about the App struct, configuration, theming, and output management.
  4. Modules and Services — Understand how UI modules and backend services are structured.

If you’re an existing developer looking for a reference:

  • Jump directly to the specific chapter you need (e.g., Writing a New Module or D-Bus Services Pattern).
  • Use the search feature (top of the page) to find specific topics.
  • Check the Reference section for configuration details, environment variables, and D-Bus interfaces.

Project Identity

ashell is designed around two core principles:

  • “Ready to go” — It works out of the box with sensible defaults and minimal configuration.
  • “Everything built in” — All features are integrated directly rather than relying on external scripts or tools.

The project uses a full Rust stack intentionally, prioritizing consistency, type safety, and a unified development experience.

Prerequisites

System Requirements

Rust Toolchain

ashell requires Rust 1.89+ (edition 2024). Install via rustup:

curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh

Build Dependencies

The following system libraries are required to compile ashell:

PackagePurpose
pkg-configLibrary discovery
llvm-devLLVM development files
libclang-dev / clangClang for bindgen (PipeWire/PulseAudio bindings)
libxkbcommon-devKeyboard handling
libwayland-devWayland client protocol
libpipewire-0.3-devPipeWire audio integration
libpulse-devPulseAudio integration
libudev-devDevice monitoring
dbusD-Bus daemon and development files

Ubuntu / Debian

sudo apt-get install -y pkg-config llvm-dev libclang-dev clang \
  libxkbcommon-dev libwayland-dev dbus libpipewire-0.3-dev \
  libpulse-dev libudev-dev

Fedora

sudo dnf install -y pkg-config llvm-devel clang-devel clang \
  libxkbcommon-devel wayland-devel dbus-devel pipewire-devel \
  pulseaudio-libs-devel systemd-devel

Arch Linux

sudo pacman -S pkg-config llvm clang libxkbcommon wayland \
  dbus pipewire libpulse systemd-libs

Nix (Alternative)

If you use Nix, you can skip all of the above. The project’s flake.nix provides a complete development shell with all dependencies:

nix develop

See Development Environment for details.

Runtime Dependencies

At runtime, ashell needs:

  • Wayland client libraries (libwayland-client)
  • D-Bus
  • libxkbcommon
  • PipeWire libraries (libpipewire-0.3)
  • PulseAudio libraries (libpulse)
  • A running Hyprland or Niri compositor

Building from Source

Quick Build

The simplest way to build ashell:

cargo build --release

The binary will be at target/release/ashell.

Using the Makefile

The project includes a Makefile with convenience targets:

TargetCommandDescription
make buildcargo build --releaseBuild release binary
make startBuild + ./target/release/ashellBuild and run
make installBuild + sudo cp -f target/release/ashell /usr/binInstall to system
make fmtcargo fmtFormat code
make checkcargo fmt + cargo check + cargo clippy -- -D warningsFull lint check

What build.rs Does

The build.rs script runs at compile time and performs two tasks:

1. Font Subsetting

ashell bundles Nerd Font for icons. The full font files are ~4.8 MB. To reduce binary size, build.rs uses the allsorts crate to:

  1. Parse src/components/icons.rs to find all Unicode codepoints in use (e.g., \u{f0e7})
  2. Subset the Nerd Font TTF files to only include those glyphs
  3. Write the optimized fonts to target/generated/

This means adding a new icon to icons.rs automatically includes it in the subset on the next build.

2. Git Hash Extraction

build.rs runs git rev-parse --short HEAD and embeds the result as the GIT_HASH environment variable. This is used in the --version output:

ashell 0.7.0 (abc1234)

Release Profile

The release build profile in Cargo.toml is optimized for production:

[profile.release]
lto = "thin"       # Thin Link-Time Optimization
strip = true       # Strip debug symbols
opt-level = 3      # Maximum optimization
panic = "abort"    # Abort on panic (smaller binary, no unwinding)

Common Build Issues

  • Missing system libraries: If you get pkg-config errors, ensure all prerequisites are installed.
  • Font subsetting failure: The target/generated/ directory is created automatically by build.rs. If the build fails on font subsetting, ensure assets/SymbolsNerdFont-Regular.ttf exists.
  • Slow first build: The first build compiles all dependencies including iced (which is large). Subsequent builds are incremental and much faster.

Development Environment

The easiest way to get a fully working development environment is with Nix:

nix develop

This provides:

  • The correct Rust toolchain (stable, latest)
  • All system library dependencies (Wayland, PipeWire, PulseAudio, etc.)
  • rust-analyzer for editor integration
  • Correct LD_LIBRARY_PATH for runtime libraries (Wayland, Vulkan, Mesa, OpenGL)
  • RUST_SRC_PATH set for rust-analyzer goto-definition

You can then build and run normally:

cargo build --release
./target/release/ashell

Manual Setup

If you don’t use Nix, install the prerequisites for your distribution and ensure Rust 1.89+ is installed.

Editor Setup

rust-analyzer

ashell uses a custom fork of iced (see Architecture Overview). This means rust-analyzer resolves iced types from the git dependency. Go-to-definition works, but it will navigate into ~/.cargo/git/ rather than a local checkout.

If you need to edit or deeply explore the iced fork, clone it locally:

git clone https://github.com/MalpenZibo/iced.git

Then temporarily override the dependency in Cargo.toml using a [patch] section (don’t commit this).

Running ashell

Standard Run

make start
# or
cargo run --release

ashell must be launched within a Wayland session running Hyprland or Niri. It cannot run under X11 or without a compositor.

Custom Config Path

ashell --config-path /path/to/my/config.toml

The default config path is ~/.config/ashell/config.toml.

Logging

ashell uses flexi_logger and writes logs to /tmp/ashell/.

  • Log files rotate daily and are kept for 7 days.
  • In debug builds, logs are also printed to stdout.
  • The log level is controlled by the log_level field in the config file (default: "warn").
  • The log level follows the env_logger syntax, e.g., "debug", "info", "ashell=debug,iced=warn".

To watch logs in real time:

tail -f /tmp/ashell/*.log

Signal Handling

  • SIGUSR1: Toggles bar visibility. Useful for keybindings:
    kill -USR1 $(pidof ashell)
    

Project Layout

Root Directory

ashell/
├── src/                     # Rust source code
├── assets/                  # Fonts and icons
├── .github/workflows/       # CI/CD pipelines
├── website/                 # User-facing Docusaurus website
├── docs/                    # This developer guide (mdbook)
├── build.rs                 # Build script (font subsetting, git hash)
├── Cargo.toml               # Dependencies and project metadata
├── Cargo.lock               # Locked dependency versions
├── Makefile                 # Development convenience targets
├── flake.nix                # Nix development environment
├── dist-workspace.toml      # cargo-dist release configuration
├── README.md                # Project overview
├── CHANGELOG.md             # Version history
└── LICENSE                  # MIT License

Source Tree

src/
├── main.rs                  # Entry point: logging, CLI args, iced daemon launch
├── app.rs                   # App struct, Message enum, update/view/subscription
├── config.rs                # TOML config parsing, defaults, hot-reload via inotify
├── outputs.rs               # Multi-monitor management, layer surface creation
├── theme.rs                 # Theme system: colors, spacing, fonts, bar styles
├── menu.rs                  # Menu lifecycle: open/toggle/close, layer switching
├── password_dialog.rs       # Password prompt dialog for network auth
│
├── components/
│   └── icons.rs             # Nerd Font icon constants (~80+ icons)
│
├── modules/                 # UI modules (what the user sees in the bar)
│   ├── mod.rs               # Module registry, routing, section builder
│   ├── clock.rs             # Time display (deprecated, use Tempo)
│   ├── tempo.rs             # Advanced clock: timezones, calendar, weather
│   ├── workspaces.rs        # Workspace indicators and switching
│   ├── window_title.rs      # Active window title display
│   ├── system_info.rs       # CPU, RAM, disk, network, temperature
│   ├── keyboard_layout.rs   # Keyboard layout indicator
│   ├── keyboard_submap.rs   # Hyprland submap display
│   ├── tray.rs              # System tray icon integration
│   ├── media_player.rs      # MPRIS media player control
│   ├── privacy.rs           # Microphone/camera/screenshare indicators
│   ├── updates.rs           # Package update checker
│   ├── custom_module.rs     # User-defined custom modules
│   └── settings/            # Settings panel (complex, multi-part)
│       ├── mod.rs            # Settings container and navigation
│       ├── audio.rs          # Volume and audio device control
│       ├── bluetooth.rs      # Bluetooth device management
│       ├── brightness.rs     # Screen brightness slider
│       ├── network.rs        # WiFi and VPN management
│       └── power.rs          # Power menu (shutdown, reboot, sleep)
│
├── services/                # Backend system integrations (no UI)
│   ├── mod.rs               # Service traits (ReadOnlyService, Service)
│   ├── compositor/          # Window manager abstraction
│   │   ├── mod.rs            # Compositor service, backend detection, broadcast
│   │   ├── types.rs          # CompositorState, CompositorEvent, CompositorCommand
│   │   ├── hyprland.rs       # Hyprland IPC integration
│   │   └── niri.rs           # Niri IPC integration
│   ├── audio.rs             # PulseAudio/PipeWire audio service
│   ├── brightness.rs        # Display brightness via sysfs
│   ├── bluetooth/
│   │   ├── mod.rs            # Bluetooth service logic
│   │   └── dbus.rs           # BlueZ D-Bus proxy definitions
│   ├── network/
│   │   ├── mod.rs            # Network service logic
│   │   ├── dbus.rs           # NetworkManager D-Bus proxies
│   │   └── iwd_dbus/         # IWD (iNet Wireless Daemon) D-Bus bindings
│   ├── mpris/
│   │   ├── mod.rs            # Media player service
│   │   └── dbus.rs           # MPRIS D-Bus proxies
│   ├── tray/
│   │   ├── mod.rs            # System tray service
│   │   └── dbus.rs           # StatusNotifierItem D-Bus proxies
│   ├── upower/
│   │   ├── mod.rs            # Battery/power service
│   │   └── dbus.rs           # UPower D-Bus proxies
│   ├── privacy.rs           # Privacy monitoring (PipeWire portals)
│   ├── idle_inhibitor.rs    # Idle/sleep prevention
│   ├── logind.rs            # systemd-logind (sleep/wake detection)
│   └── throttle.rs          # Stream rate-limiting utility
│
├── utils/
│   ├── mod.rs               # Utility module exports
│   ├── launcher.rs          # Shell command execution
│   └── remote_value.rs      # Remote state tracking with local cache
│
└── widgets/                 # Custom iced widgets
    ├── mod.rs               # Widget exports, ButtonUIRef type
    ├── centerbox.rs         # Three-column layout (left/center/right)
    ├── position_button.rs   # Button that reports its screen position
    └── menu_wrapper.rs      # Menu container with backdrop overlay

Assets

assets/
├── SymbolsNerdFont-Regular.ttf       # Nerd Font (source, ~2.4 MB)
├── SymbolsNerdFontMono-Regular.ttf   # Nerd Font Mono (source, ~2.4 MB)
├── AshellCustomIcon-Regular.otf      # Custom ashell icons (~8 KB)
├── battery/                           # Battery state SVG icons
├── weather_icon/                      # Weather condition icons
└── ashell_custom_icon_project.gs2     # Glyphs Studio project file

The full Nerd Font files in assets/ are the source. At build time, build.rs subsets them into target/generated/ containing only the glyphs actually used in the code.

Other Directories

  • website/ — The user-facing documentation site built with Docusaurus. Deployed to GitHub Pages. This is separate from this developer guide.
  • .github/workflows/ — CI/CD pipeline definitions. See CI Pipeline.

Architecture Overview

High-Level Design

ashell is structured in three layers:

┌──────────────────────────────────────────────────┐
│                   main.rs                        │
│          (logging, CLI args, iced daemon)         │
└──────────────────────┬───────────────────────────┘
                       │
┌──────────────────────▼───────────────────────────┐
│                  Core Layer                       │
│  app.rs · config.rs · outputs.rs · theme.rs      │
│  menu.rs · password_dialog.rs                    │
│                                                  │
│  Central state, message routing, config,         │
│  multi-monitor management, theming               │
└───────┬──────────────────────────────┬───────────┘
        │                              │
┌───────▼──────────┐    ┌─────────────▼────────────┐
│   Modules (UI)   │    │   Services (Backend)     │
│                  │    │                          │
│  clock, tempo,   │    │  compositor, audio,      │
│  workspaces,     │    │  bluetooth, network,     │
│  settings,       │◄───│  mpris, tray, upower,    │
│  system_info,    │    │  brightness, privacy,    │
│  tray, media,    │    │  logind, idle_inhibitor  │
│  privacy, etc.   │    │                          │
└──────────────────┘    └──────────────────────────┘
  • Core Layer: The App struct owns all state. It routes messages, manages windows/surfaces, and coordinates modules.
  • Modules: Self-contained UI components displayed in the bar. Each module has its own Message type, view(), update(), and subscription().
  • Services: Backend integrations that produce events and accept commands. They have no UI. Modules consume services via subscriptions.

Why iced?

iced is a cross-platform GUI library for Rust that follows the Elm Architecture (Model-View-Update). It was chosen for ashell because:

  • Rust-native: No FFI bindings to GTK/Qt, keeping the stack uniform.
  • Elm Architecture: Predictable state management with unidirectional data flow.
  • Wayland layer shell support: Available through a fork (see below).
  • GPU-accelerated rendering: Via wgpu.

The iced Fork Chain

ashell does not use upstream iced directly. Instead, it uses a chain of forks:

upstream iced
    └── Pop!_OS / cosmic-iced (adds Wayland layer shell support via SCTK)
            └── MalpenZibo/iced (ashell's fork: fixes, features, Wayland tweaks)

The Pop!_OS fork adds SCTK (Smithay Client Toolkit) integration for Wayland layer surfaces, which is essential for a status bar. MalpenZibo’s fork on top of that includes additional fixes and features specific to ashell’s needs.

This fork dependency is tracked in Cargo.toml as a git dependency with a pinned revision:

iced = { git = "https://github.com/MalpenZibo/iced", rev = "...", features = [...] }

Note: The fork dependency is a known maintenance burden. See Known Limitations for more context and the long-term plans.

Design Principles

  1. Modular: Each module is self-contained and optional. Adding or removing a module should not affect others.
  2. Reactive: State flows in one direction. Events come in through subscriptions, state is updated, and the view re-renders.
  3. Service-agnostic UI: Modules don’t directly interact with system APIs. They consume data from services, making the UI layer testable and compositor-independent.
  4. Configuration-driven: Everything is configurable via a TOML file with sensible defaults. The bar works out of the box with zero configuration.

The Elm Architecture in ashell

Model-View-Update (MVU)

ashell follows the Elm Architecture, a pattern for building interactive applications with unidirectional data flow. In iced’s terminology:

          ┌──────────────────────┐
          │     Subscription     │
          │  (external events)   │
          └──────────┬───────────┘
                     │ Message
                     ▼
┌────────────────────────────────────┐
│            update()                │
│   fn update(&mut self, msg)        │
│       -> Task<Message>             │
│                                    │
│   Mutates state, returns effects   │
└────────────────┬───────────────────┘
                 │ state changed
                 ▼
┌────────────────────────────────────┐
│             view()                 │
│   fn view(&self, id)               │
│       -> Element<Message>          │
│                                    │
│   Pure function of state           │
│   (immutable borrow)               │
└────────────────┬───────────────────┘
                 │ user interaction
                 │ Message
                 └──────► back to update()

The Three Core Methods

In src/app.rs, the App struct implements these key methods:

App::new — Creates the initial state and returns any startup tasks:

#![allow(unused)]
fn main() {
pub fn new(
    (logger, config, config_path): (LoggerHandle, Config, PathBuf),
) -> impl FnOnce() -> (Self, Task<Message>) {
    move || {
        let (outputs, task) = Outputs::new(/* ... */);
        (App { /* all fields */ }, task)
    }
}
}

App::update — Processes a Message and returns a Task<Message> for side effects:

#![allow(unused)]
fn main() {
// Conceptual structure (simplified)
fn update(&mut self, message: Message) -> Task<Message> {
    match message {
        Message::Settings(msg) => { /* delegate to settings module */ }
        Message::ConfigChanged(config) => { /* hot-reload config */ }
        Message::ToggleMenu(menu_type, id, button_ref) => { /* open/close menu */ }
        // ... one arm per message variant
    }
}
}

App::view — Renders the UI for a given window. This is a pure function of the current state:

#![allow(unused)]
fn main() {
fn view(&self, id: Id) -> Element<Message> {
    // Determine which output this window belongs to
    // Render the bar with left/center/right module sections
    // Or render the menu popup if this is a menu surface
}
}

Subscriptions

Subscriptions are long-lived event sources. They run in the background and produce Message values:

#![allow(unused)]
fn main() {
fn subscription(&self) -> Subscription<Message> {
    Subscription::batch(vec![
        config::subscription(/* ... */),                    // Config file changes
        self.modules_subscriptions(/* ... */),              // All module subscriptions
        CompositorService::subscribe().map(/* ... */),      // Compositor events
        // ... more subscriptions
    ])
}
}

Each subscription is identified by a TypeId or a unique key, ensuring only one instance runs per subscription type.

Daemon Mode

ashell uses iced’s daemon mode, which supports multiple windows (surfaces). Unlike a standard iced application with a single window, the daemon can:

  • Create and destroy windows dynamically (for multi-monitor support)
  • Have different views per window (main bar vs. menu popup)
  • Apply different themes and scale factors per window

The daemon is configured in main.rs:

#![allow(unused)]
fn main() {
iced::daemon(App::title, App::update, App::view)
    .subscription(App::subscription)
    .theme(App::theme)
    .style(App::style)
    .scale_factor(App::scale_factor)
    .font(/* embedded fonts */)
    .run_with(App::new(/* ... */))
}

Why This Matters

The Elm Architecture provides several benefits for ashell:

  • Predictability: All state changes flow through update(). There’s no scattered mutation.
  • Debuggability: You can inspect the Message that caused any state change.
  • Modularity: Each module follows the same pattern, making it easy to add new ones.
  • No data races: The single-threaded update loop eliminates shared mutable state concerns in the UI.

Data Flow: Messages, Tasks, and Subscriptions

The Message Enum

All events in ashell flow through a single Message enum defined in src/app.rs:

#![allow(unused)]
fn main() {
pub enum Message {
    // Config
    ConfigChanged(Box<Config>),

    // Menu management
    ToggleMenu(MenuType, Id, ButtonUIRef),
    CloseMenu(Id),
    CloseAllMenus,

    // Module messages (one variant per module)
    Custom(String, custom_module::Message),
    Updates(modules::updates::Message),
    Workspaces(modules::workspaces::Message),
    WindowTitle(modules::window_title::Message),
    SystemInfo(modules::system_info::Message),
    KeyboardLayout(modules::keyboard_layout::Message),
    KeyboardSubmap(modules::keyboard_submap::Message),
    Tray(modules::tray::Message),
    Clock(modules::clock::Message),
    Tempo(modules::tempo::Message),
    Privacy(modules::privacy::Message),
    Settings(modules::settings::Message),
    MediaPlayer(modules::media_player::Message),

    // System events
    OutputEvent((OutputEvent, WlOutput)),
    ResumeFromSleep,
    ToggleVisibility,
    None,
}
}

Each module defines its own Message type (e.g., modules::clock::Message), which is wrapped in the top-level Message enum. This pattern keeps module logic self-contained while enabling centralized routing.

Message Lifecycle

A typical message flows through the system like this:

1. External Event (D-Bus signal, timer tick, user click)
       │
2.     ▼ Subscription produces Message
   ServiceEvent::Update(data)
       │
3.     ▼ Module subscription maps to top-level Message
   Message::Settings(settings::Message::Audio(audio::Message::ServiceUpdate(event)))
       │
4.     ▼ App::update() matches on Message variant
   Delegates to self.settings.update(msg)
       │
5.     ▼ Module update() processes the message
   Returns Action or Task
       │
6.     ▼ App interprets the Action
   May produce more Tasks (e.g., close menu, send command to service)

Tasks vs. Subscriptions

ConceptPurposeLifetimeExample
TaskOne-shot side effectRuns once, produces one MessageSetting brightness, switching workspace
SubscriptionOngoing event streamRuns for the lifetime of the appWatching for compositor events, timer ticks

Tasks are returned from update():

#![allow(unused)]
fn main() {
// Example: batching multiple tasks
Task::batch(vec![
    menu.close(),
    set_layer(id, Layer::Background),
])
}

Subscriptions are returned from subscription():

#![allow(unused)]
fn main() {
// Example: timer-based subscription
every(Duration::from_secs(1)).map(|_| Message::Update)
}

The ServiceEvent Pattern

Services communicate with modules through a standard ServiceEvent<S> enum:

#![allow(unused)]
fn main() {
pub enum ServiceEvent<S: ReadOnlyService> {
    Init(S),                    // Initial state when service starts
    Update(S::UpdateEvent),     // Incremental state update
    Error(S::Error),            // Service error
}
}

A module’s subscription typically looks like:

#![allow(unused)]
fn main() {
CompositorService::subscribe()
    .map(|event| Message::Workspaces(workspaces::Message::CompositorEvent(event)))
}

The Action Pattern

Some modules return an Action enum from their update() method instead of (or in addition to) a Task. Actions are interpreted by App::update() to perform cross-cutting operations:

#![allow(unused)]
fn main() {
// Example: settings module action
pub enum Action {
    None,
    Command(Task<Message>),
    CloseMenu,
    RequestKeyboard,
    ReleaseKeyboard,
    ReleaseKeyboardWithCommand(Task<Message>),
}
}

This pattern allows modules to request operations they can’t perform themselves (like closing the menu or changing keyboard interactivity), while keeping the module decoupled from the App internals.

Event Sources

ashell subscribes to many event sources:

SourceMechanismProduces
Compositor (Hyprland/Niri)IPC socketWorkspace changes, window focus, keyboard layout
PulseAudiolibpulse mainloop on dedicated threadVolume changes, device hotplug
D-Bus (BlueZ, NM, UPower, etc.)zbus signal watchersDevice state changes
Config fileinotifyConfigChanged
System signalssignal-hookSIGUSR1ToggleVisibility
Timersiced time::everyPeriodic updates (clock, system info)
WaylandLayer shell eventsOutput add/remove
systemd-logindD-BusSleep/wake (ResumeFromSleep)

Surface Model: Layer Shell and Multi-Monitor

Wayland Layer Shell

ashell uses the wlr-layer-shell protocol to position itself as a status bar. Key concepts:

  • Layer surface: A special Wayland surface that lives in a specific layer (Background, Bottom, Top, Overlay).
  • Anchor: Where the surface attaches (top, bottom, left, right edges).
  • Exclusive zone: Space reserved by the bar that other windows won’t overlap.

Surface Architecture

For each monitor output, ashell creates two layer surfaces:

┌─────────────────────────────────────────┐
│              Monitor Output              │
│                                          │
│  ┌──────────────────────────────────┐   │
│  │     Main Layer Surface           │   │
│  │  (Top/Bottom layer, 34px high)   │   │
│  │  Namespace: "ashell-main-layer"  │   │
│  │  Exclusive zone: yes             │   │
│  │  Keyboard: None                  │   │
│  └──────────────────────────────────┘   │
│                                          │
│  ┌──────────────────────────────────┐   │
│  │     Menu Layer Surface           │   │
│  │  (Background ↔ Overlay layer)    │   │
│  │  Namespace: "ashell-menu-layer"  │   │
│  │  Exclusive zone: no              │   │
│  │  Keyboard: None ↔ OnDemand       │   │
│  └──────────────────────────────────┘   │
│                                          │
└─────────────────────────────────────────┘
  • Main surface: Always visible, displays the bar content. Uses an exclusive zone so windows don’t overlap it.
  • Menu surface: Hidden by default (on Background layer). When a menu opens, it’s promoted to Overlay layer. When closed, it’s demoted back to Background.

Multi-Monitor Configuration

The outputs config field controls which monitors get a bar:

# Default: bar on all monitors
outputs = "All"

# Only on the active monitor
outputs = "Active"

# Specific monitors by name
outputs = { Targets = ["eDP-1", "HDMI-A-1"] }

The Outputs Struct

src/outputs.rs defines the Outputs struct:

#![allow(unused)]
fn main() {
pub struct Outputs(Vec<(String, Option<ShellInfo>, Option<WlOutput>)>);
}

Each entry is a tuple of:

  • Name: Monitor name (e.g., "eDP-1") or "Fallback" for the default
  • ShellInfo: The layer surfaces and their state (if active)
  • WlOutput: The Wayland output object (if known)

Lifecycle

  1. Startup: A fallback surface is created (not tied to any specific output).
  2. Output detected: When Wayland reports a new output, ashell creates surfaces for it (if it matches the config filter).
  3. Output removed: Surfaces for that output are destroyed.
  4. Config change: The sync method reconciles surfaces with the new config.

When a menu opens:

#![allow(unused)]
fn main() {
// In menu.rs
pub fn open(&mut self, ...) -> Task<Message> {
    self.menu_info.replace((menu_type, button_ui_ref));
    Task::batch(vec![
        set_layer(self.id, Layer::Overlay),           // Promote to top
        set_keyboard_interactivity(self.id, OnDemand), // Enable keyboard (if needed)
    ])
}

pub fn close(&mut self) -> Task<Message> {
    self.menu_info.take();
    Task::batch(vec![
        set_layer(self.id, Layer::Background),        // Hide
        set_keyboard_interactivity(self.id, None),    // Disable keyboard
    ])
}
}

This approach avoids creating and destroying surfaces on every menu toggle, which would be expensive.

Bar Positioning

The position config field (default: Bottom) controls where the bar appears:

  • Top: Anchored to top edge
  • Bottom: Anchored to bottom edge

The layer config field (default: Bottom) controls the Wayland layer:

  • Top: Bar appears above normal windows
  • Bottom: Bar appears below floating windows (default preference)
  • Overlay: Bar appears above everything

Note: The Bottom layer default was a deliberate choice by the maintainer — the bar sits below floating windows. The Top layer option was added for users who prefer the bar always visible, especially in Niri’s overview mode.

Known Limitations and Design Debt

This chapter documents known architectural limitations and design tradeoffs. Understanding these helps contributors make informed decisions and avoid re-discovering known issues.

The iced Fork Dependency

Issue: ashell depends on a fork of a fork of iced (upstream → Pop!_OS/cosmic → MalpenZibo). This creates maintenance burden:

  • Upstream iced improvements must be manually cherry-picked.
  • The fork diverges over time, making rebases increasingly difficult.
  • Contributors can’t easily use upstream iced documentation or examples without checking for differences.

Why it exists: Upstream iced doesn’t support Wayland layer shell surfaces. The Pop!_OS fork adds SCTK integration, and MalpenZibo’s fork adds further fixes needed by ashell.

Status: The maintainer has explored alternatives (including an experimental GUI library called “guido”), but there are no concrete plans to migrate away from the iced fork. The fork is updated periodically to track upstream changes.

Related: GitHub Issue #450 (Roadmap)

Issue: Context menus currently use a fullscreen transparent overlay surface. This has several drawbacks:

  • Memory waste: ~140 MB VRAM per 4K monitor for a transparent surface
  • Layering bugs: The overlay doesn’t correctly layer popups on top of other windows in all cases
  • Conflicts: Can interfere with other layer surfaces on the Background layer

Correct approach: Use zwlr_layer_surface_v1::get_popup to create proper xdg_popup surfaces. However, the SCTK library has this method but the iced fork doesn’t expose it.

Workaround: The current fullscreen overlay approach was also chosen because iced has a HiDPI scaling regression where newly created surfaces initially render blurry.

Related: GitHub Issue #491

Memory Usage

Issue: ashell’s process RSS (total memory) is 100–300 MB, despite the application heap being only ~3.5 MB.

Breakdown (from DHAT profiling):

  • Font system (fontdb + cosmic_text): ~59% of peak heap
  • Shader compilation (naga/wgpu): ~16%
  • A bare wgpu application uses >50 MB RSS

Factors that increase usage:

  • High-refresh-rate monitors (reported: 300 MB on a 240 Hz 49“ ultra-wide)
  • Multiple monitors
  • Complex bar configurations with many modules

Possible improvements:

  • Adding a tiny-skia CPU renderer could reduce RAM by ~80 MB (at the cost of CPU usage and battery)
  • Users can set renderer.backend = "egl" in the iced configuration as a partial workaround

Related: GitHub Issue #529

Services Refactoring

Issue: The service layer has inconsistencies across different services. Some use broadcast channels, others use mpsc. Error handling patterns vary.

Status: This is an ongoing refactoring effort tracked in GitHub Issue #445. The network service is the most problematic, with unreliable WiFi scanning and incompatible architectural patterns between the NetworkManager and IWD backends.

No Test Suite

Issue: The project currently has no automated test suite. The UI is tested manually, and services are verified by running ashell on real hardware.

Impact: Regressions can slip through, especially for compositor-specific behavior (Hyprland vs. Niri) or hardware-specific features (brightness, Bluetooth).

NVIDIA Compatibility

Issue: ashell can crash or fail to render on NVIDIA GPUs with the Niri compositor.

Workaround: Set the WGPU_BACKEND=gl environment variable to use the OpenGL backend instead of Vulkan.

Related: GitHub Issue #471

The App Struct

The App struct in src/app.rs is the central state container for the entire application. It owns all module instances, the configuration, the theme, and the output/surface management.

Fields

#![allow(unused)]
fn main() {
pub struct App {
    config_path: PathBuf,               // Path to the TOML config file
    pub theme: AshellTheme,             // Current theme (colors, spacing, fonts)
    logger: LoggerHandle,               // flexi_logger handle for runtime log level changes
    pub general_config: GeneralConfig,  // Extracted config subset (outputs, modules, layer)
    pub outputs: Outputs,               // Multi-monitor surface management

    // Module instances
    pub custom: HashMap<String, Custom>,     // User-defined custom modules
    pub updates: Option<Updates>,            // Package update checker (optional)
    pub workspaces: Workspaces,              // Workspace indicators
    pub window_title: WindowTitle,           // Active window display
    pub system_info: SystemInfo,             // CPU/RAM/disk/network stats
    pub keyboard_layout: KeyboardLayout,     // Keyboard layout indicator
    pub keyboard_submap: KeyboardSubmap,     // Hyprland submap display
    pub tray: TrayModule,                    // System tray
    pub clock: Clock,                        // Time display (deprecated)
    pub tempo: Tempo,                        // Advanced clock/calendar/weather
    pub privacy: Privacy,                    // Mic/camera/screenshare indicators
    pub settings: Settings,                  // Settings panel
    pub media_player: MediaPlayer,           // MPRIS media control

    pub visible: bool,                       // Bar visibility (toggled via SIGUSR1)
}
}

GeneralConfig

A subset of the config used at the App level:

#![allow(unused)]
fn main() {
pub struct GeneralConfig {
    outputs: config::Outputs,     // Which monitors to show the bar on
    pub modules: Modules,         // Left/center/right module layout
    pub layer: config::Layer,     // Wayland layer (Top/Bottom/Overlay)
    enable_esc_key: bool,         // Whether ESC closes menus
}
}

Initialization

App::new() returns a closure that produces the initial state and a startup task:

#![allow(unused)]
fn main() {
pub fn new(
    (logger, config, config_path): (LoggerHandle, Config, PathBuf),
) -> impl FnOnce() -> (Self, Task<Message>) {
    move || {
        let (outputs, task) = Outputs::new(/* style, position, layer, scale_factor */);

        // Initialize all modules from config
        let custom = config.custom_modules.into_iter()
            .map(|o| (o.name.clone(), Custom::new(o)))
            .collect();

        (App { /* all fields */ }, task)
    }
}
}

The startup task creates the initial layer surfaces.

Config Hot-Reload

When the config file changes, App::refesh_config() propagates changes to all modules:

#![allow(unused)]
fn main() {
fn refesh_config(&mut self, config: Box<Config>) {
    // Update general config
    self.general_config = GeneralConfig { /* ... */ };

    // Update theme
    self.theme = AshellTheme::new(config.position, &config.appearance);

    // Update logger level
    self.logger.set_new_spec(get_log_spec(&config.log_level));

    // Sync outputs (may create/destroy surfaces)
    let task = self.outputs.sync(/* ... */);

    // Propagate to each module via ConfigReloaded messages
    self.workspaces.update(workspaces::Message::ConfigReloaded(config.workspaces));
    self.settings.update(settings::Message::ConfigReloaded(config.settings));
    // ... and so on for each module
}
}

This enables live editing of the config file without restarting ashell.

The Message Enum

The Message enum in src/app.rs is the central event type for the entire application. Every state change flows through it.

All Variants

#![allow(unused)]
fn main() {
pub enum Message {
    // Config file changed (hot-reload)
    ConfigChanged(Box<Config>),

    // Menu management
    ToggleMenu(MenuType, Id, ButtonUIRef),   // Open/close a menu at a specific position
    CloseMenu(Id),                            // Close menu on a specific output
    CloseAllMenus,                            // Close menus on all outputs

    // Module-specific messages
    Custom(String, custom_module::Message),    // Custom module (keyed by name)
    Updates(modules::updates::Message),
    Workspaces(modules::workspaces::Message),
    WindowTitle(modules::window_title::Message),
    SystemInfo(modules::system_info::Message),
    KeyboardLayout(modules::keyboard_layout::Message),
    KeyboardSubmap(modules::keyboard_submap::Message),
    Tray(modules::tray::Message),
    Clock(modules::clock::Message),
    Tempo(modules::tempo::Message),
    Privacy(modules::privacy::Message),
    Settings(modules::settings::Message),
    MediaPlayer(modules::media_player::Message),

    // System events
    OutputEvent((OutputEvent, WlOutput)),      // Wayland monitor added/removed
    ResumeFromSleep,                           // System woke from sleep
    ToggleVisibility,                          // SIGUSR1 signal received
    None,                                      // No-op
}
}

Routing Pattern

In App::update(), each message variant is matched and delegated to the appropriate handler:

#![allow(unused)]
fn main() {
fn update(&mut self, message: Message) -> Task<Message> {
    match message {
        Message::ConfigChanged(config) => {
            self.refesh_config(config);
            // ...
        }
        Message::Settings(msg) => {
            match self.settings.update(msg) {
                settings::Action::None => Task::none(),
                settings::Action::CloseMenu => { /* close menu */ }
                settings::Action::Command(task) => task,
                // ...
            }
        }
        Message::Workspaces(msg) => {
            self.workspaces.update(msg)
        }
        // ... one arm per variant
    }
}
}

Special Messages

ConfigChanged

Emitted by the config file watcher subscription when the TOML file is modified. Triggers a full config reload across all modules.

OutputEvent

Emitted by Wayland when monitors are connected or disconnected. Triggers creation or destruction of layer surfaces.

ToggleVisibility

Emitted when the process receives a SIGUSR1 signal. Toggles the visible field, which controls whether the bar is shown or hidden.

ResumeFromSleep

Emitted by the logind service when the system wakes from sleep. Used to refresh stale data (e.g., re-check network status, update clock).

CloseAllMenus

Emitted when all menus should close (e.g., when the ESC key is pressed with enable_esc_key = true).

Configuration System

The configuration system is defined in src/config.rs. ashell uses a TOML file for all user-facing settings.

Config File Location

Default path: ~/.config/ashell/config.toml

Override with the --config-path CLI flag:

ashell --config-path /path/to/config.toml

The Config Struct

#![allow(unused)]
fn main() {
pub struct Config {
    pub log_level: String,                          // Default: "warn"
    pub position: Position,                         // Top or Bottom
    pub layer: Layer,                               // Top, Bottom, or Overlay
    pub outputs: Outputs,                           // All, Active, or Targets
    pub modules: Modules,                           // Left/center/right module layout
    pub custom_modules: Vec<CustomModuleDef>,        // [[CustomModule]] array
    pub updates: Option<UpdatesModuleConfig>,
    pub workspaces: WorkspacesModuleConfig,
    pub window_title: WindowTitleConfig,
    pub system_info: SystemInfoModuleConfig,
    pub clock: ClockModuleConfig,
    pub tempo: TempoModuleConfig,
    pub settings: SettingsModuleConfig,
    pub appearance: Appearance,
    pub media_player: MediaPlayerModuleConfig,
    pub keyboard_layout: KeyboardLayoutModuleConfig,
    pub enable_esc_key: bool,                       // Default: false
}
}

Every field has a serde #[serde(default)] attribute, so an empty config file is valid — the bar works with zero configuration.

Module Layout

Modules are arranged in three sections:

[modules]
left = ["Workspaces"]
center = ["Tempo"]
right = [["SystemInfo", "Settings"]]  # Inner array = grouped modules

The ModuleDef enum handles both individual modules and groups:

#![allow(unused)]
fn main() {
pub enum ModuleDef {
    Single(ModuleName),         // A single module
    Group(Vec<ModuleName>),     // Multiple modules grouped in one "island"
}
}

Groups are rendered together in a single container, which is especially visible with the Islands bar style.

Hot-Reload

Config changes are detected via inotify file watching:

  1. The config::subscription() function watches the config file’s parent directory for CREATE, MODIFY, DELETE, and MOVE events.
  2. Events are batched using ready_chunks(10) to handle editors that perform atomic saves (write to temp file, then rename).
  3. DELETE events include a 500ms delay before re-reading, to handle atomic save patterns where the file is briefly absent.
  4. On change, the new config is parsed and sent as Message::ConfigChanged(Box<Config>).

The subscription uses TypeId::of::<Config>() as its ID to ensure only one watcher runs.

Module-Specific Configs

Each module has its own config struct. Examples:

# Workspace visibility mode
[workspaces]
visibility_mode = "MonitorSpecific"
enable_workspace_filling = true

# Tempo clock format
[tempo]
format = "%H:%M"
date_format = "%A, %B %d"

# System info thresholds
[system_info.cpu]
warn_threshold = 60
alert_threshold = 80

# Updates checker
[updates]
check_cmd = "checkupdates | wc -l"
update_cmd = "foot -e sudo pacman -Syu"
interval = 3600

Appearance Config

[appearance]
style = "Islands"          # Islands, Solid, or Gradient
opacity = 0.9
font_name = "JetBrains Mono"
scale_factor = 1.0

[appearance.background]
base = "#1e1e2e"

[appearance.menu]
opacity = 0.95
backdrop_blur = true

See the Configuration Reference for a complete list of all configuration options.

Theme System

The theme system is defined in src/theme.rs. It wraps iced’s built-in theming with ashell-specific tokens for spacing, radius, font sizes, and bar styles.

AshellTheme Struct

#![allow(unused)]
fn main() {
pub struct AshellTheme {
    pub iced_theme: Theme,                                    // iced's built-in theme
    pub space: Space,                                         // Spacing tokens
    pub radius: Radius,                                       // Border radius tokens
    pub font_size: FontSize,                                  // Font size tokens
    pub bar_position: Position,                               // Top or Bottom
    pub bar_style: AppearanceStyle,                           // Islands, Solid, or Gradient
    pub opacity: f32,                                         // Bar opacity (0.0-1.0)
    pub menu: MenuAppearance,                                 // Menu-specific styling
    pub workspace_colors: Vec<AppearanceColor>,               // Per-workspace color cycling
    pub special_workspace_colors: Option<Vec<AppearanceColor>>, // Special workspace colors
    pub scale_factor: f64,                                    // DPI scale factor
}
}

Design Tokens

Spacing

#![allow(unused)]
fn main() {
pub struct Space {
    pub xxs: u16,  // 4px
    pub xs: u16,   // 8px
    pub sm: u16,   // 12px
    pub md: u16,   // 16px
    pub lg: u16,   // 24px
    pub xl: u16,   // 32px
    pub xxl: u16,  // 48px
}
}

Border Radius

#![allow(unused)]
fn main() {
pub struct Radius {
    pub sm: u16,   // 4px
    pub md: u16,   // 8px
    pub lg: u16,   // 16px
    pub xl: u16,   // 32px
}
}

Font Sizes

#![allow(unused)]
fn main() {
pub struct FontSize {
    pub xxs: u16,  // 8px
    pub xs: u16,   // 10px
    pub sm: u16,   // 12px
    pub md: u16,   // 16px
    pub lg: u16,   // 20px
    pub xl: u16,   // 22px
    pub xxl: u16,  // 32px
}
}

Bar Styles

ashell supports three visual styles:

  • Solid: Flat background color across the entire bar width.
  • Gradient: The background fades from solid to transparent, away from the bar’s edge. The gradient direction is determined by the bar position (top = downward fade, bottom = upward fade).
  • Islands: No continuous background. Each module (or module group) gets its own rounded container with the background color, creating a “floating islands” look.

Color System

Colors are defined through the AppearanceColor enum:

# Simple: just a hex color
background = "#1e1e2e"

# Complete: base + strong + weak + text variants
[appearance.primary]
base = "#cba6f7"
strong = "#dbbcff"
weak = "#a385d8"
text = "#1e1e2e"

Colors map to iced’s Extended palette system with base, strong, weak, and text variants.

Button Styles

theme.rs defines multiple button style methods used across the UI:

MethodUsed By
module_button_style(grouped)Module buttons in the bar
ghost_button_style()Transparent buttons in menus
quick_settings_button_style()Quick settings toggles
workspace_button_style(index, active)Workspace indicator buttons
menu_button_style()Items inside dropdown menus

Each method returns a closure compatible with iced’s button styling API:

#![allow(unused)]
fn main() {
pub fn module_button_style(&self, grouped: bool) -> impl Fn(&Theme, Status) -> button::Style {
    // Returns different styles for hovered, pressed, and default states
    // Handles Islands vs Solid/Gradient backgrounds differently
}
}

Theme Construction

The theme is built from the config’s Appearance section:

#![allow(unused)]
fn main() {
impl AshellTheme {
    pub fn new(position: Position, appearance: &Appearance) -> Self {
        AshellTheme {
            iced_theme: Theme::custom_with_fn(/* ... */),
            space: Space::default(),
            radius: Radius::default(),
            font_size: FontSize::default(),
            bar_position: position,
            bar_style: appearance.style,
            opacity: appearance.opacity,
            // ...
        }
    }
}
}

The iced theme is created with Theme::custom_with_fn(), which builds a palette from the configured colors.

Outputs and Surface Management

The output and surface management is defined in src/outputs.rs. It handles multi-monitor support and layer surface creation.

The Outputs Struct

#![allow(unused)]
fn main() {
pub struct Outputs(Vec<(String, Option<ShellInfo>, Option<WlOutput>)>);
}

Each entry in the vector represents a known monitor:

FieldTypeDescription
NameStringMonitor name (e.g., "eDP-1") or "Fallback"
ShellInfoOption<ShellInfo>Layer surfaces for this output (if active)
WlOutputOption<WlOutput>Wayland output object (if discovered)

ShellInfo

#![allow(unused)]
fn main() {
pub struct ShellInfo {
    pub id: Id,                  // Main surface window ID
    pub position: Position,      // Top or Bottom
    pub layer: config::Layer,    // Wayland layer
    pub style: AppearanceStyle,  // Bar style
    pub menu: Menu,              // Menu surface state
    pub scale_factor: f64,
}
}

Surface Creation

Each output gets two layer surfaces created via create_output_layers():

#![allow(unused)]
fn main() {
pub fn create_output_layers(
    style: AppearanceStyle,
    wl_output: Option<WlOutput>,
    position: Position,
    layer: config::Layer,
    scale_factor: f64,
) -> (Id, Id, Task<Message>) {
    // Main layer: "ashell-main-layer"
    //   - Anchored to top or bottom edge + left + right
    //   - Exclusive zone = bar height (reserves screen space)
    //   - Keyboard interactivity: None

    // Menu layer: "ashell-menu-layer"
    //   - Anchored to all edges (fullscreen)
    //   - No exclusive zone
    //   - Starts on Background layer (invisible)
    //   - Keyboard interactivity: None (until menu opens)
}
}

HasOutput Enum

Used in App::view() to determine what to render for a given window ID:

#![allow(unused)]
fn main() {
pub enum HasOutput<'a> {
    Main,                                        // Render the bar
    Menu(Option<&'a (MenuType, ButtonUIRef)>),   // Render the menu (if open)
}
}

Sync on Config Change

When the config changes, Outputs::sync() reconciles the current surfaces with the new configuration:

  • Creates surfaces for newly targeted outputs
  • Destroys surfaces for outputs no longer targeted
  • Updates position, layer, and style for existing surfaces

Adding and Removing Outputs

When Wayland reports output events:

  • Output added: If the output matches the config filter (All/Active/Targets), create surfaces for it.
  • Output removed: Destroy the associated surfaces.
  • Fallback: If no specific outputs match, the fallback surface is used.

Menu System

The menu system is defined in src/menu.rs. It manages popup menus that appear when users click on modules in the bar.

Each module that supports a popup menu has a corresponding MenuType:

#![allow(unused)]
fn main() {
pub enum MenuType {
    Updates,
    Settings,
    Tray(String),     // Tray menus are identified by app name
    MediaPlayer,
    SystemInfo,
    Tempo,
}
}
#![allow(unused)]
fn main() {
pub struct Menu {
    pub id: Id,                                       // Layer surface ID
    pub menu_info: Option<(MenuType, ButtonUIRef)>,   // Currently open menu + button position
}
}
  • When menu_info is None, no menu is open and the surface is on the Background layer.
  • When menu_info is Some(...), the menu is open, positioned relative to the button, and the surface is on the Overlay layer.

Open

#![allow(unused)]
fn main() {
pub fn open(&mut self, menu_type, button_ui_ref, request_keyboard) -> Task<Message> {
    self.menu_info.replace((menu_type, button_ui_ref));
    Task::batch(vec![
        set_layer(self.id, Layer::Overlay),               // Make visible
        // Optionally enable keyboard for text input (e.g., WiFi password)
    ])
}
}

Close

#![allow(unused)]
fn main() {
pub fn close(&mut self) -> Task<Message> {
    self.menu_info.take();
    Task::batch(vec![
        set_layer(self.id, Layer::Background),            // Hide
        set_keyboard_interactivity(self.id, None),        // Disable keyboard
    ])
}
}

Toggle

#![allow(unused)]
fn main() {
pub fn toggle(&mut self, menu_type, button_ui_ref, request_keyboard) -> Task<Message> {
    match self.menu_info.as_mut() {
        None => self.open(menu_type, button_ui_ref, request_keyboard),
        Some((current, _)) if *current == menu_type => self.close(),
        Some((current, ref_)) => {
            // Switch to a different menu type without close/open cycle
            *current = menu_type;
            *ref_ = button_ui_ref;
            Task::none()
        }
    }
}
}

Menus are positioned relative to the button that triggered them. The ButtonUIRef carries the button’s screen position and size:

#![allow(unused)]
fn main() {
pub struct ButtonUIRef {
    pub position: Point,
    pub size: Size,
}
}

In App::menu_wrapper(), the menu content is wrapped in a MenuWrapper widget that:

  1. Positions the content relative to the button (aligned to the button’s horizontal center).
  2. Renders a backdrop overlay behind the menu.
  3. Handles click-outside-to-close.

Menus use predefined width categories:

#![allow(unused)]
fn main() {
pub enum MenuSize {
    Small,   // 250px
    Medium,  // 350px
    Large,   // 450px
    XLarge,  // 650px
}
}

Keyboard Interactivity

By default, layer surfaces have keyboard interactivity set to None (performance optimization — Wayland doesn’t need to track keyboard focus for the bar). When a menu needs text input (e.g., WiFi password entry), keyboard interactivity is set to OnDemand.

Modules Overview

Modules are the UI building blocks of ashell. Each module is a self-contained component that renders content in the bar and optionally provides a popup menu.

Available Modules

ModuleConfig NameDescriptionHas Menu
Workspaces"Workspaces"Workspace indicators and switchingNo
WindowTitle"WindowTitle"Active window title/class displayNo
SystemInfo"SystemInfo"CPU, RAM, disk, network, temperatureYes
KeyboardLayout"KeyboardLayout"Keyboard layout indicator (click to cycle)No
KeyboardSubmap"KeyboardSubmap"Hyprland submap displayNo
Tray"Tray"System tray iconsYes (per-app)
Clock"Clock"Simple time display (deprecated)No
Tempo"Tempo"Advanced clock with calendar, weather, timezonesYes
Privacy"Privacy"Microphone/camera/screenshare indicatorsNo
Settings"Settings"Settings panel (audio, network, bluetooth, etc.)Yes
MediaPlayer"MediaPlayer"MPRIS media player controlYes
Updates"Updates"Package update indicatorYes
Custom"Custom:name"User-defined modulesNo

Configuration

Modules are arranged in three bar sections via the config file:

[modules]
left = ["Workspaces"]
center = ["Tempo"]
right = [["SystemInfo", "Settings"], "Tray"]

Grouping

Modules can be grouped using nested arrays:

right = [["SystemInfo", "Settings"], "Tray"]
#         └── group ──────────────┘   └── single

In the Islands bar style, grouped modules share a single background container. In Solid/Gradient styles, grouping has no visual effect.

The config uses the ModuleDef enum:

#![allow(unused)]
fn main() {
pub enum ModuleDef {
    Single(ModuleName),         // "Tempo"
    Group(Vec<ModuleName>),     // ["SystemInfo", "Settings"]
}
}

Module vs Service

A key architectural distinction:

  • Modules are UI components. They have a view() method that renders iced Elements.
  • Services are backend integrations. They produce events and accept commands but have no UI.

Modules consume services through subscriptions. For example, the Workspaces module subscribes to CompositorService events to know about workspace changes.

How Modules Are Rendered

The modules_section() method in src/modules/mod.rs builds the three bar sections:

#![allow(unused)]
fn main() {
pub fn modules_section(&self, id: Id, theme: &AshellTheme) -> [Element<Message>; 3] {
    // Returns [left_elements, center_elements, right_elements]
    // Each module is wrapped in a button (if interactive) or plain container
}
}

These three sections are placed into a Centerbox widget that keeps the center truly centered.

Anatomy of a Module

Modules follow a consistent pattern, though they don’t implement a formal trait. Instead, they follow a convention that the App struct and modules/mod.rs rely on.

The Module Pattern

Every module has:

1. A Message Enum

#![allow(unused)]
fn main() {
#[derive(Debug, Clone)]
pub enum Message {
    // Module-specific events
}
}

2. A Struct

#![allow(unused)]
fn main() {
pub struct MyModule {
    config: MyModuleConfig,
    // Module state
}
}

3. Constructor

#![allow(unused)]
fn main() {
impl MyModule {
    pub fn new(config: MyModuleConfig) -> Self {
        Self { config, /* ... */ }
    }
}
}

4. Update Method

#![allow(unused)]
fn main() {
pub fn update(&mut self, message: Message) -> /* Action or Task or () */ {
    match message {
        // Handle each message variant
    }
}
}

5. View Method

#![allow(unused)]
fn main() {
pub fn view(&self, theme: &AshellTheme) -> Element<Message> {
    // Return iced elements
}
}

6. Subscription Method

#![allow(unused)]
fn main() {
pub fn subscription(&self) -> Subscription<Message> {
    // Return event sources (timers, service events, etc.)
}
}

Optional: Menu View

Modules with popup menus also implement:

#![allow(unused)]
fn main() {
pub fn menu_view(&self, theme: &AshellTheme) -> Element<Message> {
    // Return the menu popup content
}
}

The Action Pattern

Some modules return an Action enum from update() instead of a plain Task. This allows modules to request operations they can’t perform themselves:

#![allow(unused)]
fn main() {
pub enum Action {
    None,
    Command(Task<Message>),
    CloseMenu,
    RequestKeyboard,
    ReleaseKeyboard,
    ReleaseKeyboardWithCommand(Task<Message>),
}
}

The App::update() method interprets these actions. For example, CloseMenu tells the App to close the menu surface, which the module can’t do directly.

Modules that use the Action pattern: Settings, Tray, Updates, MediaPlayer, Tempo.

Service Consumption

Modules consume services through their subscription:

#![allow(unused)]
fn main() {
pub fn subscription(&self) -> Subscription<Message> {
    CompositorService::subscribe()
        .map(|event| Message::CompositorEvent(event))
}
}

The module’s Message enum includes variants for service events:

#![allow(unused)]
fn main() {
pub enum Message {
    CompositorEvent(ServiceEvent<CompositorService>),
    // ...
}
}

And the update() method handles them:

#![allow(unused)]
fn main() {
Message::CompositorEvent(ServiceEvent::Init(service)) => {
    self.compositor = Some(service);
}
Message::CompositorEvent(ServiceEvent::Update(event)) => {
    if let Some(compositor) = &mut self.compositor {
        compositor.update(event);
    }
}
}

Integration with App

Each module is integrated into the App through several touchpoints:

  1. Field in App struct (src/app.rs)
  2. Variant in Message enum (src/app.rs)
  3. Match arm in App::update() (src/app.rs)
  4. Entry in get_module_view() (src/modules/mod.rs)
  5. Entry in get_module_subscription() (src/modules/mod.rs)
  6. ModuleName variant (src/config.rs)

Module Registry and Routing

The module registry in src/modules/mod.rs connects module names to their implementations. It handles routing views and subscriptions.

get_module_view

This method maps a ModuleName to its rendered view and interaction type:

#![allow(unused)]
fn main() {
fn get_module_view(&self, id: Id, module_name: &ModuleName)
    -> Option<(Element<Message>, Option<OnModulePress>)>
{
    match module_name {
        ModuleName::Clock => Some((
            self.clock.view(&self.theme).map(Message::Clock),
            None,  // No interaction
        )),
        ModuleName::Settings => Some((
            self.settings.view(&self.theme).map(Message::Settings),
            Some(OnModulePress::ToggleMenu(MenuType::Settings)),
        )),
        // ... one arm per module
    }
}
}

The return type is Option<(Element, Option<OnModulePress>)>:

  • None means the module has nothing to display (e.g., privacy module when no indicators are active)
  • Some((view, None)) renders the module without interaction
  • Some((view, Some(action))) wraps the module in an interactive button

OnModulePress

Defines what happens when a user clicks a module:

#![allow(unused)]
fn main() {
pub enum OnModulePress {
    // Emit a specific message on click
    Action(Box<Message>),

    // Toggle a popup menu
    ToggleMenu(MenuType),

    // Toggle menu + right-click and scroll handlers
    ToggleMenuWithExtra {
        menu_type: MenuType,
        on_right_press: Option<Box<Message>>,
        on_scroll_up: Option<Box<Message>>,
        on_scroll_down: Option<Box<Message>>,
    },
}
}

For example, the Tempo module uses ToggleMenuWithExtra to:

  • Left-click: Open the calendar/weather menu
  • Right-click: Cycle the time format
  • Scroll: Cycle through timezones

get_module_subscription

Maps each module to its subscriptions:

#![allow(unused)]
fn main() {
fn get_module_subscription(&self, module_name: &ModuleName) -> Option<Subscription<Message>> {
    match module_name {
        ModuleName::Clock => Some(self.clock.subscription().map(Message::Clock)),
        ModuleName::Settings => Some(self.settings.subscription().map(Message::Settings)),
        // ...
    }
}
}

modules_section

Builds the three bar sections (left, center, right):

#![allow(unused)]
fn main() {
pub fn modules_section(&self, id: Id, theme: &AshellTheme) -> [Element<Message>; 3] {
    [left, center, right].map(|modules_def| {
        let mut row = Row::new();
        for module_def in modules_def {
            row = row.push_maybe(match module_def {
                ModuleDef::Single(module) => self.single_module_wrapper(id, theme, module),
                ModuleDef::Group(group) => self.group_module_wrapper(id, theme, group),
            });
        }
        row.into()
    })
}
}

Module Wrapping

single_module_wrapper

Wraps a single module:

  • If the module has an OnModulePress action, it’s wrapped in a PositionButton
  • Otherwise, it’s wrapped in a plain container
  • In Islands style, non-interactive modules get a rounded background

group_module_wrapper

Wraps a group of modules:

  • All modules in the group are placed in a Row
  • In Islands style, the entire group shares one rounded background container
  • Each module within the group still has its own click handler if applicable

Walkthrough: The Clock Module

The Clock module (src/modules/clock.rs) is the simplest module in ashell at just 60 lines. It’s an ideal example for understanding the module pattern.

Note: The Clock module is deprecated in favor of the more feature-rich Tempo module. It remains in the codebase for backwards compatibility but logs a deprecation warning.

Complete Source (annotated)

#![allow(unused)]
fn main() {
use crate::{config::ClockModuleConfig, theme::AshellTheme};
use chrono::{DateTime, Local};
use iced::{Element, Subscription, time::every, widget::text};
use log::warn;
use std::time::Duration;

// 1. Message enum — defines what events this module handles
#[derive(Debug, Clone)]
pub enum Message {
    Update,    // Fired by the timer subscription
}

// 2. Module struct — holds the module's state
pub struct Clock {
    config: ClockModuleConfig,      // Format string from config
    date: DateTime<Local>,          // Current time
}

impl Clock {
    // 3. Constructor — creates the module from config
    pub fn new(config: ClockModuleConfig) -> Self {
        warn!("Clock module is deprecated. Please migrate to the Tempo module.");
        Self {
            config,
            date: Local::now(),
        }
    }

    // 4. Update — handles messages, mutates state
    pub fn update(&mut self, message: Message) {
        match message {
            Message::Update => {
                self.date = Local::now();
            }
        }
    }

    // 5. View — renders the UI (pure function of state)
    pub fn view(&'_ self, _: &AshellTheme) -> Element<'_, Message> {
        text(self.date.format(&self.config.format).to_string()).into()
    }

    // 6. Subscription — event source (timer)
    pub fn subscription(&self) -> Subscription<Message> {
        // Smart interval: 1s if format includes seconds, 5s otherwise
        let second_specifiers = ["%S", "%T", "%X", "%r", "%:z", "%s"];
        let interval = if second_specifiers
            .iter()
            .any(|&spec| self.config.format.contains(spec))
        {
            Duration::from_secs(1)
        } else {
            Duration::from_secs(5)
        };

        every(interval).map(|_| Message::Update)
    }
}
}

Key Observations

Simplicity

The entire module is:

  • 1 enum (Message) with 1 variant
  • 1 struct (Clock) with 2 fields
  • 4 methods: new, update, view, subscription

No Service Dependency

The Clock module doesn’t use any service. It gets the time directly via chrono::Local::now(). More complex modules would subscribe to a service instead.

Smart Subscription Interval

The subscription adjusts its frequency based on the configured format string. If the format includes seconds (%S, %T, etc.), it ticks every second. Otherwise, it ticks every 5 seconds to save resources.

No Action Pattern

update() returns () (nothing). It simply mutates state. More complex modules (like Settings) return an Action enum to request App-level operations.

Integration Points

In src/app.rs:

#![allow(unused)]
fn main() {
pub struct App {
    pub clock: Clock,    // Field
    // ...
}

pub enum Message {
    Clock(modules::clock::Message),   // Variant
    // ...
}
}

In App::update():

#![allow(unused)]
fn main() {
Message::Clock(msg) => {
    self.clock.update(msg);
    Task::none()
}
}

In src/modules/mod.rs:

#![allow(unused)]
fn main() {
// get_module_view
ModuleName::Clock => Some((
    self.clock.view(&self.theme).map(Message::Clock),
    None,   // No click interaction
)),

// get_module_subscription
ModuleName::Clock => Some(self.clock.subscription().map(Message::Clock)),
}

In src/config.rs:

#![allow(unused)]
fn main() {
pub enum ModuleName {
    Clock,
    // ...
}
}

Deep Dive: The Settings Module

The Settings module (src/modules/settings/) is the most complex module in ashell. It composes multiple sub-modules and interacts with several services simultaneously.

Structure

modules/settings/
├── mod.rs          # Main settings container, sub-menu navigation
├── audio.rs        # Volume control, sink/source selection
├── bluetooth.rs    # Bluetooth device management
├── brightness.rs   # Screen brightness slider
├── network.rs      # WiFi and VPN management
└── power.rs        # Power menu (shutdown, reboot, sleep, logout)

The Settings panel uses a SubMenu enum for navigation:

#![allow(unused)]
fn main() {
pub enum SubMenu {
    Audio,
    Bluetooth,
    Network,
    // ... other sub-menus
}
}

The main settings view shows quick-access buttons. Clicking one navigates to the sub-menu view.

The Action Enum

Settings is one of the modules that uses the Action pattern:

#![allow(unused)]
fn main() {
pub enum Action {
    None,
    Command(Task<Message>),
    CloseMenu,
    RequestKeyboard,
    ReleaseKeyboard,
    ReleaseKeyboardWithCommand(Task<Message>),
}
}
  • RequestKeyboard: When the WiFi password input field needs keyboard focus, the module requests keyboard interactivity for the menu surface.
  • ReleaseKeyboard: When the password dialog is dismissed.
  • CloseMenu: When an action should close the settings panel.

Service Integration

The Settings module interacts with multiple services:

Sub-moduleServiceOperations
AudioAudioServiceList sinks/sources, set volume, toggle mute
BluetoothBluetoothServiceList devices, connect/disconnect, toggle power
BrightnessBrightnessServiceGet/set brightness level
NetworkNetworkServiceList WiFi networks, connect, manage VPN
PowerLogindServiceShutdown, reboot, sleep, hibernate

Password Dialog Integration

The network sub-module can trigger a password dialog for WiFi authentication. This is handled through the password_dialog.rs module at the app level, since the dialog needs its own input focus and keyboard interactivity.

Custom Buttons

The Settings config supports user-defined buttons with status indicators:

[settings]
custom_buttons = [
    { icon = "\u{f023}", label = "VPN", status_cmd = "vpn-status", on_click = "vpn-toggle" }
]

These execute shell commands and display the result as a status indicator.

Idle Inhibitor

The Settings panel includes an idle inhibitor toggle that prevents the system from going to sleep. This uses the IdleInhibitorManager service, which interacts with systemd-logind’s inhibit API.

Writing a New Module

This guide walks through adding a new module to ashell, step by step.

Step 1: Create the Module File

Create src/modules/my_module.rs:

#![allow(unused)]
fn main() {
use crate::theme::AshellTheme;
use iced::{Element, Subscription, widget::text};

#[derive(Debug, Clone)]
pub enum Message {
    // Define your messages here
    Tick,
}

pub struct MyModule {
    // Your state here
    value: String,
}

impl MyModule {
    pub fn new() -> Self {
        Self {
            value: "Hello".to_string(),
        }
    }

    pub fn update(&mut self, message: Message) {
        match message {
            Message::Tick => {
                // Handle the message
            }
        }
    }

    pub fn view(&self, _theme: &AshellTheme) -> Element<Message> {
        text(&self.value).into()
    }

    pub fn subscription(&self) -> Subscription<Message> {
        Subscription::none()
    }
}
}

Step 2: Register the Module Name

In src/config.rs, add your module to the ModuleName enum:

#![allow(unused)]
fn main() {
pub enum ModuleName {
    // ... existing variants
    MyModule,
}
}

Make sure the serde deserialization handles the string representation (the enum variant name is used as the TOML string).

Step 3: Add to Module Declarations

In src/modules/mod.rs, add the module declaration:

#![allow(unused)]
fn main() {
pub mod my_module;
}

Step 4: Add to App Struct

In src/app.rs, add the field:

#![allow(unused)]
fn main() {
pub struct App {
    // ... existing fields
    pub my_module: MyModule,
}
}

Step 5: Initialize in App::new

In App::new():

#![allow(unused)]
fn main() {
(App {
    // ... existing fields
    my_module: MyModule::new(),
}, task)
}

Step 6: Add Message Variant

In src/app.rs, add to the Message enum:

#![allow(unused)]
fn main() {
pub enum Message {
    // ... existing variants
    MyModule(modules::my_module::Message),
}
}

Step 7: Wire Up in App::update

In App::update(), add the match arm:

#![allow(unused)]
fn main() {
Message::MyModule(msg) => {
    self.my_module.update(msg);
    Task::none()
}
}

Step 8: Wire Up in Module Registry

In src/modules/mod.rs:

get_module_view

#![allow(unused)]
fn main() {
ModuleName::MyModule => Some((
    self.my_module.view(&self.theme).map(Message::MyModule),
    None,  // Or Some(OnModulePress::ToggleMenu(MenuType::MyModule)) if you have a menu
)),
}

get_module_subscription

#![allow(unused)]
fn main() {
ModuleName::MyModule => Some(
    self.my_module.subscription().map(Message::MyModule),
),
}

Step 9: Add Config (Optional)

If your module needs configuration, add a config struct in src/config.rs:

#![allow(unused)]
fn main() {
#[derive(Deserialize, Clone, Debug)]
#[serde(default)]
pub struct MyModuleConfig {
    pub some_setting: String,
}

impl Default for MyModuleConfig {
    fn default() -> Self {
        Self {
            some_setting: "default_value".to_string(),
        }
    }
}
}

Add the field to the Config struct:

#![allow(unused)]
fn main() {
pub struct Config {
    // ...
    pub my_module: MyModuleConfig,
}
}

Then accept it in your module’s constructor:

#![allow(unused)]
fn main() {
pub fn new(config: MyModuleConfig) -> Self { /* ... */ }
}

Step 10: Add a Menu (Optional)

If your module needs a popup menu:

  1. Add a variant to MenuType in src/menu.rs:
#![allow(unused)]
fn main() {
pub enum MenuType {
    // ...
    MyModule,
}
}
  1. Add a menu_view() method to your module.

  2. Change get_module_view to return Some(OnModulePress::ToggleMenu(MenuType::MyModule)).

  3. Handle the menu rendering in App::view() / App::menu_wrapper().

Step 11: Handle Config Reload (Optional)

If your module needs to respond to config changes, add a ConfigReloaded variant to your Message:

#![allow(unused)]
fn main() {
pub enum Message {
    ConfigReloaded(MyModuleConfig),
    // ...
}
}

And call it from App::refesh_config().

Testing Your Module

  1. Add your module to the config file:
[modules]
right = ["MyModule"]
  1. Build and run:
make start
  1. Edit the config to test hot-reload:
# Changes should appear without restarting ashell

Services Overview

Services are the backend layer of ashell. They manage communication with system APIs and produce events that modules consume. Services have no UI.

Available Services

ServiceLocationBackendProtocol
Compositorservices/compositor/Hyprland / NiriIPC socket
Audioservices/audio.rsPulseAudiolibpulse C library
Brightnessservices/brightness.rssysfs + logindFile I/O + D-Bus
Bluetoothservices/bluetooth/BlueZD-Bus
Networkservices/network/NetworkManager / IWDD-Bus
MPRISservices/mpris/Media playersD-Bus
Trayservices/tray/StatusNotifierItemD-Bus
UPowerservices/upower/UPower daemonD-Bus
Privacyservices/privacy.rsPipeWire portalsD-Bus
Idle Inhibitorservices/idle_inhibitor.rssystemd-logindD-Bus
Logindservices/logind.rssystemd-logindD-Bus
Throttleservices/throttle.rs(utility)Stream adapter

Services vs. Modules

AspectModuleService
Has UIYes (view())No
Interacts with systemNo (consumes services)Yes
Has Message typeYesHas UpdateEvent + ServiceEvent
Defined byConventionReadOnlyService / Service trait
Runs onMain thread (iced event loop)Async (tokio) or dedicated thread

Service Communication Pattern

Service (async/background)
    │
    ▼ ServiceEvent<S>
Module subscription
    │
    ▼ Module::Message
App::update()
    │
    ▼ Service::command() (for bidirectional services)
Service (executes command, returns result)

Threading Model

  • Main thread: iced event loop + rendering
  • Tokio runtime: Most services (D-Bus watchers, timers, IPC)
  • Dedicated OS thread: PulseAudio mainloop (libpulse requires its own event loop)
  • Communication: tokio::sync::mpsc channels between threads, iced channel() for subscriptions

Service Traits: ReadOnlyService and Service

The service abstraction is defined in src/services/mod.rs. It provides a standard interface for all backend services.

ServiceEvent

All services communicate through a common event enum:

#![allow(unused)]
fn main() {
#[derive(Debug, Clone)]
pub enum ServiceEvent<S: ReadOnlyService> {
    Init(S),                    // Service initialized, here's the initial state
    Update(S::UpdateEvent),     // Incremental update
    Error(S::Error),            // Something went wrong
}
}
  • Init(S): Sent once when the service starts. Contains the full initial state.
  • Update(S::UpdateEvent): Sent whenever the service state changes. Contains only the change delta.
  • Error(S::Error): Sent when the service encounters an error.

ReadOnlyService

For services that only produce events (no commands):

#![allow(unused)]
fn main() {
pub trait ReadOnlyService: Sized {
    type UpdateEvent;
    type Error: Clone;

    fn update(&mut self, event: Self::UpdateEvent);
    fn subscribe() -> Subscription<ServiceEvent<Self>>;
}
}
  • update(): Applies an incremental update to the service state. Called by the module when it receives a ServiceEvent::Update.
  • subscribe(): Returns an iced Subscription that produces ServiceEvent<Self>. This is the event source.

Service

For services that accept commands (bidirectional):

#![allow(unused)]
fn main() {
pub trait Service: ReadOnlyService {
    type Command;

    fn command(&mut self, command: Self::Command) -> Task<ServiceEvent<Self>>;
}
}
  • command(): Executes a command and returns a Task that may produce a ServiceEvent.

Examples of commands:

  • AudioCommand::SetVolume(device, volume)
  • CompositorCommand::FocusWorkspace(id)
  • BluetoothCommand::Connect(device_path)

Subscription Pattern

Services implement subscribe() using iced’s channel primitive:

#![allow(unused)]
fn main() {
fn subscribe() -> Subscription<ServiceEvent<Self>> {
    Subscription::run_with_id(
        TypeId::of::<Self>(),    // Ensures single instance
        channel(CAPACITY, async move |mut output| {
            // 1. Initialize the service
            let service = MyService::init().await;
            output.send(ServiceEvent::Init(service)).await;

            // 2. Listen for changes in a loop
            loop {
                let event = wait_for_change().await;
                output.send(ServiceEvent::Update(event)).await;
            }
        }),
    )
}
}

Key details:

  • TypeId::of::<Self>(): Each service type gets exactly one subscription instance. If multiple modules subscribe to the same service, they share the same event stream.
  • channel(capacity, ...): Creates a bounded channel that bridges the async service loop with iced’s subscription system.
  • The async closure runs for the lifetime of the subscription and continuously sends events.

Usage in Modules

A module consumes a service like this:

#![allow(unused)]
fn main() {
// In the module's subscription:
fn subscription(&self) -> Subscription<Message> {
    MyService::subscribe().map(|event| Message::ServiceUpdate(event))
}

// In the module's update:
fn update(&mut self, message: Message) {
    match message {
        Message::ServiceUpdate(ServiceEvent::Init(service)) => {
            self.service = Some(service);
        }
        Message::ServiceUpdate(ServiceEvent::Update(event)) => {
            if let Some(service) = &mut self.service {
                service.update(event);
            }
        }
        Message::ServiceUpdate(ServiceEvent::Error(err)) => {
            log::error!("Service error: {err:?}");
        }
    }
}
}

Compositor Service and Abstraction Layer

The compositor service (src/services/compositor/) abstracts over multiple Wayland compositors, currently supporting Hyprland and Niri.

Architecture

services/compositor/
├── mod.rs       # Service trait impl, backend detection, broadcast system
├── types.rs     # CompositorState, CompositorEvent, CompositorCommand, CompositorChoice
├── hyprland.rs  # Hyprland IPC integration
└── niri.rs      # Niri IPC integration

Backend Detection

The compositor is detected automatically via environment variables:

#![allow(unused)]
fn main() {
fn detect_backend() -> Option<CompositorChoice> {
    if hyprland::is_available() {         // Checks HYPRLAND_INSTANCE_SIGNATURE
        Some(CompositorChoice::Hyprland)
    } else if niri::is_available() {      // Checks NIRI_SOCKET
        Some(CompositorChoice::Niri)
    } else {
        None
    }
}
}

The detected backend is stored in a global OnceLock and never changes during the process lifetime.

Broadcast Pattern

Unlike other services that use direct channels, the compositor service uses a broadcast pattern via tokio::sync::broadcast:

#![allow(unused)]
fn main() {
static BROADCASTER: OnceCell<broadcast::Sender<ServiceEvent<CompositorService>>> =
    OnceCell::const_new();
}

This allows multiple subscribers (e.g., Workspaces, WindowTitle, KeyboardLayout modules) to receive the same compositor events without duplication.

Flow

Compositor IPC Socket
    │
    ▼ (single listener thread)
broadcaster_event_loop()
    │
    ▼ broadcast::Sender::send()
    ├── Subscriber 1 (Workspaces module)
    ├── Subscriber 2 (WindowTitle module)
    ├── Subscriber 3 (KeyboardLayout module)
    └── Subscriber 4 (KeyboardSubmap module)

Each call to CompositorService::subscribe() creates a new broadcast::Receiver, getting all events from that point forward.

CompositorState

The unified state across both backends:

#![allow(unused)]
fn main() {
pub struct CompositorState {
    pub workspaces: Vec<Workspace>,
    pub active_window: Option<WindowInfo>,
    pub keyboard_layout: Option<String>,
    pub keyboard_submap: Option<String>,
    pub monitors: Vec<Monitor>,
}
}

CompositorEvent

#![allow(unused)]
fn main() {
pub enum CompositorEvent {
    StateChanged(Box<CompositorState>),    // Full state update
    ActionPerformed,                        // Command completed successfully
}
}

CompositorCommand

Commands that can be sent to the compositor:

#![allow(unused)]
fn main() {
pub enum CompositorCommand {
    FocusWorkspace(WorkspaceId),
    ScrollWorkspace(ScrollDirection),
    ToggleSpecialWorkspace(String),
    NextLayout,
    CustomDispatch(String),
}
}

Backend Implementations

Hyprland (hyprland.rs)

Uses the hyprland crate for IPC communication:

  • Connects to Hyprland’s Unix domain socket
  • Listens for events (workspace changes, window focus, layout changes)
  • Sends commands via the dispatcher

Niri (niri.rs)

Uses the niri-ipc crate:

  • Connects to Niri’s IPC socket (path from NIRI_SOCKET env var)
  • Listens for events and translates them to the common CompositorEvent format
  • Sends commands via the IPC protocol

D-Bus Services Pattern

Most of ashell’s services communicate with system daemons over D-Bus using the zbus crate (version 5).

D-Bus Overview

D-Bus is the standard IPC mechanism on Linux desktops. ashell connects to the system bus (for hardware services like BlueZ, UPower, logind) and the session bus (for user services like MPRIS, StatusNotifier).

The zbus Proxy Pattern

ashell uses zbus’s #[proxy] attribute macro to generate type-safe D-Bus client code. These are defined in dbus.rs files alongside each service:

#![allow(unused)]
fn main() {
// Example from services/bluetooth/dbus.rs
#[zbus::proxy(
    interface = "org.bluez.Adapter1",
    default_service = "org.bluez",
)]
trait Adapter1 {
    #[zbus(property)]
    fn powered(&self) -> zbus::Result<bool>;

    #[zbus(property)]
    fn set_powered(&self, value: bool) -> zbus::Result<()>;

    #[zbus(property)]
    fn discovering(&self) -> zbus::Result<bool>;

    fn start_discovery(&self) -> zbus::Result<()>;
    fn stop_discovery(&self) -> zbus::Result<()>;
}
}

The #[zbus::proxy] macro generates a Adapter1Proxy struct with async methods for each D-Bus method and property.

Services Using D-Bus

ServiceBusD-Bus Service NameProxy File
BluetoothSystemorg.bluezservices/bluetooth/dbus.rs
Network (NM)Systemorg.freedesktop.NetworkManagerservices/network/dbus.rs
Network (IWD)Systemnet.connman.iwdservices/network/iwd_dbus/
UPowerSystemorg.freedesktop.UPowerservices/upower/dbus.rs
LogindSystemorg.freedesktop.login1services/logind.rs
MPRISSessionorg.mpris.MediaPlayer2.*services/mpris/dbus.rs
TraySessionorg.kde.StatusNotifierWatcherservices/tray/dbus.rs
PrivacySessionorg.freedesktop.portal.Desktopservices/privacy.rs
BrightnessSystemorg.freedesktop.login1.Sessionservices/brightness.rs

Common D-Bus Service Structure

A typical D-Bus service follows this pattern:

services/my_service/
├── mod.rs    # Service trait impl, business logic
└── dbus.rs   # zbus proxy definitions

In mod.rs, the subscription connects to D-Bus and watches for signals/property changes:

#![allow(unused)]
fn main() {
fn subscribe() -> Subscription<ServiceEvent<Self>> {
    Subscription::run_with_id(
        TypeId::of::<Self>(),
        channel(10, async move |mut output| {
            // 1. Connect to D-Bus
            let connection = zbus::Connection::system().await.unwrap();

            // 2. Create proxy
            let proxy = MyProxy::new(&connection).await.unwrap();

            // 3. Get initial state
            let state = MyService::from_proxy(&proxy).await;
            output.send(ServiceEvent::Init(state)).await;

            // 4. Watch for changes
            let mut stream = proxy.receive_property_changed().await;
            while let Some(change) = stream.next().await {
                output.send(ServiceEvent::Update(change.into())).await;
            }
        }),
    )
}
}

IWD Bindings

The IWD (iNet Wireless Daemon) integration has the most extensive D-Bus bindings in the project, located in services/network/iwd_dbus/. This includes proxy definitions for:

  • Station — WiFi station management
  • Network — WiFi network connections
  • KnownNetwork — Previously connected networks
  • Device — Wireless device control
  • AccessPoint — AP mode (not used by ashell but defined for completeness)

Signal Watching vs. Property Polling

ashell uses two approaches depending on the D-Bus service:

  • Signal watching (preferred): Subscribe to D-Bus signals for real-time updates. Used for Bluetooth device changes, MPRIS playback state, etc.
  • Property polling: Some services don’t emit reliable signals for all changes. In these cases, ashell uses periodic polling or watches the PropertiesChanged signal.

Audio Service (PulseAudio/PipeWire)

The audio service (src/services/audio.rs) manages volume control and audio device routing through PulseAudio (which PipeWire implements as a compatibility layer).

Architecture

Unlike D-Bus services, the audio service uses libpulse (the PulseAudio C library) via the libpulse-binding crate. This requires a fundamentally different threading model.

┌──────────────────────┐     ┌──────────────────────┐
│   PulseAudio Thread  │     │     Tokio Runtime     │
│                      │     │                       │
│  libpulse Mainloop   │────►│  UnboundedReceiver    │
│  (OS thread, !Send)  │ tx  │                       │
│                      │     │  ThrottleExt adapter  │
│                      │◄────│                       │
│                      │ cmd │  iced Subscription    │
└──────────────────────┘     └──────────────────────┘

Why a Dedicated Thread?

libpulse’s Mainloop is !Send — it cannot be moved between threads. It also has its own event loop that conflicts with tokio. The solution is:

  1. Spawn a dedicated OS thread (std::thread::spawn)
  2. Run the PulseAudio mainloop on that thread
  3. Communicate with the tokio runtime via tokio::sync::mpsc::UnboundedSender/Receiver

Data Model

#![allow(unused)]
fn main() {
pub struct Device {
    pub name: String,
    pub description: String,
    pub volume: ChannelVolumes,
    pub is_mute: bool,
    pub is_filter: bool,        // Virtual devices (e.g., audio filters)
    pub ports: Vec<Port>,
}

pub struct Port {
    pub name: String,
    pub description: String,
    pub device_type: DevicePortType,
    pub active: bool,
}

pub struct Route<'a> {
    pub device: &'a Device,
    pub port: Option<&'a Port>,
}
}

Throttling

PulseAudio can emit events very rapidly (e.g., during volume slider dragging). The ThrottleExt stream adapter in services/throttle.rs rate-limits these events to prevent UI thrashing:

#![allow(unused)]
fn main() {
// Conceptual usage
let stream = pa_events.throttle(Duration::from_millis(50));
}

This ensures the UI updates at most once every 50ms regardless of how fast PulseAudio emits events.

Commands

The audio service implements the Service trait with these commands:

  • Set default sink/source
  • Set volume for a sink/source
  • Toggle mute for a sink/source
  • Move audio to a different device/port

PipeWire Compatibility

Most modern Linux distributions use PipeWire, which provides a PulseAudio-compatible API. ashell’s audio service works transparently with both PulseAudio and PipeWire — no code changes needed.

The privacy.rs service separately uses PipeWire’s portal API for detecting active microphone/camera/screenshare sessions.

Network Service (NetworkManager/IWD)

The network service (src/services/network/) manages WiFi connections and VPN, supporting two backends: NetworkManager and IWD.

Structure

services/network/
├── mod.rs       # Service implementation, backend abstraction
├── dbus.rs      # NetworkManager D-Bus proxy definitions
└── iwd_dbus/    # IWD D-Bus proxy definitions (extensive)
    ├── mod.rs
    ├── station.rs
    ├── network.rs
    ├── known_network.rs
    ├── device.rs
    └── ...

Dual Backend

The network service supports two backends:

  • NetworkManager: The traditional Linux network management daemon. Used on most distributions.
  • IWD (iNet Wireless Daemon): Intel’s lightweight wireless daemon. Used on some minimal setups and can be used as a backend for NetworkManager.

The backend is detected based on which D-Bus service is available.

Capabilities

  • List available WiFi networks
  • Connect/disconnect from WiFi networks
  • WiFi network scanning
  • VPN connection management
  • Connection state monitoring
  • Signal strength display

Known Challenges

The network service is the most problematic service in the codebase (see GitHub Issue #445):

  • WiFi scanning reliability: Scan results can be stale or incomplete depending on the backend.
  • Architectural differences: NetworkManager and IWD have fundamentally different D-Bus APIs and event models, making a unified abstraction difficult.
  • Connection state tracking: Race conditions can occur between connection state changes and UI updates.

This is an active area of refactoring.

IWD D-Bus Bindings

The IWD integration includes a comprehensive set of D-Bus proxy definitions — one of the most extensive in the project. This covers:

InterfacePurpose
net.connman.iwd.StationWiFi station management
net.connman.iwd.NetworkNetwork connection control
net.connman.iwd.KnownNetworkSaved network management
net.connman.iwd.DeviceWireless device control
net.connman.iwd.AdapterPhysical adapter properties

Writing a New Service

This guide shows how to add a new backend service to ashell.

Read-Only D-Bus Service

Most new services will use D-Bus. Here’s a template:

Step 1: Create the Service Files

src/services/my_service/
├── mod.rs    # Service logic
└── dbus.rs   # D-Bus proxy definitions

Step 2: Define D-Bus Proxies (dbus.rs)

#![allow(unused)]
fn main() {
use zbus::proxy;

#[proxy(
    interface = "org.example.MyService1",
    default_service = "org.example.MyService",
    default_path = "/org/example/MyService"
)]
trait MyService1 {
    #[zbus(property)]
    fn status(&self) -> zbus::Result<String>;

    #[zbus(property)]
    fn value(&self) -> zbus::Result<u32>;
}
}

Step 3: Implement the Service (mod.rs)

#![allow(unused)]
fn main() {
use crate::services::{ReadOnlyService, ServiceEvent};
use iced::{Subscription, stream::channel};
use iced::futures::SinkExt;
use std::any::TypeId;

mod dbus;

// Define the update event
#[derive(Debug, Clone)]
pub enum UpdateEvent {
    StatusChanged(String),
    ValueError(u32),
}

// Define the service state
#[derive(Debug, Clone)]
pub struct MyService {
    pub status: String,
    pub value: u32,
}

impl ReadOnlyService for MyService {
    type UpdateEvent = UpdateEvent;
    type Error = String;

    fn update(&mut self, event: Self::UpdateEvent) {
        match event {
            UpdateEvent::StatusChanged(s) => self.status = s,
            UpdateEvent::ValueError(v) => self.value = v,
        }
    }

    fn subscribe() -> Subscription<ServiceEvent<Self>> {
        Subscription::run_with_id(
            TypeId::of::<Self>(),
            channel(10, async move |mut output| {
                // Connect to D-Bus
                let connection = zbus::Connection::system().await.unwrap();
                let proxy = dbus::MyService1Proxy::new(&connection).await.unwrap();

                // Send initial state
                let status = proxy.status().await.unwrap_or_default();
                let value = proxy.value().await.unwrap_or_default();
                let _ = output.send(ServiceEvent::Init(MyService { status, value })).await;

                // Watch for property changes
                let mut status_stream = proxy.receive_status_changed().await;
                loop {
                    use iced::futures::StreamExt;
                    if let Some(change) = status_stream.next().await {
                        if let Ok(new_status) = change.get().await {
                            let _ = output.send(
                                ServiceEvent::Update(UpdateEvent::StatusChanged(new_status))
                            ).await;
                        }
                    }
                }
            }),
        )
    }
}
}

Step 4: Register in services/mod.rs

#![allow(unused)]
fn main() {
pub mod my_service;
}

Step 5: Consume from a Module

In your module’s subscription:

#![allow(unused)]
fn main() {
use crate::services::my_service::MyService;
use crate::services::{ReadOnlyService, ServiceEvent};

pub fn subscription(&self) -> Subscription<Message> {
    MyService::subscribe().map(|event| Message::ServiceUpdate(event))
}
}

Bidirectional Service (with Commands)

If your service needs to accept commands, additionally implement the Service trait:

#![allow(unused)]
fn main() {
#[derive(Debug, Clone)]
pub enum Command {
    SetValue(u32),
}

impl Service for MyService {
    type Command = Command;

    fn command(&mut self, command: Self::Command) -> Task<ServiceEvent<Self>> {
        match command {
            Command::SetValue(val) => {
                self.value = val;
                Task::perform(
                    async move {
                        // Execute the D-Bus call
                        let connection = zbus::Connection::system().await.unwrap();
                        let proxy = dbus::MyService1Proxy::new(&connection).await.unwrap();
                        proxy.set_value(val).await.unwrap();
                        ServiceEvent::Update(UpdateEvent::ValueError(val))
                    },
                    |event| event,
                )
            }
        }
    }
}
}

Non-D-Bus Service

For services that don’t use D-Bus (e.g., file watching, IPC sockets):

#![allow(unused)]
fn main() {
fn subscribe() -> Subscription<ServiceEvent<Self>> {
    Subscription::run_with_id(
        TypeId::of::<Self>(),
        channel(10, async move |mut output| {
            // Your custom event source here
            // Could be: file watching, socket reading, periodic polling, etc.
            loop {
                let data = read_from_source().await;
                let _ = output.send(ServiceEvent::Update(data)).await;
            }
        }),
    )
}
}

Using ThrottleExt

If your service produces events very rapidly, use the throttle adapter:

#![allow(unused)]
fn main() {
use crate::services::throttle::ThrottleExt;

// In your subscription loop:
let throttled_stream = event_stream.throttle(Duration::from_millis(100));
}

This prevents UI updates from overwhelming the rendering pipeline.

Widgets Overview

ashell includes three custom iced widgets in src/widgets/ that provide functionality not available in iced’s built-in widget set.

Custom Widgets

WidgetFilePurpose
Centerboxwidgets/centerbox.rsThree-column layout that keeps the center truly centered
PositionButtonwidgets/position_button.rsButton that reports its screen position on press
MenuWrapperwidgets/menu_wrapper.rsMenu container with backdrop and click-outside-to-close

ButtonUIRef

Defined in widgets/mod.rs, this type carries a button’s screen position and size:

#![allow(unused)]
fn main() {
#[derive(Debug, Clone, Default)]
pub struct ButtonUIRef {
    pub position: Point,
    pub size: Size,
}
}

This is used by PositionButton to tell the menu system where to position popup menus relative to the button that triggered them.

Why Custom Widgets?

iced provides a rich set of built-in widgets (buttons, text, rows, columns, containers, sliders, etc.), but a status bar has specific needs:

  • Centerbox: iced’s Row doesn’t guarantee the center element stays centered when left/right content has different widths.
  • PositionButton: Standard iced buttons don’t report their screen position, which is needed for menu placement.
  • MenuWrapper: No built-in support for modal overlays with backdrop click-to-close.

Centerbox

src/widgets/centerbox.rs

Purpose

The Centerbox is a three-column horizontal layout widget. Unlike iced’s Row, it guarantees that the center element is truly centered on the screen, regardless of the widths of the left and right elements.

How It Works

┌──────────────┬──────────────┬──────────────┐
│    Left      │    Center    │    Right     │
│  (shrink)    │  (centered)  │  (shrink)    │
└──────────────┴──────────────┴──────────────┘

The layout algorithm:

  1. Measures the left and right children
  2. Centers the middle child in the remaining space
  3. Ensures the center stays at the true horizontal midpoint, even if left and right have different widths

API

#![allow(unused)]
fn main() {
pub struct Centerbox<'a, Message, Theme, Renderer> {
    children: [Element<'a, Message, Theme, Renderer>; 3],
    // ...
}

impl Centerbox {
    pub fn new(children: [Element; 3]) -> Self;
    pub fn spacing(self, amount: impl Into<Pixels>) -> Self;
    pub fn padding(self, padding: impl Into<Padding>) -> Self;
    pub fn width(self, width: impl Into<Length>) -> Self;
    pub fn height(self, height: impl Into<Length>) -> Self;
    pub fn align_y(self, align: Alignment) -> Self;
}
}

Usage in ashell

The Centerbox is used as the main bar layout:

#![allow(unused)]
fn main() {
// In App::view()
Centerbox::new(self.modules_section(id, &self.theme))
    .width(Length::Fill)
    .height(Length::Fill)
    .align_y(Alignment::Center)
}

Where modules_section() returns [left_modules, center_modules, right_modules].

PositionButton

src/widgets/position_button.rs

Purpose

A button that reports its screen position and size when pressed. This information is needed to position popup menus relative to the button that triggered them.

Why Not a Regular Button?

iced’s built-in Button widget emits a message on press, but it doesn’t include any information about where the button is on screen. For menu positioning, ashell needs to know the exact screen coordinates of the clicked button.

API

#![allow(unused)]
fn main() {
pub fn position_button<'a>(content: impl Into<Element<'a, Message>>) -> PositionButton<'a, Message>;

impl PositionButton {
    // Standard click: callback receives ButtonUIRef with position info
    pub fn on_press_with_position(self, f: impl Fn(ButtonUIRef) -> Message) -> Self;

    // Standard click without position info
    pub fn on_press(self, msg: Message) -> Self;

    // Right-click handler
    pub fn on_right_press(self, msg: Message) -> Self;

    // Scroll handlers
    pub fn on_scroll_up(self, msg: Message) -> Self;
    pub fn on_scroll_down(self, msg: Message) -> Self;

    // Styling
    pub fn padding(self, padding: impl Into<Padding>) -> Self;
    pub fn height(self, height: impl Into<Length>) -> Self;
    pub fn style(self, style: impl Fn(&Theme, Status) -> button::Style) -> Self;
}
}

ButtonUIRef

#![allow(unused)]
fn main() {
pub struct ButtonUIRef {
    pub position: Point,    // Screen coordinates of the button's top-left corner
    pub size: Size,         // Width and height of the button
}
}

Usage

#![allow(unused)]
fn main() {
// In modules/mod.rs
position_button(content)
    .on_press_with_position(move |button_ui_ref| {
        Message::ToggleMenu(MenuType::Settings, output_id, button_ui_ref)
    })
}

The button_ui_ref is then used by MenuWrapper to position the popup relative to the button.

MenuWrapper

src/widgets/menu_wrapper.rs

Purpose

A container widget that positions menu popup content relative to a triggering button, with a backdrop overlay that handles click-outside-to-close.

How It Works

┌─────────────────────────────────────┐
│  Backdrop (transparent overlay)     │
│                                     │
│          ┌──────────────┐           │
│          │  Menu Content│           │
│          │  (positioned │           │
│          │   relative   │           │
│          │   to button) │           │
│          └──────────────┘           │
│                                     │
│  Click anywhere on backdrop = close │
└─────────────────────────────────────┘

The MenuWrapper:

  1. Renders a fullscreen backdrop (semi-transparent or transparent)
  2. Positions the menu content horizontally aligned with the triggering button
  3. Positions the menu vertically above or below the bar (depending on bar position)
  4. Handles click events on the backdrop to close the menu

Integration

The App::menu_wrapper() method creates the MenuWrapper for the currently open menu:

#![allow(unused)]
fn main() {
fn menu_wrapper(&self, output: &ShellInfo, menu_type: &MenuType, button_ui_ref: &ButtonUIRef)
    -> Element<Message>
{
    let content = match menu_type {
        MenuType::Settings => self.settings.menu_view(&self.theme),
        MenuType::Updates => self.updates.menu_view(&self.theme),
        // ...
    };

    MenuWrapper::new(content, button_ui_ref, bar_position, menu_size)
        .on_backdrop_press(Message::CloseMenu(output.id))
}
}

The wrapper uses predefined size categories for menu width:

SizeWidth
Small250px
Medium350px
Large450px
XLarge650px

Cargo and Dependencies

ashell’s dependencies are managed in Cargo.toml. This chapter covers the key dependencies and why they’re used.

Core Dependencies

UI Framework

CrateVersionPurpose
icedGit (MalpenZibo fork)GUI framework with Wayland layer shell support

The iced dependency uses many features:

  • tokio — Async runtime integration
  • multi-window — Multiple layer surfaces (bar + menu per monitor)
  • advanced — Custom widget support
  • wgpu — GPU-accelerated rendering
  • winit — Window system integration
  • wayland — Wayland protocol support
  • image, svg, canvas — Graphics capabilities

Async Runtime

CrateVersionPurpose
tokio1Async runtime for services
tokio-stream0.1Stream utilities

Compositor Integration

CrateVersionPurpose
hyprland0.4.0-beta.2Hyprland IPC client
niri-ipc25.11.0Niri IPC client

System Integration

CrateVersionPurpose
zbus5D-Bus client (BlueZ, NM, UPower, etc.)
libpulse-binding2.28PulseAudio client library
pipewire0.9PipeWire integration
wayland-client0.31.12Wayland protocol client
wayland-protocols0.32.10Wayland protocol definitions
sysinfo0.37CPU, RAM, disk, network statistics
udev0.9Device monitoring

Configuration

CrateVersionPurpose
toml0.9TOML config file parsing
serde1.0Serialization/deserialization
serde_json1JSON for tray menu data
serde_with3.12Advanced serde derivation
clap4.5CLI argument parsing
inotify0.11.0File change watching

Utilities

CrateVersionPurpose
chrono0.4Date/time handling
chrono-tz0.10.4Timezone support
regex1.12.2Regular expressions (config parsing)
hex_color3Hex color parsing in config
itertools0.14Iterator utilities
anyhow1Error handling
log0.4Logging facade
flexi_logger0.31Logging implementation
signal-hook0.4.3Unix signal handling (SIGUSR1)
reqwest0.13HTTP client (weather data)
uuid1UUID generation
url2.5.7URL parsing
freedesktop-icons0.4XDG icon lookup
linicon-theme1.2.0Icon theme resolution
shellexpand3Tilde/env var expansion in paths
parking_lot0.12.5Synchronization primitives
pin-project-lite0.2.16Pin projection (for throttle stream)
libc0.2.182System call interfaces

Build Dependencies

CrateVersionPurpose
allsorts0.15Font parsing and subsetting in build.rs

Release Profile

[profile.release]
lto = "thin"       # Link-Time Optimization (balances speed vs compile time)
strip = true       # Remove debug symbols from binary
opt-level = 3      # Maximum optimization
panic = "abort"    # No stack unwinding (smaller binary)

Runtime Package Dependencies

For binary distribution, runtime dependencies are declared in Cargo.toml metadata:

[package.metadata.nfpm]
provides = ["ashell"]
depends = ["libxkbcommon", "dbus"]

[package.metadata.nfpm.deb]
depends = ["libwayland-client0", "libpipewire-0.3-0t64", "libpulse0"]

[package.metadata.nfpm.rpm]
depends = ["libwayland-client", "pipewire-libs", "pulseaudio-libs"]

build.rs: Font Subsetting

The build script (build.rs) runs at compile time and performs two tasks: Nerd Font subsetting and git hash extraction.

Font Subsetting

ashell uses Nerd Font symbols for icons (battery, WiFi, Bluetooth, volume, etc.). The full font files are ~4.8 MB. Since ashell only uses ~80 icons, the build script subsets the fonts to include only the needed glyphs.

How It Works

  1. Parse icons: Read src/components/icons.rs and find all \u{XXXX} Unicode escape sequences.

  2. Convert to characters: Each hex code is converted to its Unicode character.

  3. Subset the font: Using the allsorts crate, create new TTF files containing only the needed glyphs.

  4. Write output: Save the subsetted fonts to target/generated/:

    • SymbolsNerdFont-Regular-Subset.ttf
    • SymbolsNerdFontMono-Regular-Subset.ttf

Source Files

InputOutput
assets/SymbolsNerdFont-Regular.ttf (~2.4 MB)target/generated/SymbolsNerdFont-Regular-Subset.ttf (~few KB)
assets/SymbolsNerdFontMono-Regular.ttf (~2.4 MB)target/generated/SymbolsNerdFontMono-Regular-Subset.ttf (~few KB)

Adding a New Icon

To add a new icon to ashell:

  1. Find the Unicode codepoint from the Nerd Fonts cheat sheet.
  2. Add a constant to src/components/icons.rs:
    #![allow(unused)]
    fn main() {
    pub const MY_ICON: char = '\u{f0001}';
    }
  3. Build — build.rs automatically detects the new codepoint and includes it in the subset.

No manual font editing is required.

Git Hash Extraction

The build script also extracts the current git commit hash:

#![allow(unused)]
fn main() {
let output = Command::new("git")
    .args(["rev-parse", "--short", "HEAD"])
    .output();

match output {
    Ok(output) if output.status.success() => {
        let git_hash = String::from_utf8(output.stdout)?;
        println!("cargo:rustc-env=GIT_HASH={}", git_hash.trim());
    }
    _ => {
        println!("cargo:rustc-env=GIT_HASH=unknown");
    }
}
}

This is used in the --version output via clap:

#![allow(unused)]
fn main() {
#[command(version = concat!(env!("CARGO_PKG_VERSION"), " (", env!("GIT_HASH"), ")"))]
}

Producing output like: ashell 0.7.0 (abc1234)

Font Loading at Runtime

The subsetted fonts are embedded in the binary at compile time:

#![allow(unused)]
fn main() {
// In main.rs
const NERD_FONT: &[u8] = include_bytes!("../target/generated/SymbolsNerdFont-Regular-Subset.ttf");
const NERD_FONT_MONO: &[u8] = include_bytes!("../target/generated/SymbolsNerdFontMono-Regular-Subset.ttf");
const CUSTOM_FONT: &[u8] = include_bytes!("../assets/AshellCustomIcon-Regular.otf");
}

These are loaded into iced’s font system at startup:

#![allow(unused)]
fn main() {
iced::daemon(/* ... */)
    .font(Cow::from(NERD_FONT))
    .font(Cow::from(NERD_FONT_MONO))
    .font(Cow::from(CUSTOM_FONT))
}

Nix Flake

ashell provides a flake.nix for reproducible builds and development environments.

Development Shell

nix develop

This provides:

  • Latest stable Rust toolchain via rust-overlay
  • rust-analyzer for editor integration
  • All native build dependencies (Wayland, PipeWire, PulseAudio, etc.)
  • Correct LD_LIBRARY_PATH for runtime libraries
  • RUST_SRC_PATH set for rust-analyzer

Building with Nix

nix build

The built binary includes a wrapper that sets LD_LIBRARY_PATH for runtime dependencies (Wayland, Vulkan, Mesa, OpenGL).

Flake Inputs

InputPurpose
craneRust build system for Nix
nixpkgsPackage repository (nixos-unstable channel)
rust-overlayRust toolchain overlay

Build Dependencies

buildInputs = [
  rustToolchain.default
  rustPlatform.bindgenHook   # For C library bindings
  pkg-config
  libxkbcommon
  libGL
  pipewire
  libpulseaudio
  wayland
  vulkan-loader
  udev
];

Runtime Dependencies

runtimeDependencies = [
  libpulseaudio
  wayland
  mesa
  vulkan-loader
  libGL
  libglvnd
];

The postInstall step wraps the binary with wrapProgram to set LD_LIBRARY_PATH:

postInstall = ''
  wrapProgram "$out/bin/ashell" --prefix LD_LIBRARY_PATH : "${ldLibraryPath}"
'';

CI Pipeline

ashell uses GitHub Actions for continuous integration. All workflow files are in .github/workflows/.

Main CI Workflow (ci.yml)

Trigger: Push to main, pull requests targeting main.

Runner: ubuntu-24.04

Steps:

  1. Install dependencies: All system libraries needed for compilation

    sudo apt-get install -y pkg-config llvm-dev libclang-dev clang \
      libxkbcommon-dev libwayland-dev dbus libpipewire-0.3-dev \
      libpulse-dev libudev-dev
    
  2. Format check: cargo fmt --all -- --check

    • Fails if any code is not properly formatted.
  3. Clippy lint: cargo clippy --all-features -- -D warnings

    • Zero warnings policy. All clippy warnings are treated as errors.
  4. Build: cargo build

    • Ensures the project compiles successfully.

Nix CI (nix-ci.yml)

Verifies that the Nix flake builds correctly.

Website and Developer Guide CI

  • gh-pages-test.yml: Tests both the Docusaurus website and the mdbook developer guide build on PRs.
  • gh-pages-deploy.yml: Builds the website and developer guide, then deploys them together to GitHub Pages on push to main. The developer guide is built with mdbook build and copied into the website output at /dev-guide/.

Dependency Management (dependabot.yml)

Dependabot is configured to:

  • Check for Rust dependency updates (Cargo)
  • Check for GitHub Actions updates
  • Create PRs for available updates

All Workflows

WorkflowTriggerPurpose
ci.ymlPush/PR to mainFormat, lint, build
nix-ci.ymlPush/PRNix flake validation
release.ymlManual dispatchBuild release artifacts
pre-release.ymlPre-release tagPre-release builds
generate-installers.ymlCalled by releaseBuild .deb/.rpm packages
gh-pages-deploy.ymlPush to mainDeploy website
gh-pages-test.ymlPRTest website build
update-arch-package.ymlReleaseUpdate AUR package
release-drafter.ymlPush/PRAuto-draft release notes
remove-manifest-assets.ymlPost-releaseClean up dist manifests

Release Process

ashell uses cargo-dist (v0.30.0) for automated release builds and GitHub Releases.

Note: Releases are managed by the project maintainer (@MalpenZibo). This page documents the process for reference.

How a Release Works

  1. Draft release notes: The release-drafter.yml workflow automatically drafts release notes based on merged PRs. The maintainer reviews and edits the draft in GitHub Releases.

  2. Trigger the release: The maintainer goes to Actions → Release → Run workflow and enters the version tag (e.g., v0.8.0).

  3. Automated pipeline: The release workflow:

    • Runs dist plan to determine build matrix
    • Builds platform-specific artifacts (Linux binary + archives)
    • Builds global artifacts (shell installer, checksums)
    • Generates .deb and .rpm packages via generate-installers.yml
    • Uploads all artifacts to the GitHub Release
    • Un-drafts the release
  4. Post-release: Downstream packaging jobs run automatically:

    • update-arch-package.yml updates the AUR package
    • remove-manifest-assets.yml cleans up dist manifests from the release

cargo-dist Configuration

dist-workspace.toml configures the release build:

[workspace]
members = ["cargo:."]

[dist]
cargo-dist-version = "0.30.0"
ci = "github"
installers = ["shell"]
targets = ["x86_64-unknown-linux-gnu"]

Dry Run

To test the release process without actually publishing, the maintainer can:

  1. Go to Actions → Release → Run workflow
  2. Enter dry-run as the tag
  3. This runs the full pipeline but doesn’t create a GitHub Release

Versioning

  • Version is defined in Cargo.toml: version = "0.7.0"
  • Tags follow semver: v0.7.0
  • Pre-releases use suffixes: v0.8.0-beta.1
  • The --version flag shows: ashell 0.7.0 (abc1234) (version + git hash)

Packaging (deb, rpm, Arch, Nix)

ashell is distributed through multiple packaging formats.

.deb Packages (Debian/Ubuntu)

Generated by generate-installers.yml using nfpm.

Package metadata comes from Cargo.toml:

[package.metadata.nfpm.deb]
depends = ["libwayland-client0", "libpipewire-0.3-0t64", "libpulse0"]

.rpm Packages (Fedora/RHEL)

Also generated by generate-installers.yml with nfpm.

[package.metadata.nfpm.rpm]
depends = ["libwayland-client", "pipewire-libs", "pulseaudio-libs"]

Arch Linux (AUR)

The update-arch-package.yml workflow updates the AUR package after each release. Users install via:

yay -S ashell
# or
paru -S ashell

Nix

The flake.nix provides a Nix package:

# Run directly
nix run github:MalpenZibo/ashell

# Install
nix profile install github:MalpenZibo/ashell

Shell Installer

cargo-dist generates a shell installer script for manual installation:

curl --proto '=https' --tlsv1.2 -LsSf https://github.com/MalpenZibo/ashell/releases/latest/download/ashell-installer.sh | sh

Common Dependencies Across Formats

All packages share these runtime dependencies:

DependencyPurpose
libxkbcommonKeyboard handling
dbusD-Bus daemon
libwayland-clientWayland protocol
libpipewirePipeWire audio
libpulsePulseAudio compatibility

Contribution Workflow

Getting Started

  1. Fork the repository on GitHub.
  2. Clone your fork locally.
  3. Create a branch from main:
    git checkout -b feat/my-feature
    
  4. Make your changes.
  5. Run checks before pushing:
    make check
    
  6. Push and open a Pull Request against main.

Branch Naming

Follow the conventional prefix pattern:

PrefixPurposeExample
feat/New featuresfeat/ddc-brightness
fix/Bug fixesfix/bluetooth-crash
chore/Maintenance, dependencieschore/update-iced-rev
docs/Documentationdocs/developer-guide
refactor/Code restructuringrefactor/network-service

Maintainer Model

  • MalpenZibo (Simone Camito) is the project creator and primary maintainer. Has final merge authority.
  • romanstingler is a collaborator focusing on backend/service work, bug fixes, and Hyprland testing.
  • clotodex is a collaborator providing Niri and NixOS testing and architectural feedback.

Pull Request Process

  1. PRs should target the main branch.
  2. CI must pass (format, clippy, build).
  3. At least one maintainer review is expected for non-trivial changes.
  4. Keep PRs focused — one feature or fix per PR when possible.

Issue Tracking

Issues are tracked on GitHub with labels:

  • bug — Something is broken
  • enhancement — Improvement to existing feature
  • feature — New feature request
  • discussion — Open-ended design discussion
  • good first issue — Suitable for new contributors
  • help wanted — Looking for community contributions
  • UI/UX — User interface related
  • performance — Performance improvements
  • blocked / postponed — Cannot proceed currently

AI-Assisted Contributions

AI-assisted contributions are accepted in this project. If you use AI tools to help write code, documentation, or other contributions, that is fine — the same quality standards apply regardless of how the code was written.

Using top-tier, frontier-class models is strongly recommended, but you are responsible for the code you submit no matter what tools you use. Review AI-generated output carefully, ensure it passes all checks (make check), and be prepared to explain and defend your changes during review.

For the full AI contribution guide including workflow recommendations, common pitfalls, and best practices, see AI-Assisted Contributions.

Communication

  • Primary communication is through GitHub issues and PR comments.

Code Style and Conventions

Formatting

Use cargo fmt with the default rustfmt configuration:

cargo fmt

CI enforces formatting with cargo fmt --all -- --check.

Linting

All clippy warnings are treated as errors:

cargo clippy -- -D warnings

This is enforced in CI. Fix all warnings before submitting a PR.

Quick Check

The Makefile runs both:

make check
# Equivalent to: cargo fmt && cargo check && cargo clippy -- -D warnings

Module Structure Conventions

File Naming

  • Simple modules: src/modules/my_module.rs
  • Complex modules with sub-parts: src/modules/my_module/mod.rs + sub-files
  • Services follow the same pattern: src/services/my_service.rs or src/services/my_service/mod.rs

Message Enums

Every module defines its own Message enum:

#![allow(unused)]
fn main() {
#[derive(Debug, Clone)]
pub enum Message {
    // Module-specific variants
}
}

Action Pattern

Modules that need to communicate side effects to the App use an Action enum:

#![allow(unused)]
fn main() {
pub enum Action {
    None,
    Command(Task<Message>),
    CloseMenu,
    // ...
}
}

Constructor Convention

Modules take their config in new():

#![allow(unused)]
fn main() {
pub fn new(config: MyModuleConfig) -> Self { /* ... */ }
}

Logging

Use the log crate macros:

#![allow(unused)]
fn main() {
use log::{debug, info, warn, error};

debug!("Detailed debugging info");
info!("Notable events");
warn!("Something unexpected but recoverable");
error!("Something went wrong");
}

Avoid println! — all output should go through the logger so it’s captured in log files.

Error Handling

  • Services use anyhow or custom error types
  • Config parsing uses Box<dyn Error>
  • Prefer logging errors over panicking in service code
  • Use unwrap_or_default() or unwrap_or_else() for recoverable cases

Imports

  • Group imports by crate (std, external, internal)
  • Use crate:: prefix for internal imports
  • Prefer specific imports over glob imports

Testing and Debugging

Current Test Status

ashell does not currently have an automated test suite. Testing is done manually by running the application on real hardware with Hyprland or Niri.

Debugging with Logs

ashell writes logs to /tmp/ashell/. To watch logs in real time:

tail -f /tmp/ashell/*.log

Adjusting Log Level

In the config file:

log_level = "debug"

Common levels: error, warn, info, debug, trace.

You can also set per-module levels:

log_level = "warn,ashell::services::audio=debug"

Debug Build Logging

In debug builds (cargo build without --release), all logs are also printed to stdout.

Common Debugging Scenarios

Service Not Starting

Check logs for initialization errors:

grep -i "error\|failed\|panic" /tmp/ashell/*.log

D-Bus Issues

Use busctl to check if the D-Bus service is available:

# System bus services
busctl --system list | grep -i "bluez\|networkmanager\|upower"

# Session bus services
busctl --user list | grep -i "mpris\|statusnotifier"

Compositor Detection

If ashell fails to detect your compositor, check the environment variables:

echo $HYPRLAND_INSTANCE_SIGNATURE
echo $NIRI_SOCKET

NVIDIA Issues

If you experience rendering issues on NVIDIA, try:

WGPU_BACKEND=gl ashell

Running a Test Configuration

You can run ashell with a custom config for testing:

ashell --config-path /tmp/test-config.toml

Create a minimal config to test specific features in isolation.

Hot-Reload Testing

Edit the config file while ashell is running. Changes should apply immediately without restart. Useful for testing:

  • Theme changes
  • Module layout changes
  • Module-specific settings

Multi-Monitor Testing

If you only have one monitor, you can test multi-monitor behavior by:

  • Using virtual outputs in Hyprland
  • Using a headless Wayland compositor for basic testing

Common Development Tasks

Adding a New Icon

  1. Find the Unicode codepoint from the Nerd Fonts cheat sheet.
  2. Add it to src/components/icons.rs:
    #![allow(unused)]
    fn main() {
    pub const MY_NEW_ICON: char = '\u{f0001}';
    }
  3. Build — build.rs automatically subsets the font to include the new glyph.

Adding a New Config Option

  1. Add the field to the relevant config struct in src/config.rs:

    #![allow(unused)]
    fn main() {
    #[derive(Deserialize, Clone, Debug)]
    #[serde(default)]
    pub struct MyModuleConfig {
        pub new_option: bool,  // Add this
    }
    }
  2. Set a default value in the Default impl:

    #![allow(unused)]
    fn main() {
    impl Default for MyModuleConfig {
        fn default() -> Self {
            Self {
                new_option: false,
            }
        }
    }
    }
  3. Use the option in your module.

  4. If the option should be hot-reloadable, handle it in the module’s ConfigReloaded message.

Adding a D-Bus Integration

  1. Create proxy definitions in a dbus.rs file:

    #![allow(unused)]
    fn main() {
    #[zbus::proxy(
        interface = "org.example.Service1",
        default_service = "org.example.Service",
    )]
    trait Service1 {
        #[zbus(property)]
        fn my_property(&self) -> zbus::Result<String>;
    }
    }
  2. Implement the ReadOnlyService or Service trait.

  3. Subscribe from a module. See Writing a New Service.

Working with the Theme

To add a new style or modify existing styles, edit src/theme.rs:

#![allow(unused)]
fn main() {
// Add a new button style method
pub fn my_button_style(&self) -> impl Fn(&Theme, Status) -> button::Style {
    let opacity = self.opacity;
    move |theme, status| {
        // Return button::Style based on status and theme
    }
}
}

Running Checks Before Committing

Always run the full check before pushing:

make check

This runs cargo fmt, cargo check, and cargo clippy -- -D warnings.

Debugging a Specific Module

To see debug output for a specific module:

# In config.toml
log_level = "warn,ashell::modules::my_module=debug"

Testing Config Hot-Reload

  1. Start ashell: make start
  2. Edit ~/.config/ashell/config.toml in another terminal
  3. Save — changes should appear immediately
  4. Check logs if changes don’t apply: tail -f /tmp/ashell/*.log

AI-Assisted Contributions

Project Stance

AI-assisted contributions are accepted in ashell. If you use AI tools to help write code, documentation, or other contributions, that is fine — the same quality standards apply regardless of how the code was written.

That said, generating code is the easy part. The critical challenge is understanding what the AI produced and ensuring it fits the project. All code review is done manually by maintainers in their free time, and review remains the bottleneck — not implementation.

The bottom line: you are responsible for the code you submit, no matter how it was written.

Guidelines

Prefer Frontier-Class Models

Using top-tier, frontier-class models (e.g., Claude Opus or equivalent) is strongly recommended. Lower-capability models tend to produce subtly incorrect code, miss architectural conventions, or introduce patterns that don’t fit the codebase. You can use whatever tool you prefer, but the quality bar for contributions does not change — if the output doesn’t meet it, you’ll be asked to revise.

You Own Your Code

Regardless of the tools used, you are responsible for the code you submit. Review AI-generated output carefully, ensure it passes all checks (make check), and be prepared to explain and defend your changes during review.

Discuss Before Implementing

Before working on a feature or large change, talk to the maintainers first. Open an issue or comment on an existing one to discuss:

  • Does this fit the project?
  • What architectural decisions make sense?
  • Are there constraints or context that aren’t obvious from the code?

This applies to all contributions, AI-assisted or not, but is especially important when AI makes it cheap to generate large amounts of code that may not be wanted.

Small, Incremental PRs

Big refactors and complex changes take a long time to review manually. Keep PRs focused and incremental. One feature or fix per PR. This is better for everyone: easier to review, easier to revert if needed, and less risk of regressions hiding in large diffs.

Workflow Recommendations

Research → Plan → Implement (RPI)

For non-trivial work, an effective workflow is:

  1. Research — understand the codebase, existing patterns, and constraints
  2. Plan — design the approach, discuss it with maintainers
  3. Implement — execute the plan

This avoids the common failure mode of generating plausible-looking code that doesn’t fit the project’s architecture or conventions.

Self-Review Before Requesting Review

Make your code review-ready before asking maintainers to look at it. This means:

  • All checks pass (make check)
  • You’ve read through the diff yourself
  • The PR description explains what changed and why
  • You’ve verified the change works as intended

Don’t loop maintainers into reviews before you consider the code “good”. This is a hobby project maintained in people’s free time — respect that time.

Focus AI Narrowly on Review Feedback

After receiving review comments, constrain your AI tool to only address the specific feedback. A common failure mode is pointing AI at review comments and having it “fix” them while also making unsolicited changes elsewhere — destroying code that was already reviewed and approved. Be explicit: only modify what was commented on.

Where AI Works Well

  • Documentation and grammar — especially for non-native English speakers
  • Quick prototyping — exploring how a feature might look or work
  • Refactoring discovery — finding code that could be improved (but verify suggestions match project intent — some patterns are intentional)
  • Boilerplate and repetitive patterns — when the pattern is well-established in the codebase

What to Watch Out For

  • Hallucinations — LLMs can fabricate plausible-looking but incorrect code. Always verify the output against the actual codebase.
  • Added complexity — AI tends to over-engineer: adding unnecessary abstractions, caching layers, or pre-loading logic that makes things worse, not better. Start simple.
  • Shifting bugs around — fixing an issue in one place while introducing the same issue elsewhere. Review the full scope of changes, not just the area you asked it to fix.
  • Overgeneration — LLMs produce verbose code by default. Prefer minimal, lightweight solutions and only add complexity when needed.
  • Outdated knowledge — AI suggestions may be based on outdated training data. Verify that patterns, APIs, and conventions match the current state of the codebase.

Review Process

  • All code review is manual, performed by maintainers in their free time
  • Documentation PRs may receive lighter review since they don’t affect runtime behavior
  • Complex or architectural changes will take longer to review — this is expected
  • The project prefers getting it right over getting it fast

Configuration Reference

Complete reference for all configuration options in ~/.config/ashell/config.toml.

Top-Level Options

FieldTypeDefaultDescription
log_levelString"warn"Log level (env_logger syntax)
position"Top" | "Bottom""Bottom"Bar position on screen
layer"Top" | "Bottom" | "Overlay""Bottom"Wayland layer (Bottom = below floating windows)
outputs"All" | "Active" | { Targets = [...] }"All"Which monitors show the bar
enable_esc_keyboolfalseWhether ESC key closes menus

Module Layout

[modules]
left = ["Workspaces"]
center = ["Tempo"]
right = [["SystemInfo", "Settings"], "Tray"]

Module names: "Workspaces", "WindowTitle", "SystemInfo", "KeyboardLayout", "KeyboardSubmap", "Tray", "Clock", "Tempo", "Privacy", "Settings", "MediaPlayer", "Updates", "Custom:name".

Appearance

[appearance]
style = "Islands"               # "Islands", "Solid", or "Gradient"
opacity = 0.9                   # 0.0-1.0
font_name = "JetBrains Mono"   # Optional custom font
scale_factor = 1.0              # DPI scale factor

Colors

# Simple hex color
[appearance]
background = "#1e1e2e"

# Complete color with variants
[appearance.primary]
base = "#cba6f7"
strong = "#dbbcff"
weak = "#a385d8"
text = "#1e1e2e"

Available color fields: background, text, primary, secondary, success, danger.

[appearance.menu]
opacity = 0.95
backdrop_blur = true

Workspace Colors

[appearance]
workspace_colors = ["#cba6f7", "#f38ba8", "#a6e3a1", "#89b4fa"]
special_workspace_colors = ["#fab387"]

Updates Module

[updates]
check_cmd = "checkupdates | wc -l"    # Command to check for updates
update_cmd = "foot -e sudo pacman -Syu" # Command to run updates
interval = 3600                         # Check interval in seconds

If the [updates] section is omitted entirely, the Updates module is disabled.

Workspaces Module

[workspaces]
visibility_mode = "All"              # "All", "MonitorSpecific", "MonitorSpecificExclusive"
group_by_monitor = false
enable_workspace_filling = false     # Fill empty workspace slots
disable_special_workspaces = false
max_workspaces = 10                  # Optional: limit workspace count
workspace_names = ["1", "2", "3"]    # Optional: custom names
enable_virtual_desktops = false

Window Title Module

[window_title]
mode = "Title"                       # "Title", "Class", "InitialTitle", "InitialClass"
truncate_title_after_length = 150

Keyboard Layout Module

[keyboard_layout]
labels = { "English (US)" = "EN", "Italian" = "IT" }

System Info Module

[system_info]
# CPU thresholds
[system_info.cpu]
warn_threshold = 60
alert_threshold = 80

# Memory thresholds
[system_info.memory]
warn_threshold = 60
alert_threshold = 80

# Temperature thresholds
[system_info.temperature]
warn_threshold = 60
alert_threshold = 80

# Disk thresholds
[system_info.disk]
warn_threshold = 60
alert_threshold = 80

Clock Module (Deprecated)

[clock]
format = "%H:%M"    # chrono format string

Tempo Module

[tempo]
format = "%H:%M"
date_format = "%A, %B %d"
timezones = ["America/New_York", "Europe/London"]
weather_location = "Rome"              # Or coordinates
weather_format = "{temp}°C"

Settings Module

[settings]
# Custom buttons in the settings panel
[[settings.custom_buttons]]
icon = "\u{f023}"
label = "VPN"
status_cmd = "vpn-status"
on_click = "vpn-toggle"

Media Player Module

[media_player]
format = "{artist} - {title}"

Custom Modules

[[CustomModule]]
name = "mymodule"
type = "Text"                   # "Text" or "Button"
cmd = "echo Hello"
interval = 5
format = "Result: {}"

[[CustomModule]]
name = "launcher"
type = "Button"
icon = "\u{f0e7}"
on_click = "rofi -show drun"

Custom module fields:

FieldTypeRequiredDescription
nameStringYesUnique identifier
type"Text" | "Button"NoDisplay mode
cmdStringNoCommand to execute for display text
on_clickStringNoCommand on click (Button type)
iconStringNoIcon character
intervalu64NoRefresh interval in seconds
formatStringNoOutput format string

Reference a custom module in the layout as "Custom:name":

[modules]
right = ["Custom:mymodule", "Settings"]

Environment Variables

Compositor Detection

VariableChecked ByPurpose
HYPRLAND_INSTANCE_SIGNATUREservices/compositor/hyprland.rsDetects Hyprland compositor
NIRI_SOCKETservices/compositor/niri.rsDetects Niri compositor

ashell checks these in order. The first one found determines the compositor backend.

Config Path

VariablePurpose
XDG_CONFIG_HOMEBase directory for config. Default config path is $XDG_CONFIG_HOME/ashell/config.toml (or ~/.config/ashell/config.toml if unset)

The config path can also be overridden with the --config-path CLI flag, which takes precedence over environment variables.

Graphics

VariablePurpose
WGPU_BACKENDForce a specific GPU backend. Set to gl for OpenGL (useful for NVIDIA compatibility)

Logging

ashell uses flexi_logger which reads the log level from the config file’s log_level field. The format follows env_logger syntax:

# In config.toml
log_level = "debug"
log_level = "warn,ashell::services=debug"
log_level = "info,ashell::modules::settings=trace"

Wayland

VariablePurpose
WAYLAND_DISPLAYThe Wayland display socket. Must be set for ashell to run
LD_LIBRARY_PATHMay need to include paths to Wayland/Vulkan/Mesa libraries (handled automatically by Nix wrapper)

D-Bus Interfaces

ashell connects to several D-Bus services. This reference lists all interfaces used and where their proxy definitions are located.

System Bus

ServiceInterfaceProxy FilePurpose
BlueZorg.bluez.Adapter1services/bluetooth/dbus.rsBluetooth adapter control
BlueZorg.bluez.Device1services/bluetooth/dbus.rsBluetooth device management
NetworkManagerorg.freedesktop.NetworkManagerservices/network/dbus.rsNetwork state and connections
NetworkManagerorg.freedesktop.NetworkManager.Device.Wirelessservices/network/dbus.rsWiFi device control
NetworkManagerorg.freedesktop.NetworkManager.AccessPointservices/network/dbus.rsWiFi access point info
IWDnet.connman.iwd.Stationservices/network/iwd_dbus/WiFi station management
IWDnet.connman.iwd.Networkservices/network/iwd_dbus/WiFi network connections
IWDnet.connman.iwd.KnownNetworkservices/network/iwd_dbus/Saved networks
IWDnet.connman.iwd.Deviceservices/network/iwd_dbus/Wireless device
UPowerorg.freedesktop.UPowerservices/upower/dbus.rsPower daemon
UPowerorg.freedesktop.UPower.Deviceservices/upower/dbus.rsBattery/device info
logindorg.freedesktop.login1.Managerservices/logind.rsSleep/wake detection, power actions
logindorg.freedesktop.login1.Sessionservices/brightness.rsBrightness control via SetBrightness

Session Bus

ServiceInterfaceProxy FilePurpose
MPRISorg.mpris.MediaPlayer2services/mpris/dbus.rsMedia player discovery
MPRISorg.mpris.MediaPlayer2.Playerservices/mpris/dbus.rsPlayback control
StatusNotifierorg.kde.StatusNotifierWatcherservices/tray/dbus.rsSystem tray icon registration
StatusNotifierorg.kde.StatusNotifierItemservices/tray/dbus.rsIndividual tray icons
Portalorg.freedesktop.portal.Desktopservices/privacy.rsPrivacy indicators (mic/camera)

Checking D-Bus Availability

You can verify that D-Bus services are running:

# System bus
busctl --system list | grep -E "bluez|NetworkManager|UPower|login1|connman"

# Session bus
busctl --user list | grep -E "mpris|StatusNotifier|portal"

D-Bus Introspection

To explore a D-Bus interface:

# Example: inspect BlueZ adapter
busctl --system introspect org.bluez /org/bluez/hci0

# Example: inspect UPower battery
busctl --system introspect org.freedesktop.UPower /org/freedesktop/UPower/devices/battery_BAT0

Glossary

Wayland Terminology

TermDefinition
WaylandThe display server protocol used by modern Linux desktops, replacing X11
CompositorThe program that manages windows and display output (e.g., Hyprland, Niri)
Layer shellA Wayland protocol (wlr-layer-shell) that allows surfaces to be placed in specific layers (Background, Bottom, Top, Overlay)
Layer surfaceA Wayland surface managed by the layer shell protocol
AnchorEdges of the screen that a layer surface attaches to (top, bottom, left, right)
Exclusive zoneScreen space reserved by a layer surface that other windows won’t overlap
OutputA display/monitor in Wayland terminology
SCTKSmithay Client Toolkit — Rust library for Wayland client development
xdg_popupWayland protocol for creating popup surfaces attached to other surfaces

iced Terminology

TermDefinition
icedThe Rust GUI framework used by ashell
ElementAn iced widget tree node — the return type of view()
TaskA one-shot async effect that produces a message when complete
SubscriptionA long-lived event stream that continuously produces messages
daemoniced’s multi-window mode, where the application manages multiple surfaces
Themeiced’s styling system with palette-based colors
PaletteA set of named colors (background, text, primary, secondary, success, danger)
WidgetA UI component (button, text, row, column, container, etc.)

ashell Terminology

TermDefinition
ModuleA self-contained UI component displayed in the bar (e.g., Clock, Workspaces, Settings)
ServiceA backend integration that communicates with system APIs (e.g., audio, bluetooth, compositor)
IslandsA bar style where each module group has its own rounded background container
SolidA bar style with a continuous flat background
GradientA bar style where the background fades from solid to transparent
MenuA popup panel that appears when clicking certain modules
CenterboxCustom widget providing a three-column layout with true centering
ButtonUIRefPosition and size information of a button, used for menu placement
Hot-reloadAutomatic application of config changes without restarting
TempoThe advanced clock module (replacement for the deprecated Clock module)
Custom moduleA user-defined module that executes shell commands

Architecture Terminology

TermDefinition
MVUModel-View-Update — the Elm Architecture pattern used by iced
MessageAn event type that triggers state changes (the “Update” in MVU)
ActionA module-level return type that communicates side effects to the App
ServiceEventThe standard event enum for services (Init, Update, Error)
ReadOnlyServiceA service that only produces events (no commands)
Service (trait)A service that produces events and accepts commands
BroadcastThe pattern used by the compositor service to share events across multiple subscribers

System Terminology

TermDefinition
D-BusThe standard Linux IPC mechanism for communicating with system services
zbusThe Rust crate used for D-Bus communication
BlueZThe Linux Bluetooth stack
NetworkManagerStandard Linux network management daemon
IWDiNet Wireless Daemon — Intel’s lightweight wireless daemon
UPowerPower management daemon (battery info, power profiles)
MPRISMedia Player Remote Interfacing Specification — D-Bus interface for media player control
StatusNotifierItemD-Bus protocol for system tray icons
logindsystemd’s login manager (handles sleep/wake, power actions)
PulseAudioLinux audio server (also provided as a compatibility layer by PipeWire)
PipeWireModern Linux multimedia framework (replaces PulseAudio and JACK)
Nerd FontA font family patched with programming icons and symbols
cargo-distRust tool for creating distributable binaries and installers
nfpmTool for creating .deb and .rpm packages