Files
moonarch/CLAUDE.md
T
nevaforget 54b22a9141 feat(quickshell): operate popouts fully by keyboard
Popouts can now be opened by niri keybind and driven entirely without a
mouse — the whole point of the keybinds.

- keybind: PopoutRouter singleton + IpcHandler (target "popout") route
  toggle/open/close to the focused monitor's PopoutState via
  Niri.activeOutput; each widget registers its popout by name and its
  click handler toggles by name. Mod+A now opens the audio popout
  (replacing walker's audio menu).
- navigation: shared controls (Toggle/ListRow/VolumeSlider) and the new
  IconButton are focusable (activeFocusOnTab, focus ring, Space/Enter,
  slider Left/Right); PopoutPanel focuses the first control on open and
  maps Up/Down onto Qt's Tab chain; an open popout grabs the keyboard.
- ad-hoc glyph buttons (audio mute, media transport, notif dismiss, BT
  forget) folded into IconButton; destructive actions turn red.

Tray stays mouse-only (per-icon menu, not keybind-addressable).
2026-07-08 09:46:01 +02:00

13 KiB

Moonarch

Reproducible Arch Linux setup based on archinstall + post-install automation.

Project Structure

  • config/ — archinstall configuration (incl. custom-commands that clone the repo to /opt/moonarch, root-owned)
  • scripts/ — post-install and helper scripts
  • packages/ — package lists (official + AUR), maintained separately
  • defaults/ — XDG configs, shell config, helper binaries, systemd services, udev rules, greetd/moongreet config, wallpaper

Battery Conservation Mode

Laptops with charge_control_end_threshold support (ThinkPad, Framework, etc.) get a Waybar toggle:

  • Clicking the battery module toggles the charge limit between 80% and 100%
  • When conservation is active, a ♥ icon appears next to the battery indicator
  • State is persisted in /var/lib/moonarch/batsaver-threshold and restored on boot via a systemd service (moonarch-batsaver-restore)
  • Toggle flow: moonarch-batsaver-toggle (user script) reads sysfs, decides 80↔100, calls pkexec /usr/bin/moonarch-batsaver-apply $NEW for the privileged sysfs+state write step. Standard pkexec prompt (password once per session cache)
  • On desktops without battery support the feature is hidden entirely

Nightlight (Blue Light Filter)

Waybar toggle for wlsunset (Wayland-native blue light filter), persistent state via systemd:

  • wlsunset.service (systemd user service) with After=kanshi.service — starts only once all outputs are configured
  • Default OFF — fresh installs start without the filter. The PKGBUILD deliberately creates NO symlink for wlsunset in /etc/systemd/user/graphical-session.target.wants/, and post-install.sh does not enable the service.
  • Clicking the nightlight module in group/brightness toggles wlsunset on/off (enable --now / disable --now)
  • State survives reboots (user-scope symlink in ~/.config/systemd/user/...wants/)
  • Active state shows 󰌵 in Catppuccin Yellow, inactive 󰌶 in the default text color
  • Signal SIGRTMIN+11 for immediate Waybar refresh — the ExecStartPost pkill has a leading - (ExecStartPost=-/usr/bin/pkill …) so a missing Waybar (e.g. a quickshell-only session) doesn't fail the unit. Without it, pkill's exit 1 trips Restart=on-failure and wlsunset loops, flickering the screen.
  • Scripts: moonarch-nightlight (toggle), moonarch-waybar-nightlight (status JSON)
  • Important: Never create a global-scope symlink at /etc/systemd/user/...wants/wlsunset.service — it overrides any user disable and makes the filter effectively impossible to turn off.

Waybar Config Merger (moonarch-waybar)

Waybar is started via moonarch-waybar (not directly). The wrapper merges an optional user config (~/.config/waybar/userconfig) with the system config (/etc/xdg/waybar/config):

  • prepend/append keys in the userconfig extend the modules-left/modules-center/modules-right arrays
  • All other top-level keys are inserted as module definitions via object merge
  • The merge runs only on changes (timestamp comparison)
  • On error: notify-send + logger, Waybar starts with the system config
  • Generates ~/.config/waybar/style.css with an @import of the system styles if not present
  • Requires jq (declared as a dependency in the PKGBUILD)
  • The system config must be valid JSON (no JSONC)

Quickshell Bar

quickshell is the default bar and notification daemon, spawned by niri as quickshell -c moonarch (defaults/xdg/niri/config.kdl). It is shipped as a named config at /etc/xdg/quickshell/moonarch/ — the officially recommended way to distribute a quickshell config as a distro package ($XDG_CONFIG_DIRS/quickshell/<name>). Both Waybar and swaync stay fully installed as reserves (no hard deletes) but are no longer autostarted: Waybar toggles via Alt+W (pkill quickshell ⇄ moonarch-waybar, restart is quickshell -c moonarch); swaync's spawn-at-startup is commented out so it releases org.freedesktop.Notifications for quickshell's own NotificationServer. To switch back: re-enable swaync's spawn and drop quickshell's NotifToastHost from shell.qml.

Customization = composition, not a toggle layer. quickshell has no native settings/toggle/override/merge concept (verified) — a config is composable QML. moonarch ships the widgets as an importable component library; the composition lives in shell.qml.

  • Customize — copy /etc/xdg/quickshell/moonarch/shell.qml to ~/.config/quickshell/moonarch/shell.qml and edit it: add/remove/reorder widgets. The user copy wins by XDG precedence ($XDG_CONFIG_HOME is searched before $XDG_CONFIG_DIRS; verified in quickshell src/launch/command.cpp — configBaseDirs() prepends config-home, first shell.qml found wins). System component updates still flow via the import; structural updates to your own shell.qml do not.

  • Bar.qml is a generic container exposing three composition slots — leftContent / centerContent / rightContent (assign a list of widgets; they reparent into the cluster) — plus a popouts alias so composed widgets bind popouts: bar.popouts. shell.qml fills the slots; that is the whole composition:

    import QtQuick
    import QtQuick.Layouts
    import Quickshell                               // ShellRoot, Variants, Quickshell.screens
    import "file:///etc/xdg/quickshell/moonarch"   // Theme + Bar/ModuleBox/… via the qmldir
    ShellRoot {
        Variants { model: Quickshell.screens
            Bar { id: bar
                rightContent: [ ModuleBox { Layout.alignment: Qt.AlignVCenter; Clock {} } ]
            }
        }
    }
    

    ModuleBox wraps its content in a Row (and self-hides when its content collapses to zero width). Do not anchor a direct child with anchors.centerIn: parent — a Row rejects horizontal child anchors with a runtime warning (QML Row: Cannot specify … centerIn anchors for items inside Row) and ignores them. There is no binding loop (the Row sizes from children's implicit sizes, not childrenRect), but the centering silently does nothing. Center vertically with anchors.verticalCenter: parent.verticalCenter instead.

  • qmldir exposes the singletons/components for the cross-dir import. It is generated from the .qml files; regenerate when adding components:

    cd defaults/xdg/quickshell/moonarch && for f in *.qml; do [ "$f" = shell.qml ] && continue; n=${f%.qml}; grep -q 'pragma Singleton' "$f" && echo "singleton $n $f" || echo "$n $f"; done | sort > qmldir
    
  • Own popouts: a composed widget carries its own popout as a Component and renders it in the shared morphing host. Pattern (see any indicator, e.g. AudioIndicator.qml): an inline Component { AudioPopout { popouts: root.popouts } }; the widget declares a popoutName and registers on completion (popouts.register(popoutName, popoutComponent, root, payloadFn)), then its click handler calls popouts.toggleByName(popoutName). The widget file needs pragma ComponentBehavior: Bound so the popout resolves root when the shared host instantiates it. The low-level popouts.open(component, rect, payload) still exists for non-registered popouts (Tray).

  • Popouts by keybind: registered popouts are addressable by name through the PopoutRouter singleton, which exposes an IpcHandler { target: "popout" } with toggle/open/close. A niri keybind fires qs -c moonarch ipc call popout toggle <name> (e.g. Mod+A → audio, in defaults/xdg/niri/config.kdl). The router routes to the focused monitor's PopoutState via Niri.activeOutput (workspace is_focused → output, matched against screen.name); an open popout grabs the keyboard (Escape closes). Names: audio backlight network bluetooth nightlight cpugov battery notifications media. Tray is not keybind-addressable.

  • Keyboard navigation: an open popout grabs the keyboard, so it is fully operable without the mouse. Controls (Toggle, ListRow, VolumeSlider, IconButton) set activeFocusOnTab and show a focus ring; PopoutPanel focuses the first control on open and maps ↑/↓ onto Qt's native Tab chain (nextItemInFocusChain + an activeFocusOnTab filter). Keys: Tab/↑/↓ move focus, ←/→ nudge a slider ±5%, Space/Enter activate, Esc closes. Icon actions use the shared IconButton (destructive ones set activeColor: Theme.red); Rectangle-shaped buttons (clear-all, notification actions) are made focusable inline.

Rationale + spike results: DECISIONS.md (2026-07-05 pilot + user-extension, 2026-07-06 composition + composable popouts, 2026-07-08 keybind registry + IPC router, keyboard-navigable controls).

mpv + ModernZ OSC

The video player is mpv with ModernZ as the OSC, thumbnails via thumbfast:

  • mpv-modernz-git provides modernz.lua + font + default config to /etc/mpv/
  • mpv-thumbfast-git provides thumbfast.lua to /etc/mpv/scripts/ (auto-detected by ModernZ)
  • defaults/etc/mpv/mpv.conf is installed directly to /etc/mpv/mpv.conf by moonarch-git (owned)
  • Stock OSC + title bar disabled, autofit-larger=80%x80% caps oversized windows
  • ModernZ overrides via script-opts-append in mpv.conf: orange accent → Catppuccin Lavender (#b4befe), OSC scale 0.75, window_title_font_size=18, ontop_button=no
  • Important: mpv treats # as a mid-line comment; hex colors must be quoted: script-opts-append="modernz-seekbarfg_color=#b4befe" (not \#, which only escapes and swallows the rest)
  • Niri opens mpv floating (window-rule in defaults/xdg/niri/config.kdl)

System Health Check (moonarch-doctor / moondoc)

Diagnostic script that checks the system state against moonarch defaults:

  • Packages (official.txt + aur.txt installed? Orphans?)
  • System services (NetworkManager, bluetooth, greetd, ufw, auto-cpufreq, etc.)
  • User services (kanshi, wlsunset, stasis, walker, nautilus, cliphist-text, cliphist-image)
  • Config files (SHA256 comparison deployed vs. moonarch default)
  • Helper scripts + symlinks (moonup, moondoc)
  • System config (UFW, pacman/paru repos, default shell)
  • Directories + permissions

Fontconfig Defaults

System-wide generic-family defaults via defaults/etc/fonts/conf.d/65-moonarch-fonts.conf (owned by moonarch-git):

  • sans-serif → UbuntuSans Nerd Font, monospace → UbuntuSansMono Nerd Font
  • Number 65: loads after 60-latin.conf, so the moonarch prefs beat the stock default (Noto/DejaVu). local.conf (at 51 via 51-local.conf) loads too early and is overridden by 60-latin — therefore do not use it (it is also reserved for local user overrides).
  • Aliases need binding="strong" — a weak <prefer> (fontconfig default) ranks behind the effective generic fallback and does not take effect.
  • Only applies to apps that use generic families (e.g. Firefox web fallback). moonarch apps (Waybar, foot, GTK, walker, swaync) set the font explicitly.

Browser Idle-Inhibit (xdg-desktop-portal)

So that windowed browser video (Firefox/Waterfox) keeps the screen awake, via defaults/etc/xdg-desktop-portal/niri-portals.conf (owned by moonarch-git, higher priority than niri's /usr/share/xdg-desktop-portal/niri-portals.conf):

  • xdg-desktop-portal-gtk reports the Inhibit interface as success even though nobody implements it under Niri → Firefox believes the idle-inhibit went through the portal and does not use the native Wayland idle-inhibit. Result: no inhibitor, screen sleeps.
  • Fix: org.freedesktop.impl.portal.Inhibit=none → Firefox falls back to zwp_idle_inhibit, which Niri honors. The remaining [preferred] lines are taken 1:1 from niri's default (portals.conf is not merged — the highest-priority file applies in full).
  • stasis is uninvolved here: monitor_media (pactl) does not capture browser audio by design (non-browser players only); browsers go through the inhibit path.
  • Activation: restart xdg-desktop-portal + the browser — Firefox queries portal support at startup.

Windowed / Muted Video: wayland-pipewire-idle-inhibit

The portal fix only applies to fullscreen video — Firefox/Waterfox send the idle-inhibit only in fullscreen. Windowed video remains unprotected: stasis ignores browser audio (pactl, browser-excluded, no MPRIS backend), Niri gets no inhibitor, the screen sleeps mid-video.

Solution: wayland-pipewire-idle-inhibit (AUR, systemd user service; in packages/aur.txt + post-install.sh USER_SERVICES + moonarch-doctor). Holds a Wayland zwp_idle_inhibit inhibitor while PipeWire outputs audio (default threshold 5s). Niri then suppresses idle → stasis stays awake, daemon-independent. Releases automatically when audio stops.

  • Catches windowed video with sound. Misses muted video (no audio signal) — edge case, accepted.
  • Never put waterfox/a browser in stasis inhibit_apps: that inhibits unconditionally as long as the browser process runs → the system never idles. This exact wrong fix caused the "no idle" bug.

Conventions

  • Package lists are plain text files, one package per line, comments with #
  • Shell scripts must be POSIX-compatible or explicitly bash/zsh
  • All paths in the archinstall config are relative to the install target