diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..822a3b0 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,45 @@ +name: lint + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +jobs: + shell: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install shellcheck + run: sudo apt-get update && sudo apt-get install -y shellcheck + + # The plugins and the install script. SC1091 is "can't follow source", + # which is expected: they source $HOME/.config/sketchybar/*.sh, a path that + # only exists once stow has run. + - name: shellcheck + run: | + shellcheck -S warning -e SC1091 \ + sketchybar/.config/sketchybar/*.sh \ + sketchybar/.config/sketchybar/plugins/*.sh \ + raycast/.config/raycast/scripts/*.sh \ + scripts/*.sh + + # install is zsh, not bash — shellcheck cannot read it, so just parse it. + - name: zsh parse install + run: | + sudo apt-get install -y zsh + zsh -n install + + sketchybar: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + # Catches the SketchyBar-specific failures that are invisible at runtime: + # a plugin without its execute bit, a self-hiding item missing updates=on, + # and a comment pointing at a section that no longer exists. None of these + # produce any error on the host — the item simply never updates. + - name: sketchybar semantics + run: ./scripts/lint-sketchybar.sh diff --git a/.gitignore b/.gitignore index e33940e..333adb5 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,7 @@ # Anchored with a leading slash so it matches only the repo root, never the # tracked `claude/` stow package. /.claude +CLAUDE.local.md # ============================================================ diff --git a/Brewfile b/Brewfile index 448c7db..d16bc3e 100644 --- a/Brewfile +++ b/Brewfile @@ -33,6 +33,7 @@ brew "eza" brew "fzf" brew "jq" brew "zoxide" +brew "media-control" # --- Utilities --- brew "fastfetch" @@ -48,6 +49,7 @@ brew "gh" brew "git-delta" brew "lazygit" brew "lazydocker" +brew "shellcheck" # --- Languages & runtimes --- brew "direnv" diff --git a/CLAUDE.md b/CLAUDE.md index 500d613..ac1a562 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,54 +2,113 @@ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. +## Working agreement + +**Never run git operations** — no commits, no branches, no staging, no history rewriting — unless +explicitly asked in that message. Edit files and report; the user drives git. + ## What This Is -macOS dotfiles managed with GNU Stow. Each top-level directory (aerospace, bat, btop, claude, git, hushlogin, kitty, lazygit, nvim, ssh, zsh) is a stow package whose contents mirror the home directory structure (e.g. `zsh/.zshrc` symlinks to `~/.zshrc`). +macOS dotfiles managed with GNU Stow. Each top-level directory (`aerospace`, `bat`, `borders`, +`btop`, `claude`, `git`, `kitty`, `lazygit`, `nvim`, `raycast`, `sketchybar`, `ssh`, `zsh`) is a +stow package whose contents mirror `$HOME` (e.g. `zsh/.zshrc` → `~/.zshrc`). + +**Stow runs `--no-folding`** (set in `install` and `.stowrc`): directories are created for real and +only tracked leaf files are symlinked. This stops an app-managed directory (`~/.ssh`, `~/.claude`) +being folded into a symlink pointing *into the repo*, which would let runtime state and secrets get +written inside it. **Tradeoff: a NEW file in an existing package needs a re-stow before anything +sees it** — an edit to an existing file takes effect immediately, a new file does not. ## Commands -- Syntax-check the install script: `zsh -n install` -- Re-stow a single package: `stow -d ~/dotfiles --no-folding -R ` -- Run the full bootstrap: `./install` (interactive, prompts y/N for each step; use `./install --yes` for non-interactive, `./install --adopt` to allow stow conflict adoption) - -## Shell - -All scripts use **zsh** (not bash). The install script relies on zsh-specific builtins (`read -q`, `print`) and glob qualifiers (`*(/)` for directories). - -## Install Script - -`./install` is an interactive bootstrap with `set -eu`. It skips already-installed brew packages, catches individual failures with `|| warn`. Flags: `--yes`/`-y` for non-interactive mode, `--adopt` to opt into stow conflict adoption. - -- **Stow conflicts fail by default.** If `$HOME` already has files that would collide with the symlinks, the script errors out. To resolve, re-run with `--adopt` — but that's destructive toward the repo: stow moves the existing home-directory file *into the repo* (overwriting the tracked version) and symlinks it back. Always `git diff` after running with `--adopt` before committing. -- **Brewfile loop reads from FD 3** (`while ... <&3; done 3< Brewfile`) because `ask()`'s `read -q` consumes stdin. If you refactor the loop to a plain `< Brewfile`, every `ask` prompt eats the next Brewfile line and packages get silently skipped. -- **Brewfile parser handles `tap`, `brew`, `cask`** — all three must use the exact ` "pkg"` quoting; the regex (`^(brew|cask|tap)[[:space:]]+"([^"]+)"`) won't match anything else. -- **Third-party taps must be trusted, not just added.** Homebrew 6+ refuses to load formulae/casks from an untrusted tap (`Error: Refusing to load ... from untrusted tap`). Trust is separate state in `${XDG_CONFIG_HOME:-~/.config}/homebrew/trust.json`, falling back to `~/.homebrew/trust.json` (the script probes both, in that order), so `brew tap` alone is not enough and a tap can be present but unusable. The install script's tap branch runs `brew trust --tap` after tapping, and re-prompts for taps that are already added but untrusted. Keep the tap list minimal — each one is a third party trusted to run install code — and prefer homebrew-core when it carries the package. -- **`brew install` in the install script passes `--formula` deliberately.** Some taps ship a formula *and* a cask under the same name (`azure/azd` does). A bare `brew install` can resolve to the cask, which then never matches the `brew list --formula` skip-check, so the package re-prompts on every run. -- **.NET global tools live in the `install` script, not a `dotnet-tools.json`.** That filename is the *local* tool manifest format: it belongs inside a project, is created by `dotnet new tool-manifest`, and is consumed by `dotnet tool restore`. Global (`-g`) tools have no manifest at all — they install into `~/.dotnet/tools` (put on PATH by `.zprofile`). Don't "consolidate" the `dotnet_tools` array into a manifest file; they are different mechanisms with different invocation (`dotnet-ef` vs `dotnet ef`). Array entries are NuGet package IDs, which can differ from the command they provide (`dotnet-outdated-tool` → `dotnet-outdated`). -- **The hand-rolled Brewfile parser is deliberate, not an oversight.** `brew bundle` exists and would replace it, but it is all-or-nothing; the parser is what enables the per-package y/N prompt. Don't "simplify" it to `brew bundle` without dropping that feature knowingly. - -## Adding a New Stow Package - -Create a directory whose internal structure mirrors the home-relative path (e.g. `foo/.config/foo/config.toml`). The install script's stow loop picks it up automatically via the `*(/)` glob qualifier. - -## Architecture Notes - -- **Catppuccin Frappé** is the unified theme across kitty, nvim, bat, btop, lazygit, delta (git pager), and fzf (via `FZF_DEFAULT_OPTS` in `.zshrc`). When adding new tools with theme support, use Catppuccin Frappé for consistency. -- **Kitty theme** is extracted to `kitty/.config/kitty/themes/catppuccin-frappe.conf` via `include` — edit the theme file, not `kitty.conf`. -- **Git pager** is `delta` (not `less`). The `[delta]` section in `.gitconfig` is the single source of truth — lazygit invokes bare `delta`, which reads that section itself. lazygit's key is `git.pagers[].pager` (an *array* since 0.55; `git.paging` is the legacy form it migrates from). A bare `git.pager` is silently ignored, with no warning — verify changes actually took effect rather than trusting the config to be read. -- **SSH config is first-match-wins.** `Host *` must stay at the bottom of `ssh/.ssh/config`; a keyword set there can never be overridden by a `Host` block below it. `Include ~/.ssh/config.local` stays at the very top so machine-local overrides win (a missing include file is not an error). -- **AeroSpace binds keys system-wide**, ahead of kitty and zsh. Its config deliberately uses *numeric* workspaces only: upstream's `default-config.toml` binds `alt-` for 26 letter-named workspaces, which would swallow `^[c` (fzf-cd-widget), `^[b`/`^[f` (word motion), `^[d` (kill-word) and more. Before adding any `alt-` binding — in `aerospace.toml`, `kitty.conf`, or `.zshrc` — check it against `bindkey -M emacs` first. Config lives at `aerospace/.config/aerospace/aerospace.toml`; a stray `~/.aerospace.toml` makes AeroSpace error on the ambiguity. -- **`.zprofile` opens with `typeset -U path PATH`, and that line is load-bearing.** This file appends to PATH unconditionally and every login shell re-sources it (a terminal login, kitty, and VS Code each add another copy), so without the unique-array flag entries accumulate. It must come before any PATH manipulation for later additions — including those in `.zshrc` — to be deduped. -- **The `Telemetry` block in `.zprofile` must stay ahead of the `Homebrew` block.** The Homebrew section runs `brew shellenv`, so `HOMEBREW_NO_ANALYTICS` has to already be exported by then. Variable names there were each taken from the tool itself rather than from memory (e.g. `AZURE_CORE_COLLECT_TELEMETRY` is what knack builds from `ENV_VAR_PREFIX='AZURE'`); verify against the tool before adding more. -- **gitignore has no trailing-comment syntax.** `#` only starts a comment at the start of a line, so `.ionide/ # cache` becomes part of the pattern and silently stops matching — no error, no warning. Keep comments on their own lines in both `.gitignore` and `git/.config/git/ignore`. Those two files are different scopes: the former covers this repo, the latter is stowed to `~/.config/git/ignore` and applies machine-wide. -- **Powerlevel10k** is the zsh prompt. Config lives in `zsh/.p10k.zsh`. The instant prompt block at the top of `.zshrc` must remain first — nothing can print to stdout before it. -- **Neovim** uses lazy.nvim for plugin management. Plugin specs live in `nvim/.config/nvim/lua/plugins/`. Core config (options, keymaps) lives in `nvim/.config/nvim/lua/config/`. There is deliberately no LSP (stripped in 539109d) — so nothing publishes diagnostics, and `blink.cmp` has no `lsp` source. Add diagnostic keymaps back only alongside something that produces them. -- **`claude/.claude/statusline.sh` is vendored third-party code** ([daniel3303/ClaudeCodeStatusLine](https://github.com/daniel3303/ClaudeCodeStatusLine), see `VERSION` at the top). It reads OAuth credentials and makes network calls, so review diffs before pulling upstream changes. Local deviations from upstream: single-pass `jq` parsing, `$TMPDIR` cache dir, and the bearer token passed via `curl --config -` (never argv, which `ps` exposes). It must stay **bash 3.2**-compatible — `bash` resolves to `/bin/bash` on a machine without Homebrew's bash, which is not in the Brewfile. -- **Zsh load order** in `.zshrc` is critical and must be preserved: - 1. Powerlevel10k instant prompt (must be first — nothing can print to stdout before it) - 2. Oh My Zsh config + `source $ZSH/oh-my-zsh.sh` — any `fpath` additions (e.g. `$HOME/.docker/completions`) must come *before* the source line, since OMZ runs `compinit` during sourcing. In the `plugins=()` array, `fzf-tab` must come *before* `zsh-autosuggestions` and `zsh-syntax-highlighting` — fzf-tab wraps the completion widget and the syntax/autosuggestion plugins wrap the line editor; swapping their order silently breaks tab completion or kills syntax highlighting. - 3. Aliases and shell tool inits (fzf, zoxide, direnv) - 4. `source ~/.p10k.zsh` - 5. `setopt aliases` (required — p10k leaks `noaliases` from its config) -- **Brewfile format** uses `tap "pkg"`, `brew "pkg"`, or `cask "pkg"` — the install script's regex parser depends on this exact quoting. -- **Stow uses `--no-folding`** (set in `install` and `.stowrc`): target directories are created as real directories and only tracked leaf files are symlinked. This stops stow from folding an app-managed directory (`~/.ssh`, `~/.claude`, `~/.config/btop`) into a single symlink that points into the repo — which would otherwise let app runtime state and secrets (e.g. `~/.claude/.credentials.json`) get written *inside* the repo. Tradeoff: adding a *new* file to an existing package requires a re-stow to link it. `.gitignore` also defensively ignores everything under `ssh/.ssh/` and `claude/.claude/` except the tracked configs. +```sh +zsh -n install # syntax-check (install is zsh, not bash) +stow -d ~/dotfiles --no-folding -R # re-stow one package +./install [--yes] [--adopt] # bootstrap; interactive y/N per step +./scripts/lint-sketchybar.sh # SketchyBar silent-failure checks +./scripts/lint-aerospace.sh # AeroSpace cross-file coupling checks +``` + +`install` is **zsh**, not bash: it uses `read -q`, `print`, and the `*(/)` glob qualifier. + +`--adopt` is destructive toward the repo — stow moves the existing `$HOME` file *into* the repo, +overwriting the tracked version. Always diff after using it. + +## Hardware assumptions + +**Mac Studio (M4 Max), one 2560×1440 external.** No battery, no built-in display, no notch. +AeroSpace and SketchyBar are tuned for exactly this: no per-monitor gaps, no +`workspace-to-monitor-force-assignment`, no notch arithmetic. Don't reintroduce multi-monitor or +laptop assumptions without a second display actually present. + +## Keyboard — two independent hazards on `alt` + +- **`alt-` collides with the Danish layout.** `[ ] { } \` live on the Option layer of the + digit row (`alt-8`, `alt-9`, `alt-shift-7/8/9`) with no other route, so binding those makes them + untypable system-wide. **Workspaces are on `ctrl-` for this reason** — don't "tidy" them + back to upstream's `alt`. Measured with `UCKeyTranslate`: `ctrl-8` → `8`, but `cmd-alt-8` → `[`, + so `cmd` is *not* an escape. Same for Norwegian/Swedish/Finnish/German. +- **`alt-` collides with zsh widgets** (`^[b`/`^[f` word motion, `^[d` kill-word, …). + Check `bindkey -M emacs` before adding one. + +`kitty.conf` sets `macos_option_as_alt left`, so in the terminal the **right** Option key composes +layout characters and the left one sends `ESC` sequences. + +## SketchyBar — four traps, each rediscovered more than once + +All four fail *silently*: the bar comes up, an item is simply absent or stale, and nothing says why. +`./scripts/lint-sketchybar.sh` checks the first three. + +1. **An item whose script sets item-level `drawing=off` MUST also set `updates=on`.** Under the + config-wide `updates=when_shown`, a hidden item stops being updated entirely — so it works after + a reload, hides itself, and never runs again. `updates=on` with `update_freq=0` stays event-only, + so it costs no polling. Note `label.drawing=off` is *not* this — it hides a component, not the + item. +2. **New plugin scripts need `chmod +x`.** SketchyBar `fork_exec`s them and reports nothing when the + bit is missing. +3. **A plugin has less TCC access than your terminal.** The bar is launched by AeroSpace with no + Full Disk Access. Anything TCC-protected under `~/Library` reads fine by hand and fails with + `EPERM` in the plugin, at correct Unix permissions. **Test reads from a script the bar actually + runs**, not from a shell. +4. **Don't trust `macmon`'s `temp.cpu_temp_avg`.** Bimodal at flat idle — measured landing on ~31.9 + or ~38.1 and never between, dropping to 20 °C at 0.06 W. It is a mean over a varying sensor set. + `helpers/thermal.swift` exists because of this. + +Several properties are **not echoed by `sketchybar --query`** (`label.max_chars`, `blur_radius`, +`notch_*`), so a wrong value looks identical to a right one. Verify behaviour, not the query. + +## Install script gotchas + +- **The Brewfile loop reads from FD 3** (`... <&3; done 3< Brewfile`) because `ask()`'s `read -q` + consumes stdin. Convert it to a plain `< Brewfile` and every prompt eats the next line, silently + skipping packages. +- **`brew install` passes `--formula` deliberately** — some taps ship a formula *and* a cask under + one name, and the cask never matches the `brew list --formula` skip-check, so it re-prompts + forever. +- **Taps must be trusted, not just added** (Homebrew 6+). The script runs `brew trust --tap`; a tap + can be present but unusable. +- **The hand-rolled Brewfile parser is deliberate.** `brew bundle` would replace it but is + all-or-nothing; the parser is what enables the per-package y/N prompt. +- Brewfile format is exactly `tap "pkg"` / `brew "pkg"` / `cask "pkg"` — the regex depends on it. + +## `.zshrc` load order — must be preserved + +1. Powerlevel10k instant prompt **first**; nothing may print to stdout before it. +2. Oh My Zsh config, then `source $ZSH/oh-my-zsh.sh`. Any `fpath` additions go *before* the source + line (OMZ runs `compinit` during it). In `plugins=()`, **`fzf-tab` must precede + `zsh-autosuggestions` and `zsh-syntax-highlighting`** — fzf-tab wraps the completion widget while + the other two wrap the line editor; the wrong order silently breaks completion or highlighting. +3. Aliases and tool inits (fzf, zoxide, direnv) +4. `source ~/.p10k.zsh` +5. `setopt aliases` — required, p10k leaks `noaliases`. + +## Other + +- **Theme is Catppuccin Frappé** across kitty, nvim, bat, btop, lazygit, delta and fzf. Match it. +- **`claude/.claude/statusline.sh` is vendored** ([daniel3303/ClaudeCodeStatusLine]); it reads OAuth + credentials and makes network calls, so review diffs before pulling upstream. Must stay **bash + 3.2**-compatible. +- A stray `~/.aerospace.toml` makes AeroSpace error on the ambiguity — config lives only at + `aerospace/.config/aerospace/aerospace.toml`. + +[daniel3303/ClaudeCodeStatusLine]: https://github.com/daniel3303/ClaudeCodeStatusLine diff --git a/README.md b/README.md index 28733ee..7019d03 100644 --- a/README.md +++ b/README.md @@ -70,7 +70,7 @@ each declared tap trust as it adds it. | Package | Contents | |---------|----------| -| `aerospace` | [AeroSpace](https://github.com/nikitabobko/AeroSpace) tiling WM. Numeric workspaces on `alt-1`–`alt-9`, focus/move on `alt-hjkl`. Needs Accessibility permission; does **not** start at login | +| `aerospace` | [AeroSpace](https://github.com/nikitabobko/AeroSpace) tiling WM. Numeric workspaces on `ctrl-1`–`ctrl-9` (**not** `alt` — that is where a Danish layout keeps `[ ] { } \`), focus/move on `alt-hjkl`. Needs Accessibility permission; does **not** start at login | | `bat` | bat config (Catppuccin Frappé theme) | | `btop` | btop Catppuccin Frappé theme (the install script seeds `color_theme = "catppuccin_frappe"` for you) | | `claude` | Claude Code settings and statusline | diff --git a/aerospace/.config/aerospace/aerospace.toml b/aerospace/.config/aerospace/aerospace.toml index c7ff264..8e987a9 100644 --- a/aerospace/.config/aerospace/aerospace.toml +++ b/aerospace/.config/aerospace/aerospace.toml @@ -6,40 +6,14 @@ # # AeroSpace reads either ~/.aerospace.toml or this XDG path. Having BOTH is an # error ("ambiguity is reported"), so ~/.aerospace.toml must not exist. -# -# WHY THIS DIFFERS FROM UPSTREAM'S default-config.toml: -# AeroSpace binds keys system-wide — it sees them before kitty or zsh do. The -# upstream default binds alt- for 26 letter-named workspaces, which would -# swallow this setup's shell keys: ^[c (fzf-cd-widget), ^[b / ^[f (word motion), -# ^[d (kill-word), ^[m, ^[n, ^[p, ^[g, ^[a. So: numeric workspaces only. -# Before adding any alt- binding here, check it against `bindkey -M emacs`. -# -# Conscious losses to the shell, all low-value: -# ^[h run-help -> alt-h is 'focus left' -# ^[l OMZ ls widget -> alt-l is 'focus right' -# ^[1..^[9 digit-argument -> workspace switching -# -# alt-ctrl-shift- reaches the terminal as ^[^ (Shift is dropped -# for control characters), costing two more. Both keep an alternative: -# ^[^H backward-kill-word -> alt-ctrl-shift-h throws a window one monitor left -# (alt-backspace, ^[^?, still does it) -# ^[^L clear-screen -> alt-ctrl-shift-l throws a window one monitor right -# (ctrl-l still does it) -# ^[F forward-word -> alt-shift-f is 'fullscreen' (^[f is the same widget) -# -# ON ANIMATIONS: there are none, and none are possible. AeroSpace repositions a -# window with a single Accessibility API write — there is no tween and no config -# option for one. (Upstream only discusses animation to explain why it replaced -# native macOS Spaces, whose switching animation can't be disabled.) The focus -# cue below is the substitute: JankyBorders draws a ring on the focused window, -# and that ring pulses on every focus change. - config-version = 2 # ============================================================ # Startup # ============================================================ +# What AeroSpace runs at launch and how it reloads this file. +# # The escape hatch is `aerospace enable toggle`, which suspends window management # without quitting — so there's no reason to keep launching it by hand. start-at-login = true @@ -54,8 +28,6 @@ auto-reload-config = true # ring's lifetime is tied to the window manager's: quit AeroSpace, no orphan. after-startup-command = [ 'exec-and-forget borders', - # The status bar. Both run with no arguments so they read their own stowed - # config (~/.config/borders/bordersrc, ~/.config/sketchybar/sketchybarrc). 'exec-and-forget sketchybar', ] on-mode-changed = [] @@ -64,11 +36,18 @@ on-mode-changed = [] # ============================================================ # Layout # ============================================================ +# How windows are arranged by default and the gaps left around them. + +# New windows tile rather than float or stack. default-root-container-layout = 'tiles' + # 'auto' = horizontal on wide monitors, vertical on tall ones default-root-container-orientation = 'auto' accordion-padding = 30 +# Keep the tree tidy as windows come and go: flatten drops containers left +# holding a single child, and opposite-orientation makes a nested container split +# the other way from its parent so new windows land predictably. enable-normalization-flatten-containers = true enable-normalization-opposite-orientation-for-nested-containers = true @@ -77,30 +56,19 @@ gaps.inner.horizontal = 8 gaps.inner.vertical = 8 gaps.outer.left = 8 gaps.outer.bottom = 8 -# Per-monitor, because the built-in display has a notch and the externals don't. -# -# SketchyBar's islands are 26pt tall centred in a 32pt transparent bar, so their -# bottom edge is at y=29; 37 = 29 + the usual 8pt gap. The bar draws inside the -# area AeroSpace tiles into, so without this windows slide underneath it. -# -# The built-in gets 5, not 37. AeroSpace tiles inside the monitor's VISIBLE rect, -# and macOS reserves that display's top 32pt as notch safe area even with the -# menu bar auto-hidden (measured: safeAreaInsets.top = 32, visibleFrame 1512x950 -# of a 1512x982 frame). So the 32 is already excluded and only the remaining 5 -# has to be asked for. Measured to confirm: with a flat 34 here, a window on the -# built-in landed at y=66 (= 32 + 34) while one on an external landed at y=34 — -# a 32pt dead strip on the laptop only. 5 + 32 = 37 puts both at the same place. -# -# Also paired with the macOS menu bar being auto-hidden (see the install script): -# with the menu bar visible this would sit on top of its 30, and the bar would -# land below it rather than at the top of the screen. -gaps.outer.top = [{ monitor.'built-in' = 5 }, 37] +# 43 = SketchyBar's height (38, set in sketchybarrc) + 5pt clearance. These two +# numbers are coupled: raise the bar and windows tuck underneath it, lower the +# bar and a band of wallpaper opens above them. +gaps.outer.top = 43 gaps.outer.right = 8 # ============================================================ # Behaviour # ============================================================ +# Focus feedback, the bridge that keeps SketchyBar in sync, and a few +# interaction defaults. +# # Focus cue. AeroSpace can't animate a window move (see the note at the top), so # the JankyBorders ring flashes lavender for ~120ms and settles back to mauve. # Re-invoking `borders` reconfigures the running instance rather than starting a @@ -109,14 +77,9 @@ gaps.outer.right = 8 # kitty uses for active_tab_background). They must stay in sync with bordersrc. # If a held-down alt-h/alt-l ever makes the pulse look like flicker, drop this # callback — the static ring survives on its own. -# -# Deliberately NOT 'move-mouse window-lazy-center' here: the pointer stays put -# on window focus changes. The monitor callback below is a different thing — it -# only fires when the focused *monitor* changes. on-focus-changed = [ 'exec-and-forget /bin/sh -c "borders active_color=0xffbabbf1; sleep 0.12; borders active_color=0xffca9ee6"', ] -on-focused-monitor-changed = ['move-mouse monitor-lazy-center'] # Pushes workspace switches to SketchyBar, which has no way to learn about them # on its own. Inert while sketchybar isn't running — the command just fails into @@ -126,6 +89,8 @@ exec-on-workspace-change = [ '/bin/bash', '-c', 'sketchybar --trigger aerospace_workspace_change FOCUSED_WORKSPACE=$AEROSPACE_FOCUSED_WORKSPACE', ] +# Focus only on keypress (not on hover), leave macOS-hidden apps hidden, and +# read the keyboard as QWERTY regardless of the active layout. focus-follows-mouse.enabled = false automatically-unhide-macos-hidden-apps = false key-mapping.preset = 'qwerty' @@ -148,99 +113,50 @@ persistent-workspaces = ["1", "2", "3", "4", "5", "6", "7", "8", "9"] # Bundle IDs were read off each app's Info.plist, not guessed. To add one: # `aerospace list-apps` while it's running. # -# Deliberately NOT here: browsers, kitty, VS Code. This fires for *every* new -# window, so a placement rule on those would yank a window you just opened onto -# another monitor mid-task. on-window-detected = [ # Float — utilities and preference panes; a tile only distorts the layout. { if = 'test %{app-bundle-id} = com.apple.systempreferences', run = 'layout floating' }, { if = 'test %{app-bundle-id} = com.apple.calculator', run = 'layout floating' }, - { if = 'test %{app-bundle-id} = com.fasttracksoftware.adminbyrequest', run = 'layout floating' }, { if = 'test %{app-bundle-id} = app.monitorcontrol.MonitorControl', run = 'layout floating' }, { if = 'test %{app-bundle-id} = com.logi.optionsplus', run = 'layout floating' }, { if = 'test %{app-bundle-id} = com.crystalidea.macsfancontrol', run = 'layout floating' }, { if = 'test %{app-bundle-id} = com.focusrite.control', run = 'layout floating' }, { if = 'test %{app-bundle-id} = com.titanium.OnyX', run = 'layout floating' }, { if = 'test %{app-bundle-id} = com.jetbrains.toolbox', run = 'layout floating' }, - # Raycast — the launcher panel itself is a non-activating window AeroSpace - # already ignores, but its Settings window tiles. Left COMMENTED because the - # bundle ID is unverified: Raycast is in the Brewfile but not installed here, - # and the Homebrew cask carries no zap/quit stanza to read it from. Per the - # rule above, read it rather than guess — install Raycast, open its Settings - # window, run `aerospace list-apps`, paste what it reports, and uncomment. - # A wrong ID does not error; the rule just never fires. - # { if = 'test %{app-bundle-id} = com.raycast.macos', run = 'layout floating' }, - - # Place — chat/mail/notes open on their home screens (see the map below). + { if = 'test %{app-bundle-id} = com.raycast.macos', run = 'layout floating' }, + { if = 'test %{app-bundle-id} = com.codeweavers.CrossOver', run = 'layout floating' }, + { if = 'test %{app-bundle-id} = com.valvesoftware.steam', run = 'layout floating' }, { if = 'test %{app-bundle-id} = com.microsoft.teams2', run = 'move-node-to-workspace 6' }, { if = 'test %{app-bundle-id} = com.microsoft.Outlook', run = 'move-node-to-workspace 7' }, { if = 'test %{app-bundle-id} = md.obsidian', run = 'move-node-to-workspace 8' }, ] -# ============================================================ -# Monitors -# ============================================================ -# The desk, left to right — AeroSpace ordinals are ordered the same way, so its -# numbering already matches the physical layout: -# 1 = Built-in Retina Display (left, laptop) -# 2 = P24h-2L (1) (middle, macOS main display — the work screen) -# 3 = P24h-2L (2) (right, side monitor) -# -# Both externals report the same name ("P24h-2L"); macOS appends (1)/(2) and -# those suffixes can swap on reconnect, so they are never matched by name. -# -# The low numbers go to the middle screen deliberately: easiest keys on the -# busiest monitor, at the cost of alt-N no longer tracking desk position. -# -# LAPTOP MAIN (middle) SIDE -# ┌───────┐ ┌─────────────────┐ ┌───────┐ -# │ 8 9 │ │ 1 2 3 4 5 │ │ 6 7 │ -# └───────┘ └─────────────────┘ └───────┘ -# -# Every entry ends in a pattern that always resolves, so undocking or closing -# the lid collapses workspaces onto a live screen instead of stranding them. -# -# NOTE: force-assignment makes `move-workspace-to-monitor` a no-op. Use -# `move-node-to-monitor` (alt-ctrl-shift-h/l below) to shuffle windows instead. -[workspace-to-monitor-force-assignment] - 1 = 'main' # middle — the work screen - 2 = 'main' - 3 = 'main' - 4 = 'main' - 5 = 'main' - 6 = ['3', 'secondary'] # right — ordinal with 3 screens, secondary with 2 - 7 = ['3', 'secondary'] - 8 = ['built-in', 'main'] # laptop — 'main' covers clamshell - 9 = ['built-in', 'main'] - - # ============================================================ # Main bindings # ============================================================ +# Everyday window management, all on the `alt` modifier. Note that every +# alt- here also shadows a zsh line-editor widget (^[b/^[f word motion, +# ^[d kill-word, …), so check `bindkey -M emacs` before adding a new one — see +# CLAUDE.md. [mode.main.binding] - # Launch a terminal, i3/sway style. Deliberately alt-SHIFT-enter and not the - # idiomatic alt-enter: AeroSpace binds system-wide, ahead of kitty and zsh, - # so alt-enter would permanently swallow ^[^M — which is self-insert-unmeta - # in zsh's emacs keymap and "insert newline without submitting" in Claude - # Code. Neither can be won back per-app once AeroSpace has the key. - # `open -na` for a new instance each time; kitty has no single-instance or - # remote-control setup here for a new window to attach to. + # Launch a terminal, then focus by direction. Focus wraps at the screen edge + # (cheap to undo), unlike the moves further down which deliberately stop. alt-shift-enter = 'exec-and-forget open -na kitty' - # Focus — the three monitors sit side by side, so h/l treat them as one - # continuous frame and walk across the edges. j/k stay workspace-local. - alt-h = 'focus --boundaries all-monitors-outer-frame left' - alt-j = 'focus down' - alt-k = 'focus up' - alt-l = 'focus --boundaries all-monitors-outer-frame right' + alt-h = 'focus --wrap-around left' + alt-j = 'focus --wrap-around down' + alt-k = 'focus --wrap-around up' + alt-l = 'focus --wrap-around right' - # Move the focused window — h/l carry it onto the neighbouring monitor - alt-shift-h = 'move --boundaries all-monitors-outer-frame left' + # Move the focused window. NOT --wrap-around, deliberately: wrapping a MOVE + # reorders the tree and is awkward to undo, whereas a focus that wraps costs + # nothing. A move that stops at the edge is the safe default. + alt-shift-h = 'move left' alt-shift-j = 'move down' alt-shift-k = 'move up' - alt-shift-l = 'move --boundaries all-monitors-outer-frame right' + alt-shift-l = 'move right' # Resize alt-minus = 'resize smart -50' @@ -249,57 +165,69 @@ on-window-detected = [ # Layout alt-slash = 'layout tiles horizontal vertical' alt-comma = 'layout accordion horizontal vertical' - # ^[F is bound to forward-word, but so is ^[f — this costs a duplicate. - alt-shift-f = 'fullscreen' - # Workspaces - alt-1 = 'workspace 1' - alt-2 = 'workspace 2' - alt-3 = 'workspace 3' - alt-4 = 'workspace 4' - alt-5 = 'workspace 5' - alt-6 = 'workspace 6' - alt-7 = 'workspace 7' - alt-8 = 'workspace 8' - alt-9 = 'workspace 9' - - # Send the focused window to a workspace - alt-shift-1 = 'move-node-to-workspace 1' - alt-shift-2 = 'move-node-to-workspace 2' - alt-shift-3 = 'move-node-to-workspace 3' - alt-shift-4 = 'move-node-to-workspace 4' - alt-shift-5 = 'move-node-to-workspace 5' - alt-shift-6 = 'move-node-to-workspace 6' - alt-shift-7 = 'move-node-to-workspace 7' - alt-shift-8 = 'move-node-to-workspace 8' - alt-shift-9 = 'move-node-to-workspace 9' + # ctrl, NOT upstream's alt — do not "fix" this back. On the Danish layout + # `[ ] { } \` live on the Option layer of the digit row (alt-8, alt-9, + # alt-shift-7/8/9) with no other route to them, so binding alt- makes + # those characters untypable system-wide, in every app. Measured with + # UCKeyTranslate: ctrl-8 gives 8, but cmd-alt-8 still gives [ — cmd is not an + # escape hatch. Same trap on Norwegian, Swedish, Finnish and German. + ctrl-1 = 'workspace 1' + ctrl-2 = 'workspace 2' + ctrl-3 = 'workspace 3' + ctrl-4 = 'workspace 4' + ctrl-5 = 'workspace 5' + ctrl-6 = 'workspace 6' + ctrl-7 = 'workspace 7' + ctrl-8 = 'workspace 8' + ctrl-9 = 'workspace 9' + + # Send the focused window to a workspace. ctrl-shift, matching the switch + # bindings above — alt-shift-7/8/9 are \ { } on this layout. + ctrl-shift-1 = 'move-node-to-workspace 1' + ctrl-shift-2 = 'move-node-to-workspace 2' + ctrl-shift-3 = 'move-node-to-workspace 3' + ctrl-shift-4 = 'move-node-to-workspace 4' + ctrl-shift-5 = 'move-node-to-workspace 5' + ctrl-shift-6 = 'move-node-to-workspace 6' + ctrl-shift-7 = 'move-node-to-workspace 7' + ctrl-shift-8 = 'move-node-to-workspace 8' + ctrl-shift-9 = 'move-node-to-workspace 9' alt-tab = 'workspace-back-and-forth' # Window-level counterpart to alt-tab. ^[` is unbound in emacs mode. alt-backtick = 'focus-back-and-forth' - # Was move-workspace-to-monitor, which force-assignment turns into a no-op. - alt-shift-tab = 'focus-monitor --wrap-around next' - # Throw the focused window one screen over and follow it there - alt-ctrl-shift-h = 'move-node-to-monitor --focus-follows-window left' - alt-ctrl-shift-l = 'move-node-to-monitor --focus-follows-window right' + # alt-shift-tab (focus-monitor) and alt-ctrl-shift-h/l (move-node-to-monitor) + # lived here and are gone with the second screen. That hands ^[^H + # (backward-kill-word) and ^[^L (clear-screen) back to the shell, and leaves + # three easy chords free if something else wants them. - alt-shift-semicolon = 'mode service' + alt-shift-semicolon = ['mode service', 'exec-and-forget sketchybar --set aerospace_mode drawing=on'] # ============================================================ # Service mode # ============================================================ # Entered with alt-shift-semicolon; every binding returns to main mode. +# +# THE SERVICE PILL. Entering the mode shows a "SERVICE" pill (island.mode in +# sketchybarrc); the trailing `… drawing=off` on every binding hides it again on the +# way back to main. This is manual on purpose: on-mode-changed carries no mode name +# (AeroSpace issue #390 — `aerospace list-exec-env-vars` shows no AEROSPACE_MODE), so +# no single callback can tell the bar "service" from "main". CONSEQUENCE: a NEW binding +# added here MUST carry the same hide, or the pill sticks on SERVICE after that key. +# Harmless while sketchybar isn't running — the --set just fails into the void. [mode.service.binding] - esc = ['reload-config', 'mode main'] - r = ['flatten-workspace-tree', 'mode main'] # reset layout - f = ['layout floating tiling', 'mode main'] # toggle float/tile - b = ['balance-sizes', 'mode main'] # equalise all windows - s = ['swap --swap-focus dfs-next', 'mode main'] # swap with next window - backspace = ['close-all-windows-but-current', 'mode main'] - - alt-shift-h = ['join-with left', 'mode main'] - alt-shift-j = ['join-with down', 'mode main'] - alt-shift-k = ['join-with up', 'mode main'] - alt-shift-l = ['join-with right', 'mode main'] + esc = ['reload-config', 'mode main', 'exec-and-forget sketchybar --set aerospace_mode drawing=off'] # exit + reload + r = ['flatten-workspace-tree', 'mode main', 'exec-and-forget sketchybar --set aerospace_mode drawing=off'] # reset layout + f = ['layout floating tiling', 'mode main', 'exec-and-forget sketchybar --set aerospace_mode drawing=off'] # toggle float/tile + m = ['fullscreen', 'mode main', 'exec-and-forget sketchybar --set aerospace_mode drawing=off'] # toggle fullscreen + b = ['balance-sizes', 'mode main', 'exec-and-forget sketchybar --set aerospace_mode drawing=off'] # equalise windows + s = ['swap --swap-focus dfs-next', 'mode main', 'exec-and-forget sketchybar --set aerospace_mode drawing=off'] # swap with next + backspace = ['close-all-windows-but-current', 'mode main', 'exec-and-forget sketchybar --set aerospace_mode drawing=off'] # close others + + alt-shift-h = ['join-with left', 'mode main', 'exec-and-forget sketchybar --set aerospace_mode drawing=off'] + alt-shift-j = ['join-with down', 'mode main', 'exec-and-forget sketchybar --set aerospace_mode drawing=off'] + alt-shift-k = ['join-with up', 'mode main', 'exec-and-forget sketchybar --set aerospace_mode drawing=off'] + alt-shift-l = ['join-with right', 'mode main', 'exec-and-forget sketchybar --set aerospace_mode drawing=off'] diff --git a/claude/.claude/settings.json b/claude/.claude/settings.json index d00bfc7..d1137ad 100644 --- a/claude/.claude/settings.json +++ b/claude/.claude/settings.json @@ -4,6 +4,10 @@ "type": "command", "command": "bash ~/.claude/statusline.sh" }, + "enabledPlugins": { + "playwright@claude-plugins-official": true, + "skill-creator@claude-plugins-official": true + }, "effortLevel": "xhigh", "tui": "fullscreen", "theme": "auto", diff --git a/install b/install index 1d35be7..78f62c1 100755 --- a/install +++ b/install @@ -444,20 +444,37 @@ fi # ============================================================ # Post-stow seeding # ============================================================ -# --- sketchybar mic helper --- +# --- sketchybar compiled helpers --- # Compiled, not interpreted: `swift ` on a trivial script measured 1.25s, -# which is not pollable. The binary is written next to the stowed source, which +# which is not pollable. Binaries are written next to the stowed source, which # lands in $HOME rather than in the repo because stow runs --no-folding (helpers/ -# is a real directory containing one symlink). mic.sh rebuilds it on demand too, -# so a `git pull` that changes the source doesn't need a re-run of this script. -mic_src="${HOME}/.config/sketchybar/helpers/mic.swift" -if [[ -r $mic_src ]] && command -v swiftc &>/dev/null; then - if swiftc -O -o "${mic_src:h}/mic" "$mic_src" 2>/dev/null; then - ok "sketchybar mic helper built." +# is a real directory containing symlinks). Both callers (mic.sh, system.sh) +# rebuild on demand too, so a `git pull` that changes a source doesn't need a +# re-run of this script. +# +# THREE-WAY GUARD, DELIBERATELY. An earlier version tested +# `[[ -r $src ]] && command -v swiftc` in one condition, which meant a MISSING +# SOURCE was indistinguishable from a missing toolchain and both exited silently +# — no ok, no warn, nothing. thermal.swift was untracked in git for a while, and +# a fresh clone would have produced a bar with no temperature and no explanation. +# Distinguish the three outcomes so absence is loud. +build_sb_helper() { + local name=$1 what=$2 + local src="${HOME}/.config/sketchybar/helpers/${name}.swift" + + if [[ ! -r $src ]]; then + warn "sketchybar ${name} helper source missing at ${src}; ${what}" + elif ! command -v swiftc &>/dev/null; then + warn "swiftc not found, so the sketchybar ${name} helper was not built; ${what}" + elif swiftc -O -o "${src:h}/${name}" "$src" 2>/dev/null; then + ok "sketchybar ${name} helper built." else - warn "sketchybar mic helper failed to build; the mic item will stay hidden." + warn "sketchybar ${name} helper failed to build; ${what}" fi -fi +} + +build_sb_helper mic "the mic item will stay hidden." +build_sb_helper thermal "the temperature will be omitted from the system island." # --- btop theme --- # btop.conf is machine-local (btop rewrites it on every exit) so it isn't tracked. diff --git a/raycast/.config/raycast/scripts/reload-sketchybar.sh b/raycast/.config/raycast/scripts/reload-sketchybar.sh index bdb650f..9a14304 100755 --- a/raycast/.config/raycast/scripts/reload-sketchybar.sh +++ b/raycast/.config/raycast/scripts/reload-sketchybar.sh @@ -20,7 +20,7 @@ # the same repair sketchybarrc makes at the top of itself. # USER the client resolves the running bar's mach port through it and aborts # with "sketchybar-msg: 'env USER' not set! abort.." if it is missing. -export PATH="/opt/homebrew/bin:/opt/homebrew/sbin:/usr/bin:/bin:$PATH" +export PATH="/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/usr/local/sbin:/usr/bin:/bin:$PATH" export USER="${USER:-$(id -un)}" # --reload re-executes the config in place, so the bar keeps its PID and nothing diff --git a/scripts/lint-aerospace.sh b/scripts/lint-aerospace.sh new file mode 100755 index 0000000..7b43673 --- /dev/null +++ b/scripts/lint-aerospace.sh @@ -0,0 +1,108 @@ +#!/usr/bin/env bash +# Catches the AeroSpace failure classes that are INVISIBLE when they happen — +# the sibling of scripts/lint-sketchybar.sh, same philosophy. +# +# AeroSpace's own validator (`reload-config --dry-run --warnings-as-errors`) +# already covers syntax and unknown commands, so this script does NOT re-check +# those. What it adds is the two CROSS-FILE couplings the validator cannot see, +# because each half is individually valid — they are only wrong together: +# +# * gaps.outer.top must track SketchyBar's bar height. Get it wrong and windows +# tuck under the bar or leave a band of wallpaper; nothing errors. +# * the border colour AeroSpace settles on after its focus pulse must match the +# resting colour in bordersrc. Get it wrong and every focus change "settles" +# on the old colour; nothing errors. +# +# Plus the ~/.aerospace.toml ambiguity trap, and — when the AeroSpace app is +# actually running — the built-in validator as a bonus. +# +# Run by hand or from CI. Exits non-zero on any failure. The static checks need +# no binary and no running app, so they are CI-safe; the dry-run self-skips. + +set -uo pipefail +cd "$(dirname "$0")/.." || exit 1 + +TOML="aerospace/.config/aerospace/aerospace.toml" +RC="sketchybar/.config/sketchybar/sketchybarrc" +BORDERS="borders/.config/borders/bordersrc" +CLEARANCE=5 # gaps.outer.top - bar height, by design +fail=0 + +note() { printf ' %s\n' "$*"; } +bad() { printf 'FAIL %s\n' "$*"; fail=1; } + +# --------------------------------------------------------------- +# 0. The files must exist where we expect them. +for f in "$TOML" "$RC" "$BORDERS"; do + [ -f "$f" ] || { bad "missing file: $f"; } +done +[ "$fail" -eq 0 ] || { printf '\nFAIL — repo layout unexpected\n'; exit 1; } + +# --------------------------------------------------------------- +# 1. Top gap must equal the SketchyBar bar height + clearance. +# +# aerospace.toml `gaps.outer.top` and sketchybarrc `--bar height` are coupled; +# the comments in both files spell out the +5. The height= match is anchored so +# it does not pick up `background.height=`. +printf '\n== bar height <-> top gap coupling ==\n' +height="$(grep -oE '^[[:space:]]+height=[0-9]+' "$RC" | grep -oE '[0-9]+' | head -1)" +top="$(grep -E '^gaps\.outer\.top' "$TOML" | grep -oE '[0-9]+' | head -1)" +if [ -z "$height" ] || [ -z "$top" ]; then + bad "could not read height ('$height') or top gap ('$top')" +elif [ "$top" -eq "$((height + CLEARANCE))" ]; then + note "ok top=$top == height=$height + $CLEARANCE" +else + bad "top gap $top != bar height $height + $CLEARANCE (=$((height + CLEARANCE))) — windows will tuck under the bar or leave a band" +fi + +# --------------------------------------------------------------- +# 2. Border settle colour must match bordersrc's resting active colour. +# +# on-focus-changed pulses to lavender then back to a mauve; that second (settle) +# colour is the one that must equal bordersrc active_color. Take the LAST +# active_color on the pulse line as the settle value. +printf '\n== border settle colour <-> bordersrc ==\n' +settle="$(grep -E 'active_color=0x' "$TOML" | grep -oE 'active_color=0x[0-9a-fA-F]+' | tail -1 | cut -d= -f2)" +resting="$(grep -oE 'active_color=0x[0-9a-fA-F]+' "$BORDERS" | head -1 | cut -d= -f2)" +if [ -z "$settle" ] || [ -z "$resting" ]; then + bad "could not read settle ('$settle') or bordersrc active ('$resting')" +elif [ "$settle" = "$resting" ]; then + note "ok settle=$settle == bordersrc active=$resting" +else + bad "on-focus-changed settles on $settle but bordersrc rests at $resting — every focus change will settle on the wrong colour" +fi + +# --------------------------------------------------------------- +# 3. No stray ~/.aerospace.toml. +# +# AeroSpace reads EITHER ~/.aerospace.toml OR the XDG path; having both is a +# hard error and the app refuses to start. The repo config lives only at the +# XDG path, so the home-dir file must not exist. +printf '\n== no ambiguous ~/.aerospace.toml ==\n' +if [ -e "$HOME/.aerospace.toml" ]; then + bad "$HOME/.aerospace.toml exists — AeroSpace will error on the ambiguity with the XDG config" +else + note "ok none present" +fi + +# --------------------------------------------------------------- +# 4. AeroSpace's own validator, when the app is reachable. +# +# reload-config is a client->server command: it validates the INSTALLED (stowed) +# config and needs the app running. Skip cleanly when the binary is absent (CI) +# or the server is down, so this stays a bonus rather than a false failure. +printf '\n== aerospace validator ==\n' +if ! command -v aerospace >/dev/null 2>&1; then + note "skip aerospace not installed" +elif ! aerospace list-workspaces --all >/dev/null 2>&1; then + note "skip AeroSpace app not running (validator needs the server)" +elif aerospace reload-config --dry-run --warnings-as-errors >/dev/null 2>&1; then + note "ok reload-config --dry-run --warnings-as-errors" +else + bad "aerospace reload-config --dry-run --warnings-as-errors reported problems" +fi + +printf '\n' +if [ "$fail" -eq 0 ]; then printf 'PASS — no silent-failure patterns found\n' +else printf 'FAIL — see above\n'; fi +exit "$fail" diff --git a/scripts/lint-sketchybar.sh b/scripts/lint-sketchybar.sh new file mode 100755 index 0000000..312ba0a --- /dev/null +++ b/scripts/lint-sketchybar.sh @@ -0,0 +1,124 @@ +#!/usr/bin/env bash +# Catches the SketchyBar failure classes that are INVISIBLE when they happen. +# +# Every check here exists because the failure it detects looks exactly like +# "working, with nothing to report". That is the property that makes them +# expensive: the bar comes up, the item is simply absent or stale, and nothing +# anywhere says why. +# +# Run by hand, or from CI. Exits non-zero on any failure. +# +# NOT a general shell linter — it defers to shellcheck for that, and runs it if +# present. These are the checks shellcheck cannot make because they are about +# SketchyBar's semantics, not the shell's. + +set -uo pipefail +cd "$(dirname "$0")/.." || exit 1 + +PLUGINS="sketchybar/.config/sketchybar/plugins" +RC="sketchybar/.config/sketchybar/sketchybarrc" +fail=0 + +note() { printf ' %s\n' "$*"; } +bad() { printf 'FAIL %s\n' "$*"; fail=1; } + +# --------------------------------------------------------------- +# 1. Every plugin must be executable. +# +# SketchyBar fork_execs its plugins. When the bit is missing it reports NOTHING +# AT ALL — the item just never updates, which is indistinguishable from a script +# that runs and decides to do nothing. Editors and `git apply` both create files +# without it. +printf '\n== executable bits ==\n' +for f in "$PLUGINS"/*.sh; do + if [ -x "$f" ]; then note "ok $(basename "$f")" + else bad "$(basename "$f") is not executable — sketchybar will never run it"; fi +done + +# --------------------------------------------------------------- +# 2. Any item whose plugin can hide it must declare updates=on. +# +# Under the config-wide `updates=when_shown` default a hidden item stops being +# updated ENTIRELY: bar_item_update() gates timed and event runs behind +# `updates_only_when_shown ? is_shown : true`, and bar_draw() clears the bar +# association of anything it does not draw. So the item works right after a +# reload, hides itself when there is nothing to show, and never runs again. +# Eight items were found in that state at once. +printf '\n== self-hiding items declare updates=on ==\n' + +# ITEM-level drawing=off only. `label.drawing=off`, `background.drawing=off` and +# `popup.drawing=off` hide a COMPONENT and leave the item drawn and updating — +# amphetamine and pomodoro both dim their label this way and are correctly on the +# when_shown default. Requiring a preceding space excludes the dotted forms. +# Calling hide() or require() counts too: both end in an item-level drawing=off +# inside lib.sh. +can_hide() { + grep -qE '(^|[[:space:]])drawing=off' "$1" && return 0 + grep -qE '^[[:space:]]*(hide|require)([[:space:]]|$)|\|\|[[:space:]]*hide|&&[[:space:]]*hide' "$1" +} + +# Which items a plugin writes. Usually itself, but system.sh writes three. +items_for() { + case "$(basename "$1" .sh)" in + system) printf 'cpu memory thermals' ;; + workspaces) printf '' ;; # drives space.N; none of them ever hide + *) basename "$1" .sh ;; + esac +} + +for f in "$PLUGINS"/*.sh; do + can_hide "$f" || continue + for item in $(items_for "$f"); do + block="$(awk -v it="$item" ' + $0 ~ ("--add item[[:space:]]+" it "[[:space:]]") {found=1} + found {print} + found && /script=/ {exit}' "$RC")" + # PASSIVE ITEMS ARE NOT AT RISK. cpu and memory carry no script of their + # own — they are written by thermals' script, which has updates=on and + # therefore un-hides them on its next run. Only an item that must run its + # OWN script to recover can be trapped by when_shown. + if [ -z "$block" ]; then + bad "item '$item' hides itself but has no --add item block in sketchybarrc" + elif ! printf '%s' "$block" | grep -q 'script='; then + note "skip $item (passive — driven by another item's script)" + elif printf '%s' "$block" | grep -q 'updates=on'; then + note "ok $item" + else + bad "item '$item' can set drawing=off but does not declare updates=on" + fi + done +done + +# --------------------------------------------------------------- +# 3. Cross-references must resolve. +# +# The comments in this repo carry the measurements, so a comment that points at +# a section which no longer exists is a defect, not untidiness. Three were live +# at once: a deleted WIDTH BUDGET section, a privacy note that was never written, +# and a claim that an installed app was not installed. +printf '\n== cross-references resolve ==\n' +while IFS= read -r ref; do + [ -z "$ref" ] && continue + target="${ref#see the }"; target="${target% *}" + if grep -qF "$target" "$RC" "$PLUGINS"/*.sh 2>/dev/null; then + note "ok -> $target" + else + bad "dangling reference: '$ref' has no target" + fi +done < <(grep -ohE 'see the [A-Z][A-Z ]{3,}' "$RC" "$PLUGINS"/*.sh 2>/dev/null | sort -u) + +# --------------------------------------------------------------- +# 4. shellcheck, if available. +printf '\n== shellcheck ==\n' +if command -v shellcheck >/dev/null 2>&1; then + shellcheck -S warning -e SC1091 "$PLUGINS"/*.sh \ + sketchybar/.config/sketchybar/lib.sh || fail=1 + note "shellcheck done" +else + note "skip shellcheck not installed (brew install shellcheck)" +fi + +printf '\n' +if [ "$fail" -eq 0 ]; then printf 'PASS — no silent-failure patterns found\n' +else printf 'FAIL — see above\n'; fi +exit "$fail" diff --git a/sketchybar/.config/sketchybar/colors.sh b/sketchybar/.config/sketchybar/colors.sh index 593a409..29bd20e 100755 --- a/sketchybar/.config/sketchybar/colors.sh +++ b/sketchybar/.config/sketchybar/colors.sh @@ -23,16 +23,5 @@ export LAVENDER=0xffbabbf1 export TRANSPARENT=0x00000000 -# Island backgrounds. The bar itself is fully transparent (color=0x0 in -# sketchybarrc) — these pills are the only thing drawn, so their alpha byte is -# the single knob for how see-through the whole bar looks. -# 0xe6 90% 0xcc 80% 0xb3 70% (current) 0x99 60% 0x80 50% -# Below about 60% the Catppuccin text starts losing contrast on a busy wallpaper. -# There is no blur to fall back on: blur_radius is a BAR-level property and there -# is no background.blur_radius, so enabling it would frost the entire bar -# rectangle including the gaps between islands — a blurred band across the whole -# screen top, which is exactly the effect the islands exist to avoid. export ISLAND=0xb3232634 -# Kept fully opaque on purpose: as the fill gets more transparent the border is -# what keeps the pill's edge crisp and its shape readable against the desktop. export ISLAND_BORDER=0xff414559 diff --git a/sketchybar/.config/sketchybar/helpers/mic.swift b/sketchybar/.config/sketchybar/helpers/mic.swift index 3b7a01f..e3b6620 100644 --- a/sketchybar/.config/sketchybar/helpers/mic.swift +++ b/sketchybar/.config/sketchybar/helpers/mic.swift @@ -2,20 +2,6 @@ // // Prints "on" or "off". Used by plugins/mic.sh. // -// WHY THIS AND NOT ioreg: every shell recipe for this reads -// AppleHDAEngineInput or AppleUSBAudioEngine out of the IORegistry. Both were -// measured returning nothing at all on this machine (Apple Silicon, macOS 26) — -// the properties those recipes match on are gone. -// -// WHY IT NEEDS NO PERMISSION: this only enumerates devices and reads a property. -// It never opens an input stream, so it never touches TCC and never raises a -// microphone prompt. kAudioDevicePropertyDeviceIsRunningSomewhere is true when -// ANY process on the system is running that device — which is exactly the -// question, and is why this reports other apps' recording, not our own. -// -// WHY IT IS COMPILED: `/usr/bin/swift` interpreting even a trivial script -// measured 1.25s, which is not pollable. Compiled it runs in single-digit ms. -// mic.sh builds it on demand; the install script builds it up front. import CoreAudio import Foundation @@ -34,9 +20,6 @@ func devices() -> [AudioObjectID] { return ids } -// A device is an input if it has at least one channel on the input scope. -// Output-only devices report a zero-length stream configuration there, and every -// speaker on the system would otherwise count as a microphone. func hasInput(_ id: AudioObjectID) -> Bool { var addr = AudioObjectPropertyAddress( mSelector: kAudioDevicePropertyStreamConfiguration, diff --git a/sketchybar/.config/sketchybar/helpers/thermal.swift b/sketchybar/.config/sketchybar/helpers/thermal.swift new file mode 100644 index 0000000..e546132 --- /dev/null +++ b/sketchybar/.config/sketchybar/helpers/thermal.swift @@ -0,0 +1,62 @@ +// Average SoC die temperature, in degrees Celsius. +// +// Prints one number to one decimal, or nothing at all with a non-zero exit when +// no usable sensor exists. Used by plugins/system.sh. + +import Foundation +import IOKit + +private typealias CreateFn = @convention(c) (CFAllocator?) -> AnyObject? +private typealias MatchFn = @convention(c) (AnyObject?, CFDictionary?) -> Void +private typealias ServicesFn = @convention(c) (AnyObject?) -> CFArray? +private typealias EventFn = @convention(c) (AnyObject?, Int64, Int32, Int64) -> AnyObject? +private typealias FloatFn = @convention(c) (AnyObject?, Int64) -> Double +private typealias PropFn = @convention(c) (AnyObject?, CFString) -> AnyObject? + +private let kTemperature: Int64 = 15 + +private func sensors() -> [(name: String, value: Double)] { + guard let iokit = dlopen("/System/Library/Frameworks/IOKit.framework/IOKit", RTLD_NOW) else { + return [] + } + func sym(_ name: String) -> T? { + guard let p = dlsym(iokit, name) else { return nil } + return unsafeBitCast(p, to: T.self) + } + guard let create: CreateFn = sym("IOHIDEventSystemClientCreate"), + let setMatching: MatchFn = sym("IOHIDEventSystemClientSetMatching"), + let copyServices: ServicesFn = sym("IOHIDEventSystemClientCopyServices"), + let copyEvent: EventFn = sym("IOHIDServiceClientCopyEvent"), + let floatValue: FloatFn = sym("IOHIDEventGetFloatValue"), + let copyProperty: PropFn = sym("IOHIDServiceClientCopyProperty") + else { return [] } + + let client = create(kCFAllocatorDefault) + setMatching(client, ["PrimaryUsagePage": 0xff00, "PrimaryUsage": 5] as CFDictionary) + guard let services = copyServices(client) as? [AnyObject] else { return [] } + + return services.compactMap { service in + guard let name = copyProperty(service, "Product" as CFString) as? String, + let event = copyEvent(service, kTemperature, 0, 0) + else { return nil } + return (name, floatValue(event, kTemperature << 16)) + } +} + +let all = sensors() + +let rules: [(String, (String) -> Bool)] = [ + ("PMU tdie", { $0.hasPrefix("PMU tdie") }), + ("cluster MTR", { $0.contains("ACC MTR Temp") }), + ("SOC MTR", { $0.hasPrefix("SOC MTR") }), +] + +for (_, matches) in rules { + let values = all.filter { matches($0.name) }.map(\.value).filter { $0 > 0 } + if !values.isEmpty { + print(String(format: "%.1f", values.reduce(0, +) / Double(values.count))) + exit(0) + } +} + +exit(1) diff --git a/sketchybar/.config/sketchybar/lib.sh b/sketchybar/.config/sketchybar/lib.sh new file mode 100644 index 0000000..c1361ec --- /dev/null +++ b/sketchybar/.config/sketchybar/lib.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +# Shared plugin helpers. + +# The "$@" pass-through is deliberate — it lets a plugin reset one more property +# while hiding. No caller needs it today, which is exactly what SC2120 warns +# about, so the check is a false positive on an API kept open on purpose. +# shellcheck disable=SC2120 +hide() { + sketchybar --set "$NAME" drawing=off popup.drawing=off "$@" + exit 0 +} + +truncate_label() { + local s=$1 n=$2 + if [ "${#s}" -gt "$n" ]; then + printf '%s…' "${s:0:n}" + else + printf '%s' "$s" + fi +} + +state_file() { + printf '%s/sketchybar-%s' "${TMPDIR:-/tmp}" "$1" +} + +ensure_helper() { + local name=$1 + local dir="$HOME/.config/sketchybar/helpers" + local src="$dir/$name.swift" bin="$dir/$name" + + if [ ! -x "$bin" ] || [ "$src" -nt "$bin" ]; then + [ -r "$src" ] || return 1 + command -v swiftc >/dev/null 2>&1 || return 1 + if swiftc -O -o "$bin.new" "$src" >/dev/null 2>&1; then + mv "$bin.new" "$bin" + else + rm -f "$bin.new" + return 1 + fi + fi + printf '%s' "$bin" +} + +require() { + command -v "$1" >/dev/null 2>&1 && return 0 + printf 'sketchybar/%s: missing dependency: %s\n' "${NAME:-?}" "$1" >&2 + hide +} diff --git a/sketchybar/.config/sketchybar/plugins/aerospace.sh b/sketchybar/.config/sketchybar/plugins/aerospace.sh deleted file mode 100755 index b9ac10b..0000000 --- a/sketchybar/.config/sketchybar/plugins/aerospace.sh +++ /dev/null @@ -1,29 +0,0 @@ -#!/usr/bin/env bash -# Workspace indicator. One item per workspace; $1 is the workspace this item -# represents. - -source "$HOME/.config/sketchybar/colors.sh" - -sid="$1" - -focused="$FOCUSED_WORKSPACE" -if [ -z "$focused" ]; then - focused="$(aerospace list-workspaces --focused 2>/dev/null)" -fi - -if [ "$sid" = "$focused" ]; then - sketchybar --set "$NAME" \ - background.drawing=on \ - label.color="$CRUST" \ - icon.color="$CRUST" -elif aerospace list-workspaces --monitor all --empty no 2>/dev/null | grep -qx "$sid"; then - sketchybar --set "$NAME" \ - background.drawing=off \ - label.color="$TEXT" \ - icon.color="$TEXT" -else - sketchybar --set "$NAME" \ - background.drawing=off \ - label.color="$SURFACE2" \ - icon.color="$SURFACE2" -fi diff --git a/sketchybar/.config/sketchybar/plugins/amphetamine.sh b/sketchybar/.config/sketchybar/plugins/amphetamine.sh index 8cac5a2..5367317 100755 --- a/sketchybar/.config/sketchybar/plugins/amphetamine.sh +++ b/sketchybar/.config/sketchybar/plugins/amphetamine.sh @@ -3,6 +3,7 @@ # long. Click toggles an indefinite session on/off. source "$HOME/.config/sketchybar/colors.sh" +source "$HOME/.config/sketchybar/lib.sh" draw_off() { sketchybar --set "$NAME" icon.color="$OVERLAY0" label="" label.drawing=off \ @@ -14,15 +15,19 @@ if ! pgrep -x Amphetamine >/dev/null 2>&1; then exit 0 fi -state="$(osascript \ - -e 'tell application "Amphetamine"' \ - -e 'set a to session is active' \ - -e 'set t to session time remaining' \ - -e 'end tell' \ - -e 'return (a as text) & "," & (t as text)' 2>/dev/null)" +read_session() { + local state + state="$(osascript \ + -e 'tell application "Amphetamine"' \ + -e 'set a to session is active' \ + -e 'set t to session time remaining' \ + -e 'end tell' \ + -e 'return (a as text) & "," & (t as text)' 2>/dev/null)" + active="${state%%,*}" + remaining="${state##*,}" +} -active="${state%%,*}" -remaining="${state##*,}" +read_session if [ "$SENDER" = "mouse.clicked" ]; then if [ "$active" = "true" ]; then @@ -31,14 +36,7 @@ if [ "$SENDER" = "mouse.clicked" ]; then osascript -e 'tell application "Amphetamine" to start new session with options {duration:0, interval:0, displaySleepAllowed:false}' >/dev/null 2>&1 fi sleep 0.4 - state="$(osascript \ - -e 'tell application "Amphetamine"' \ - -e 'set a to session is active' \ - -e 'set t to session time remaining' \ - -e 'end tell' \ - -e 'return (a as text) & "," & (t as text)' 2>/dev/null)" - active="${state%%,*}" - remaining="${state##*,}" + read_session fi if [ "$active" != "true" ]; then diff --git a/sketchybar/.config/sketchybar/plugins/battery.sh b/sketchybar/.config/sketchybar/plugins/battery.sh index 8d84c90..80b468e 100755 --- a/sketchybar/.config/sketchybar/plugins/battery.sh +++ b/sketchybar/.config/sketchybar/plugins/battery.sh @@ -3,27 +3,28 @@ # power_source_change. source "$HOME/.config/sketchybar/colors.sh" +source "$HOME/.config/sketchybar/lib.sh" batt="$(pmset -g batt)" -pct="$(printf '%s' "$batt" | grep -Eo '[0-9]+%' | head -1 | tr -d '%')" -charging="$(printf '%s' "$batt" | grep -c "AC Power")" + +case "$batt" in + *%*) + pct="${batt%%\%*}" # drop from the '%' onward + pct="${pct##*[!0-9]}" # keep the trailing digit run + ;; + *) pct="" ;; # no battery — the guard below hides the item +esac + +case "$batt" in + *"; charging"*) charging=1 ;; + *) charging=0 ;; +esac if [ -z "$pct" ]; then sketchybar --set "$NAME" drawing=off exit 0 fi -# THE ONE PLACE THIS BAR LEAVES MATERIAL DESIGN. Every other icon is md-*, but -# all 95 md-battery_* glyphs are VERTICAL — the horizontal series (battery_horiz) -# postdates this Nerd Font build and is not present. Font Awesome's battery set -# is horizontal and happens to have exactly the five steps this script already -# used, so it maps 1:1. Checked by scanning every glyph name in the font, not -# assumed. -# -# CHARGING is carried by colour alone, because Font Awesome has no -# battery-with-bolt glyph. Using the md charging icon here would flip the shape -# between vertical and horizontal as the cable goes in and out, which is worse -# than losing the bolt. The level still shows while charging. if [ "$pct" -ge 80 ]; then icon=""; color="$TEXT" # fa-battery_full elif [ "$pct" -ge 60 ]; then icon=""; color="$TEXT" # fa-battery_three_quarters elif [ "$pct" -ge 40 ]; then icon=""; color="$YELLOW" # fa-battery_half diff --git a/sketchybar/.config/sketchybar/plugins/bluetooth.sh b/sketchybar/.config/sketchybar/plugins/bluetooth.sh index a34735f..e4cf1c9 100755 --- a/sketchybar/.config/sketchybar/plugins/bluetooth.sh +++ b/sketchybar/.config/sketchybar/plugins/bluetooth.sh @@ -2,6 +2,7 @@ # Bluetooth: power state, and what's actually connected. source "$HOME/.config/sketchybar/colors.sh" +source "$HOME/.config/sketchybar/lib.sh" MAX_LEN=8 @@ -34,9 +35,7 @@ case "$state" in case "$label" in *' devices') ;; *) - if [ "${#label}" -gt "$MAX_LEN" ]; then - label="$(printf '%s' "$label" | cut -c1-"$MAX_LEN")…" - fi + label="$(truncate_label "$label" "$MAX_LEN")" ;; esac sketchybar --set "$NAME" drawing=on icon="󰂱" icon.color="$BLUE" \ diff --git a/sketchybar/.config/sketchybar/plugins/brightness.sh b/sketchybar/.config/sketchybar/plugins/brightness.sh index f6c395e..cb10c68 100755 --- a/sketchybar/.config/sketchybar/plugins/brightness.sh +++ b/sketchybar/.config/sketchybar/plugins/brightness.sh @@ -1,7 +1,8 @@ #!/usr/bin/env bash -# Display brightness for the BUILT-IN screen. +# Display brightness, for whichever display is MAIN. source "$HOME/.config/sketchybar/colors.sh" +source "$HOME/.config/sketchybar/lib.sh" if [ "$SENDER" = "brightness_change" ] && [ -n "$INFO" ]; then pct="$INFO" @@ -9,16 +10,43 @@ else pct="$(python3 -c ' import ctypes, sys -ds = ctypes.CDLL("/System/Library/PrivateFrameworks/DisplayServices.framework/DisplayServices") -ds.DisplayServicesGetBrightness.argtypes = [ctypes.c_uint32, ctypes.POINTER(ctypes.c_float)] -ds.DisplayServicesGetBrightness.restype = ctypes.c_int +cg = ctypes.CDLL("/System/Library/Frameworks/CoreGraphics.framework/CoreGraphics") +cg.CGMainDisplayID.restype = ctypes.c_uint32 +cg.CGDisplayIsBuiltin.argtypes = [ctypes.c_uint32] +cg.CGDisplayIsBuiltin.restype = ctypes.c_uint32 -for d in range(1, 17): +did = cg.CGMainDisplayID() + +if cg.CGDisplayIsBuiltin(did): + ds = ctypes.CDLL("/System/Library/PrivateFrameworks/DisplayServices.framework/DisplayServices") + ds.DisplayServicesGetBrightness.argtypes = [ctypes.c_uint32, ctypes.POINTER(ctypes.c_float)] + ds.DisplayServicesGetBrightness.restype = ctypes.c_int b = ctypes.c_float() - if ds.DisplayServicesGetBrightness(ctypes.c_uint32(d), ctypes.byref(b)) == 0: - print(round(b.value * 100)) - sys.exit() -sys.exit(1) + if ds.DisplayServicesGetBrightness(ctypes.c_uint32(did), ctypes.byref(b)) != 0: + sys.exit(1) + print(round(b.value * 100)) + sys.exit() + +cg.CGDisplayGammaTableCapacity.argtypes = [ctypes.c_uint32] +cg.CGDisplayGammaTableCapacity.restype = ctypes.c_uint32 +cg.CGGetDisplayTransferByTable.argtypes = [ + ctypes.c_uint32, ctypes.c_uint32, + ctypes.POINTER(ctypes.c_float), ctypes.POINTER(ctypes.c_float), + ctypes.POINTER(ctypes.c_float), ctypes.POINTER(ctypes.c_uint32), +] +cg.CGGetDisplayTransferByTable.restype = ctypes.c_int + +cap = cg.CGDisplayGammaTableCapacity(did) +if cap == 0: + sys.exit(1) + +Table = ctypes.c_float * cap +r, g, b = Table(), Table(), Table() +n = ctypes.c_uint32() +if cg.CGGetDisplayTransferByTable(did, cap, r, g, b, ctypes.byref(n)) != 0 or n.value == 0: + sys.exit(1) + +print(round(r[n.value - 1] * 100)) ' 2>/dev/null)" fi diff --git a/sketchybar/.config/sketchybar/plugins/calendar.sh b/sketchybar/.config/sketchybar/plugins/calendar.sh index 32fd085..5ef4b8c 100755 --- a/sketchybar/.config/sketchybar/plugins/calendar.sh +++ b/sketchybar/.config/sketchybar/plugins/calendar.sh @@ -2,21 +2,18 @@ # Next calendar event today. Hidden when there is nothing left in the day. source "$HOME/.config/sketchybar/colors.sh" +source "$HOME/.config/sketchybar/lib.sh" + +require icalBuddy MAX_LEN=42 -hide() { - sketchybar --set "$NAME" drawing=off popup.drawing=off - exit 0 -} case "$SENDER" in mouse.entered) sketchybar --set "$NAME" popup.drawing=on; exit 0 ;; mouse.exited) sketchybar --set "$NAME" popup.drawing=off; exit 0 ;; esac -command -v icalBuddy >/dev/null 2>&1 || hide - raw="$(icalBuddy -n -nc -nrd -ea -b "" -df "" -tf "%H:%M" -li 1 \ -iep "datetime,title" -ps "|@|" eventsToday 2>/dev/null)" @@ -32,9 +29,7 @@ title="$(printf '%s' "$raw" \ [ -n "$title" ] || title="(untitled)" -if [ "${#title}" -gt "$MAX_LEN" ]; then - title="$(printf '%s' "$title" | cut -c1-"$MAX_LEN")…" -fi +title="$(truncate_label "$title" "$MAX_LEN")" sketchybar --set "$NAME" drawing=on \ icon="󰃭" icon.color="$PEACH" \ diff --git a/sketchybar/.config/sketchybar/plugins/clock.sh b/sketchybar/.config/sketchybar/plugins/clock.sh index 4767086..a709e11 100755 --- a/sketchybar/.config/sketchybar/plugins/clock.sh +++ b/sketchybar/.config/sketchybar/plugins/clock.sh @@ -1,4 +1,4 @@ #!/usr/bin/env bash # 24-hour clock with the date. Updated on a timer from sketchybarrc. -sketchybar --set "$NAME" label="$(date '+%a %d %b %H:%M')" +sketchybar --set "$NAME" label="$(date '+%a %d %b %H:%M:%S')" diff --git a/sketchybar/.config/sketchybar/plugins/github.sh b/sketchybar/.config/sketchybar/plugins/github.sh index bc4c9fc..7eb719d 100755 --- a/sketchybar/.config/sketchybar/plugins/github.sh +++ b/sketchybar/.config/sketchybar/plugins/github.sh @@ -2,8 +2,11 @@ # Unread GitHub notification count. Hidden at zero, which is most of the time. source "$HOME/.config/sketchybar/colors.sh" +source "$HOME/.config/sketchybar/lib.sh" -CACHE="${TMPDIR:-/tmp}/sketchybar-github" +require gh + +CACHE="$(state_file github)" count="$(gh api notifications --jq 'length' 2>/dev/null)" diff --git a/sketchybar/.config/sketchybar/plugins/mic.sh b/sketchybar/.config/sketchybar/plugins/mic.sh index ede9583..143e745 100755 --- a/sketchybar/.config/sketchybar/plugins/mic.sh +++ b/sketchybar/.config/sketchybar/plugins/mic.sh @@ -3,26 +3,11 @@ # bar. Hidden unless something is actually recording. source "$HOME/.config/sketchybar/colors.sh" +source "$HOME/.config/sketchybar/lib.sh" -HELPER_DIR="$HOME/.config/sketchybar/helpers" -SRC="$HELPER_DIR/mic.swift" -BIN="$HELPER_DIR/mic" +bin="$(ensure_helper mic)" || hide -hide() { - sketchybar --set "$NAME" drawing=off - exit 0 -} - -if [ ! -x "$BIN" ] || [ "$SRC" -nt "$BIN" ]; then - [ -r "$SRC" ] || hide - command -v swiftc >/dev/null 2>&1 || hide - swiftc -O -o "$BIN.new" "$SRC" >/dev/null 2>&1 && mv "$BIN.new" "$BIN" || { - rm -f "$BIN.new" - hide - } -fi - -[ "$("$BIN" 2>/dev/null)" = "on" ] || hide +[ "$("$bin" 2>/dev/null)" = "on" ] || hide sketchybar --set "$NAME" drawing=on \ icon="󰍬" icon.color="$RED" \ diff --git a/sketchybar/.config/sketchybar/plugins/music.sh b/sketchybar/.config/sketchybar/plugins/music.sh index baa171b..03ef02b 100755 --- a/sketchybar/.config/sketchybar/plugins/music.sh +++ b/sketchybar/.config/sketchybar/plugins/music.sh @@ -1,14 +1,37 @@ #!/usr/bin/env bash -# Apple Music now-playing. Click toggles play/pause. +# Now-playing for ANY player, via macOS's system-wide Now Playing state — the +# same source Control Center shows. Left-click toggles play/pause, right-click +# opens the transport popup. +# +# WHY NOT AppleScript. This used to be Apple-Music-only (`pgrep -x Music`), and +# per-app AppleScript branching was the obvious way to widen it. It cannot work +# here: IINA — the actual music player on this machine — ships no .sdef, leaves +# NSAppleScriptEnabled unset, and its iina-cli is open-only with no way to query +# state. It does link MediaPlayer.framework, so it publishes to Now Playing and +# is visible to MediaRemote and to nothing else. +# +# PRIVATE API, KNOWINGLY. media-control reads MediaRemote, which Apple gated +# behind an entitlement in macOS 15.4 — that is what broke nowplaying-cli. +# Measured on 26.6: dlopen succeeds and MRMediaRemoteGetNowPlayingInfo's callback +# fires from an ad-hoc-signed process, but hands back a NULL dictionary while +# IINA was demonstrably publishing. media-control gets real data from the same +# machine because bin/media-control is `#!/usr/bin/perl` and execs its adapter +# under that interpreter, inheriting APPLE's signature on /usr/bin/perl. +# +# So this depends on a hole Apple has closed once already. If it closes again the +# `null` branch below hides the pill cleanly — no error, no stale label. To +# confirm the tool itself rather than guess: `media-control test` exits non-zero +# when it cannot operate on the running macOS. +# +# --no-artwork is NOT an optimisation of runtime — with and without measured 23 +# and 24ms CPU, inside noise. It is about payload: 414 bytes versus 49,579, since +# the default embeds the cover as base64 JPEG on every single poll. Nothing here +# renders artwork. source "$HOME/.config/sketchybar/colors.sh" +source "$HOME/.config/sketchybar/lib.sh" -hide() { - sketchybar --set "$NAME" drawing=off popup.drawing=off - exit 0 -} - -pgrep -x Music >/dev/null 2>&1 || hide +require media-control if [ "$SENDER" = "mouse.clicked" ]; then case "$BUTTON" in @@ -17,37 +40,34 @@ if [ "$SENDER" = "mouse.clicked" ]; then exit 0 ;; *) - osascript -e 'tell application "Music" to playpause' >/dev/null 2>&1 + media-control toggle-play-pause >/dev/null 2>&1 sleep 0.3 ;; esac fi -IFS=$'\t' read -r state track artist </dev/null) +# `null` is the documented response when nothing holds a Now Playing session — +# not an error, and the normal state most of the day. Empty output means the +# adapter failed outright; both hide. +info="$(media-control get --no-artwork 2>/dev/null)" +[ -n "$info" ] && [ "$info" != "null" ] || hide + +IFS=$'\t' read -r playing track artist </dev/null) EOF -case "$state" in - stopped|'') hide ;; -esac -[ -n "$track" ] || exit 0 +# A session can exist with no title (a stream still resolving, an app that +# registered before it had metadata). Nothing worth drawing, so hide rather than +# leave the previous track's label sitting there. +[ -n "$track" ] || hide -case "$state" in - playing|"fast forwarding"|rewinding) icon="󰎇"; color="$GREEN" ;; - paused) icon="󰏤"; color="$OVERLAY0" ;; - *) exit 0 ;; -esac +# `playing` is a mandatory key in the adapter's payload — it is never null — so +# this is a real two-state test, not a fallthrough. +if [ "$playing" = "true" ]; then + icon="󰎇"; color="$GREEN" +else + icon="󰏤"; color="$OVERLAY0" +fi if [ -n "$artist" ]; then label="$track — $artist" diff --git a/sketchybar/.config/sketchybar/plugins/pomodoro.sh b/sketchybar/.config/sketchybar/plugins/pomodoro.sh index 2c2343d..0a8b0a7 100755 --- a/sketchybar/.config/sketchybar/plugins/pomodoro.sh +++ b/sketchybar/.config/sketchybar/plugins/pomodoro.sh @@ -2,16 +2,13 @@ # Pomodoro timer. Click cycles: idle → 25min work → 5min break → idle. source "$HOME/.config/sketchybar/colors.sh" +source "$HOME/.config/sketchybar/lib.sh" -STATE="${TMPDIR:-/tmp}/sketchybar-pomodoro" +STATE="$(state_file pomodoro)" WORK_MIN=25 BREAK_MIN=5 -# md-timer, not md-alarm — an alarm bell reads as "alarm clock" and competes with -# the actual clock two pills to its right. Held in a variable because the idle and -# running branches below both draw it, and they drifted apart the moment they -# didn't share one. ICON="󱎫" now=$(date +%s) diff --git a/sketchybar/.config/sketchybar/plugins/system.sh b/sketchybar/.config/sketchybar/plugins/system.sh index 1ef3202..9bc9bc7 100755 --- a/sketchybar/.config/sketchybar/plugins/system.sh +++ b/sketchybar/.config/sketchybar/plugins/system.sh @@ -1,10 +1,12 @@ #!/usr/bin/env bash -# CPU load, memory, temperature and fan speed — every reading macmon gives us, -# from ONE sample. +# CPU load, memory, temperature and fan speed. source "$HOME/.config/sketchybar/colors.sh" +source "$HOME/.config/sketchybar/lib.sh" -IFS=$'\t' read -r temp rpm cpu_pct ram_used ram_pct </dev/null | python3 -c ' import json, sys @@ -14,34 +16,42 @@ except Exception: print(0, -1, 0, 0, 0, sep=chr(9)) sys.exit() -temp = d.get("temp", {}).get("cpu_temp_avg") or 0 fans = d.get("fans", []) or [] -# Fans are per-side and rarely equal; the louder one is the one you can hear. rpm = max((f.get("rpm", 0) for f in fans), default=0) if fans else -1 - -# cpu_usage_pct is a RATIO despite the name — measured 0.0558 for 5.6% load. cpu = round((d.get("cpu_usage_pct") or 0) * 100) mem = d.get("memory", {}) or {} total = mem.get("ram_total") or 0 used = mem.get("ram_usage") or 0 -# GiB to one decimal; macmon reports bytes (25769803776 = 24 GiB). gib = used / (1024 ** 3) pct = round(used * 100 / total) if total else 0 -print(round(temp), round(rpm), cpu, "%.1f" % gib, pct, sep=chr(9)) +print(1, round(rpm), cpu, "%.1f" % gib, pct, sep=chr(9)) ') EOF -if [ -z "$temp" ] || [ "$temp" -eq 0 ]; then +if [ "$ok" != "1" ]; then sketchybar --set thermals drawing=off --set cpu drawing=off --set memory drawing=off exit 0 fi -if [ "$temp" -ge 85 ]; then temp_color="$RED" -elif [ "$temp" -ge 70 ]; then temp_color="$PEACH" -elif [ "$temp" -ge 55 ]; then temp_color="$YELLOW" -else temp_color="$GREEN" +temp="" +if bin="$(ensure_helper thermal)"; then + temp="$("$bin" 2>/dev/null)" + case "$temp" in + ''|*[!0-9.]*) temp="" ;; + esac +fi + +if [ -n "$temp" ]; then + temp="$(printf '%.0f' "$temp")" + if [ "$temp" -ge 85 ]; then temp_color="$RED" + elif [ "$temp" -ge 70 ]; then temp_color="$PEACH" + elif [ "$temp" -ge 55 ]; then temp_color="$YELLOW" + else temp_color="$GREEN" + fi +else + temp_color="$OVERLAY0" fi if [ "$cpu_pct" -ge 80 ]; then cpu_color="$RED" @@ -55,16 +65,17 @@ elif [ "$ram_pct" -ge 75 ]; then ram_color="$PEACH" else ram_color="$TEXT" fi -if [ -n "$rpm" ] && [ "$rpm" -ge 0 ]; then - thermal_label="${temp}° 󰈐 ${rpm}" -else +if [ -n "$temp" ] && [ -n "$rpm" ] && [ "$rpm" -ge 0 ]; then + thermal_label="${temp}° 󰈐 ${rpm}" +elif [ -n "$temp" ]; then thermal_label="${temp}°" +elif [ -n "$rpm" ] && [ "$rpm" -ge 0 ]; then + thermal_label="󰈐 ${rpm}" +else + thermal_label="—" fi sketchybar \ - `# md-speedometer, not md-cpu_64_bit: cpu sits directly beside memory in` \ - `# island.system, and md-cpu_64_bit is a detailed chip that reads as almost` \ - `# the same picture as md-memory at 13pt.` \ --set cpu drawing=on icon="󰓅" icon.color="$cpu_color" \ label.color="$TEXT" label="${cpu_pct}%" \ --set memory drawing=on icon="󰍛" icon.color="$ram_color" \ diff --git a/sketchybar/.config/sketchybar/plugins/volume.sh b/sketchybar/.config/sketchybar/plugins/volume.sh index 5600c87..6059e60 100755 --- a/sketchybar/.config/sketchybar/plugins/volume.sh +++ b/sketchybar/.config/sketchybar/plugins/volume.sh @@ -3,28 +3,29 @@ # percentage), so there is no polling. source "$HOME/.config/sketchybar/colors.sh" +source "$HOME/.config/sketchybar/lib.sh" -if [ "$SENDER" = "volume_change" ]; then - vol="$INFO" -else - vol="$(osascript -e 'output volume of (get volume settings)' 2>/dev/null)" -fi +settings="$(osascript -e 'get volume settings' 2>/dev/null)" -muted="$(osascript -e 'output muted of (get volume settings)' 2>/dev/null)" +vol="${settings#*output volume:}" +vol="${vol%%,*}" +muted="${settings##*output muted:}" -# An unreadable volume is not a volume of zero. Kept separate from the branch -# below so the bar never states a level it does not actually know — the same rule -# thermals follows in system.sh. -if [ -z "$vol" ]; then - sketchybar --set "$NAME" drawing=off - exit 0 +case "$vol" in + '' | *'missing value'*) + sketchybar --set "$NAME" drawing=off + exit 0 + ;; +esac + +if [ "$SENDER" = "volume_change" ] && [ -n "$INFO" ]; then + vol="$INFO" fi -# Muted reads 0%, not "muted": it is the same quantity as every other state of -# this item rather than a different kind of thing, so it lines up with the -# neighbouring percentages instead of making the pill jump width. The struck-out -# speaker icon is what carries "muted" — note this branch also catches a genuine -# 0% that is not muted, which is why the icon does the work and the text does not. +case "$muted" in + *'missing value'*) muted="false" ;; +esac + if [ "$muted" = "true" ] || [ "$vol" -eq 0 ] 2>/dev/null; then sketchybar --set "$NAME" drawing=on icon="󰖁" icon.color="$OVERLAY0" \ label.color="$OVERLAY0" label="0%" diff --git a/sketchybar/.config/sketchybar/plugins/vpn.sh b/sketchybar/.config/sketchybar/plugins/vpn.sh index f3b6331..79f4c49 100755 --- a/sketchybar/.config/sketchybar/plugins/vpn.sh +++ b/sketchybar/.config/sketchybar/plugins/vpn.sh @@ -2,6 +2,7 @@ # VPN connection state. Hidden unless something is actually connected. source "$HOME/.config/sketchybar/colors.sh" +source "$HOME/.config/sketchybar/lib.sh" MAX_LEN=14 @@ -14,12 +15,8 @@ if [ -z "$name" ]; then exit 0 fi -if [ "${#name}" -gt "$MAX_LEN" ]; then - name="$(printf '%s' "$name" | cut -c1-"$MAX_LEN")…" -fi +name="$(truncate_label "$name" "$MAX_LEN")" sketchybar --set "$NAME" drawing=on \ - `# md-vpn, not md-shield_lock — a shield reads as firewall/password manager` \ - `# /antivirus just as easily. MDI has a dedicated VPN glyph.` \ icon="󰖂" icon.color="$GREEN" \ label.drawing=on label.color="$TEXT" label="$name" diff --git a/sketchybar/.config/sketchybar/plugins/weather.sh b/sketchybar/.config/sketchybar/plugins/weather.sh index 0e46e73..8060928 100755 --- a/sketchybar/.config/sketchybar/plugins/weather.sh +++ b/sketchybar/.config/sketchybar/plugins/weather.sh @@ -2,11 +2,19 @@ # Current conditions from wttr.in. source "$HOME/.config/sketchybar/colors.sh" +source "$HOME/.config/sketchybar/lib.sh" -LOCATION="" -CACHE="${TMPDIR:-/tmp}/sketchybar-weather" +require curl -reading="$(curl -s --max-time 5 "https://wttr.in/${LOCATION}?format=%C|%t" 2>/dev/null)" +LOCATION="${SKETCHYBAR_WEATHER_LOCATION:-}" +CACHE="$(state_file weather)" + +if [ "$LOCATION" = "off" ]; then + sketchybar --set "$NAME" drawing=off + exit 0 +fi + +reading="$(curl -sf --max-time 5 "https://wttr.in/${LOCATION}?format=%C|%t" 2>/dev/null)" case "$reading" in *"|"*"°"*) printf '%s' "$reading" > "$CACHE" ;; diff --git a/sketchybar/.config/sketchybar/plugins/wifi.sh b/sketchybar/.config/sketchybar/plugins/wifi.sh index 14fe9cf..4cfd047 100755 --- a/sketchybar/.config/sketchybar/plugins/wifi.sh +++ b/sketchybar/.config/sketchybar/plugins/wifi.sh @@ -3,17 +3,23 @@ # disconnect), with a query for the startup pass. source "$HOME/.config/sketchybar/colors.sh" +source "$HOME/.config/sketchybar/lib.sh" -dev="$(networksetup -listallhardwareports 2>/dev/null \ - | awk '/Hardware Port: Wi-Fi/{getline; print $2; exit}')" -dev="${dev:-en0}" - -if ifconfig "$dev" 2>/dev/null | grep -q "status: active"; then - up=1 +DEV_CACHE="$(state_file wifi-dev)" +if [ -s "$DEV_CACHE" ]; then + read -r dev < "$DEV_CACHE" else - up=0 + dev="$(networksetup -listallhardwareports 2>/dev/null \ + | awk '/Hardware Port: Wi-Fi/{getline; print $2; exit}')" + dev="${dev:-en0}" + printf '%s' "$dev" > "$DEV_CACHE" fi +case "$(ifconfig "$dev" 2>/dev/null)" in + *"status: active"*) up=1 ;; + *) up=0 ;; +esac + if [ "$SENDER" = "wifi_change" ]; then ssid="$INFO" else diff --git a/sketchybar/.config/sketchybar/plugins/workspaces.sh b/sketchybar/.config/sketchybar/plugins/workspaces.sh new file mode 100755 index 0000000..67ed0fe --- /dev/null +++ b/sketchybar/.config/sketchybar/plugins/workspaces.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +# Repaints EVERY workspace pill from ONE pass. Driven by the spaces.driver item. + +source "$HOME/.config/sketchybar/colors.sh" +source "$HOME/.config/sketchybar/lib.sh" + +require aerospace + +focused="${FOCUSED_WORKSPACE:-$(aerospace list-workspaces --focused 2>/dev/null)}" +occupied=" $(aerospace list-workspaces --empty no 2>/dev/null | tr '\n' ' ')" + +args=() +for sid in $(aerospace list-workspaces --all 2>/dev/null); do + if [ "$sid" = "$focused" ]; then + args+=(--set "space.$sid" + background.drawing=on label.color="$CRUST" icon.color="$CRUST") + elif [ "${occupied#* $sid }" != "$occupied" ]; then + args+=(--set "space.$sid" + background.drawing=off label.color="$TEXT" icon.color="$TEXT") + else + args+=(--set "space.$sid" + background.drawing=off label.color="$SURFACE2" icon.color="$SURFACE2") + fi +done + +[ ${#args[@]} -gt 0 ] && sketchybar "${args[@]}" diff --git a/sketchybar/.config/sketchybar/sketchybarrc b/sketchybar/.config/sketchybar/sketchybarrc index 0cd13d3..1f1f59b 100755 --- a/sketchybar/.config/sketchybar/sketchybarrc +++ b/sketchybar/.config/sketchybar/sketchybarrc @@ -4,136 +4,38 @@ # ============================================================ # Docs: https://felixkratz.github.io/SketchyBar/ # -# WHY THIS EXISTS: with the Dock auto-hidden and nine numeric workspaces spread -# over three monitors, there was no way to see which workspaces held windows -# without switching to each one to look. The workspace items on the left are the -# point; everything else is filler. +# ADD ORDER IS THE LAYOUT, and it runs in opposite directions on the two sides: +# left items render in add order (first added sits furthest LEFT), right items +# render in reverse (first added sits furthest RIGHT). So the right-hand half of +# this file reads as the mirror of what is on screen. Moving an item between +# sides means reversing its position in the file. # -# Started by aerospace's after-startup-command, next to borders — not by -# `brew services`, so the bar's lifetime is tied to the window manager's. -# To poke at it by hand: `pkill -x sketchybar` then `sketchybar &`. -# -# SHAPE: the bar itself is transparent. The three brackets at the bottom of this -# file are the only backgrounds drawn — a workspace island, a front-app island -# and a status island. Nothing is ever placed in the CENTRE, and that is what -# makes the CENTRE notch-safe: the built-in display's notch is 185pt wide -# (measured, 1512 - 663 - 664 via NSScreen auxiliaryTopLeft/RightArea) and it -# cuts through empty transparent space rather than through an item. Put a centre -# item here and the laptop display is the one that breaks. -# -# WIDTH BUDGET — check any new item against this before adding it. Neither EDGE -# is automatically safe; only the centre is. The built-in is 1512pt wide and the -# notch spans x=663..849, which splits the usable bar into two runs: -# -# LEFT 396pt front-app island ends x≈255, notch starts x≈663, less a -# 12pt spacer. Grows rightward. -# RIGHT 655pt bar edge x=1504, notch ends x≈849. Grows leftward. -# -# Measured item widths, for estimating: amphetamine 24 (icon only), brightness -# 53, battery 54, volume 65, wifi 68, clock 154, bluetooth 95 uncapped, music -# 269 at MAX_LEN=32. Roughly 8pt per label character. -# -# LEFT, measured on the built-in. island.media is a CONSTANT 162pt — music has a -# fixed width with scrolling text, so its size no longer depends on what is -# playing. Only island.system still moves, because it carries the fan RPM at all -# times (including the 0 Apple Silicon reads at idle) and the digit count varies: -# idle island.system 202..407 (205pt) island.media 419..581 clear 82pt -# under load island.system 202..435 (233pt) island.media 447..609 clear 54pt -# The front-app island used to sit between the workspaces and island.system; it -# is commented out, which is what moved everything 65pt left and turned what was -# a 10pt squeeze into 82pt of room. Re-enable it and those numbers go back to -# 267..479 / 491..653, which still fits — but only just. -# -# RIGHT, four islands, measured left to right on screen. Re-measured after the -# switch to inset items, in the ordinary state — calendar, github, mic and vpn -# hidden, weather showing: -# island.status 928..1037 109pt weather github mic amphetamine pomodoro -# island.network 1049..1149 100pt wifi bluetooth vpn -# island.hardware 1161..1332 171pt battery brightness volume -# island.time 1344..1504 160pt calendar clock -# Leftmost edge 928 against a notch ending at 848.5 — 79pt of clearance here. -# -# The inset items cost at most 6pt per island END, and only in the states where -# that end's member is hidden — which are precisely the roomy states. Where both -# ends are visible they replace the background.padding they displaced and cost -# nothing, so the crowded case below is unaffected. Measured: time and network -# each grew 6pt, hardware grew 0. +# EVERYTHING HERE FAILS SILENTLY. A broken item does not error — it is simply +# absent, or frozen at its last value, and the bar comes up looking fine. The +# four traps that cause this are in CLAUDE.md; `scripts/lint-sketchybar.sh` +# catches the three that are checkable statically. Run it after editing. # -# Splitting one island into four cost 72pt: 12pt of spacer plus 12pt of interior -# padding for each of the three new ones. That is the price of the grouping and -# it is charged whether or not the members are visible, because the spacers are -# ordinary items with a fixed width. -# -# The tail is still the constraint. Measured before the split, cumulatively: -# steady state, every transient hidden 527pt clear 128pt -# + calendar (an event still to come today) 592pt clear 64pt -# + pomodoro running 634pt clear 22pt -# + a bluetooth device name 707pt OVER 52pt -# + vpn connected, mic live, unread notifications 861pt OVER 205pt -# Add 72 to every row for the current layout. Three ordinary things at once -# already touches the notch and everything at once overruns badly. This is a real -# limit, not a rounding error — do not add another always-drawing item to the -# right side, or another island, without taking width back first. -# -# Where the width is, in order of how much it buys: -# clock 154pt -> 65 as %H:%M. The single biggest reclaim available, -# kept at full date format by explicit choice. -# bluetooth 73pt -> 24 by dropping the device name (MAX_LEN in -# bluetooth.sh) and showing the icon alone. -# calendar 65pt -> 0 on the laptop via `display=`, which scopes an -# weather 65pt item to a display index. The two externals are -# github 45pt 2560pt wide with ~1000pt spare and never overrun, -# so scoping the optional items to them costs nothing -# there. Caveat: display indices shift when monitors -# are unplugged, so this is a per-machine tweak and -# not something to bake in blindly. -# -# HEIGHT vs. THE NOTCH vs. GAPS — four settings that must move together: -# 1. this bar's height (32) and `notch_display_height` (32). 32 is the -# measured height of the built-in's notch band (safeAreaInsets.top), so the -# bar fills it exactly and the islands sit at the same y on the laptop as -# on the externals. -# 2. the islands' `background.height` (26), centred in that 32 — so their -# bottom edge lands at y=29. -# 3. `gaps.outer.top` in aerospace.toml: 37 (= 29 + the usual 8pt gap) on the -# externals, 5 on the built-in. macOS reserves the built-in's top 32pt as -# notch safe area even with the menu bar hidden (visibleFrame is 1512x950 -# of a 1512x982 frame) and AeroSpace tiles below that band, so it needs -# only the remaining 5. Get this wrong and windows either slide under the -# islands or leave a dead strip on one display but not the others. -# 4. the macOS menu bar auto-hidden (`_HIHideMenuBar`, set by the install -# script). With it visible, it reserves ~30pt and pushes this bar down -# below it — measured, origin y=29/31 on the displays that carry one, y=0 -# on the one that doesn't. -# Change the height and 37/5 stop matching. Re-show the menu bar and the bar -# stops sitting at the top of the screen. +# To poke at it by hand: `pkill -x sketchybar` then `sketchybar &`. CONFIG_DIR="$HOME/.config/sketchybar" PLUGIN_DIR="$CONFIG_DIR/plugins" -# Plugins shell out to `aerospace` and `sketchybar`. Set PATH explicitly so the -# bar behaves the same whether it was launched from a terminal or by launchd, -# which starts with a minimal PATH. -export PATH="/opt/homebrew/bin:/opt/homebrew/sbin:$PATH" +export PATH="/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/usr/local/sbin:$PATH" source "$CONFIG_DIR/colors.sh" FONT="FiraCode Nerd Font" # --- Bar --------------------------------------------------- -# Transparent: the islands at the bottom of this file draw everything visible. -# padding 8 matches aerospace's gaps.outer.left/right, so the islands line up -# with the window column underneath. -# notch_width only affects CENTRE items, of which there are none — it's a safety -# net for the day someone adds one. Measured notch is 185; 200 is the default -# and leaves margin. -# notch_display_height is redundant while height is also 32, but it's set -# explicitly so the "bar fills the notch band" invariant survives someone -# retuning height. +# The bar itself draws NOTHING — it is transparent, and the brackets at the +# bottom of this file are the only backgrounds on screen. That is what makes the +# islands read as separate frosted pills rather than one long strip. +# +# height=38 IS COUPLED TO aerospace.toml. `gaps.outer.top = 43` there is this +# height plus 5pt of clearance; change one without the other and windows either +# tuck under the bar or leave a visible band. sketchybar --bar \ - height=32 \ - notch_display_height=32 \ - notch_width=200 \ + height=38 \ position=top \ sticky=on \ padding_left=8 \ @@ -142,165 +44,54 @@ sketchybar --bar \ shadow=off # --- Item defaults ----------------------------------------- -# SPACING CONTRACT — every gap between two items comes out at 12pt. -# The gap is the trailing padding of one item plus the leading padding of the -# next, so 6 on each OUTER edge (icon.padding_left, label.padding_right) gives -# 12; the INNER 3+3 only separates an icon from its own label. Two ways it -# drifts, both of which this config has been bitten by: -# - an item that overrides an OUTER padding changes the gap. Override the -# inner ones instead, or the pill/background paddings. -# - a hidden label contributes nothing at all, its padding included, so the -# trailing edge falls back to icon.padding_right (3) and the gap collapses -# to 9. An item that hides its label must set icon.padding_right=6 while -# hidden — see amphetamine.sh and bluetooth.sh. -# - islands swallow padding at a group boundary, in both directions. A -# bracket's rect runs from the outer edge of its first member's -# background.padding_left to the outer edge of its last member's -# background.padding_right. Two consequences, both measured: -# (a) you CANNOT open a gap between two islands with item padding — both -# sides get absorbed as interior padding and the islands still touch. -# Space between islands comes only from an item that belongs to -# neither bracket — spacer.system and spacer.media below. -# (b) interior padding has to come from something INSIDE the bracket — -# either a dedicated pad item (what the islands use, see ISLAND -# PADDING) or background.padding_* on the first/last member. Both grow -# the island inward, leave the outer edge pinned to the bar padding, -# and leave every 12pt gap — inter-item and inter-island — untouched. -# background.padding_* additionally does not resize the member's own -# background, so it cannot distort a member's pill. -# Setting background.padding_* on the BRACKET itself does nothing at all; -# group bounds are computed purely from the members. -# -# ISLAND PADDING — 12pt of interior clearance at each island end, built as a -# dedicated 6pt PAD ITEM inside the bracket plus the 6pt outer padding of the -# end member beside it. 6 alone read cramped against the islands' 13pt corner -# radius, which is why it is 12. -# -# THE PAD ITEMS ARE THE POINT, and the obvious alternative is a trap. Setting -# background.padding=6 on an island's first and last member is what this config -# used to do, and it is broken by construction: per the bullet above, a hidden -# item contributes NOTHING — its background.padding included — so the moment an -# end member hides, a different item becomes the end, carries no -# background.padding, and the interior silently halves to 6. Measured on the -# running bar before the fix, in the ordinary state where calendar, vpn and -# weather are all hidden: -# island.time 6pt left / 12pt right island.hardware 12 / 12 (ok) -# island.network 6pt left / 12pt right -# island.status 12pt left / 6pt right -# That is the COMMON case, not an edge case — calendar is hidden for most of the -# day, vpn whenever nothing is connected, weather until it first fetches. A pad -# item has no script and never hides, so the interior stays 6+6 whatever else -# comes and goes, with no per-plugin bookkeeping. -# -# THE RULE: an island that must NEVER COLLAPSE gets pad items; an island that -# must be ABLE to collapse keeps background.padding on its end members, because -# a pad would never hide and would strand an empty pill. Only system and media -# collapse — see the note at their brackets. -# spaces inset.spaces.l / inset.spaces.r network inset.network.l / .r -# time inset.time.l / inset.time.r status inset.status.l / .r -# hardware inset.hardware.l / inset.hardware.r -# system cpu / thermals background.padding — collapses -# media music, both ends background.padding — collapses -# -# Verified against the running bar rather than assumed: a bracket DOES include a -# member whose icon, label and background are all drawing=off, and sizes it from -# its `width`. A three-item test bracket (6pt pad, 42pt item, 6pt pad) measured -# 54pt, with 12pt from each bracket edge to the label's ink. +# `updates=when_shown` below is the reason `updates=on` appears a dozen times in +# this file. Under it, a hidden item stops being updated ENTIRELY — so any item +# whose script can hide itself would run once, hide, and never be polled again. +# Every self-hiding item therefore overrides it. See CLAUDE.md trap 1; the linter +# checks this one automatically. sketchybar --default \ updates=when_shown \ - icon.font="$FONT:Medium:13.0" \ + icon.font="$FONT:Medium:14.5" \ icon.color="$TEXT" \ icon.padding_left=6 \ icon.padding_right=3 \ - `# OPTICAL ALIGNMENT. Icons look ~2 physical pixels low next to their text.` \ - `# The icons are innocent: measured with CoreText against this exact face,` \ - `# every Nerd Font glyph lands dead centre (0.00pt) while the LABELS ride` \ - `# high — 'Fri 31 Jul 23:26' by +1.06pt, '65%' by +0.54pt.` \ - `# Cause: text_calculate_bounds centres each run by FONT METRICS, not ink —` \ - `# origin.y = y - (line.ascent - line.descent) / 2` \ - `# The 12pt face reports ascent 11.08 / descent 3.69, so the box reserves` \ - `# descender room. A label with no descender fills only the top of that box,` \ - `# and metric-centring lifts it. Nerd Font's icons are drawn centred on the em` \ - `# box, so they land where the formula intends.` \ - `# NOT a size mismatch: the ratios are identical at both sizes` \ - `# (11.08/12 = 12.00/13 = 0.923), which also proves no font fallback.` \ - `# +1 raises the icons to meet the text. It is applied to the ICONS rather` \ - `# than the labels on purpose — the workspace items are label-only` \ - `# (icon.drawing=off), so a label offset would shift the workspace numbers` \ - `# inside their mauve pill and move every popup label too.` \ - `# The residual is content-dependent and unfixable by a constant: ink bounds` \ - `# follow the glyphs while the metric box does not, so +1 lands 0.06pt off a` \ - `# tall label and 0.46pt off '65%'. Both are better than before; ~1 physical` \ - `# pixel of spread is inherent. Set 0 to restore the old behaviour exactly.` \ icon.y_offset=1 \ - label.font="$FONT:Medium:12.0" \ + label.font="$FONT:Medium:13.5" \ label.color="$TEXT" \ label.padding_left=3 \ label.padding_right=6 \ - `# The focused-workspace pill (aerospace.sh is the only thing that turns` \ - `# this on). radius is half of height, which is what makes it a pill and` \ - `# not a rounded rectangle; 20 inside the islands' 26 insets it by 3.` \ - background.corner_radius=10 \ - background.height=20 \ + background.corner_radius=13 \ + background.height=26 \ background.color="$MAUVE" \ background.drawing=off # --- Invisible width --------------------------------------- -# One idiom, two uses: a 12pt SPACER between two islands (spacer.*), and a 6pt -# INSET inside one (inset.*). Both are ordinary items with nothing drawn, so -# `width` is the whole of them — an item with icon, label and background all off -# has no natural width. -# -# Note this is NOT the same as item-level drawing=off. A hidden item contributes -# no width at all; these are visible items that happen to paint nothing, which is -# exactly why they hold their space and never disappear. -# -# NAMING TRAP — DO NOT name a bracket member `p.`. sketchybar -# parses the member list of `--add bracket` through the same code as an item's -# POSITION argument, and its popup check is loose enough that any token starting -# with `p` and containing a dot is taken for `popup.`. These items were -# called `pad..` first, and every bracket silently failed to exist: -# [!] Add (Popup) island.time: Item 'time.r' is not a valid popup host -# [!] Set: Item not found 'island.time' -# The bar comes up with no pills at all and the only clue is on stderr, which -# nothing reads when aerospace launches this. Probed against the running bar: -# `pad.` `pads.` `pa.` `p.` `plate.` all fail; `inset.` `xad.` `zzpad.` and -# `pad_zz_l` are all fine. `pomodoro` is safe only because it has no dot in it. +# Draws nothing but still occupies its width — which `drawing=off` would not, as +# that contributes no width at all. Two distinct jobs use it, and the difference +# matters because a bracket's rect runs to the outer edge of its end members: +# spacer.* width 12, OUTSIDE every bracket — the only way to open a gap +# BETWEEN two islands. Item padding cannot do it; the islands would +# absorb it and still touch. +# inset.* width 6, INSIDE a bracket — interior padding at the island's ends. BLANK_STYLE=(icon.drawing=off label.drawing=off background.drawing=off) # --- Workspaces -------------------------------------------- -# AeroSpace pushes this event from exec-on-workspace-change; sketchybar has no -# way to learn about a workspace switch on its own. +# ONE DRIVER, NINE PILLS. `spaces.driver` is a zero-width item with the only +# script here; it repaints every space.N pill from three `aerospace` queries per +# run (workspaces.sh). The pills themselves have no script and no update_freq. +# +# Nothing tells SketchyBar that a workspace changed, so aerospace.toml's +# `exec-on-workspace-change` triggers the custom event added below. Without that +# line in the other file, this section is inert. sketchybar --add event aerospace_workspace_change -# The names are collected as they're added so the bracket at the bottom can -# reuse them. Calling `aerospace list-workspaces --all` a second time down there -# would be a second source of truth, and a bracket that names a nonexistent item -# only warns — it doesn't fail — so the drift would be silent. -# Plain digits, which is the workspace name straight from aerospace — no mapping -# table, nothing to keep in sync. -# -# Alternatives were tried and measured; if you revisit this, the numbers are: -# plain digits ink 5.29–5.98 island 182pt -# md-roman_numeral_* ink 2.02–10.50 island 182pt (U+F1088..F1090, I..IX) -# Elder Futhark runes ink 7.45–12.21 island 185pt — NOT in this font, they -# fall back to Apple Symbols -# Island totals barely differ, so this is purely a legibility call, and digits -# win it for the one item whose entire job is telling workspaces apart at a -# glance. They also have the tightest ink spread, so the focused pill stays the -# same size on every workspace — roman numerals swing from 2pt (I) to 10.5pt -# (VIII) and the pill visibly resizes as you move around. -# -# Three traps if you do try glyphs again, all of which bit during that attempt: -# 1. Verify the glyph NAME, not just that the codepoint exists. Presence and -# width say nothing about which picture you get — a guessed base codepoint -# put md-reminder, md-salesforce and md-tractor on workspaces 1, 3 and 7. -# Read names with CGFont.name(for:). -# 2. Names can mislead too: U+F1088/U+F108C report as "alpha-i"/"alpha-v" but -# really are I and V. Render to a PNG and look. -# 3. Measure INK, not the typographic advance. text_prepare_line sizes items -# with kCTLineBoundsUseGlyphPathBounds and Nerd Font glyphs are drawn wider -# than their cell, so a "monospaced" 7.38 advance means nothing. +sketchybar \ + --add item spaces.driver left \ + --subscribe spaces.driver aerospace_workspace_change front_app_switched \ + --set spaces.driver "${BLANK_STYLE[@]}" width=0 \ + updates=on \ + script="$PLUGIN_DIR/workspaces.sh" + sketchybar \ --add item inset.spaces.l left \ --set inset.spaces.l "${BLANK_STYLE[@]}" width=6 @@ -309,16 +100,12 @@ space_items=() for sid in $(aerospace list-workspaces --all); do sketchybar \ --add item "space.$sid" left \ - --subscribe "space.$sid" aerospace_workspace_change front_app_switched \ --set "space.$sid" \ label="$sid" \ icon.drawing=off \ - `# 6/6 keeps the 12pt contract (the icon is off, so the label's` \ - `# padding is the outer edge). It doubles as the pill's interior.` \ label.padding_left=6 \ label.padding_right=6 \ - click_script="aerospace workspace $sid" \ - script="$PLUGIN_DIR/aerospace.sh $sid" + click_script="aerospace workspace $sid" space_items+=("space.$sid") done @@ -327,197 +114,61 @@ sketchybar \ --set inset.spaces.r "${BLANK_STYLE[@]}" width=6 # --- Front app --------------------------------------------- -# --- Front app (disabled) ---------------------------------- -# Commented out to reclaim its 65pt on the left run (53pt item + the 12pt spacer -# that separated it from the workspaces). The focused app is already legible from -# the window itself; the workspace indicators are what this bar exists for. -# -# To restore: uncomment BOTH blocks below AND the island.app bracket further -# down. Order matters — left items render in the order they are added, so -# spacer.apps and front_app have to stay between the workspace loop above and the -# system metrics below. front_app.sh is still stowed and untouched. -# -# Note the spacer: a bracket's rect runs to the outer edge of its first and last -# member, so an item INSIDE a group can never open a gap between groups — only an -# item outside both can. drawing stays on (the default), because an item with -# drawing=off contributes no width at all and the islands would touch. -# -# sketchybar \ -# --add item spacer.apps left \ -# --set spacer.apps \ -# icon.drawing=off \ -# label.drawing=off \ -# background.drawing=off \ -# width=12 -# -# sketchybar \ -# --add item front_app left \ -# --subscribe front_app front_app_switched \ -# --set front_app \ -# icon.drawing=off \ -# label.color="$SUBTEXT0" \ -# label.padding_left=6 \ -# `# Sole member of its island, so it carries both ends' padding.` \ -# background.padding_left=6 \ -# background.padding_right=6 \ -# script="$PLUGIN_DIR/front_app.sh" - -# --- System metrics ---------------------------------------- -# These live on the LEFT because the right side is up against the notch and this -# side has ~200pt spare — see the width budget at the top of this file. -# -# ONE SAMPLE, THREE ITEMS: a macmon sample costs ~0.9s, so only `thermals` runs -# system.sh; it writes cpu and memory too. cpu and memory are passive — no -# script, no update_freq — and would never update on their own. +# End of the left run — nothing is added `left` after this. Its island breathes +# with the app name (no max_chars), which is the only variable width on this side. sketchybar \ - --add item spacer.system left \ - --set spacer.system "${BLANK_STYLE[@]}" width=12 + --add item spacer.apps left \ + --set spacer.apps "${BLANK_STYLE[@]}" width=12 sketchybar \ - --add item cpu left \ - --set cpu \ - `# background.padding, NOT a pad item — island.system is one of the two` \ - `# islands that must be able to collapse, and a pad would never hide.` \ - `# Safe here because all three members hide together, so the ends can` \ - `# never drift. See ISLAND PADDING at the top.` \ + --add item front_app left \ + --subscribe front_app front_app_switched \ + --set front_app \ + icon.drawing=off \ + label.color="$SUBTEXT0" \ + label.padding_left=6 \ background.padding_left=6 \ - --add item memory left \ - --add item thermals left \ - --subscribe thermals system_woke \ - --set thermals \ - `# Last member of island.system — carries its right interior padding.` \ background.padding_right=6 \ - `# updates=on, NOT the config-wide when_shown default: system.sh hides` \ - `# all three items when macmon fails, and a hidden item under when_shown` \ - `# stops being polled — the failure would be permanent and silent.` \ - updates=on \ - update_freq=30 \ - script="$PLUGIN_DIR/system.sh" \ - click_script="open -a 'Macs Fan Control'" + script="$PLUGIN_DIR/front_app.sh" -# --- Now playing ------------------------------------------- -# Apple Music now-playing, in its own island so it can vanish cleanly: a bracket -# whose every member is hidden goes to g_nirvana and draws nothing, leaving no -# empty pill behind. Right-click opens a transport popup, which costs no bar -# width — that is why prev/next are not inline items. -sketchybar \ - --add item spacer.media left \ - --set spacer.media "${BLANK_STYLE[@]}" width=12 +# --- Service mode indicator -------------------------------- +# A modal pill for AeroSpace's service mode (entered with alt-shift-semicolon). +# CENTER, so it sits apart from the left/right runs and add-order does not matter. +# +# Hidden by default (drawing=off) so island.mode draws nothing; AeroSpace toggles +# it from the bindings THEMSELVES, not a callback, because on-mode-changed carries +# no mode name (AeroSpace issue #390) — see aerospace.toml's service-mode section. +# +# No script and no update_freq: it is driven purely by `--set` from aerospace.toml, +# so the when_shown/updates=on trap does NOT apply here. That trap is only for an +# item that must run its OWN script to un-hide; this one is written from outside. sketchybar \ - --add item music left \ - --subscribe music mouse.clicked \ - --set music \ - `# Alone in its island, so it carries both ends' padding. Like` \ - `# island.system this stays on background.padding rather than pad items,` \ - `# because island.media exists precisely to vanish and a pad would keep` \ - `# an empty pill on screen. See ISLAND PADDING at the top.` \ - background.padding_left=6 \ - background.padding_right=6 \ - `# FIXED WIDTH + SCROLLING TEXT. This is what makes the left run's` \ - `# geometry deterministic: island.media is always the same size whatever` \ - `# is playing, so notch clearance is a constant instead of something that` \ - `# depends on the current track title. It replaces a MAX_LEN cut in` \ - `# music.sh, which capped CHARACTERS against a budget measured in POINTS` \ - `# and so overflowed on wide titles and wasted room on narrow ones.` \ - `#` \ - `# FOUR properties, and missing any one of them fails quietly:` \ - `# label.max_chars THE ONE THAT DRIVES IT. sketchybar scrolls text` \ - `# "truncated by the max_chars property" — clipping by` \ - `# label.width alone does NOT start a scroll, it just` \ - `# cuts the text off. Note it is label-scoped: a bare` \ - `# item-level max_chars is rejected outright.` \ - `# scroll_texts enables the animation. Off by default.` \ - `# label.width fixes the visible text box, so the pill is the same` \ - `# size whatever is playing. Without it the label` \ - `# renders at its natural extent — measured, the` \ - `# bracket spanned 303pt and ran 130pt into the notch` \ - `# even with item width= set, because group bounds come` \ - `# from the label, not from the item.` \ - `# width pins the pill so short titles don't shrink it.` \ - `#` \ - `# label.max_chars is NOT echoed by \`sketchybar --query\`, so a missing or` \ - `# misspelled value looks identical to a correct one. Invalid properties do` \ - `# error on --set, though, which is the only feedback available here.` \ - `#` \ - `# THE RULE, established by measurement: the max_chars-truncated text must` \ - `# FIT INSIDE label.width. Give the box less room than the text needs and` \ - `# sketchybar clips instead of animating — the pill looks right and sits` \ - `# perfectly still. It is not "scroll when the text overflows"; it is the` \ - `# other way round, and getting it backwards costs an hour.` \ - `#` \ - `# Natural label width per max_chars at this font, measured by setting both` \ - `# width and label.width to dynamic and reading the bracket (the fixed` \ - `# width=150 floor hides everything below 18 chars, so it has to come off` \ - `# first or the table reads as a flat line):` \ - `# chars 10 12 13 14 16 18 20` \ - `# points 56 71 71 86 100 115 115` \ - `# 13 chars = 71pt in a 105pt box scrolled but left 34pt of the pill dead.` \ - `# 18 chars = 115pt in a 110pt box overflowed and stopped scrolling.` \ - `# 16 chars = 100pt in a 112pt box: fills it, with 12pt of slack so that a` \ - `# window of unusually wide glyphs still fits and keeps scrolling. That` \ - `# slack is deliberate — clipping is the failure mode, so err generous.` \ - `#` \ - `# Sized from the notch: island.system ends at 415, this island starts at` \ - `# 427, the notch starts at 663. There is ~220pt of room here now that the` \ - `# front-app island is gone, so 150 is comfortable rather than a squeeze.` \ - width=160 \ - label.width=112 \ - label.max_chars=16 \ - scroll_texts=on \ - label.scroll_duration=100 \ - popup.horizontal=on \ - popup.background.color="$ISLAND" \ - popup.background.border_width=1 \ - popup.background.border_color="$ISLAND_BORDER" \ - popup.background.corner_radius=10 \ - update_freq=10 \ - script="$PLUGIN_DIR/music.sh" + --add item aerospace_mode center \ + --set aerospace_mode \ + drawing=off \ + icon="󰒓" \ + icon.color="$PEACH" \ + label="SERVICE" \ + label.color="$PEACH" -sketchybar \ - --add item music.prev popup.music \ - --set music.prev icon="󰒮" label.drawing=off \ - click_script="osascript -e 'tell application \"Music\" to previous track' >/dev/null 2>&1; sketchybar --set music popup.drawing=off" \ - --add item music.play popup.music \ - --set music.play icon="󰐊" label.drawing=off \ - click_script="osascript -e 'tell application \"Music\" to playpause' >/dev/null 2>&1; sketchybar --set music popup.drawing=off" \ - --add item music.next popup.music \ - --set music.next icon="󰒭" label.drawing=off \ - click_script="osascript -e 'tell application \"Music\" to next track' >/dev/null 2>&1; sketchybar --set music popup.drawing=off" # --- Right side -------------------------------------------- -# FOUR islands, grouped by category. Right items render in ADD order, so the -# first item added sits furthest right and the reading order below is the mirror -# of what you see: -# -# screen: [ status ] [ connectivity ] [ hardware ] [ time ] -# added: time -> hardware -> connectivity -> status -# -# volume and wifi ride native events (volume_change, wifi_change) and never poll; -# the rest are on timers, because macOS publishes no event for any of them. +# SIX islands, grouped by category. Read this half BACKWARDS: the first item +# added sits furthest right, so the file order is the mirror of the screen. # -# EVERY ISLAND NEEDS AN ITEM THAT NEVER HIDES. A bracket whose members have all -# hidden collapses to g_nirvana and draws nothing — but the spacer beside it is a -# separate item and stays, so the bar is left with an orphaned 12pt gap. The four -# anchors are clock, volume, wifi and pomodoro; the other nine items can all hide -# themselves (battery, brightness, amphetamine, bluetooth, calendar, weather, -# github, vpn, mic). Do not regroup these without checking each island keeps one. -# The pad items below do NOT count as anchors — they hold each island's interior -# padding, not its reason to exist, and an island of nothing but pads would be a -# 12pt empty pill. volume is the weakest of the four: volume.sh does hide it if -# the level is unreadable, which is a failure state rather than a normal one. +# screen: [ media ] [ system ] [ status ] [ network ] [ hardware ] [ time ] +# added: time -> hardware -> network -> status -> system -> media # -# EACH ISLAND IS BRACKETED BY AN INSET ITEM, inset..l / .r, which is what -# keeps its interior at 12pt on both ends no matter which members are hidden. -# They are added in the same mirrored order as everything else here: the .r pad -# FIRST (furthest right), the .l pad LAST. See ISLAND PADDING at the top for why -# background.padding on the end members is not good enough. -# -# Splitting is not free: each extra island costs 12pt of spacer plus 12pt of new -# interior padding. Going from one island to four cost 72pt of a 655pt budget — -# see the width budget at the top of this file. +# Two consequences that are easy to get wrong when moving an item here: +# - a SPACER is added BEFORE the island it sits to the right of, not after. +# - an island's MEMBERS are added in reverse of their screen order — the +# `cpu memory thermals` group is added thermals, memory, cpu. +# background.padding_left/right do NOT invert; they stay screen-space. # TIME ------------------------------------------------------ +# Clock, plus a calendar item that hides when the day has nothing left in it and +# shows its next event in a popup on hover. sketchybar \ --add item inset.time.r right \ --set inset.time.r "${BLANK_STYLE[@]}" width=6 @@ -525,27 +176,20 @@ sketchybar \ sketchybar \ --add item clock right \ --set clock \ - `# md-calendar_clock, not md-clock: this item shows the DATE and the` \ - `# time ("Fri 31 Jul 23:26"), so a bare clock face undersells it. It` \ - `# stays distinct from the calendar item beside it, which is plain` \ - `# md-calendar — using md-calendar here would give island.time two` \ - `# identical icons.` \ icon="󰃰" \ icon.color="$BLUE" \ - update_freq=15 \ + update_freq=1 \ script="$PLUGIN_DIR/clock.sh" -# Next event today. The bar label is the START TIME only — a title costs 60-100pt -# on this side — with the title in a popup, which costs no bar width. sketchybar \ --add item calendar right \ - `# Hover reveals the title; there is no room for it on the bar itself.` \ --subscribe calendar system_woke mouse.entered mouse.exited \ --set calendar \ popup.background.color="$ISLAND" \ popup.background.border_width=1 \ popup.background.border_color="$ISLAND_BORDER" \ popup.background.corner_radius=10 \ + updates=on \ update_freq=300 \ script="$PLUGIN_DIR/calendar.sh" \ click_script="open -a Calendar" \ @@ -557,6 +201,16 @@ sketchybar \ --set inset.time.l "${BLANK_STYLE[@]}" width=6 # HARDWARE -------------------------------------------------- +# On this machine `battery` and `volume` are hidden essentially always — a Mac +# Studio has no battery, and the class-compliant USB interface exposes no +# software volume — so `brightness` carries this island alone. It cannot vanish +# (the insets never hide), so if brightness ever failed too the result would be +# an empty 12pt pill rather than a clean disappearance. +# +# brightness polls every 5s and that is deliberate despite being the single +# largest CPU line in the bar (312 ms/min of ~1430 total). The poll is the +# PRIMARY path, not a backstop: this display is driven by MonitorControl's gamma +# dimming, which posts no brightness_change event at all. sketchybar \ --add item spacer.hardware right \ --set spacer.hardware "${BLANK_STYLE[@]}" width=12 @@ -569,16 +223,15 @@ sketchybar \ --add item battery right \ --subscribe battery power_source_change system_woke \ --set battery \ + updates=on \ update_freq=120 \ script="$PLUGIN_DIR/battery.sh" -# Built-in display brightness. Subscribed to the native event for instant -# response, with a 5s poll behind it — the event is not guaranteed to fire for -# every brightness source, and the ioreg read costs 0.01s. sketchybar \ --add item brightness right \ --subscribe brightness brightness_change system_woke \ --set brightness \ + updates=on \ update_freq=5 \ script="$PLUGIN_DIR/brightness.sh" @@ -586,8 +239,7 @@ sketchybar \ --add item volume right \ --subscribe volume volume_change \ --set volume \ - `# island.hardware's anchor — volume is the one member that survives` \ - `# battery and brightness both going away, so the pill never collapses.` \ + updates=on \ script="$PLUGIN_DIR/volume.sh" \ click_script="osascript -e 'set volume output muted not (output muted of (get volume settings))'" @@ -596,6 +248,10 @@ sketchybar \ --set inset.hardware.l "${BLANK_STYLE[@]}" width=6 # CONNECTIVITY ---------------------------------------------- +# `wifi` is the anchor — it never hides, which is what stops this island +# collapsing when bluetooth and vpn have nothing to report. It is also the reason +# wifi is the one item here without `updates=on`: when_shown is correct for an +# item that is always shown. sketchybar \ --add item spacer.network right \ --set spacer.network "${BLANK_STYLE[@]}" width=12 @@ -608,7 +264,6 @@ sketchybar \ --add item wifi right \ --subscribe wifi wifi_change system_woke \ --set wifi \ - `# island.network's anchor — wifi never hides.` \ script="$PLUGIN_DIR/wifi.sh" \ click_script="open 'x-apple.systempreferences:com.apple.Network-Settings.extension'" @@ -616,16 +271,16 @@ sketchybar \ --add item bluetooth right \ --subscribe bluetooth system_woke \ --set bluetooth \ + updates=on \ update_freq=120 \ script="$PLUGIN_DIR/bluetooth.sh" \ click_script="open 'x-apple.systempreferences:com.apple.BluetoothSettings'" -# VPN. No native event exists, and waking is when the state most often changed -# while you weren't looking — hence the poll plus system_woke. sketchybar \ --add item vpn right \ --subscribe vpn system_woke \ --set vpn \ + updates=on \ update_freq=10 \ script="$PLUGIN_DIR/vpn.sh" \ click_script="open 'x-apple.systempreferences:com.apple.Network-Settings.extension'" @@ -635,6 +290,14 @@ sketchybar \ --set inset.network.l "${BLANK_STYLE[@]}" width=6 # STATUS ---------------------------------------------------- +# Ambient indicators, and the densest cluster of self-hiding items in the file — +# weather, github and mic all come and go, so this island visibly changes width +# during the day. `pomodoro` is the member that never hides and keeps it alive. +# +# EVERY ITEM HERE THAT CAN HIDE ITSELF SETS updates=on. Without it the item stops +# being polled the moment it hides and can never read its way back — it works +# after a reload, disappears once, and is gone until the next one. This is the +# trap that has been rediscovered most often in this config. sketchybar \ --add item spacer.status right \ --set spacer.status "${BLANK_STYLE[@]}" width=12 @@ -643,35 +306,29 @@ sketchybar \ --add item inset.status.r right \ --set inset.status.r "${BLANK_STYLE[@]}" width=6 -# Current conditions. 900s because weather does not change faster than that and -# every poll is a request to a third party — see the privacy note in weather.sh. sketchybar \ --add item weather right \ --subscribe weather system_woke \ --set weather \ + updates=on \ update_freq=900 \ script="$PLUGIN_DIR/weather.sh" -# Unread GitHub notifications. Stays hidden until `gh auth login` has been run; -# 300s is 12 requests/hour against a 5000/hour authenticated limit. sketchybar \ --add item github right \ --set github \ + updates=on \ update_freq=300 \ script="$PLUGIN_DIR/github.sh" \ click_script="open 'https://github.com/notifications'" -# Microphone in use — the same signal as the menu bar's orange dot. The 2s poll -# is affordable because the helper is a compiled binary; see mic.sh. sketchybar \ --add item mic right \ --set mic \ - update_freq=2 \ + updates=on \ + update_freq=5 \ script="$PLUGIN_DIR/mic.sh" -# Amphetamine keep-awake state. Click toggles an indefinite session; the item is -# subscribed to mouse.clicked rather than using click_script so the toggle and -# the redraw live in one script. sketchybar \ --add item amphetamine right \ --subscribe amphetamine mouse.clicked system_woke \ @@ -680,78 +337,146 @@ sketchybar \ update_freq=30 \ script="$PLUGIN_DIR/amphetamine.sh" -# Pomodoro. Click cycles idle -> 25min work -> 5min break -> idle. The script -# retunes its own update_freq — 1s while counting down, 0 (event-only) when idle, -# so the 1Hz cost is paid only while a timer is actually running. sketchybar \ --add item pomodoro right \ --subscribe pomodoro mouse.clicked system_woke \ --set pomodoro \ - `# island.status's anchor — it never hides, so the pill survives every` \ - `# other member of that island going away.` \ script="$PLUGIN_DIR/pomodoro.sh" sketchybar \ --add item inset.status.l right \ --set inset.status.l "${BLANK_STYLE[@]}" width=6 -# --- Islands ----------------------------------------------- -# Must come after every member item exists — a bracket can only reference items -# that have already been added. -# -# A bracket renders BELOW its members, so aerospace.sh's mauve focused-workspace -# pill still draws on top of the workspace island rather than being hidden by it. +# SYSTEM ---------------------------------------------------- +# ONE SAMPLE, THREE ITEMS. Only `thermals` has a script; `cpu` and `memory` are +# passive — no script, no update_freq — and are written by system.sh during the +# thermals tick. So update_freq=60 sets the refresh rate for all three, and a +# macmon failure blanks all three at once. A null script on cpu/memory is by +# design, not a broken path. # -# Members with drawing=off are skipped when the rect is computed, so the status -# island shrinks correctly as music/thermals/battery/bluetooth/brightness come -# and go, and would vanish entirely if all of them hid at once (clock never -# does, so it always has an anchor). +# 60 and not 30: one macmon sample costs ~740ms wall because it collects a whole +# SoC telemetry frame to extract three numbers. It is only ~169 ms/min of CPU at +# this frequency since most of that is spent blocked. # -# The inset.* members are the exception and the reason this works: they draw -# nothing but are not hidden, so they are always counted and always contribute -# their 6pt. That is what pins each island's interior at 12pt on both ends -# regardless of which real members are showing — see ISLAND PADDING at the top. -# They must be named FIRST and LAST in each bracket; a pad in the middle would -# just add 6pt of dead space between two items. -# -# corner_radius is half of height, which is what makes these pills rather than -# rounded rectangles. 26 centred in the 32pt bar puts the bottom edge at y=29 — -# see the gaps arithmetic at the top of this file. +# Members are added thermals -> memory -> cpu so they READ cpu -> memory -> +# thermals. The background.padding below is screen-space and does not invert. +sketchybar \ + --add item spacer.system right \ + --set spacer.system "${BLANK_STYLE[@]}" width=12 + +sketchybar \ + --add item thermals right \ + --subscribe thermals system_woke \ + --set thermals \ + background.padding_right=6 \ + updates=on \ + update_freq=60 \ + script="$PLUGIN_DIR/system.sh" \ + click_script="open -a 'Macs Fan Control'" \ + --add item memory right \ + --add item cpu right \ + --set cpu \ + background.padding_left=6 + +# NOW PLAYING ----------------------------------------------- +# Reads macOS's system-wide Now Playing state via media-control, so it follows +# whatever actually holds the session — IINA, Spotify, a browser tab — not just +# Apple Music. plugins/music.sh carries the why, including the private-API +# dependency this rests on and how it hides cleanly if Apple closes it. +# +# THIS BLOCK MUST STAY LAST on the right side. Added last means furthest left, +# which is what puts island.media at the inner end of the run; and the popup +# items below can only be added once `music` exists, so they travel with it. +# +# THE THREE WIDTHS ARE ONE SET — width 215, label.width 180, label.max_chars 20. +# SketchyBar only scrolls text truncated by max_chars, and only when label.width +# is >= the truncated text; give the box less and it silently CLIPS instead, +# which looks like a correctly-sized pill that just sits still. Measured: 20 +# chars is 164pt of ink in the 180pt box. Raise max_chars and label.width +# together or not at all. Neither value is echoed by `sketchybar --query`. +sketchybar \ + --add item spacer.media right \ + --set spacer.media "${BLANK_STYLE[@]}" width=12 + +sketchybar \ + --add item music right \ + --subscribe music mouse.clicked \ + --set music \ + updates=on \ + background.padding_left=6 \ + background.padding_right=6 \ + width=215 \ + label.width=180 \ + label.max_chars=20 \ + scroll_texts=on \ + label.scroll_duration=100 \ + popup.horizontal=on \ + popup.background.color="$ISLAND" \ + popup.background.border_width=1 \ + popup.background.border_color="$ISLAND_BORDER" \ + popup.background.corner_radius=10 \ + update_freq=10 \ + script="$PLUGIN_DIR/music.sh" + +# Transport goes through media-control, not `tell application "Music"`, for the +# same reason music.sh does: the commands are addressed to whichever app owns the +# system Now Playing session, so these work for IINA and Spotify and a browser +# tab, none of which the old AppleScript could reach. Command names are exact — +# `media-control help` lists them with their MediaRemote command IDs. +sketchybar \ + --add item music.prev popup.music \ + --set music.prev icon="󰒮" label.drawing=off \ + click_script="media-control previous-track >/dev/null 2>&1; sketchybar --set music popup.drawing=off" \ + --add item music.play popup.music \ + --set music.play icon="󰐊" label.drawing=off \ + click_script="media-control toggle-play-pause >/dev/null 2>&1; sketchybar --set music popup.drawing=off" \ + --add item music.next popup.music \ + --set music.next icon="󰒭" label.drawing=off \ + click_script="media-control next-track >/dev/null 2>&1; sketchybar --set music popup.drawing=off" + +# --- Islands ----------------------------------------------- +# The frosted pills. These brackets are the ONLY backgrounds drawn on the whole +# bar, and blur_radius frosts each one individually rather than the entire strip. +# Must come after every member item exists — a bracket naming a missing item only +# warns, it does not fail, so a stale line here is invisible. +# +# THREE OF THEM CAN VANISH: island.system, island.media, and island.mode. For the +# first two, every member hides at once (system.sh when macmon fails, music.sh when +# nothing is playing), and a bracket whose members are all hidden draws nothing at +# all. That is why those two use background.padding on their end members instead of +# inset pad items — a pad never hides, and would strand an empty pill exactly where +# the island is supposed to disappear. island.mode is the deliberate case: its one +# member sits drawing=off except during service mode, so the island is invisible by +# default and appears only while AeroSpace has toggled the pill on. +# +# island.app also uses background.padding but does NOT collapse: front_app always +# draws. The remaining four are pinned open by their inset.* members. ISLAND_STYLE=( background.drawing=on - background.height=26 - background.corner_radius=13 + background.height=32 + background.corner_radius=16 background.color="$ISLAND" background.border_width=1 background.border_color="$ISLAND_BORDER" + blur_radius=30 ) sketchybar \ --add bracket island.spaces inset.spaces.l "${space_items[@]}" inset.spaces.r \ --set island.spaces "${ISLAND_STYLE[@]}" \ \ - `# island.app is disabled — see the front_app block above. A bracket naming` \ - `# an item that does not exist only WARNS, it does not fail, so leaving this` \ - `# in would have cost nothing visible and hidden the mistake.` \ - `# --add bracket island.app front_app` \ - `# --set island.app "${ISLAND_STYLE[@]}"` \ + --add bracket island.app front_app \ + --set island.app "${ISLAND_STYLE[@]}" \ + \ + --add bracket island.mode aerospace_mode \ + --set island.mode "${ISLAND_STYLE[@]}" \ \ - `# THE TWO COLLAPSING ISLANDS. No pads here, deliberately: every member of` \ - `# each hides at once — system.sh when macmon fails, music.sh when nothing` \ - `# is playing — and the bracket then goes to g_nirvana and draws nothing at` \ - `# all. A pad never hides, so adding one would strand an empty 12pt pill` \ - `# exactly where the island is supposed to disappear. Their interiors come` \ - `# from background.padding on the end members instead, which is safe only` \ - `# because their members hide as a unit and the ends cannot drift.` \ --add bracket island.system cpu memory thermals \ --set island.system "${ISLAND_STYLE[@]}" \ \ --add bracket island.media music \ --set island.media "${ISLAND_STYLE[@]}" \ \ - `# The right side, grouped by category. Each of these four has exactly one` \ - `# real member that never hides — clock, volume, wifi, pomodoro — so none` \ - `# can collapse and strand its spacer. See the note above the right side.` \ --add bracket island.time inset.time.r clock calendar inset.time.l \ --set island.time "${ISLAND_STYLE[@]}" \ \ @@ -765,4 +490,6 @@ sketchybar \ --set island.status "${ISLAND_STYLE[@]}" # --- Go ---------------------------------------------------- +# Flush the batched config: one forced update pass so every item runs its script +# and paints now, instead of waiting for its first event or tick. sketchybar --update