Skip to main content

Inside My Arch + Hyprland Setup: Building a Fully Reproducible Desktop Environment

·5260 words·25 mins
David Cajio
Author
David Cajio

I do not treat my Linux desktop like a toy anymore.

That does not mean I do not care how it looks. I absolutely do. I want my desktop to look sharp, modern, dark, readable, and intentional. I want my terminal, status bar, launcher, lock screen, and editor to feel like they belong to the same system.

But the real goal is bigger than aesthetics.

My Arch + Hyprland setup is built around one idea:

My desktop should be reproducible infrastructure.

If I wipe my laptop tomorrow, I should not have to spend a weekend trying to remember which packages I installed, which config files I edited, which environment variables fixed screen sharing, or which script made my monitors behave correctly.

I should be able to redeploy my workstation.

That mindset changes everything.

Instead of a random pile of dotfiles, my desktop becomes something closer to a small platform:

  • version-controlled configuration
  • modular Hyprland files
  • wallpaper-driven theming
  • repeatable package installation
  • machine-specific monitor templates
  • shared keybindings across systems
  • explicit handling for Wayland quirks
  • scripts for the small things I do constantly

This is the same way I think about infrastructure at work. If a process matters and I will repeat it more than once, it should eventually become documented, automated, or reproducible.

My desktop is no different.

Desktop screenshot
Desktop screenshot 2
#

The Goals Behind the Setup
#

There are plenty of ways to run Linux.

I did not choose Arch and Hyprland because it is the easiest path. I chose it because I wanted control without dragging around a full desktop environment that makes assumptions for me.

The main goals were:

  1. Full rebuildability
  2. Keeping multiple machines in sync
  3. Readable wallpaper-based theming
  4. Aesthetic consistency
  5. Low bloat
  6. Wayland-native tooling
  7. A workflow that feels fast all day

The rebuildability piece is the most important.

I use more than one machine. My desktop and laptop need to feel like the same environment. I do not want one machine to have a different launcher, different keybinds, different fonts, missing scripts, or a slightly broken terminal theme.

That kind of drift is annoying in cloud infrastructure.

It is just as annoying on a workstation.

The second big goal was theming. I like changing wallpapers, but I do not want to manually retheme my entire desktop every time I do it. The color system should follow the wallpaper automatically while still keeping text readable.

That is where tools like pywal, swww, and matugen fit into the workflow.

The wallpaper is not just decoration. It becomes an input into the theme pipeline.


Current Machine Context
#

The machine shown in this setup is my System76 Gazelle laptop running Arch Linux and Hyprland.

The current environment looks roughly like this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
OS:           Arch Linux
Compositor:   Hyprland
Session:      Wayland
Shell:        Zsh
Terminal:     Ghostty / Kitty
Status bar:   Waybar
Prompt:       Starship
Editor:       Neovim / Vim
Notes:        Obsidian
Browser:      Firefox / Chrome
Audio:        PipeWire + WirePlumber
Locking:      Hyprlock
Idle:         Hypridle
Launcher:     Rofi / Wofi
Screenshots:  grim + slurp
Clipboard:    wl-clipboard

The hardware is also important because it influences some of the design decisions.

This laptop has hybrid graphics:

1
2
Integrated GPU: Intel Iris Xe
Dedicated GPU:  NVIDIA RTX 3050 Mobile

That matters because Wayland behavior can vary depending on whether I am on Intel, AMD, or NVIDIA hardware. Some of my machines are smoother than others. Some need different environment variables. Some behave differently with external monitors. Some make screen sharing more painful.

So the config cannot assume every machine is identical.

That is one of the biggest reasons I separate shared configuration from machine-specific configuration.


The Architecture of the Desktop
#

The biggest improvement I made was thinking about the desktop as a set of layers.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
┌───────────────────────────────────────┐
│ Applications                          │
│ Firefox, Chrome, Obsidian, Teams      │
├───────────────────────────────────────┤
│ User Experience Layer                 │
│ Waybar, Rofi, Dunst, Hyprlock         │
├───────────────────────────────────────┤
│ Hyprland Configuration                │
│ Keybinds, windows, input, monitors    │
├───────────────────────────────────────┤
│ Theme System                          │
│ Wallpaper, pywal, matugen, CSS        │
├───────────────────────────────────────┤
│ System Services                       │
│ PipeWire, WirePlumber, portals        │
├───────────────────────────────────────┤
│ Hardware-Specific Layer               │
│ Monitors, GPU quirks, laptop behavior │
└───────────────────────────────────────┘

