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:
- Getting Started — Set up your development environment and build the project.
- Architecture — Understand the high-level design, the Elm architecture, and data flow.
- Core Systems — Learn about the App struct, configuration, theming, and output management.
- 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:
| Package | Purpose |
|---|---|
pkg-config | Library discovery |
llvm-dev | LLVM development files |
libclang-dev / clang | Clang for bindgen (PipeWire/PulseAudio bindings) |
libxkbcommon-dev | Keyboard handling |
libwayland-dev | Wayland client protocol |
libpipewire-0.3-dev | PipeWire audio integration |
libpulse-dev | PulseAudio integration |
libudev-dev | Device monitoring |
dbus | D-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:
| Target | Command | Description |
|---|---|---|
make build | cargo build --release | Build release binary |
make start | Build + ./target/release/ashell | Build and run |
make install | Build + sudo cp -f target/release/ashell /usr/bin | Install to system |
make fmt | cargo fmt | Format code |
make check | cargo fmt + cargo check + cargo clippy -- -D warnings | Full 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:
- Parse
src/components/icons.rsto find all Unicode codepoints in use (e.g.,\u{f0e7}) - Subset the Nerd Font TTF files to only include those glyphs
- 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-configerrors, ensure all prerequisites are installed. - Font subsetting failure: The
target/generated/directory is created automatically bybuild.rs. If the build fails on font subsetting, ensureassets/SymbolsNerdFont-Regular.ttfexists. - Slow first build: The first build compiles all dependencies including iced (which is large). Subsequent builds are incremental and much faster.
Development Environment
Nix Development Shell (Recommended)
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-analyzerfor editor integration- Correct
LD_LIBRARY_PATHfor runtime libraries (Wayland, Vulkan, Mesa, OpenGL) RUST_SRC_PATHset 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_levelfield 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
Appstruct 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
Messagetype,view(),update(), andsubscription(). - 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
- Modular: Each module is self-contained and optional. Adding or removing a module should not affect others.
- Reactive: State flows in one direction. Events come in through subscriptions, state is updated, and the view re-renders.
- Service-agnostic UI: Modules don’t directly interact with system APIs. They consume data from services, making the UI layer testable and compositor-independent.
- 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
Messagethat 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
| Concept | Purpose | Lifetime | Example |
|---|---|---|---|
| Task | One-shot side effect | Runs once, produces one Message | Setting brightness, switching workspace |
| Subscription | Ongoing event stream | Runs for the lifetime of the app | Watching 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:
| Source | Mechanism | Produces |
|---|---|---|
| Compositor (Hyprland/Niri) | IPC socket | Workspace changes, window focus, keyboard layout |
| PulseAudio | libpulse mainloop on dedicated thread | Volume changes, device hotplug |
| D-Bus (BlueZ, NM, UPower, etc.) | zbus signal watchers | Device state changes |
| Config file | inotify | ConfigChanged |
| System signals | signal-hook | SIGUSR1 → ToggleVisibility |
| Timers | iced time::every | Periodic updates (clock, system info) |
| Wayland | Layer shell events | Output add/remove |
| systemd-logind | D-Bus | Sleep/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
- Startup: A fallback surface is created (not tied to any specific output).
- Output detected: When Wayland reports a new output, ashell creates surfaces for it (if it matches the config filter).
- Output removed: Surfaces for that output are destroyed.
- Config change: The
syncmethod reconciles surfaces with the new config.
Menu Layer Switching
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 edgeBottom: Anchored to bottom edge
The layer config field (default: Bottom) controls the Wayland layer:
Top: Bar appears above normal windowsBottom: Bar appears below floating windows (default preference)Overlay: Bar appears above everything
Note: The
Bottomlayer default was a deliberate choice by the maintainer — the bar sits below floating windows. TheToplayer 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)
Menu Surface Architecture
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-skiaCPU 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:
- The
config::subscription()function watches the config file’s parent directory forCREATE,MODIFY,DELETE, andMOVEevents. - Events are batched using
ready_chunks(10)to handle editors that perform atomic saves (write to temp file, then rename). DELETEevents include a 500ms delay before re-reading, to handle atomic save patterns where the file is briefly absent.- 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:
| Method | Used 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:
| Field | Type | Description |
|---|---|---|
| Name | String | Monitor name (e.g., "eDP-1") or "Fallback" |
| ShellInfo | Option<ShellInfo> | Layer surfaces for this output (if active) |
| WlOutput | Option<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.
MenuType
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,
}
}
Menu Struct
#![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_infoisNone, no menu is open and the surface is on the Background layer. - When
menu_infoisSome(...), the menu is open, positioned relative to the button, and the surface is on the Overlay layer.
Menu Lifecycle
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()
}
}
}
}
Menu Positioning
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:
- Positions the content relative to the button (aligned to the button’s horizontal center).
- Renders a backdrop overlay behind the menu.
- Handles click-outside-to-close.
Menu Sizes
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
| Module | Config Name | Description | Has Menu |
|---|---|---|---|
| Workspaces | "Workspaces" | Workspace indicators and switching | No |
| WindowTitle | "WindowTitle" | Active window title/class display | No |
| SystemInfo | "SystemInfo" | CPU, RAM, disk, network, temperature | Yes |
| KeyboardLayout | "KeyboardLayout" | Keyboard layout indicator (click to cycle) | No |
| KeyboardSubmap | "KeyboardSubmap" | Hyprland submap display | No |
| Tray | "Tray" | System tray icons | Yes (per-app) |
| Clock | "Clock" | Simple time display (deprecated) | No |
| Tempo | "Tempo" | Advanced clock with calendar, weather, timezones | Yes |
| Privacy | "Privacy" | Microphone/camera/screenshare indicators | No |
| Settings | "Settings" | Settings panel (audio, network, bluetooth, etc.) | Yes |
| MediaPlayer | "MediaPlayer" | MPRIS media player control | Yes |
| Updates | "Updates" | Package update indicator | Yes |
| Custom | "Custom:name" | User-defined modules | No |
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 icedElements. - 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:
- Field in
Appstruct (src/app.rs) - Variant in
Messageenum (src/app.rs) - Match arm in
App::update()(src/app.rs) - Entry in
get_module_view()(src/modules/mod.rs) - Entry in
get_module_subscription()(src/modules/mod.rs) ModuleNamevariant (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>)>:
Nonemeans the module has nothing to display (e.g., privacy module when no indicators are active)Some((view, None))renders the module without interactionSome((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
OnModulePressaction, it’s wrapped in aPositionButton - Otherwise, it’s wrapped in a plain
container - In
Islandsstyle, 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
Islandsstyle, 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)
Sub-Menu Navigation
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-module | Service | Operations |
|---|---|---|
| Audio | AudioService | List sinks/sources, set volume, toggle mute |
| Bluetooth | BluetoothService | List devices, connect/disconnect, toggle power |
| Brightness | BrightnessService | Get/set brightness level |
| Network | NetworkService | List WiFi networks, connect, manage VPN |
| Power | LogindService | Shutdown, 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:
- Add a variant to
MenuTypeinsrc/menu.rs:
#![allow(unused)]
fn main() {
pub enum MenuType {
// ...
MyModule,
}
}
-
Add a
menu_view()method to your module. -
Change
get_module_viewto returnSome(OnModulePress::ToggleMenu(MenuType::MyModule)). -
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
- Add your module to the config file:
[modules]
right = ["MyModule"]
- Build and run:
make start
- 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
| Service | Location | Backend | Protocol |
|---|---|---|---|
| Compositor | services/compositor/ | Hyprland / Niri | IPC socket |
| Audio | services/audio.rs | PulseAudio | libpulse C library |
| Brightness | services/brightness.rs | sysfs + logind | File I/O + D-Bus |
| Bluetooth | services/bluetooth/ | BlueZ | D-Bus |
| Network | services/network/ | NetworkManager / IWD | D-Bus |
| MPRIS | services/mpris/ | Media players | D-Bus |
| Tray | services/tray/ | StatusNotifierItem | D-Bus |
| UPower | services/upower/ | UPower daemon | D-Bus |
| Privacy | services/privacy.rs | PipeWire portals | D-Bus |
| Idle Inhibitor | services/idle_inhibitor.rs | systemd-logind | D-Bus |
| Logind | services/logind.rs | systemd-logind | D-Bus |
| Throttle | services/throttle.rs | (utility) | Stream adapter |
Services vs. Modules
| Aspect | Module | Service |
|---|---|---|
| Has UI | Yes (view()) | No |
| Interacts with system | No (consumes services) | Yes |
Has Message type | Yes | Has UpdateEvent + ServiceEvent |
| Defined by | Convention | ReadOnlyService / Service trait |
| Runs on | Main 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::mpscchannels between threads, icedchannel()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 aServiceEvent::Update.subscribe(): Returns an icedSubscriptionthat producesServiceEvent<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 aTaskthat may produce aServiceEvent.
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_SOCKETenv var) - Listens for events and translates them to the common
CompositorEventformat - 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
| Service | Bus | D-Bus Service Name | Proxy File |
|---|---|---|---|
| Bluetooth | System | org.bluez | services/bluetooth/dbus.rs |
| Network (NM) | System | org.freedesktop.NetworkManager | services/network/dbus.rs |
| Network (IWD) | System | net.connman.iwd | services/network/iwd_dbus/ |
| UPower | System | org.freedesktop.UPower | services/upower/dbus.rs |
| Logind | System | org.freedesktop.login1 | services/logind.rs |
| MPRIS | Session | org.mpris.MediaPlayer2.* | services/mpris/dbus.rs |
| Tray | Session | org.kde.StatusNotifierWatcher | services/tray/dbus.rs |
| Privacy | Session | org.freedesktop.portal.Desktop | services/privacy.rs |
| Brightness | System | org.freedesktop.login1.Session | services/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 managementNetwork— WiFi network connectionsKnownNetwork— Previously connected networksDevice— Wireless device controlAccessPoint— 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
PropertiesChangedsignal.
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:
- Spawn a dedicated OS thread (
std::thread::spawn) - Run the PulseAudio mainloop on that thread
- 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:
| Interface | Purpose |
|---|---|
net.connman.iwd.Station | WiFi station management |
net.connman.iwd.Network | Network connection control |
net.connman.iwd.KnownNetwork | Saved network management |
net.connman.iwd.Device | Wireless device control |
net.connman.iwd.Adapter | Physical 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
| Widget | File | Purpose |
|---|---|---|
| Centerbox | widgets/centerbox.rs | Three-column layout that keeps the center truly centered |
| PositionButton | widgets/position_button.rs | Button that reports its screen position on press |
| MenuWrapper | widgets/menu_wrapper.rs | Menu 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
Rowdoesn’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:
- Measures the left and right children
- Centers the middle child in the remaining space
- 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:
- Renders a fullscreen backdrop (semi-transparent or transparent)
- Positions the menu content horizontally aligned with the triggering button
- Positions the menu vertically above or below the bar (depending on bar position)
- 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))
}
}
Menu Sizes
The wrapper uses predefined size categories for menu width:
| Size | Width |
|---|---|
| Small | 250px |
| Medium | 350px |
| Large | 450px |
| XLarge | 650px |
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
| Crate | Version | Purpose |
|---|---|---|
iced | Git (MalpenZibo fork) | GUI framework with Wayland layer shell support |
The iced dependency uses many features:
tokio— Async runtime integrationmulti-window— Multiple layer surfaces (bar + menu per monitor)advanced— Custom widget supportwgpu— GPU-accelerated renderingwinit— Window system integrationwayland— Wayland protocol supportimage,svg,canvas— Graphics capabilities
Async Runtime
| Crate | Version | Purpose |
|---|---|---|
tokio | 1 | Async runtime for services |
tokio-stream | 0.1 | Stream utilities |
Compositor Integration
| Crate | Version | Purpose |
|---|---|---|
hyprland | 0.4.0-beta.2 | Hyprland IPC client |
niri-ipc | 25.11.0 | Niri IPC client |
System Integration
| Crate | Version | Purpose |
|---|---|---|
zbus | 5 | D-Bus client (BlueZ, NM, UPower, etc.) |
libpulse-binding | 2.28 | PulseAudio client library |
pipewire | 0.9 | PipeWire integration |
wayland-client | 0.31.12 | Wayland protocol client |
wayland-protocols | 0.32.10 | Wayland protocol definitions |
sysinfo | 0.37 | CPU, RAM, disk, network statistics |
udev | 0.9 | Device monitoring |
Configuration
| Crate | Version | Purpose |
|---|---|---|
toml | 0.9 | TOML config file parsing |
serde | 1.0 | Serialization/deserialization |
serde_json | 1 | JSON for tray menu data |
serde_with | 3.12 | Advanced serde derivation |
clap | 4.5 | CLI argument parsing |
inotify | 0.11.0 | File change watching |
Utilities
| Crate | Version | Purpose |
|---|---|---|
chrono | 0.4 | Date/time handling |
chrono-tz | 0.10.4 | Timezone support |
regex | 1.12.2 | Regular expressions (config parsing) |
hex_color | 3 | Hex color parsing in config |
itertools | 0.14 | Iterator utilities |
anyhow | 1 | Error handling |
log | 0.4 | Logging facade |
flexi_logger | 0.31 | Logging implementation |
signal-hook | 0.4.3 | Unix signal handling (SIGUSR1) |
reqwest | 0.13 | HTTP client (weather data) |
uuid | 1 | UUID generation |
url | 2.5.7 | URL parsing |
freedesktop-icons | 0.4 | XDG icon lookup |
linicon-theme | 1.2.0 | Icon theme resolution |
shellexpand | 3 | Tilde/env var expansion in paths |
parking_lot | 0.12.5 | Synchronization primitives |
pin-project-lite | 0.2.16 | Pin projection (for throttle stream) |
libc | 0.2.182 | System call interfaces |
Build Dependencies
| Crate | Version | Purpose |
|---|---|---|
allsorts | 0.15 | Font 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
-
Parse icons: Read
src/components/icons.rsand find all\u{XXXX}Unicode escape sequences. -
Convert to characters: Each hex code is converted to its Unicode character.
-
Subset the font: Using the allsorts crate, create new TTF files containing only the needed glyphs.
-
Write output: Save the subsetted fonts to
target/generated/:SymbolsNerdFont-Regular-Subset.ttfSymbolsNerdFontMono-Regular-Subset.ttf
Source Files
| Input | Output |
|---|---|
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:
- Find the Unicode codepoint from the Nerd Fonts cheat sheet.
- Add a constant to
src/components/icons.rs:#![allow(unused)] fn main() { pub const MY_ICON: char = '\u{f0001}'; } - Build —
build.rsautomatically 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-analyzerfor editor integration- All native build dependencies (Wayland, PipeWire, PulseAudio, etc.)
- Correct
LD_LIBRARY_PATHfor runtime libraries RUST_SRC_PATHset 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
| Input | Purpose |
|---|---|
crane | Rust build system for Nix |
nixpkgs | Package repository (nixos-unstable channel) |
rust-overlay | Rust 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:
-
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 -
Format check:
cargo fmt --all -- --check- Fails if any code is not properly formatted.
-
Clippy lint:
cargo clippy --all-features -- -D warnings- Zero warnings policy. All clippy warnings are treated as errors.
-
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 withmdbook buildand 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
| Workflow | Trigger | Purpose |
|---|---|---|
ci.yml | Push/PR to main | Format, lint, build |
nix-ci.yml | Push/PR | Nix flake validation |
release.yml | Manual dispatch | Build release artifacts |
pre-release.yml | Pre-release tag | Pre-release builds |
generate-installers.yml | Called by release | Build .deb/.rpm packages |
gh-pages-deploy.yml | Push to main | Deploy website |
gh-pages-test.yml | PR | Test website build |
update-arch-package.yml | Release | Update AUR package |
release-drafter.yml | Push/PR | Auto-draft release notes |
remove-manifest-assets.yml | Post-release | Clean 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
-
Draft release notes: The
release-drafter.ymlworkflow automatically drafts release notes based on merged PRs. The maintainer reviews and edits the draft in GitHub Releases. -
Trigger the release: The maintainer goes to Actions → Release → Run workflow and enters the version tag (e.g.,
v0.8.0). -
Automated pipeline: The release workflow:
- Runs
dist planto 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
- Runs
-
Post-release: Downstream packaging jobs run automatically:
update-arch-package.ymlupdates the AUR packageremove-manifest-assets.ymlcleans 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:
- Go to Actions → Release → Run workflow
- Enter
dry-runas the tag - 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
--versionflag 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:
| Dependency | Purpose |
|---|---|
libxkbcommon | Keyboard handling |
dbus | D-Bus daemon |
libwayland-client | Wayland protocol |
libpipewire | PipeWire audio |
libpulse | PulseAudio compatibility |
Contribution Workflow
Getting Started
- Fork the repository on GitHub.
- Clone your fork locally.
- Create a branch from
main:git checkout -b feat/my-feature - Make your changes.
- Run checks before pushing:
make check - Push and open a Pull Request against
main.
Branch Naming
Follow the conventional prefix pattern:
| Prefix | Purpose | Example |
|---|---|---|
feat/ | New features | feat/ddc-brightness |
fix/ | Bug fixes | fix/bluetooth-crash |
chore/ | Maintenance, dependencies | chore/update-iced-rev |
docs/ | Documentation | docs/developer-guide |
refactor/ | Code restructuring | refactor/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
- PRs should target the
mainbranch. - CI must pass (format, clippy, build).
- At least one maintainer review is expected for non-trivial changes.
- Keep PRs focused — one feature or fix per PR when possible.
Issue Tracking
Issues are tracked on GitHub with labels:
bug— Something is brokenenhancement— Improvement to existing featurefeature— New feature requestdiscussion— Open-ended design discussiongood first issue— Suitable for new contributorshelp wanted— Looking for community contributionsUI/UX— User interface relatedperformance— Performance improvementsblocked/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.rsorsrc/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
anyhowor custom error types - Config parsing uses
Box<dyn Error> - Prefer logging errors over panicking in service code
- Use
unwrap_or_default()orunwrap_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
- Find the Unicode codepoint from the Nerd Fonts cheat sheet.
- Add it to
src/components/icons.rs:#![allow(unused)] fn main() { pub const MY_NEW_ICON: char = '\u{f0001}'; } - Build —
build.rsautomatically subsets the font to include the new glyph.
Adding a New Config Option
-
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 } } -
Set a default value in the
Defaultimpl:#![allow(unused)] fn main() { impl Default for MyModuleConfig { fn default() -> Self { Self { new_option: false, } } } } -
Use the option in your module.
-
If the option should be hot-reloadable, handle it in the module’s
ConfigReloadedmessage.
Adding a D-Bus Integration
-
Create proxy definitions in a
dbus.rsfile:#![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>; } } -
Implement the
ReadOnlyServiceorServicetrait. -
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
- Start ashell:
make start - Edit
~/.config/ashell/config.tomlin another terminal - Save — changes should appear immediately
- 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:
- Research — understand the codebase, existing patterns, and constraints
- Plan — design the approach, discuss it with maintainers
- 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
| Field | Type | Default | Description |
|---|---|---|---|
log_level | String | "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_key | bool | false | Whether 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.
Menu Appearance
[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:
| Field | Type | Required | Description |
|---|---|---|---|
name | String | Yes | Unique identifier |
type | "Text" | "Button" | No | Display mode |
cmd | String | No | Command to execute for display text |
on_click | String | No | Command on click (Button type) |
icon | String | No | Icon character |
interval | u64 | No | Refresh interval in seconds |
format | String | No | Output format string |
Reference a custom module in the layout as "Custom:name":
[modules]
right = ["Custom:mymodule", "Settings"]
Environment Variables
Compositor Detection
| Variable | Checked By | Purpose |
|---|---|---|
HYPRLAND_INSTANCE_SIGNATURE | services/compositor/hyprland.rs | Detects Hyprland compositor |
NIRI_SOCKET | services/compositor/niri.rs | Detects Niri compositor |
ashell checks these in order. The first one found determines the compositor backend.
Config Path
| Variable | Purpose |
|---|---|
XDG_CONFIG_HOME | Base 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
| Variable | Purpose |
|---|---|
WGPU_BACKEND | Force 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
| Variable | Purpose |
|---|---|
WAYLAND_DISPLAY | The Wayland display socket. Must be set for ashell to run |
LD_LIBRARY_PATH | May 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
| Service | Interface | Proxy File | Purpose |
|---|---|---|---|
| BlueZ | org.bluez.Adapter1 | services/bluetooth/dbus.rs | Bluetooth adapter control |
| BlueZ | org.bluez.Device1 | services/bluetooth/dbus.rs | Bluetooth device management |
| NetworkManager | org.freedesktop.NetworkManager | services/network/dbus.rs | Network state and connections |
| NetworkManager | org.freedesktop.NetworkManager.Device.Wireless | services/network/dbus.rs | WiFi device control |
| NetworkManager | org.freedesktop.NetworkManager.AccessPoint | services/network/dbus.rs | WiFi access point info |
| IWD | net.connman.iwd.Station | services/network/iwd_dbus/ | WiFi station management |
| IWD | net.connman.iwd.Network | services/network/iwd_dbus/ | WiFi network connections |
| IWD | net.connman.iwd.KnownNetwork | services/network/iwd_dbus/ | Saved networks |
| IWD | net.connman.iwd.Device | services/network/iwd_dbus/ | Wireless device |
| UPower | org.freedesktop.UPower | services/upower/dbus.rs | Power daemon |
| UPower | org.freedesktop.UPower.Device | services/upower/dbus.rs | Battery/device info |
| logind | org.freedesktop.login1.Manager | services/logind.rs | Sleep/wake detection, power actions |
| logind | org.freedesktop.login1.Session | services/brightness.rs | Brightness control via SetBrightness |
Session Bus
| Service | Interface | Proxy File | Purpose |
|---|---|---|---|
| MPRIS | org.mpris.MediaPlayer2 | services/mpris/dbus.rs | Media player discovery |
| MPRIS | org.mpris.MediaPlayer2.Player | services/mpris/dbus.rs | Playback control |
| StatusNotifier | org.kde.StatusNotifierWatcher | services/tray/dbus.rs | System tray icon registration |
| StatusNotifier | org.kde.StatusNotifierItem | services/tray/dbus.rs | Individual tray icons |
| Portal | org.freedesktop.portal.Desktop | services/privacy.rs | Privacy 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
| Term | Definition |
|---|---|
| Wayland | The display server protocol used by modern Linux desktops, replacing X11 |
| Compositor | The program that manages windows and display output (e.g., Hyprland, Niri) |
| Layer shell | A Wayland protocol (wlr-layer-shell) that allows surfaces to be placed in specific layers (Background, Bottom, Top, Overlay) |
| Layer surface | A Wayland surface managed by the layer shell protocol |
| Anchor | Edges of the screen that a layer surface attaches to (top, bottom, left, right) |
| Exclusive zone | Screen space reserved by a layer surface that other windows won’t overlap |
| Output | A display/monitor in Wayland terminology |
| SCTK | Smithay Client Toolkit — Rust library for Wayland client development |
| xdg_popup | Wayland protocol for creating popup surfaces attached to other surfaces |
iced Terminology
| Term | Definition |
|---|---|
| iced | The Rust GUI framework used by ashell |
| Element | An iced widget tree node — the return type of view() |
| Task | A one-shot async effect that produces a message when complete |
| Subscription | A long-lived event stream that continuously produces messages |
| daemon | iced’s multi-window mode, where the application manages multiple surfaces |
| Theme | iced’s styling system with palette-based colors |
| Palette | A set of named colors (background, text, primary, secondary, success, danger) |
| Widget | A UI component (button, text, row, column, container, etc.) |
ashell Terminology
| Term | Definition |
|---|---|
| Module | A self-contained UI component displayed in the bar (e.g., Clock, Workspaces, Settings) |
| Service | A backend integration that communicates with system APIs (e.g., audio, bluetooth, compositor) |
| Islands | A bar style where each module group has its own rounded background container |
| Solid | A bar style with a continuous flat background |
| Gradient | A bar style where the background fades from solid to transparent |
| Menu | A popup panel that appears when clicking certain modules |
| Centerbox | Custom widget providing a three-column layout with true centering |
| ButtonUIRef | Position and size information of a button, used for menu placement |
| Hot-reload | Automatic application of config changes without restarting |
| Tempo | The advanced clock module (replacement for the deprecated Clock module) |
| Custom module | A user-defined module that executes shell commands |
Architecture Terminology
| Term | Definition |
|---|---|
| MVU | Model-View-Update — the Elm Architecture pattern used by iced |
| Message | An event type that triggers state changes (the “Update” in MVU) |
| Action | A module-level return type that communicates side effects to the App |
| ServiceEvent | The standard event enum for services (Init, Update, Error) |
| ReadOnlyService | A service that only produces events (no commands) |
| Service (trait) | A service that produces events and accepts commands |
| Broadcast | The pattern used by the compositor service to share events across multiple subscribers |
System Terminology
| Term | Definition |
|---|---|
| D-Bus | The standard Linux IPC mechanism for communicating with system services |
| zbus | The Rust crate used for D-Bus communication |
| BlueZ | The Linux Bluetooth stack |
| NetworkManager | Standard Linux network management daemon |
| IWD | iNet Wireless Daemon — Intel’s lightweight wireless daemon |
| UPower | Power management daemon (battery info, power profiles) |
| MPRIS | Media Player Remote Interfacing Specification — D-Bus interface for media player control |
| StatusNotifierItem | D-Bus protocol for system tray icons |
| logind | systemd’s login manager (handles sleep/wake, power actions) |
| PulseAudio | Linux audio server (also provided as a compatibility layer by PipeWire) |
| PipeWire | Modern Linux multimedia framework (replaces PulseAudio and JACK) |
| Nerd Font | A font family patched with programming icons and symbols |
| cargo-dist | Rust tool for creating distributable binaries and installers |
| nfpm | Tool for creating .deb and .rpm packages |