From b2e105d202ba8559259adec364a8d4eb985d733d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 12 Aug 2026 22:00:16 +0000 Subject: [PATCH 1/5] agents: finalize base config namespaces and safe linking Move user-level agent content into the harness-agnostic namespace, keep Claude integration separate, and make both install configs back up existing paths instead of forcing replacement. --- CLAUDE.md | 24 ++++++++++--------- README.md | 6 ++--- .../CLAUDE.md => agents-config/user/AGENTS.md | 2 -- claude/{ => user/scripts}/status-line.sh | 0 install-desktop.conf.yaml | 8 +++---- install.conf.yaml | 16 +++++-------- 6 files changed, 25 insertions(+), 31 deletions(-) rename claude/CLAUDE.md => agents-config/user/AGENTS.md (98%) rename claude/{ => user/scripts}/status-line.sh (100%) diff --git a/CLAUDE.md b/CLAUDE.md index 9a6c933..bb80b36 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,15 +8,17 @@ Personal dotfiles: a shell/terminal/dev foundation plus a bspwm-based X11 desktop, installed via Dotbot from the repo root. The desktop half is optional: `--no-desktop` on `./install` and `./provision.sh` skips it. -Do not confuse `claude/CLAUDE.md` (the file this repo symlinks to -`~/.claude/CLAUDE.md` on install — guidance for using Claude Code itself) +Do not confuse `agents-config/user/AGENTS.md` (the file this repo symlinks to +`~/.claude/CLAUDE.md` on install — user-level agent guidance) with this file (guidance for editing this repo). ## Layout -- `bash/`, `git/`, `tmux/`, `kitty/`, `starship/`, `claude/` — the shell/ - terminal/dev foundation. Each is independent of the others, so they get - their own top-level directory rather than nesting under one umbrella. +- `bash/`, `git/`, `tmux/`, `kitty/`, `starship/` — the shell/terminal/dev + foundation. Each is independent of the others, so they get their own + top-level directory rather than nesting under one umbrella. +- `agents-config/` — harness-agnostic agent instructions and content. +- `claude/` — Claude-specific integration such as the user status-line script. - `desktop-environment/` — the bspwm + sxhkd + polybar X11 stack, plus picom and dunst. These stay grouped under one directory because they're developed and tested together (sxhkd's hotkeys and polybar's toggle @@ -97,10 +99,10 @@ Use scoped commits (`: `, e.g. `kitty: ...`, `bspwm: ## Machine-local overlay seams -`bash/bashrc`, `bash/bash_aliases`, `git/gitconfig`, and `claude/CLAUDE.md` -each reference an untracked `~/*.local` sidecar behind an existence guard, -so a private overlay repo can inject machine-specific config without -modifying these files. Never add a `*.local` file to this repo; it stays -public and self-contained. The same split applies to config that isn't a -sidecar: work-machine bspwm profiles live in the overlay, not in +`bash/bashrc`, `bash/bash_aliases`, and `git/gitconfig` each reference an +untracked `~/*.local` sidecar behind an existence guard, so a private overlay +repo can inject machine-specific config without modifying these files. Never +add a `*.local` file to this repo; it stays public and self-contained. The same +split applies to config that isn't a sidecar: work-machine bspwm profiles live +in the overlay, not in `desktop-environment/bspwm/profiles/`. diff --git a/README.md b/README.md index c5c72f4..c3982af 100644 --- a/README.md +++ b/README.md @@ -16,8 +16,9 @@ each gets its own top-level directory: nord-tmux, tmux-sensible) - `kitty/` — config, themes, `launch.sh`, `zenmode.py` - `starship/` — `starship.toml` (prompt theming) -- `claude/` — files this repo deploys to `~/.claude/` (`CLAUDE.md`, - `status-line.sh`) +- `agents-config/` — harness-agnostic agent instructions and content + deployed through Claude-compatible paths +- `claude/` — Claude-specific integration, including the status-line script Desktop: @@ -93,7 +94,6 @@ forking this repo: env vars) - `bash/bash_aliases` sources `~/.bash_aliases.local` if it exists (e.g. work-specific shell functions) -- `claude/CLAUDE.md` imports `~/.claude/CLAUDE.local.md` if it exists - `git/gitconfig` includes `~/.gitconfig.local` if it exists (identity, credential helpers) diff --git a/claude/CLAUDE.md b/agents-config/user/AGENTS.md similarity index 98% rename from claude/CLAUDE.md rename to agents-config/user/AGENTS.md index 4734381..ec70aca 100644 --- a/claude/CLAUDE.md +++ b/agents-config/user/AGENTS.md @@ -43,5 +43,3 @@ When writing text that will be reviewed by other humans (MR/PR descriptions, cod - `claude -p` refuses to run inside a Claude Code session (detects the `CLAUDECODE` env var) - Workaround: `env -u CLAUDECODE claude -p "..." --model sonnet` - Safe for `-p` (print/non-interactive mode) since it's stateless - -@~/.claude/CLAUDE.local.md diff --git a/claude/status-line.sh b/claude/user/scripts/status-line.sh similarity index 100% rename from claude/status-line.sh rename to claude/user/scripts/status-line.sh diff --git a/install-desktop.conf.yaml b/install-desktop.conf.yaml index d0738da..777dfd7 100644 --- a/install-desktop.conf.yaml +++ b/install-desktop.conf.yaml @@ -4,14 +4,12 @@ # after install.conf.yaml unless invoked with --no-desktop, so shell-only # machines (e.g. remote boxes) skip everything in this file. # -# Every link is forced, same as install.conf.yaml: this repo is the single -# source of truth for these paths, so install always converges to the same -# state -- a pre-existing regular file at any target is replaced, never left -# to shadow the repo copy. +# Links are relinked and existing regular paths are backed up so install +# converges without deleting a pre-existing file or directory. - defaults: link: relink: true - force: true + backup: true create: true - link: diff --git a/install.conf.yaml b/install.conf.yaml index 93d7fbc..359ed2e 100644 --- a/install.conf.yaml +++ b/install.conf.yaml @@ -2,13 +2,12 @@ # config, and additionally install-desktop.conf.yaml (the bspwm/X11 stack) # unless invoked with --no-desktop. # -# Every link is forced: this repo is the single source of truth for these -# paths, so install always converges to the same state -- a pre-existing -# regular file at any target is replaced, never left to shadow the repo copy. +# Links are relinked and existing regular paths are backed up so install +# converges without deleting a pre-existing file or directory. - defaults: link: relink: true - force: true + backup: true create: true - link: @@ -25,11 +24,8 @@ ~/.tmux: tmux ~/.tmux.conf: tmux/tmux.conf ~/.config/kitty: kitty - ~/.claude/CLAUDE.md: claude/CLAUDE.md - ~/.claude/scripts/status-line.sh: claude/status-line.sh + ~/.claude/CLAUDE.md: agents-config/user/AGENTS.md + ~/.claude/scripts/status-line.sh: claude/user/scripts/status-line.sh - shell: - - [ - git submodule update --init --recursive, - Installing/updating submodules, - ] + - [git submodule update --init --recursive, Installing/updating submodules] From 5a3bf08183b468d2996aca89a0164aadf7380cf0 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 00:04:18 +0000 Subject: [PATCH 2/5] desktop: add Woodinville profile switching and startup diagnostics Add public Woodinville monitor profiles with ACPI lid-aware auto-selection and scaled clamshell geometry, plus graphical-session diagnostics and cleanup-safe sxhkd watchdog behavior. Fix Polybar's network module to render the live Wi-Fi signal and SSID without literal environment expressions. --- CLAUDE.md | 5 +- bin/desktop-session-log | 145 ++++++++++++++++++ desktop-environment/bspwm/CLAUDE.md | 12 +- desktop-environment/bspwm/bspwmrc | 21 ++- desktop-environment/bspwm/lib/profile.py | 61 +++++++- .../work-laptop-woodinville-clamshell.yaml | 57 +++++++ .../profiles/work-laptop-woodinville.yaml | 81 ++++++++++ .../bspwm/scripts/network-env.sh | 8 +- .../bspwm/tests/test_profile.py | 46 ++++++ .../polybar/shades/modules.ini | 99 +----------- .../polybar/shades/scripts/network-label.sh | 40 +++++ .../session/dotfiles-session-log.desktop | 8 + install-desktop.conf.yaml | 4 + 13 files changed, 477 insertions(+), 110 deletions(-) create mode 100755 bin/desktop-session-log create mode 100644 desktop-environment/bspwm/profiles/work-laptop-woodinville-clamshell.yaml create mode 100644 desktop-environment/bspwm/profiles/work-laptop-woodinville.yaml create mode 100755 desktop-environment/polybar/shades/scripts/network-label.sh create mode 100644 desktop-environment/session/dotfiles-session-log.desktop diff --git a/CLAUDE.md b/CLAUDE.md index bb80b36..4ab2e1a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -103,6 +103,7 @@ Use scoped commits (`: `, e.g. `kitty: ...`, `bspwm: untracked `~/*.local` sidecar behind an existence guard, so a private overlay repo can inject machine-specific config without modifying these files. Never add a `*.local` file to this repo; it stays public and self-contained. The same -split applies to config that isn't a sidecar: work-machine bspwm profiles live -in the overlay, not in +split applies to config that isn't a sidecar: private work-machine bspwm +profiles live in the overlay, while intentionally public hardware-specific +profiles may live in `desktop-environment/bspwm/profiles/`. diff --git a/bin/desktop-session-log b/bin/desktop-session-log new file mode 100755 index 0000000..04a4327 --- /dev/null +++ b/bin/desktop-session-log @@ -0,0 +1,145 @@ +#!/usr/bin/env bash +# Capture safe, local diagnostics for graphical-session startup. +# +# GNOME runs this from XDG autostart. bspwmrc calls it directly because bspwm +# has no equivalent autostart phase. Logs stay under XDG_STATE_HOME and are +# never part of the repository. +set -u + +umask 077 + +STATE_DIR="${XDG_STATE_HOME:-$HOME/.local/state}" +LOG_DIR="$STATE_DIR/desktop-session" +mkdir -p "$LOG_DIR" || exit 1 + +BOOT_ID=unknown +if [[ -r /proc/sys/kernel/random/boot_id ]]; then + read -r BOOT_ID < /proc/sys/kernel/random/boot_id || BOOT_ID=unknown +fi +BOOT_ID="${BOOT_ID//-/}" +BOOT_LOG="$LOG_DIR/boot-${BOOT_ID}.log" +TIMESTAMP="$(date -u +%Y%m%dT%H%M%SZ)" + +section() { + printf '\n===== %s =====\n' "$1" +} + +capture_context() { + local session="$1" + + section "session metadata" + printf 'timestamp_utc=%s\n' "$TIMESTAMP" + printf 'session=%s\n' "$session" + printf 'uid=%s user=%s\n' "$(id -u)" "$(id -un)" + printf 'pid=%s ppid=%s\n' "$$" "$PPID" + printf 'boot_id=%s\n' "$BOOT_ID" + printf 'kernel=%s\n' "$(uname -srmo)" + printf 'DISPLAY=%q\n' "${DISPLAY-}" + printf 'XAUTHORITY=%q\n' "${XAUTHORITY-}" + printf 'XDG_SESSION_TYPE=%q\n' "${XDG_SESSION_TYPE-}" + printf 'XDG_SESSION_DESKTOP=%q\n' "${XDG_SESSION_DESKTOP-}" + printf 'XDG_CURRENT_DESKTOP=%q\n' "${XDG_CURRENT_DESKTOP-}" + printf 'DESKTOP_SESSION=%q\n' "${DESKTOP_SESSION-}" + printf 'XDG_SESSION_ID=%q\n' "${XDG_SESSION_ID-}" + + section "active window manager" + if command -v xprop >/dev/null 2>&1; then + xprop -root _NET_SUPPORTING_WM_CHECK _NET_WM_NAME 2>&1 || true + else + printf 'xprop is unavailable\n' + fi + if command -v bspc >/dev/null 2>&1; then + bspc wm -d 2>&1 || true + bspc query -M --names 2>&1 || true + fi + + section "monitor topology" + if command -v xrandr >/dev/null 2>&1; then + xrandr --query 2>&1 || true + else + printf 'xrandr is unavailable\n' + fi + + section "relevant processes" + ps -eo pid=,ppid=,stat=,comm=,args= 2>&1 | + awk '$4 ~ /^(Xorg|bspwm|sxhkd|gnome-session|gnome-shell|mutter|gdm|polybar|picom)$/' + + section "failed user units" + if command -v systemctl >/dev/null 2>&1; then + systemctl --user --no-pager --failed 2>&1 || true + else + printf 'systemctl is unavailable\n' + fi + + section "login session status" + if command -v loginctl >/dev/null 2>&1 && [[ -n "${XDG_SESSION_ID-}" ]]; then + loginctl session-status "$XDG_SESSION_ID" --no-pager 2>&1 || true + else + printf 'loginctl session id is unavailable\n' + fi + + section "user journal: graphical-session events" + if command -v journalctl >/dev/null 2>&1; then + journalctl --user -b -n 300 --no-pager -o short-precise 2>&1 | + rg -i 'gnome-session|gnome-shell|mutter|bspwm|sxhkd|xorg|gdm|nvidia|polybar|picom|monitor|failed|error|fatal|permission' || + true + else + printf 'journalctl is unavailable\n' + fi + + section "system journal: display-manager events" + if command -v journalctl >/dev/null 2>&1; then + journalctl -b -n 300 --no-pager -o short-precise 2>&1 | + rg -i 'gdm|gdm-x-session|Xorg|gnome-shell|mutter|bspwm|sxhkd|nvidia|failed|error|fatal|permission' || + true + else + printf 'journalctl is unavailable\n' + fi +} + +startup_log() { + local session="$1" + exec >>"$BOOT_LOG" 2>&1 + ln -sfn -- "$(basename "$BOOT_LOG")" "$LOG_DIR/latest-startup.log" + printf '=== desktop session startup: %s ===\n' "$session" + capture_context "$session" +} + +exit_log() { + local session="$1" + local status="$2" + exec >>"$BOOT_LOG" 2>&1 + printf '\n=== desktop session exit: %s status=%s ===\n' "$session" "$status" + printf 'timestamp_utc=%s\n' "$(date -u +%Y%m%dT%H%M%SZ)" + if command -v bspc >/dev/null 2>&1; then + bspc wm -d 2>&1 || true + fi +} + +diagnostic_log() { + local session="${2:-manual}" + local log_file="$LOG_DIR/diagnostic-${TIMESTAMP}-$$.log" + printf 'Writing desktop-session diagnostics to %s\n' "$log_file" + exec >"$log_file" 2>&1 + ln -sfn -- "$(basename "$log_file")" "$LOG_DIR/latest-diagnostic.log" + printf '=== manual desktop-session diagnostic: %s ===\n' "$session" + capture_context "$session" + printf '\nDiagnostic complete.\n' +} + +case "${1-}" in + --startup) + startup_log "${2:-unknown}" + ;; + --exit) + exit_log "${2:-unknown}" "${3:-unknown}" + ;; + --diagnose) + diagnostic_log "$@" + ;; + *) + printf 'Usage: %s --startup SESSION | --exit SESSION STATUS | --diagnose [SESSION]\n' \ + "$0" >&2 + exit 2 + ;; +esac diff --git a/desktop-environment/bspwm/CLAUDE.md b/desktop-environment/bspwm/CLAUDE.md index 7866e58..b378315 100644 --- a/desktop-environment/bspwm/CLAUDE.md +++ b/desktop-environment/bspwm/CLAUDE.md @@ -24,7 +24,10 @@ Ordered startup — each step depends on the previous: 4. **Launch background apps** — picom, nitrogen, redshift, nm-applet, blueman, protonvpn (all pgrep-guarded) 5. **Start sxhkd LAST** — `pkill` + 200ms wait + launch. Must be last so X key grabs succeed after monitor setup -Logs: `$XDG_STATE_HOME/bspwm/bspwm.log`, `$XDG_STATE_HOME/sxhkd/sxhkd.log` +Logs: `$XDG_STATE_HOME/bspwm/bspwm.log`, `$XDG_STATE_HOME/sxhkd/sxhkd.log`, +and `$XDG_STATE_HOME/desktop-session/boot-.log`. The latter captures +filtered GNOME/Mutter, bspwm, Xorg, GDM, NVIDIA, monitor, and failed-unit +events for both GNOME autostart and bspwm startup. ### Monitor Manager (Python) @@ -68,7 +71,12 @@ ui: # polybar bars per monitor (orientation, font_size, modules) Profiles use logical aliases (laptop, main, vertical) resolved to actual outputs at runtime. -**Current profiles:** personal-solo (eDP-1), personal-home. Work-machine profiles are not tracked here; they live in the private overlay repo alongside the rest of the machine-local config. +**Current profiles:** personal-solo (eDP-1), personal-home, +work-laptop-home, work-laptop-woodinville (laptop fallback enabled), and +work-laptop-woodinville-clamshell (external monitors only). Profile matching +uses the ACPI lid state when available and skips an enabled-laptop profile +while the lid is closed. It also falls back to requiring an active Xrandr mode +when the lid state is unavailable. ### Shell Scripts (scripts/) diff --git a/desktop-environment/bspwm/bspwmrc b/desktop-environment/bspwm/bspwmrc index 7fab8e4..3eddff6 100755 --- a/desktop-environment/bspwm/bspwmrc +++ b/desktop-environment/bspwm/bspwmrc @@ -15,12 +15,21 @@ BSPWM_DIR="$(cd "$(dirname "$(readlink -f "$0")")" && pwd)" STATE_DIR="${XDG_STATE_HOME:-$HOME/.local/state}" LOG_FILE="$STATE_DIR/bspwm/bspwm.log" SXHKD_LOG="$STATE_DIR/sxhkd/sxhkd.log" +SESSION_LOGGER="$HOME/.local/bin/desktop-session-log" mkdir -p "$STATE_DIR/bspwm" "$STATE_DIR/sxhkd" log() { echo "[$(date +%s.%N)] $1" >>"$LOG_FILE" } +session_log() { + if [ -x "$SESSION_LOGGER" ]; then + "$SESSION_LOGGER" "$@" || true + fi +} + +trap 'rc=$?; log "bspwmrc exiting with status $rc"; session_log --exit bspwm "$rc"' EXIT + # Rotate logs larger than 256KB, keeping one .1 backup rotate_if_large() { local file="$1" @@ -31,6 +40,7 @@ rotate_if_large "$SXHKD_LOG" # Append so a failing run's diagnostics survive a subsequent restart echo "=== bspwmrc started at $(date) ===" >>"$LOG_FILE" +session_log --startup bspwm # Make the X session env available to systemd user units (swapescape.service # runs setxkbmap and needs DISPLAY/XAUTHORITY) @@ -140,11 +150,16 @@ sleep 0.2 echo "=== sxhkd watchdog started at $(date) ===" >>"$SXHKD_LOG" ( exec -a sxhkd-watchdog bash -c ' - while true; do + while bspc wm -d >/dev/null 2>&1; do sxhkd 2>>"'"$SXHKD_LOG"'" - echo "=== sxhkd exited at $(date), restarting in 1s ===" >>"'"$SXHKD_LOG"'" - sleep 1 + if bspc wm -d >/dev/null 2>&1; then + echo "=== sxhkd exited at $(date), restarting in 1s ===" >>"'"$SXHKD_LOG"'" + sleep 1 + else + break + fi done + echo "=== sxhkd watchdog exiting at $(date): bspwm is unavailable ===" >>"'"$SXHKD_LOG"'" ' ) & disown diff --git a/desktop-environment/bspwm/lib/profile.py b/desktop-environment/bspwm/lib/profile.py index b489ed2..cf2cf8b 100644 --- a/desktop-environment/bspwm/lib/profile.py +++ b/desktop-environment/bspwm/lib/profile.py @@ -4,7 +4,7 @@ import yaml from dataclasses import dataclass, field from pathlib import Path -from typing import List, Dict, Optional, Tuple, Any +from typing import Callable, List, Dict, Optional, Tuple, Any from .exceptions import ProfileNotFoundError, ProfileValidationError @@ -114,14 +114,43 @@ class ProfileService: # only specify what differs. Not a profile itself. DEFAULTS_FILE = "defaults.yaml" - def __init__(self, profiles_dir: Optional[Path] = None): + def __init__( + self, + profiles_dir: Optional[Path] = None, + lid_state_reader: Optional[Callable[[], Optional[bool]]] = None, + ): """Initialize profile service. Args: profiles_dir: Directory containing YAML profiles (default: the repo's bspwm/profiles) + lid_state_reader: Optional callback returning True when the laptop + lid is closed, False when open, or None when unavailable. """ self.profiles_dir = Path(profiles_dir or self.DEFAULT_PROFILES_DIR) self._defaults: Optional[dict] = None + self._lid_state_reader = lid_state_reader or ( + self._read_lid_state if profiles_dir is None else lambda: None + ) + + @staticmethod + def _read_lid_state() -> Optional[bool]: + """Read the ACPI lid state when the kernel exposes one.""" + lid_dir = Path("/proc/acpi/button/lid") + if not lid_dir.exists(): + return None + + saw_state = False + for state_path in sorted(lid_dir.glob("*/state")): + try: + state = state_path.read_text().strip().lower() + except OSError: + continue + if "closed" in state: + return True + if "open" in state: + saw_state = True + + return False if saw_state else None def _load_defaults(self) -> dict: """Load profiles/defaults.yaml once; missing file means no defaults.""" @@ -371,12 +400,14 @@ def match_profiles(self, detected_monitors: List) -> List[Tuple[str, float, Prof """ # Extract EDIDs from detected monitors detected_edids = {m.edid for m in detected_monitors if m.connected and m.edid} + lid_closed = self._lid_state_reader() - # Build laptop EDID set from eDP-* outputs - laptop_edids = { - m.edid for m in detected_monitors - if m.connected and m.edid and m.output.startswith("eDP-") - } + # Build laptop monitor sets from eDP-* outputs. An output can remain + # EDID-connected after the lid closes, while having no active mode. + laptop_monitors = [ + m for m in detected_monitors if m.connected and m.edid and m.output.startswith("eDP-") + ] + laptop_edids = {m.edid for m in laptop_monitors} matched_profiles = [] @@ -403,9 +434,25 @@ def match_profiles(self, detected_monitors: List) -> List[Tuple[str, float, Prof matched_laptop_edid = None if laptop_required: + laptop_display = profile.displays.get(profile.laptop.alias) + laptop_requires_active_output = laptop_display is not None and laptop_display.enabled if profile.laptop.edid: # Profile specifies laptop EDID - match by EDID if profile.laptop.edid in laptop_edids: + if laptop_requires_active_output and lid_closed is True: + logger.debug( + f"Profile {profile_name}: laptop lid is closed" + ) + continue # Enabled laptop display cannot be used closed + if laptop_requires_active_output and not any( + m.edid == profile.laptop.edid and m.resolution != "unknown" + for m in laptop_monitors + ): + logger.debug( + f"Profile {profile_name}: laptop EDID detected but " + "laptop output is inactive" + ) + continue # Enabled laptop display is not active laptop_matched = True matched_laptop_edid = profile.laptop.edid else: diff --git a/desktop-environment/bspwm/profiles/work-laptop-woodinville-clamshell.yaml b/desktop-environment/bspwm/profiles/work-laptop-woodinville-clamshell.yaml new file mode 100644 index 0000000..0aab4df --- /dev/null +++ b/desktop-environment/bspwm/profiles/work-laptop-woodinville-clamshell.yaml @@ -0,0 +1,57 @@ +name: work-laptop-woodinville-clamshell +description: Woodinville with two landscape 1080p monitors only (laptop display disabled) + +# Detection rules - the two external monitors are required; the laptop panel +# is intentionally omitted because it may disappear when the lid closes. +detection: + monitors: + - output: DP-2.2 + edid: f4baae2572c9d4f8 + alias: secondary + + - output: DP-2.1 + edid: 51017c7b74d014dd + alias: main + +# Physical display configuration (xrandr) +display: + secondary: + enabled: true + resolution: "1920x1080" + position: "0x0" + rotation: normal + scale: "1.25x1.25" + primary: false + + main: + enabled: true + resolution: "1920x1080" + position: "2400x0" + rotation: normal + scale: "1.25x1.25" + primary: true + +# Window manager configuration (BSPWM) +window_manager: + monitor_order: [main, secondary] + + workspaces: + main: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] + secondary: [11, 12, 13, 14, 15, 16, 17, 18, 19, 20] + +# UI configuration (polybar) +ui: + bars: + - monitor: main + orientation: landscape + modules: + left: launcher workspaces + center: date time + right: network battery alsa + + - monitor: secondary + orientation: landscape + modules: + left: launcher workspaces + center: date + right: time diff --git a/desktop-environment/bspwm/profiles/work-laptop-woodinville.yaml b/desktop-environment/bspwm/profiles/work-laptop-woodinville.yaml new file mode 100644 index 0000000..9d2e6ce --- /dev/null +++ b/desktop-environment/bspwm/profiles/work-laptop-woodinville.yaml @@ -0,0 +1,81 @@ +name: work-laptop-woodinville +description: Work laptop with two landscape 1080p monitors at Woodinville (laptop fallback enabled) + +# Detection rules - how to identify this configuration +detection: + laptop: + output: eDP-1-1 + edid: fce90735ac007b29 + alias: laptop + + monitors: + # Left external monitor + - output: DP-2.2 + edid: f4baae2572c9d4f8 + alias: secondary + + # Right external monitor (the current primary output) + - output: DP-2.1 + edid: 51017c7b74d014dd + alias: main + +# Physical display configuration (xrandr) +display: + secondary: + enabled: true + resolution: "1920x1080" + position: "0x0" + rotation: normal + scale: "1.25x1.25" + primary: false + + main: + enabled: true + resolution: "1920x1080" + position: "2400x0" + rotation: normal + scale: "1.25x1.25" + primary: true + + # Keep the laptop panel enabled as a visible recovery path while testing + # the external outputs. + laptop: + enabled: true + resolution: "3840x2400" + position: "0x1350" + rotation: normal + scale: "1x1" + primary: false + +# Window manager configuration (BSPWM) +window_manager: + monitor_order: [main, secondary, laptop] + + workspaces: + main: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] + secondary: [11, 12, 13, 14, 15, 16, 17, 18, 19, 20] + laptop: [21, 22, 23, 24, 25, 26, 27, 28, 29, 30] + +# UI configuration (polybar) +ui: + bars: + - monitor: main + orientation: landscape + modules: + left: launcher workspaces + center: date time + right: network battery alsa + + - monitor: secondary + orientation: landscape + modules: + left: launcher workspaces + center: date + right: time + + - monitor: laptop + orientation: landscape + modules: + left: launcher workspaces + center: date + right: network battery backlight alsa time diff --git a/desktop-environment/bspwm/scripts/network-env.sh b/desktop-environment/bspwm/scripts/network-env.sh index db65066..a09e1dd 100644 --- a/desktop-environment/bspwm/scripts/network-env.sh +++ b/desktop-environment/bspwm/scripts/network-env.sh @@ -5,11 +5,9 @@ # # NETWORK_INTERFACE - the interface carrying the default route, falling back to # the built-in wifi when nothing is up yet. modules.ini reads it directly. -# NETWORK_LABEL - substituted into label-connected before polybar parses format -# tokens, so a token survives as a token. Wi-Fi gets the literal "%essid%", -# which polybar then keeps up to date as the machine roams between networks. -# A wired link has no ESSID - polybar renders %essid% as junk on one - so its -# NetworkManager connection name is resolved here and passed as plain text. +# NETWORK_LABEL - the NetworkManager connection name used by the Polybar +# network-label custom module for wired links. Wireless links resolve their +# current SSID directly so roaming updates without restarting Polybar. network_env() { local iface label diff --git a/desktop-environment/bspwm/tests/test_profile.py b/desktop-environment/bspwm/tests/test_profile.py index 4a78f78..738d9b6 100644 --- a/desktop-environment/bspwm/tests/test_profile.py +++ b/desktop-environment/bspwm/tests/test_profile.py @@ -540,6 +540,52 @@ def test_match_laptop_only(self): self.assertEqual(matches[0][0], "laptop-only") self.assertEqual(matches[0][1], 100.0) # Score: laptop match + def test_enabled_laptop_profile_skips_inactive_laptop_output(self): + """Test that an enabled laptop display does not match after lid close.""" + detected = [ + Monitor( + output="eDP-1", + edid="laptop_edid_abc", + manufacturer="LGD", + model="Generic", + resolution="unknown", + connected=True, + ) + ] + + service = ProfileService(self.profiles_dir, lid_state_reader=lambda: True) + matches = service.match_profiles(detected) + + self.assertNotIn("laptop-only", [m[0] for m in matches]) + + def test_disabled_laptop_profile_accepts_inactive_laptop_output(self): + """Test that a profile disabling the laptop can match after lid close.""" + detected = [ + Monitor( + output="eDP-1", + edid="laptop_edid_abc", + manufacturer="LGD", + model="Generic", + resolution="unknown", + connected=True, + ), + Monitor( + output="DP-1", + edid="external_monitor_edid_123", + manufacturer="Dell", + model="Generic", + resolution="2560x1440", + connected=True, + ), + ] + + service = ProfileService(self.profiles_dir, lid_state_reader=lambda: True) + matches = service.match_profiles(detected) + + dual_match = next((m for m in matches if m[0] == "dual-monitor"), None) + self.assertIsNotNone(dual_match) + self.assertEqual(dual_match[1], 200.0) + def test_match_dual_monitor(self): """Test matching with laptop + one external monitor.""" detected = [ diff --git a/desktop-environment/polybar/shades/modules.ini b/desktop-environment/polybar/shades/modules.ini index 8c9b9ad..8e2c374 100644 --- a/desktop-environment/polybar/shades/modules.ini +++ b/desktop-environment/polybar/shades/modules.ini @@ -440,98 +440,15 @@ interface = wlp3s0 ; Normal Module [module/network] -type = internal/network -interface = ${env:NETWORK_INTERFACE:wlp9s0} - -; Seconds to sleep between updates -; Default: 1 -interval = 1.0 - -; Test connectivity every Nth update -; A value of 0 disables the feature -; NOTE: Experimental (needs more testing) -; Default: 0 -;ping-interval = 3 - -; @deprecated: Define min width using token specifiers (%downspeed:min% and %upspeed:min%) -; Minimum output width of upload/download rate -; Default: 3 -;;udspeed-minwidth = 5 - -; Accumulate values from all interfaces -; when querying for up/downspeed rate -; Default: false -accumulate-stats = true - -; Consider an `UNKNOWN` interface state as up. -; Some devices have an unknown state, even when they're running -; Default: false -unknown-as-up = true - -; Available tags: -; (default) -; -format-connected = -format-connected-prefix =  -format-connected-background = ${color.foreground-alt} -format-connected-padding = 2 - -; Available tags: -; (default) -format-disconnected = -format-disconnected-prefix = 睊 -format-disconnected-background = ${color.foreground-alt} -format-disconnected-padding = 2 - -; Available tags: -; (default) -; -; -;;format-packetloss = - -; Available tokens: -; %ifname% [wireless+wired] -; %local_ip% [wireless+wired] -; %local_ip6% [wireless+wired] -; %essid% [wireless] -; %signal% [wireless] -; %upspeed% [wireless+wired] -; %downspeed% [wireless+wired] -; %linkspeed% [wired] -; Default: %ifname% %local_ip% -label-connected = "%{A1:networkmanager_dmenu &:}${env:NETWORK_LABEL:%essid%}%{A}" - -; Available tokens: -; %ifname% [wireless+wired] -; Default: (none) -label-disconnected = "%{A1:networkmanager_dmenu &:} Offline%{A}" -;;label-disconnected-foreground = #66ffffff +type = custom/script +exec = ~/.config/polybar/shades/scripts/network-label.sh +format =