That structure helps keep the config clean.

When something breaks, I want to know where it belongs.

If the issue is screen sharing, I am looking at PipeWire, portals, the browser, and Hyprland.

If the issue is colors, I am looking at the theme pipeline.

If the issue is an external monitor, I am looking at monitor templates and enforcement scripts.

If the issue is a floating window, I am looking at window rules.

This is much easier to troubleshoot than one giant hyprland.conf file with everything thrown into it.


Cleaning Up the Hyprland Config Structure
#

My Hyprland config is modular, but the naming matters.

It is very easy for a Linux config directory to slowly turn into a junk drawer:

1
2
3
4
5
6
UserAnimations.conf
WaybarStyles.sh
WaybarLayout.sh
KillActiveProcess.sh
looknfeel.conf
windowrules.conf

That works, but it does not read cleanly.

For a reproducible setup, I want names that are boring, predictable, lowercase, and obvious.

The cleaned-up structure I prefer is:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
~/.config/hypr/
├── hyprland.conf
├── modules/
│   ├── autostart.conf
│   ├── environment.conf
│   ├── input.conf
│   ├── keybinds.conf
│   ├── monitors.conf
│   ├── theme.conf
│   ├── window-rules.conf
│   └── animations.conf
├── scripts/
│   ├── apply-wallpaper
│   ├── enforce-monitors
│   ├── lock-session
│   ├── restart-waybar
│   ├── screenshot-region
│   └── volume-control
└── state/
    └── current-wallpaper

That structure gives every file a clear job.

The main hyprland.conf becomes the entrypoint:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
# ~/.config/hypr/hyprland.conf
#
# Main Hyprland entrypoint.
# Keep this file boring. Real configuration lives in modules/.

source = ~/.config/hypr/modules/environment.conf
source = ~/.config/hypr/modules/theme.conf
source = ~/.config/hypr/modules/monitors.conf
source = ~/.config/hypr/modules/input.conf
source = ~/.config/hypr/modules/keybinds.conf
source = ~/.config/hypr/modules/window-rules.conf
source = ~/.config/hypr/modules/animations.conf
source = ~/.config/hypr/modules/autostart.conf

That is the whole point.

The root config should explain the system at a glance. It should not contain every detail.


Module 1: Environment
#

Wayland, Electron apps, browsers, NVIDIA, and portals all care about environment.

I keep those settings isolated because they are one of the easiest places to create machine-specific problems.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
# ~/.config/hypr/modules/environment.conf
#
# Session-level environment variables.
# Keep hardware-specific values templated through chezmoi where possible.

env = XDG_CURRENT_DESKTOP,Hyprland
env = XDG_SESSION_TYPE,wayland
env = XDG_SESSION_DESKTOP,Hyprland

# Toolkit behavior
env = QT_QPA_PLATFORM,wayland;xcb
env = QT_WAYLAND_DISABLE_WINDOWDECORATION,1
env = GDK_BACKEND,wayland,x11
env = SDL_VIDEODRIVER,wayland
env = CLUTTER_BACKEND,wayland

# Firefox / Mozilla Wayland support
env = MOZ_ENABLE_WAYLAND,1

# Electron / Chromium-based apps
env = ELECTRON_OZONE_PLATFORM_HINT,auto

For NVIDIA-specific machines, I would keep the values separate or generated through a template:

1
2
3
4
5
6
# NVIDIA-specific values.
# Do not apply globally to every machine unless every machine is NVIDIA.

env = LIBVA_DRIVER_NAME,nvidia
env = __GLX_VENDOR_LIBRARY_NAME,nvidia
env = NVD_BACKEND,direct

I avoid dumping NVIDIA settings into the shared config unless they are genuinely safe for every machine.

That is the broader rule:

Shared config should be boring. Machine-specific config should be explicit.


Module 2: Input
#

Input settings deserve their own file because laptops and desktops are different.

A laptop has a touchpad. A desktop may not. Mouse sensitivity may differ. Natural scrolling may make sense on one machine and not another.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
# ~/.config/hypr/modules/input.conf

input {
    kb_layout = us
    follow_mouse = 1
    sensitivity = 0

    touchpad {
        natural_scroll = true
        tap-to-click = true
        disable_while_typing = true
    }
}

gestures {
    workspace_swipe = true
    workspace_swipe_fingers = 3
}

I like keeping this simple.

Most of the time, input problems are felt immediately. If this file is clean, it is easy to adjust after a rebuild.


Module 3: Keybinds
#

Keybinds are where consistency matters the most.

Once muscle memory develops, the machine should disappear. I should not have to think about whether I am on my desktop or laptop.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
# ~/.config/hypr/modules/keybinds.conf

$mod = SUPER

$terminal = ghostty
$fallbackTerminal = kitty
$fileManager = dolphin
$browser = firefox
$launcher = rofi -show drun

# Applications
bind = $mod, RETURN, exec, $terminal
bind = $mod SHIFT, RETURN, exec, $fallbackTerminal
bind = $mod, E, exec, $fileManager
bind = $mod, B, exec, $browser
bind = $mod, SPACE, exec, $launcher

# Window management
bind = $mod, Q, killactive
bind = $mod, F, fullscreen
bind = $mod SHIFT, F, togglefloating
bind = $mod, P, pseudo
bind = $mod, J, togglesplit

# Session controls
bind = $mod, L, exec, ~/.config/hypr/scripts/lock-session
bind = $mod SHIFT, R, exec, hyprctl reload
bind = $mod SHIFT, W, exec, ~/.config/hypr/scripts/restart-waybar

# Screenshots
bind = , Print, exec, ~/.config/hypr/scripts/screenshot-region

# Clipboard history
bind = $mod, V, exec, cliphist list | rofi -dmenu | cliphist decode | wl-copy

The names matter here too.

I would rather have:

1
2
3
restart-waybar
screenshot-region
lock-session

than:

1
2
3
wbrestart.sh
screenshot.sh
hyprlock.sh

The cleaner names read like commands.

That makes the whole setup easier to understand later.


Module 4: Monitors
#

Monitors are where Linux desktop configs often get ugly.

My laptop setup includes:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
Internal display:
  eDP-1
  1920x1080 @ 144Hz
  scale 1.50

External vertical display:
  HDMI-A-1
  1920x1080 @ 60Hz
  rotated vertically

External main display:
  HDMI-A-2
  3440x1440 @ 60Hz

A direct Hyprland config for that machine might look like this:

1
2
3
4
5
6
7
# ~/.config/hypr/modules/monitors.conf
#
# Generated or selected per-machine through chezmoi.

monitor = eDP-1,1920x1080@144,4520x0,1.5
monitor = HDMI-A-1,1920x1080@60,0x0,1,transform,3
monitor = HDMI-A-2,3440x1440@60,1080x0,1

This works, but it should not be globally shared across every system.

The better pattern is to treat monitor layout as machine-specific infrastructure.

In the dotfiles repo, I would structure it like this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
dot_config/hypr/
├── hyprland.conf.tmpl
├── modules/
│   ├── input.conf
│   ├── keybinds.conf
│   ├── theme.conf
│   └── window-rules.conf
└── monitors/
    ├── galactica.conf
    ├── desktop.conf
    └── fallback.conf

Then hyprland.conf.tmpl can source the right monitor file:

1
2
3
4
5
6
7
{{- if eq .chezmoi.hostname "galactica" }}
source = ~/.config/hypr/monitors/galactica.conf
{{- else if eq .chezmoi.hostname "desktop" }}
source = ~/.config/hypr/monitors/desktop.conf
{{- else }}
source = ~/.config/hypr/monitors/fallback.conf
{{- end }}

This is the difference between dotfiles and workstation infrastructure.

Dotfiles are copied.

Infrastructure adapts.


Monitor Enforcement
#

External monitor behavior is one of those things that can work perfectly for weeks and then randomly come up wrong after a reboot, dock change, driver update, or sleep/wake cycle.

So I like having an explicit monitor enforcement script.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
#!/usr/bin/env bash
# ~/.config/hypr/scripts/enforce-monitors

set -euo pipefail

hyprctl keyword monitor "eDP-1,1920x1080@144,4520x0,1.5"
hyprctl keyword monitor "HDMI-A-1,1920x1080@60,0x0,1,transform,3"
hyprctl keyword monitor "HDMI-A-2,3440x1440@60,1080x0,1"

notify-send "Hyprland" "Monitor layout enforced"

Then bind it:

1
bind = $mod SHIFT, M, exec, ~/.config/hypr/scripts/enforce-monitors

This is not fancy.

It is practical.

And practical is what makes a desktop usable every day.


Module 5: Theme
#

The theme file is generated from the wallpaper pipeline.

The goal is not just matching colors. The goal is readable matching colors.

A wallpaper can generate a beautiful palette that is terrible for text. I care more about contrast than novelty.

A clean generated Hyprland theme file might look like this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
# ~/.config/hypr/modules/theme.conf
#
# Generated by wallpaper/theme pipeline.

$background = rgb(11111b)
$surface = rgb(181825)
$overlay = rgb(313244)
$foreground = rgb(cdd6f4)
$muted = rgb(6c7086)

$primary = rgb(89b4fa)
$secondary = rgb(cba6f7)
$accent = rgb(f9e2af)
$success = rgb(a6e3a1)
$warning = rgb(fab387)
$error = rgb(f38ba8)

general {
    gaps_in = 5
    gaps_out = 10
    border_size = 2

    col.active_border = $primary $accent 45deg
    col.inactive_border = $overlay
}

decoration {
    rounding = 10

    blur {
        enabled = true
        size = 6
        passes = 2
        vibrancy = 0.16
    }

    shadow {
        enabled = true
        range = 12
        render_power = 3
        color = rgba(00000066)
    }
}

Waybar can consume the same palette through CSS:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
/* ~/.config/waybar/colors.css */
/* Generated by wallpaper/theme pipeline. */

@define-color background #11111b;
@define-color surface #181825;
@define-color overlay #313244;
@define-color foreground #cdd6f4;
@define-color muted #6c7086;

@define-color primary #89b4fa;
@define-color secondary #cba6f7;
@define-color accent #f9e2af;
@define-color success #a6e3a1;
@define-color warning #fab387;
@define-color error #f38ba8;

The important thing is that Hyprland, Waybar, Rofi, Kitty, Ghostty, and other tools should not each invent their own colors.

The theme should have a source of truth.


Wallpaper as a Theme Source
#

Changing wallpapers should update the desktop, not create more manual work.

A cleaned-up wallpaper script might look like this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
#!/usr/bin/env bash
# ~/.config/hypr/scripts/apply-wallpaper

set -euo pipefail

wallpaper="${1:-}"

if [[ -z "$wallpaper" ]]; then
    echo "Usage: apply-wallpaper /path/to/wallpaper" >&2
    exit 1
fi

if [[ ! -f "$wallpaper" ]]; then
    echo "Wallpaper does not exist: $wallpaper" >&2
    exit 1
fi

state_dir="$HOME/.config/hypr/state"
mkdir -p "$state_dir"

# Apply wallpaper.
swww img "$wallpaper" \
    --transition-type grow \
    --transition-duration 0.8 \
    --transition-fps 60

# Generate color palette.
wal -i "$wallpaper" -n

# Store current wallpaper reference.
ln -sfn "$wallpaper" "$state_dir/current-wallpaper"

# Regenerate app-specific theme files.
"$HOME/.config/hypr/scripts/generate-theme"

# Reload UI pieces that consume the theme.
hyprctl reload
"$HOME/.config/hypr/scripts/restart-waybar"

notify-send "Theme updated" "$(basename "$wallpaper")"

Then generate-theme can be responsible for writing the generated files:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
#!/usr/bin/env bash
# ~/.config/hypr/scripts/generate-theme

set -euo pipefail

wal_cache="$HOME/.cache/wal"
hypr_theme="$HOME/.config/hypr/modules/theme.conf"
waybar_colors="$HOME/.config/waybar/colors.css"

# This is intentionally simplified.
# In my real setup, I would generate these values from pywal/matugen output.

background="$(sed -n '1p' "$wal_cache/colors" | tr -d '#')"
foreground="$(sed -n '8p' "$wal_cache/colors" | tr -d '#')"
primary="$(sed -n '5p' "$wal_cache/colors" | tr -d '#')"
accent="$(sed -n '4p' "$wal_cache/colors" | tr -d '#')"

cat > "$hypr_theme" <<EOF
# Generated file. Do not edit manually.

\$background = rgb($background)
\$foreground = rgb($foreground)
\$primary = rgb($primary)
\$accent = rgb($accent)

general {
    gaps_in = 5
    gaps_out = 10
    border_size = 2

    col.active_border = \$primary \$accent 45deg
    col.inactive_border = rgba(${background}aa)
}

decoration {
    rounding = 10

    blur {
        enabled = true
        size = 6
        passes = 2
        vibrancy = 0.16
    }
}
EOF

cat > "$waybar_colors" <<EOF
/* Generated file. Do not edit manually. */

@define-color background #$background;
@define-color foreground #$foreground;
@define-color primary #$primary;
@define-color accent #$accent;
EOF

I like this pattern because it creates a clean boundary:

  • scripts generate the theme
  • Hyprland consumes the theme
  • Waybar consumes the theme
  • the wallpaper remains the input

No manual color chasing.


Module 6: Window Rules
#

Window rules are what make Hyprland feel like a real daily-driver environment instead of just a compositor.

Some windows should tile.

Some should float.

Some should center.

Some should pin.

Some should open on specific workspaces.

A cleaned-up window-rules.conf might look like this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
# ~/.config/hypr/modules/window-rules.conf

# Authentication prompts
windowrulev2 = float,class:^(org.kde.polkit-kde-authentication-agent-1)$
windowrulev2 = center,class:^(org.kde.polkit-kde-authentication-agent-1)$

# Audio controls
windowrulev2 = float,class:^(pavucontrol)$
windowrulev2 = size 900 600,class:^(pavucontrol)$
windowrulev2 = center,class:^(pavucontrol)$

# Picture-in-picture
windowrulev2 = float,title:^(Picture-in-Picture)$
windowrulev2 = pin,title:^(Picture-in-Picture)$
windowrulev2 = keepaspectratio,title:^(Picture-in-Picture)$

# File picker / dialogs
windowrulev2 = float,title:^(Open File)$
windowrulev2 = float,title:^(Save File)$

# Obsidian
windowrulev2 = workspace 1,class:^(obsidian)$

# Browser
windowrulev2 = workspace 2,class:^(firefox)$
windowrulev2 = workspace 2,class:^(google-chrome)$

# Communication
windowrulev2 = workspace 3,class:^(teams-for-linux)$
windowrulev2 = workspace 3,class:^(Mailspring)$

I do not want every app to require manual placement.

Some windows have obvious homes. The config should know that.


Module 7: Animations
#

Animations are easy to overdo.

I want the desktop to feel polished, not theatrical.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
# ~/.config/hypr/modules/animations.conf

animations {
    enabled = true

    bezier = smoothIn, 0.25, 1, 0.5, 1
    bezier = smoothOut, 0.36, 0, 0.66, -0.56
    bezier = workspaceCurve, 0.22, 1, 0.36, 1

    animation = windows, 1, 4, smoothIn
    animation = windowsOut, 1, 4, smoothOut
    animation = border, 1, 8, default
    animation = fade, 1, 4, default
    animation = workspaces, 1, 4, workspaceCurve
}

The goal is simple:

  • fast enough to feel responsive
  • smooth enough to feel modern
  • subtle enough to stay out of my way

A desktop used for actual work should not feel like it is constantly showing off.


Module 8: Autostart
#

Autostart should be boring and explicit.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
# ~/.config/hypr/modules/autostart.conf

exec-once = waybar
exec-once = dunst
exec-once = hypridle
exec-once = /usr/lib/polkit-kde-authentication-agent-1

# Clipboard history
exec-once = wl-paste --type text --watch cliphist store
exec-once = wl-paste --type image --watch cliphist store

# Wallpaper daemon
exec-once = swww-daemon

# Network / removable media helpers
exec-once = udiskie --tray

I do not like mystery startup behavior.

If something starts with my session, I want to know where it starts and why.


Waybar: The System Dashboard
#

Waybar is not just decoration.

It is the dashboard for the desktop.

I want to see enough information to understand the state of the machine without opening extra apps:

  • workspaces
  • focused window
  • network
  • audio
  • battery
  • date/time
  • tray
  • notifications
  • maybe CPU and memory

A clean Waybar structure looks like this:

1
2
3
4
~/.config/waybar/
├── config.jsonc
├── style.css
└── colors.css

colors.css is generated by the wallpaper/theme pipeline.

style.css defines the actual appearance.

config.jsonc defines the modules.

Example:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
{
  "layer": "top",
  "position": "top",
  "height": 36,
  "spacing": 8,

  "modules-left": [
    "hyprland/workspaces",
    "hyprland/window"
  ],

  "modules-center": [
    "clock"
  ],

  "modules-right": [
    "pulseaudio",
    "network",
    "battery",
    "tray"
  ],

  "hyprland/workspaces": {
    "format": "{icon}",
    "format-icons": {
      "1": "󰲠",
      "2": "󰲢",
      "3": "󰲤",
      "4": "󰲦",
      "default": "󰧞"
    }
  },

  "clock": {
    "format": " {:%I:%M   %a, %b %d}",
    "tooltip-format": "{:%Y-%m-%d}"
  },

  "pulseaudio": {
    "format": " {volume}%",
    "format-muted": "󰝟 muted"
  },

  "battery": {
    "format": "{icon} {capacity}%",
    "format-icons": [
      "󰂎",
      "󰁺",
      "󰁼",
      "󰁾",
      "󰂁",
      "󰁹"
    ]
  }
}

And the style:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
@import url("./colors.css");

* {
    font-family: "Atkinson Hyperlegible Mono Nerd Font";
    font-size: 13px;
    border: none;
    border-radius: 0;
    min-height: 0;
}

window#waybar {
    background: alpha(@background, 0.88);
    color: @foreground;
}

#workspaces button {
    padding: 0 8px;
    color: @muted;
}

#workspaces button.active {
    color: @accent;
    border-bottom: 2px solid @primary;
}

#clock,
#battery,
#network,
#pulseaudio,
#tray {
    padding: 0 10px;
    background: alpha(@surface, 0.72);
    border-radius: 10px;
}

The bar should be useful first and pretty second.

The best status bar is the one that gives me context without demanding attention.


Terminal Workflow: Ghostty, Kitty, Zsh, and Starship
#

Most of my real work happens in the terminal, so the terminal setup matters.

I use Zsh with Starship, and I currently have both Ghostty and Kitty available. Ghostty is my primary terminal right now, but I like having Kitty installed as a fallback.

The terminal needs to be:

  • fast
  • readable
  • consistent with the desktop theme
  • good with Nerd Fonts
  • predictable across machines

Starship is useful because it gives me context without a giant prompt.

For DevOps work, I care about things like:

  • current directory
  • Git branch
  • Git status
  • AWS profile
  • Docker context
  • command duration

Example:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
# ~/.config/starship.toml

format = """
$username\
$hostname\
$directory\
$git_branch\
$git_status\
$aws\
$docker_context\
$cmd_duration\
$line_break\
$character
"""

[directory]
truncation_length = 3
truncate_to_repo = true

[aws]
symbol = "  "
format = 'on [$symbol($profile )(\($region\) )]($style)'

[docker_context]
symbol = " "
format = 'via [$symbol$context]($style) '

[cmd_duration]
min_time = 750
format = "took [$duration]($style) "

The AWS profile piece is especially important.

I do not want to guess which AWS account or profile I am pointed at. That is how mistakes happen.

The prompt should make important context visible.


Screen Sharing on Wayland
#

Screen sharing is one of the biggest practical issues with a Hyprland workstation.

On Wayland, applications do not just get unrestricted access to the screen. That is good for security, but it means screen sharing depends on the correct portal and PipeWire stack.

The chain looks like this:

1
2
3
4
5
6
7
8
9
Application
xdg-desktop-portal
xdg-desktop-portal-hyprland
PipeWire
Hyprland

The packages that matter are:

1
2
3
4
5
sudo pacman -S \
  pipewire \
  pipewire-pulse \
  wireplumber \
  xdg-desktop-portal-hyprland

When screen sharing breaks, I start here:

1
2
3
4
systemctl --user status pipewire
systemctl --user status wireplumber
systemctl --user status xdg-desktop-portal
systemctl --user status xdg-desktop-portal-hyprland

If the services are running but screen sharing is still acting weird:

1
2
systemctl --user restart xdg-desktop-portal
systemctl --user restart xdg-desktop-portal-hyprland

Then I restart the browser or Teams.

The annoying part is that Teams, Firefox, Chrome, and Electron apps can behave differently. A setup can work perfectly in one app and fail in another.

That is why screen sharing is part of my rebuild checklist.

I do not consider a workstation rebuild complete until screen sharing works.


Clipboard Issues on Wayland
#

Clipboard behavior is another area where Wayland can feel different from X11.

I use wl-clipboard:

1
sudo pacman -S wl-clipboard

Basic test:

1
2
printf "clipboard test" | wl-copy
wl-paste

The basic tools usually work fine.

The pain tends to show up between browsers, Teams, Electron apps, and native Wayland applications.

A better setup includes clipboard history:

1
sudo pacman -S cliphist

Then autostart:

1
2
exec-once = wl-paste --type text --watch cliphist store
exec-once = wl-paste --type image --watch cliphist store

And bind a picker:

1
bind = $mod, V, exec, cliphist list | rofi -dmenu | cliphist decode | wl-copy

That gives me a way to inspect and recover clipboard history instead of treating the clipboard like invisible magic.

For a work machine, that matters.


Screenshots with grim and slurp
#

Screenshots are part of my daily workflow.

I use them for documentation, bug reports, notes, blog posts, and quick visual references.

The Wayland-native stack is simple:

1
sudo pacman -S grim slurp

A cleaned-up screenshot script:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
#!/usr/bin/env bash
# ~/.config/hypr/scripts/screenshot-region

set -euo pipefail

screenshot_dir="$HOME/Pictures/Screenshots"
mkdir -p "$screenshot_dir"

file="$screenshot_dir/screenshot-$(date +%Y-%m-%d-%H%M%S).png"

geometry="$(slurp)"
grim -g "$geometry" "$file"

wl-copy < "$file"

notify-send "Screenshot captured" "$file"

Keybind:

1
bind = , Print, exec, ~/.config/hypr/scripts/screenshot-region

Simple, reliable, and easy to remember.


Locking and Idle Management
#

I use Hyprlock and Hypridle for locking and idle behavior.

The goal is:

  • lock the session after inactivity
  • turn off displays after a longer idle period
  • restore displays cleanly
  • avoid weird resume behavior

Example:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
# ~/.config/hypr/hypridle.conf

general {
    lock_cmd = hyprlock
    before_sleep_cmd = loginctl lock-session
    after_sleep_cmd = hyprctl dispatch dpms on
}

listener {
    timeout = 300
    on-timeout = hyprlock
}

listener {
    timeout = 600
    on-timeout = hyprctl dispatch dpms off
    on-resume = hyprctl dispatch dpms on
}

The lock script stays boring:

1
2
3
4
5
6
#!/usr/bin/env bash
# ~/.config/hypr/scripts/lock-session

set -euo pipefail

hyprlock

I do not want my lock behavior buried in a giant config file. It is important enough to be obvious.


Handling Node Version Managers and GUI Apps
#

One of the more annoying issues I have run into is environment mismatch between terminal apps and GUI apps.

In a terminal, my shell loads Zsh config, initializes tools like fnm or mise, and everything works.

But GUI apps launched from Hyprland do not always inherit the same environment as an interactive shell.

That can create weird issues with Node-based workflows, editor integrations, or tools that expect a specific runtime path.

My shell config might include:

1
2
3
4
# ~/.zshrc

eval "$(fnm env --use-on-cd)"
eval "$(mise activate zsh)"

That works for interactive shells.

But for session-level environment, I prefer to make important values explicit through Hyprland environment config or systemd user environment imports.

Example:

1
2
3
4
# ~/.config/hypr/modules/environment.conf

exec-once = dbus-update-activation-environment --systemd WAYLAND_DISPLAY XDG_CURRENT_DESKTOP
exec-once = systemctl --user import-environment WAYLAND_DISPLAY XDG_CURRENT_DESKTOP PATH

This is not the most glamorous part of the setup, but it is important.

A desktop environment is not just windows and wallpapers. It is also the environment your tools inherit.


Package Bootstrapping
#

A reproducible desktop needs an explicit package list.

For the core desktop, I would start with:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
sudo pacman -S \
  hyprland \
  hypridle \
  hyprlock \
  waybar \
  ghostty \
  kitty \
  zsh \
  starship \
  rofi \
  wofi \
  dunst \
  swaync \
  python-pywal16 \
  grim \
  slurp \
  wl-clipboard \
  pipewire \
  pipewire-pulse \
  wireplumber \
  pavucontrol \
  xdg-desktop-portal-hyprland \
  dolphin \
  firefox \
  neovim \
  obsidian \
  polkit-kde-agent \
  udiskie \
  qt5-wayland \
  qt6-wayland

For AUR packages:

1
2
3
4
5
6
yay -S \
  google-chrome \
  teams-for-linux-bin \
  mailspring-bin \
  morgen-bin \
  aws-session-manager-plugin

I do not think everyone should copy my exact package list.

The more important point is that the package list exists.

A rebuild should not depend on memory.


Where Chezmoi Fits
#

Chezmoi is what turns the whole setup into something I can safely sync across machines.

The dotfiles repo should separate shared files, generated files, templates, and machine-specific logic.

A clean repo structure might look like this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
chezmoi-repo/
├── dot_config/
│   ├── hypr/
│   │   ├── hyprland.conf.tmpl
│   │   ├── modules/
│   │   └── monitors/
│   ├── waybar/
│   ├── ghostty/
│   ├── kitty/
│   ├── rofi/
│   └── starship.toml
├── private_dot_ssh/
├── run_once_install-packages.sh.tmpl
└── scripts/

The monitor selection can be templated:

1
2
3
4
5
6
7
{{- if eq .chezmoi.hostname "galactica" }}
source = ~/.config/hypr/monitors/galactica.conf
{{- else if eq .chezmoi.hostname "desktop" }}
source = ~/.config/hypr/monitors/desktop.conf
{{- else }}
source = ~/.config/hypr/monitors/fallback.conf
{{- end }}

Package installation can also be templated:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
#!/usr/bin/env bash
set -euo pipefail

{{- if eq .chezmoi.os "linux" }}

sudo pacman -S --needed \
  hyprland \
  waybar \
  ghostty \
  zsh \
  starship \
  rofi \
  grim \
  slurp \
  wl-clipboard \
  pipewire \
  wireplumber

{{- end }}

The rule is simple:

  • shared config goes in Git
  • secrets stay private
  • machine-specific values are templated
  • generated files are either ignored or clearly marked
  • rebuild steps are scripted

That is how the desktop stays manageable.


Performance and UX Tuning
#

A pretty desktop is useless if it feels slow.

My priorities are:

  1. low input latency
  2. fast launcher
  3. readable text
  4. reliable screen sharing
  5. predictable monitor behavior
  6. consistent keybinds
  7. no unnecessary background noise

I like blur, animations, rounded corners, Nerd Fonts, and polished Waybar modules.

But I do not want the desktop to feel like it is performing a magic trick every time I open a terminal.

The best Hyprland setup is one that feels quiet.

It should look good, respond quickly, and stay out of the way.


Gotchas I Had to Work Around
#

This is the part that matters more than the screenshots.

Screen Sharing Is Still the Big One
#

Wayland screen sharing depends on portals and PipeWire.

If Teams or a browser cannot share the screen, check the portal stack before wasting time randomly changing Hyprland settings.

1
2
systemctl --user status pipewire wireplumber
systemctl --user status xdg-desktop-portal xdg-desktop-portal-hyprland

Restarting portals often fixes weird behavior:

1
2
systemctl --user restart xdg-desktop-portal
systemctl --user restart xdg-desktop-portal-hyprland

NVIDIA Needs Its Own Lane
#

NVIDIA can work well, but it still needs special care.

I avoid mixing NVIDIA-specific assumptions into the shared config. If a setting only applies to NVIDIA, it belongs in a machine-specific file or template.

This matters even more when syncing dotfiles between machines with different GPUs.

Clipboard Behavior Can Be Weird
#

Clipboard issues show up most often between Teams, Firefox, Chrome, and Electron apps.

The basic wl-copy and wl-paste tools are essential, but adding cliphist makes the clipboard easier to inspect and recover from.

GUI Apps Do Not Always Inherit Your Shell
#

If Node, npm, pnpm, fnm, or mise work in the terminal but not in a GUI app, the issue may be environment inheritance.

Do not assume .zshrc fixes everything.

Session-level environment matters.

External Monitors Need a Recovery Path
#

Docking, undocking, sleep, wake, and GPU behavior can all affect monitors.

Having an enforce-monitors script gives me a fast recovery path instead of manually fighting display layout every time something comes up wrong.


What I Still Want to Improve
#

The setup is reproducible, but it is not finished.

Linux desktops are never really finished.

The next things I want to improve are:

  • cleaner machine-specific monitor templates
  • better clipboard history integration
  • stronger first-run bootstrap scripts
  • more deterministic wallpaper theme generation
  • better documentation for each helper script
  • fewer overlapping tools where I currently have duplicates
  • a rebuild checklist that validates screen sharing, audio, monitors, and clipboard behavior

The goal is not perfection.

The goal is to make the setup easier to rebuild, easier to understand, and easier to trust.


Final Thoughts
#

Arch and Hyprland are not the easiest way to run a Linux desktop.

That is fine.

I am not optimizing for easiest. I am optimizing for control, speed, reproducibility, and a desktop that feels like mine.

The difference between a fragile rice and a real workstation setup is discipline.

A fragile rice looks good in screenshots.

A real workstation survives rebuilds.

That is what I want from my desktop. I want the wallpaper, colors, terminal, status bar, keybinds, monitors, scripts, and applications to work together as one reproducible system.

If I wipe the machine, I should not feel like I lost my setup.

I should feel like I am redeploying it.

My Linux desktop is not just a collection of dotfiles.

It is infrastructure.