diff --git a/docs/configuration/ml4w.md b/docs/configuration/ml4w.md index 9034692a..ae3fb566 100644 --- a/docs/configuration/ml4w.md +++ b/docs/configuration/ml4w.md @@ -75,6 +75,23 @@ updates, so custom bindings live in a separate **variant**. `conf/keybinding.lua If you add a keybinding, add it to the profile JSON and re-run the generator — never to `dreamcoder.lua` unless it is a native-only bind. +### Tracking upstream `default.lua` (ML4W 2.16) + +`dreamcoder.lua` follows upstream `default.lua` minus the profile-owned binds. +The ML4W 2.16 delta is ported as follows: + +| Upstream 2.16 change | In `dreamcoder.lua` | +| --- | --- | +| Overview moved to `~/.local/share/quickshell-overview` | `SUPER + Tab` runs `qs -p ~/.local/share/quickshell-overview ipc call overview toggle` | +| `SUPER + ALT + B` statusbar autohide | Ported | +| `SUPER + ALT + D` dock autohide | Ported | +| Reload Dock moved to `SUPER + SHIFT + D` | Not bound — the profile owns that combo (theme toggle) | +| Rewritten AZERTY detection (`fr`, `be`) | Ported; binds the AZERTY keysyms only on AZERTY layouts, since the profile owns the digit workspace binds | + +`tests/ml4w/keybindings_variant.bats` fails on any new collision between the +variant and the profile. The `SUPER + SHIFT + arrows` overlap (variant resize, +profile move) predates 2.16 and is listed there as a known exception. + ## hyprctl dispatch is broken on Hyprland 0.55+ — native dispatchers used Hyprland's Lua config parses `hyprctl dispatch ` as Lua @@ -97,6 +114,41 @@ translates the following `hyprctl dispatch` commands in profiles to native Any other command still falls back to `hl.dsp.exec_cmd(...)`. +## Upgrade-proof hooks: Dreamcoder hooks live in Dreamcoder-owned files + +ML4W upgrades overwrite every file ML4W ships (`hyprland.lua`, the +`ml4w-wallpaper` runner, shipped keybinding variants). A line injected into +one of those files silently disappears on the next upgrade. The rule: + +- **Put hooks in files ML4W never ships.** `custom.lua` (generated from the + profile) loads `dreamcoder-colors`; `hyprland.lua` already requires + `custom.lua` when it exists, so nothing is injected into `hyprland.lua`. + `dreamcoder doctor` accepts the loader from `custom.lua` (a legacy require in + `hyprland.lua` is still recognised). +- **When a hook must live in an ML4W file, make it re-appliable.** + `scripts/apply-ml4w-hooks.sh` appends the wallpaper hook to + `~/.config/ml4w/scripts/ml4w-wallpaper` (the runner ML4W 2.16's Quickshell + wallpaper app calls with `$IMAGE_PATH`) between + `# >>> Dreamcoder wallpaper hook >>>` markers. Each run replaces the block, so + re-running it after an ML4W upgrade restores the hook without duplicates. +- **waypaper is optional.** ML4W 2.16 no longer installs it; its + `post_command` is hooked only when `~/.config/waypaper/config.ini` exists. +- **Colour files may be regular files.** ML4W 2.16 ships + `~/.config/hypr/colors.lua` / `colors.conf` as regular files and the theme + sync writes Dreamcoder colours through them (it only re-points files that are + already symlinks). `doctor.sh` and `verify-ml4w-setup.sh` accept either a + symlink into a Dreamcoder variant or a regular file whose bytes match a + `DreamcoderThemes/dreamcoder/hypr-colors-*` variant. + +After every ML4W upgrade: + +```bash +./scripts/generate-custom-lua.sh # custom.lua (keybinds + colour loader) +./scripts/apply-ml4w-hooks.sh # re-hook the wallpaper runner +./scripts/dreamcoder sync # rewrite colors.lua / colors.conf +./scripts/verify-ml4w-setup.sh +``` + ## What setup-hyprland.sh does 1. **Symlinks** wlogout + swaync `colors.css` → waybar (single theme toggle point) @@ -148,8 +200,15 @@ DreamcoderProfiles/dreamcoder/ └── asus-vivobook15.json # ASUS VivoBook 15 profile (all Fn keys) tests/ml4w/ -├── generate_custom_lua.bats # 13 tests for the generator -├── setup_hyprland.bats # 9 tests for the orchestrator -├── profile_validation.bats # 11 tests for JSON profiles -└── setup.bash # BATS test helper +├── apply_ml4w_hooks.bats # wallpaper hook (ML4W 2.16 runner fixture) +├── args.bats # script argument handling +├── generate_custom_lua.bats # generator, incl. the dreamcoder-colors loader +├── keybindings_variant.bats # dreamcoder.lua vs ML4W 2.16 and the profile +├── ml4w_managed.bats # ML4W ownership + colour-file predicates +├── setup_hyprland.bats # orchestrator +├── profile_validation.bats # JSON profiles +└── waybar_override.bats # Waybar accent override + +tests/fixtures/ml4w/ +└── ml4w-wallpaper-2.16 # upstream runner at tag 2.16 (3960570) ``` diff --git a/docs/installation/linux.md b/docs/installation/linux.md index 6afb9550..9e6215cb 100644 --- a/docs/installation/linux.md +++ b/docs/installation/linux.md @@ -77,8 +77,8 @@ readlink ~/.config/btop/themes/dreamcoder.theme # → dreamcoder-light.them readlink ~/.config/waybar/colors.css # → colors-light.css or colors-dark.css readlink ~/.config/rofi/colors.rasi # → colors-light.rasi or colors-dark.rasi -# 3. Hyprland imports dreamcoder -grep "dreamcoder-colors" ~/.config/hypr/hyprland.lua # → require("dreamcoder-colors") +# 3. Hyprland loads dreamcoder colours (from the generated custom.lua) +grep "dreamcoder-colors" ~/.config/hypr/custom.lua # → require("dreamcoder-colors") # 4. Timer is active systemctl --user is-active dreamcoder-theme-auto.timer # → active @@ -115,8 +115,8 @@ rm ~/.config/hypr/dreamcoder-colors.lua rm ~/.config/btop/themes/dreamcoder.theme sed -i 's/color_theme = "dreamcoder"/color_theme = "matugen"/' ~/.config/btop/btop.conf -# Remove dreamcoder import from hyprland.lua: -# Edit ~/.config/hypr/hyprland.lua and remove line: require("dreamcoder-colors") +# The dreamcoder-colors loader lives in the generated ~/.config/hypr/custom.lua; +# with dreamcoder-colors.lua removed, its guard skips the require. # Disable timer systemctl --user disable --now dreamcoder-theme-auto.timer diff --git a/docs/sources.md b/docs/sources.md index a9345d00..fc74ab17 100644 --- a/docs/sources.md +++ b/docs/sources.md @@ -43,8 +43,8 @@ repository that ships the dotfiles. Gentleman.Dots is hosted at | Upstream | Kind | Verified remote (HTTPS) | Pinned ref | Status | | --- | --- | --- | --- | --- | -| ML4W (Hyprland desktop dotfiles) | Desktop base environment | | `46f2ca7f73fe98b16ce4ab6433a9ac29fa9fd033` | Pinned — verified against remote HEAD | -| Gentleman.Dots | Shell / editor / terminal base configuration | | `02584500de6378ff5f54d252dc28fce8424b088a` | Pinned — verified against remote HEAD | +| ML4W (Hyprland desktop dotfiles) | Desktop base environment | | `3960570f47f4f691c424ff46b387d389a8e69bca` (tag `2.16`) | Pinned — verified against tag 2.16 and remote HEAD | +| Gentleman.Dots | Shell / editor / terminal base configuration | | `6f44b797b016aea92772d8a6d81a5f1bc53a84bb` | Pinned — verified against remote HEAD | ### Pin mechanism diff --git a/docs/upstream-manifest.json b/docs/upstream-manifest.json index e1c14f32..7f9c8827 100644 --- a/docs/upstream-manifest.json +++ b/docs/upstream-manifest.json @@ -1,24 +1,24 @@ { "version": 1, "provenance": { - "verified_on": "2026-08-10T01:27:49Z", - "method": "Resolved each upstream HTTPS remote HEAD with git ls-remote and recorded the exact returned commit; a second independent re-verification on the same day returned the same refs, so both stay pinned. No ref was ever filled without verification.", - "command": "git ls-remote https://github.com/mylinuxforwork/dotfiles.git HEAD; git ls-remote https://github.com/Gentleman-Programming/Gentleman.Dots.git HEAD" + "verified_on": "2026-09-28T05:42:13Z", + "method": "Resolved each upstream HTTPS remote with git ls-remote and recorded the exact returned commit. ML4W is pinned to tag 2.16 (lightweight tag, 3960570f47f4f691c424ff46b387d389a8e69bca), which was also the remote HEAD at verification; Gentleman.Dots is pinned to main HEAD. No ref was ever filled without verification.", + "command": "git ls-remote https://github.com/mylinuxforwork/dotfiles.git HEAD refs/tags/2.16; git ls-remote https://github.com/Gentleman-Programming/Gentleman.Dots.git HEAD refs/heads/main" }, "upstreams": { "ml4w": { "name": "ML4W (Hyprland desktop dotfiles)", "url": "https://github.com/mylinuxforwork/dotfiles.git", "status": "pinned", - "pinned_ref": "46f2ca7f73fe98b16ce4ab6433a9ac29fa9fd033", - "verified_on": "2026-08-10T01:27:49Z" + "pinned_ref": "3960570f47f4f691c424ff46b387d389a8e69bca", + "verified_on": "2026-09-28T05:42:13Z" }, "gentleman-dots": { "name": "Gentleman.Dots (shell / editor / terminal base configuration)", "url": "https://github.com/Gentleman-Programming/Gentleman.Dots.git", "status": "pinned", - "pinned_ref": "02584500de6378ff5f54d252dc28fce8424b088a", - "verified_on": "2026-08-10T01:27:49Z" + "pinned_ref": "6f44b797b016aea92772d8a6d81a5f1bc53a84bb", + "verified_on": "2026-09-28T05:42:13Z" } }, "owned_paths": {} diff --git a/lib/ml4w.sh b/lib/ml4w.sh index bb4c2467..23b6e962 100644 --- a/lib/ml4w.sh +++ b/lib/ml4w.sh @@ -2,9 +2,9 @@ # ============================================================================ # ml4w.sh — ML4W ownership predicates (pure; no side effects) # ============================================================================ -# This library deliberately omits `set -euo pipefail` (and <30 lines total): -# sourcing a library must not mutate the caller's shell options. Both callers -# set their own options before sourcing this file. +# This library deliberately omits `set -euo pipefail`: sourcing a library must +# not mutate the caller's shell options. Callers set their own options before +# sourcing this file. # # ML4W ownership is accepted from either supported layout: # - old layout: individual config files symlinked from the ML4W dotfiles @@ -25,3 +25,30 @@ waybar_is_ml4w_managed() { [[ -f "${HOME}/.config/waybar/launch.sh" ]] && return 0 return 1 } + +# Hyprland colour files (colors.lua / colors.conf) carry Dreamcoder colours when +# they are a symlink into a Dreamcoder variant, or a regular file byte-identical +# to one of the DreamcoderThemes hypr-colors-* variants. Regular files are the +# supported layout: ML4W 2.16 ships them as regular files and the theme sync +# writes through whatever sits at the path (it only flips existing symlinks). +hypr_colors_is_dreamcoder() { + local path="$1" variant + [[ -e "${path}" ]] || return 1 + [[ -L "${path}" && "$(readlink "${path}")" == *dreamcoder* ]] && return 0 + for variant in "${DREAMCODER_DOTS_DIR}"/DreamcoderThemes/dreamcoder/hypr-colors-*."${path##*.}"; do + [[ -f "${variant}" ]] && cmp -s "${path}" "${variant}" && return 0 + done + return 1 +} + +# Waybar colors.css carries Dreamcoder colours when it is a symlink into a +# Dreamcoder variant, or a regular file written by the theme sync (identified by +# its generator header). The sync renders the active mode straight into the +# path, so a regular file is the supported layout on ML4W 2.16. +waybar_colors_is_dreamcoder() { + local path="$1" + [[ -e "${path}" ]] || return 1 + [[ -L "${path}" && "$(readlink "${path}")" == *dreamcoder* ]] && return 0 + [[ -L "${path}" ]] && return 1 + head -n 5 "${path}" | grep -q 'Generated by Dreamcoder sync' +} diff --git a/ml4w_assets/hypr/conf/keybindings/dreamcoder.lua b/ml4w_assets/hypr/conf/keybindings/dreamcoder.lua index a0adfe97..3b270434 100644 --- a/ml4w_assets/hypr/conf/keybindings/dreamcoder.lua +++ b/ml4w_assets/hypr/conf/keybindings/dreamcoder.lua @@ -49,6 +49,10 @@ hl.bind(mainMod .. " + ALT + W", hl.dsp.exec_cmd("~/.config/ml4w/scripts/ml4w-wa hl.bind(mainMod .. " + CTRL + RETURN", hl.dsp.exec_cmd("~/.config/hypr/scripts/launcher.sh"), { description = "Open application launcher" }) hl.bind(mainMod .. " + SHIFT + B", hl.dsp.exec_cmd("~/.config/ml4w/scripts/ml4w-reload-statusbar"), { description = "Reload Status Bar" }) hl.bind(mainMod .. " + CTRL + B", hl.dsp.exec_cmd("~/.config/ml4w/scripts/ml4w-toggle-statusbar"), { description = "Toggle Status Bar" }) +hl.bind(mainMod .. " + ALT + B", hl.dsp.exec_cmd("~/.config/ml4w/scripts/ml4w-toggle-statusbar-autohide"), { description = "Toggle Status Bar Autohide" }) +-- ML4W 2.16 moved "Reload Dock" to SUPER + SHIFT + D, which is profile-owned +-- here (Dreamcoder theme toggle), so the dock reload stays unbound. +hl.bind(mainMod .. " + ALT + D", hl.dsp.exec_cmd("~/.config/ml4w/scripts/ml4w-toggle-dock-autohide"), { description = "Toggle Dock Autohide" }) hl.bind(mainMod .. " + SHIFT + R", hl.dsp.exec_cmd("~/.config/hypr/scripts/loadconfig.sh"), { description = "Reload hyprland config" }) hl.bind(mainMod .. " + CTRL + T", hl.dsp.exec_cmd("~/.config/waybar/themeswitcher.sh"), { description = "Open waybar theme switcher" }) hl.bind(mainMod .. " + SHIFT + M", hl.dsp.exec_cmd("~/.config/ml4w/scripts/ml4w-toggle-theme"), { description = "Toggle between light and dark mode" }) @@ -56,9 +60,53 @@ hl.bind(mainMod .. " + ALT + G", hl.dsp.exec_cmd("~/.config/hypr/scripts/gamemod hl.bind(mainMod .. " + CTRL + L", hl.dsp.exec_cmd("~/.config/ml4w/scripts/ml4w-power -l"), { description = "Lock Screen" }) -- Note: SHIFT + H is profile-owned (Move Window Left); hyprsunset is -- profile-owned too via SHIFT + U (on) / SHIFT + I (off). -hl.bind(mainMod .. " + Tab", hl.dsp.exec_cmd("qs -p ~/.config/quickshell/overview ipc call overview toggle"), { description = "Open Select Window Menu" }) +hl.bind(mainMod .. " + Tab", hl.dsp.exec_cmd("qs -p ~/.local/share/quickshell-overview ipc call overview toggle"), { description = "Open Select Window Menu" }) hl.bind("CTRL + ALT + T", hl.dsp.exec_cmd("~/.config/ml4w/themes/themes.sh"), { description = "Open Select Window Menu" }) +-- AZERTY keyboard layout setup (ported from ML4W 2.16 default.lua) +-- The profile owns SUPER + [0-9] workspace binds. On AZERTY the number row +-- needs Shift, so Hyprland sees the unshifted keysyms instead of the digits; +-- bind those keysyms here only when an AZERTY layout is detected, so QWERTY +-- layouts never get duplicate workspace binds. +local azerty_keys = { + fr = { "ampersand", "eacute", "quotedbl", "apostrophe", "parenleft", + "minus", "egrave", "underscore", "ccedilla", "agrave" }, + be = { "ampersand", "eacute", "quotedbl", "apostrophe", "parenleft", + "section", "egrave", "exclam", "ccedilla", "agrave" }, +} + +-- Variants of the layouts above that are not AZERTY +local non_azerty_variants = { + fr = { us = true, bepo = true, bepo_afnor = true, dvorak = true }, + be = { wang = true }, +} + +local function detect_azerty() + local f = io.open(os.getenv("HOME") .. "/.config/hypr/input.lua", "r") + if not f then return nil end + local content = f:read("*all") + f:close() + + -- kb_layout may be a list ("be,us"); the first entry is the primary one + local layout = content:match('kb_layout%s*=%s*"([^",]*)') + local variant = content:match('kb_variant%s*=%s*"([^",]*)') or "" + if not layout then return nil end + layout = layout:lower():gsub("%s", "") + variant = variant:lower():gsub("%s", "") + + local excluded = non_azerty_variants[layout] + if excluded and excluded[variant] then return nil end + return azerty_keys[layout] +end + +local ws_keys = detect_azerty() +if ws_keys then + for i = 1, 10 do + hl.bind(mainMod .. " + " .. ws_keys[i], hl.dsp.focus({ workspace = i }), { description = "Focus workspace " .. i }) + hl.bind(mainMod .. " + SHIFT + " .. ws_keys[i], hl.dsp.window.move({ workspace = i }), { description = "Move window to workspace " .. i }) + end +end + -- Special workspace (scratchpad) -- Note: SHIFT + S is profile-owned (screenshot screen), so only plain S here. hl.bind(mainMod .. " + S", hl.dsp.workspace.toggle_special("scratchpad"), { description = "Toggle special workspace scratchpad" }) diff --git a/odd/tasks/ml4w-2.16-gentleman-sync.md b/odd/tasks/ml4w-2.16-gentleman-sync.md new file mode 100644 index 00000000..e26772d5 --- /dev/null +++ b/odd/tasks/ml4w-2.16-gentleman-sync.md @@ -0,0 +1,110 @@ +# ML4W 2.16 + Gentleman.Dots sync + +## Objective + +Keep the Dreamcoder design system layered correctly on top of ML4W 2.16 and the +latest Gentleman.Dots `main`, so upstream upgrades no longer erase Dreamcoder hooks. + +## Problem + +- The user upgraded ML4W to 2.16 (upstream `3960570`); the repo pins `46f2ca7`. +- 2.16 moved the Quickshell overview to `~/.local/share/quickshell-overview`, dropped + waypaper from its install set, and added new keybinds. +- The ML4W upgrade rewrote ML4W-owned files, so Dreamcoder hooks injected into them + (`ml4w-wallpaper` block, `require('dreamcoder-colors')` in `hyprland.lua`) are gone. +- Gentleman.Dots `main` advanced to `6f44b79` (nvim clipboard over SSH); the pin is `0258450`. + +## Why + +Dreamcoder must survive upstream upgrades: its hooks should live in files ML4W never +ships (`custom.lua`, `keybindings/dreamcoder.lua`, Dreamcoder scripts), not in lines +injected into ML4W-owned files. + +## Scope + +- In: `ml4w_assets/hypr/conf/keybindings/dreamcoder.lua`, `scripts/apply-ml4w-hooks.sh`, + `scripts/generate-custom-lua.sh`, doctor/verify checks, `tests/ml4w/*`, + `docs/upstream-manifest.json`, `docs/configuration/ml4w.md`. +- Out: unrelated working-tree edits already present on `main` (fish prompt, warp, + apply-system-mode, bun completions) — never staged in this feature. + +## Constraints + +- Colors only from `DreamcoderThemes/dreamcoder/tokens.json`. +- Idempotent scripts; no secrets. +- TDD: off (no project/session TDD configuration found). Source: none. Runners: + `bats tests/ml4w/`, `python -m pytest tests/`. + +## Tasks + +- [x] T1 — Keybindings: point SUPER+Tab at `~/.local/share/quickshell-overview` + (fallback to the legacy path only if the new one is missing) and port the 2.16 + upstream keybind additions (ALT+B statusbar autohide, ALT+D dock autohide, + SHIFT+D reload dock, AZERTY detection) into `dreamcoder.lua`. Route: delegated writer. + - Evidence: commit `f7c2b9b` (`fix(ml4w): align the Dreamcoder keybind variant with ML4W 2.16`); + `bats tests/ml4w/keybindings_variant.bats` 4/4, `luac -p` ok. + - Rationale: mirrors upstream 2.16 exactly (no legacy fallback, a clean 2.16 install + has no `~/.config/quickshell/overview`); Reload Dock left unbound because SHIFT+D is + the profile theme toggle; AZERTY keysyms bound only on AZERTY (profile owns digits). + Pre-existing SUPER+SHIFT+arrows overlap (variant resize vs profile move) allowlisted + in the collision test, pending a product decision. +- [x] T2 — Upgrade-proof hooks: load `dreamcoder-colors` from `custom.lua` instead of + relying on an injected line in ML4W's `hyprland.lua`; update doctor to accept it. + Make the wallpaper hook idempotent, re-appliable after ML4W upgrades, with waypaper + optional; update bats fixtures to the 2.16 layout. Route: delegated writer. + - Evidence: commit `2ebb433` (`fix(ml4w): make Dreamcoder hooks survive ML4W upgrades`); + `bats tests/ml4w/` 85/85 ok; `python -m pytest tests/ -q` exit 0; shellcheck clean. + - Rationale: runner hook is a marked block replaced on each run (byte-identical on + re-run, migrates the old unmarked block, keeps symlinks); waypaper only when its + config exists. `custom.lua` now loads `dreamcoder-colors` (guarded) and doctor + prefers it. colors.lua/colors.conf checks accept managed regular files whose bytes + match a DreamcoderThemes `hypr-colors-*` variant: the sync writer writes through the + path and `_flip_bridge_symlinks` only flips existing symlinks, and the live 2.16 + files are regular and byte-identical to `hypr-colors-dark.*`. + - Caveat: `custom.lua` is required after ML4W's `conf.*`, so the late + `dreamcoder-colors` require only re-defines colour globals; borders get Dreamcoder + colours because the sync writes `colors.lua` itself. Live + `~/.config/hypr/hypr-colors-*.lua` (the `dreamcoder-colors.lua` targets) are stale + (Sep 10, older palette) — refresh in T4. +- [x] T3 — Pins and docs: bump `docs/upstream-manifest.json` (ML4W `3960570` / tag 2.16, + Gentleman.Dots `6f44b79`), update `docs/configuration/ml4w.md`. Route: delegated writer. + - Evidence: commit `chore(upstream): pin ML4W 2.16 and Gentleman.Dots 6f44b79`; + `git ls-remote` (2026-09-28): ML4W `HEAD` and `refs/tags/2.16` (lightweight) = + `3960570`, Gentleman.Dots `main` = `6f44b79`; `upstream-diff.py --check-pins` both + current; `verify-repo-sync.py` ok; markdown links ok. + - Rationale: the manifest schema forbids extra upstream keys, so the tag is recorded + in `provenance.method` and `docs/sources.md`. `docs/sources.md` and the + `test_verify_repo_sync.py` fixture must track the pins (docs-consistency check). + `docs/configuration/ml4w.md` documents the 2.16 keybind delta and the + "hooks live in Dreamcoder-owned files" rule; `docs/installation/linux.md` now + checks `custom.lua` for the loader. `docs/migration/*` had no stale references. +- [x] T4 — Apply to the live system and verify (`./scripts/dreamcoder sync`, hooks + re-applied, doctor/verify clean). Route: inline. + - Evidence: backup at `~/.config/hypr/.dreamcoder-backup-20260928`; refreshed + `hypr-colors-*`; `setup-hyprland.sh --profile asus-vivobook15` (custom.lua loader, + dreamcoder.lua, marked wallpaper hook); `dreamcoder sync` rewrote hypr colors; + `verify-ml4w-setup.sh` 20 passed / 0 failed; `dreamcoder doctor` guardrails passed; + `hyprctl configerrors` empty; SUPER+Tab → `~/.local/share/quickshell-overview`. + - Fixes found while applying: `61a1ec8` dispatcher exported PYTHONPATH only for + CONTROL routes (`dreamcoder sync` raised ModuleNotFoundError); `e715d09` verify + required a symlink for the sync-rendered `waybar/colors.css`. + - Pre-existing, out of scope: `tests/shell/test_apply_theme.bats` "kanagawa bridge + carries night-derived colors" also fails on clean `main`. + +## Acceptance criteria + +- `bats tests/ml4w/` and `python -m pytest tests/` pass. +- Re-running the ML4W hook application after an ML4W upgrade restores every Dreamcoder + hook without duplication. +- SUPER+Tab works on a clean ML4W 2.16 install. + +## Progress + +- Baseline (2026-09-28): bats 66/66 ok, pytest green. +- `~/Gentleman.Dots` moved from detached `1c3dcb3` (origin/nix-migration) to `main` @ `6f44b79`. +- Branch: `chore/ml4w-2.16-gentleman-sync`. + +## Next step + +Feature complete. Pending user decisions: SUPER+SHIFT+arrows overlap (resize vs +move), the pre-existing kanagawa bridge test, push/PR of the branch. diff --git a/scripts/apply-ml4w-hooks.sh b/scripts/apply-ml4w-hooks.sh index aa824276..ec2bed22 100755 --- a/scripts/apply-ml4w-hooks.sh +++ b/scripts/apply-ml4w-hooks.sh @@ -1,31 +1,93 @@ #!/usr/bin/env bash +# ============================================================================ +# apply-ml4w-hooks.sh — (re)install Dreamcoder's wallpaper hook into ML4W +# ============================================================================ +# Primary target: ML4W's wallpaper runner (~/.config/ml4w/scripts/ml4w-wallpaper, +# which receives the image as $IMAGE_PATH). ML4W 2.16 drives it directly from +# its Quickshell wallpaper app; waypaper is no longer part of its install set, +# so waypaper's post_command is hooked only when that config exists. +# +# ML4W upgrades overwrite the runner, so re-run this script after every ML4W +# update. The runner hook sits between markers and is replaced on each run, +# never duplicated; the unmarked block older releases of this script appended +# is migrated to the marked form. +# +# Overrides: ML4W_WALLPAPER_SCRIPT, ML4W_WALLPAPER_VAR (older ML4W releases used +# ~/.config/hypr/scripts/wallpaper.sh with $used_wallpaper), WAYPAPER_CONFIG. +# ============================================================================ set -euo pipefail source "${DREAMCODER_DOTS_DIR:-$(cd "$(dirname "$0")/.." && pwd)}/lib/env.sh" ensure_dots_dir WAYPAPER_CONFIG="${WAYPAPER_CONFIG:-${HOME}/.config/waypaper/config.ini}" -# Current ML4W runs ~/.config/ml4w/scripts/ml4w-wallpaper with $IMAGE_PATH; an older release symlinked ~/.config/hypr/scripts/wallpaper.sh and used $used_wallpaper. Both overridable. ML4W_WALLPAPER_SCRIPT="${ML4W_WALLPAPER_SCRIPT:-${HOME}/.config/ml4w/scripts/ml4w-wallpaper}" ML4W_WALLPAPER_VAR="${ML4W_WALLPAPER_VAR:-IMAGE_PATH}" -HOOK="${DREAMCODER_DOTS_DIR}/scripts/wallpaper-hook.sh \"\$wallpaper\" > /dev/null 2>&1" -BLOCK="\"${DREAMCODER_DOTS_DIR}/scripts/wallpaper-hook.sh\" \"\$${ML4W_WALLPAPER_VAR}\"" +HOOK_SCRIPT="${DREAMCODER_DOTS_DIR}/scripts/wallpaper-hook.sh" +BEGIN_MARK='# >>> Dreamcoder wallpaper hook >>>' +END_MARK='# <<< Dreamcoder wallpaper hook <<<' +LEGACY_MARK='# Dreamcoder final wallpaper/theme sync' -if [[ -f "${WAYPAPER_CONFIG}" ]] && ! grep -q 'wallpaper-hook.sh' "${WAYPAPER_CONFIG}"; then - # `&` in a sed replacement expands to the whole match, and the hook text - # contains `2>&1`; unescaped it corrupts the line by re-inserting the match. - HOOK_SED="${HOOK//&/\\&}" - sed -i "s|^post_command = \(.*\)|post_command = \1; ${HOOK_SED}|" "${WAYPAPER_CONFIG}" -fi +# Print the runner without any Dreamcoder block (marked or legacy) and without +# trailing blank lines, so re-appending always yields the same bytes. +strip_runner_hook() { + awk -v begin="${BEGIN_MARK}" -v end="${END_MARK}" -v legacy="${LEGACY_MARK}" ' + $0 == begin { skip = 1; next } + skip && $0 == end { skip = 0; next } + $0 == legacy { in_legacy = 1; next } + in_legacy { if ($0 ~ /^fi[[:space:]]*$/) in_legacy = 0; next } + skip { next } + { lines[++n] = $0 } + END { + while (n > 0 && lines[n] ~ /^[[:space:]]*$/) n-- + for (i = 1; i <= n; i++) print lines[i] + } + ' "$1" +} -if [[ -f "${ML4W_WALLPAPER_SCRIPT}" ]] && ! grep -q 'wallpaper-hook.sh' "${ML4W_WALLPAPER_SCRIPT}"; then - cat >>"${ML4W_WALLPAPER_SCRIPT}" <&2 + return 0 + fi + local current desired + current="$(cat "${ML4W_WALLPAPER_SCRIPT}")" + desired="$(strip_runner_hook "${ML4W_WALLPAPER_SCRIPT}") -# Dreamcoder final wallpaper/theme sync -if [[ -x "${DREAMCODER_DOTS_DIR}/scripts/wallpaper-hook.sh" ]]; then - ${BLOCK} -fi -ML4W_HOOK +${BEGIN_MARK} +# Managed by dreamcoder-dots scripts/apply-ml4w-hooks.sh; re-run it after ML4W upgrades. +if [[ -x \"${HOOK_SCRIPT}\" ]]; then + \"${HOOK_SCRIPT}\" \"\$${ML4W_WALLPAPER_VAR}\" fi +${END_MARK}" + if [[ "${current}" == "${desired}" ]]; then + printf '✓ ML4W wallpaper runner hook already current\n' + return 0 + fi + # Write through the path (no temp-file rename) so a symlinked runner stays a + # symlink and keeps its executable mode. + printf '%s\n' "${desired}" >"${ML4W_WALLPAPER_SCRIPT}" + printf '✓ ML4W wallpaper runner hooked: %s\n' "${ML4W_WALLPAPER_SCRIPT}" +} + +hook_waypaper() { + if [[ ! -f "${WAYPAPER_CONFIG}" ]]; then + printf '✓ waypaper config absent (not used by ML4W 2.16), skipped\n' + return 0 + fi + if grep -q 'wallpaper-hook.sh' "${WAYPAPER_CONFIG}"; then + printf '✓ waypaper post_command hook already present\n' + return 0 + fi + local hook="${HOOK_SCRIPT} \"\$wallpaper\" > /dev/null 2>&1" + # `&` in a sed replacement expands to the whole match, and the hook text + # contains `2>&1`; unescaped it corrupts the line by re-inserting the match. + local hook_sed="${hook//&/\\&}" + sed -i "s|^post_command = \(.*\)|post_command = \1; ${hook_sed}|" "${WAYPAPER_CONFIG}" + printf '✓ waypaper post_command hooked: %s\n' "${WAYPAPER_CONFIG}" +} + +hook_ml4w_runner +hook_waypaper "${DREAMCODER_DOTS_DIR}/scripts/theme-auto.sh" printf '✓ Dreamcoder ML4W hooks applied\n' diff --git a/scripts/doctor.sh b/scripts/doctor.sh index 755e5484..f8dc33f2 100755 --- a/scripts/doctor.sh +++ b/scripts/doctor.sh @@ -5,6 +5,7 @@ source "${DREAMCODER_DOTS_DIR:-$(cd "$(dirname "$0")/.." && pwd)}/lib/logging.sh source "${DREAMCODER_DOTS_DIR}/lib/env.sh" source "${DREAMCODER_DOTS_DIR}/lib/checks.sh" source "${DREAMCODER_DOTS_DIR}/lib/safety.sh" +source "${DREAMCODER_DOTS_DIR}/lib/ml4w.sh" ensure_dots_dir @@ -201,7 +202,18 @@ section '3. Colour file chain' check_symlink "${CONFIG_HOME}/waybar/colors.css" "" "waybar → dreamcoder" check_symlink "${CONFIG_HOME}/wlogout/colors.css" "../../waybar/colors.css" "wlogout → waybar" check_symlink "${CONFIG_HOME}/swaync/colors.css" "../../waybar/colors.css" "swaync → waybar" -check_symlink "${CONFIG_HOME}/hypr/colors.lua" "" "hypr → dreamcoder" +# Hyprland colour files may be managed regular files (ML4W 2.16 ships them as +# regular files and the theme sync writes through them), so check content. +for hypr_colors in colors.lua colors.conf; do + hypr_colors_path="${CONFIG_HOME}/hypr/${hypr_colors}" + if hypr_colors_is_dreamcoder "${hypr_colors_path}"; then + ok "hypr/${hypr_colors} carries Dreamcoder colours" + elif [[ -e "${hypr_colors_path}" ]]; then + fail "hypr/${hypr_colors} has no Dreamcoder colours — run ./scripts/dreamcoder sync" + else + fail "missing: ${hypr_colors_path}" + fi +done # Verify colour files exist if [[ -L "${CONFIG_HOME}/waybar/colors.css" ]]; then diff --git a/scripts/dreamcoder b/scripts/dreamcoder index 9ad6e80d..3ad26059 100755 --- a/scripts/dreamcoder +++ b/scripts/dreamcoder @@ -6,7 +6,10 @@ ENV_FILE="${DREAMCODER_DOTS_ENV:-${0%/*}/dreamcoder-env.sh}" [[ -f "${ENV_FILE}" ]] && source "${ENV_FILE}" CMD="${1:-help}" shift || true -CONTROL() { PYTHONPATH="${DREAMCODER_DOTS_DIR}/src${PYTHONPATH:+:${PYTHONPATH}}" python3 -m dreamcoder_theme.control "$@"; } +# Every Python entrypoint imports dreamcoder_theme from src/; the package is +# not required to be installed, so expose it to all routes, not only CONTROL. +export PYTHONPATH="${DREAMCODER_DOTS_DIR}/src${PYTHONPATH:+:${PYTHONPATH}}" +CONTROL() { python3 -m dreamcoder_theme.control "$@"; } case "${CMD}" in install) exec "${DREAMCODER_DOTS_DIR}/scripts/install.sh" "$@" ;; diff --git a/scripts/generate-custom-lua.sh b/scripts/generate-custom-lua.sh index e3db8ed9..e56f5c4d 100755 --- a/scripts/generate-custom-lua.sh +++ b/scripts/generate-custom-lua.sh @@ -153,8 +153,39 @@ if [[ "${BINDINGS_COUNT}" -eq 0 ]]; then fi # ── generate Lua ──────────────────────────────────────────────────────────── +# Header plus the Dreamcoder colour loader. Emitted even for an empty +# profile: custom.lua is a file ML4W never ships (its hyprland.lua only +# requires it when present), so ML4W upgrades that rewrite hyprland.lua +# cannot drop this require the way they dropped the old injected line. +emit_header() { + cat </dev/null || echo "") - if [[ "$COLOR_TARGET" == *"${CURRENT_MODE}"* ]]; then + if [[ -z "$COLOR_TARGET" ]] && waybar_colors_is_dreamcoder "${HOME}/.config/waybar/colors.css"; then + ok "waybar/colors.css is rendered by the sync for the active mode" + elif [[ "$COLOR_TARGET" == *"${CURRENT_MODE}"* ]]; then ok "waybar/colors.css matches current mode" else warn "waybar/colors.css (${COLOR_TARGET}) may not match mode (${CURRENT_MODE})" diff --git a/src/dreamcoder_theme/doctor.py b/src/dreamcoder_theme/doctor.py index 4d6a43e4..3a008fb3 100644 --- a/src/dreamcoder_theme/doctor.py +++ b/src/dreamcoder_theme/doctor.py @@ -39,23 +39,35 @@ def summarize_checks(checks: list[dict[str, str]]) -> dict[str, int]: } +def _check_hypr_colors_loader(ch: Path) -> HealthCheck: + """Check that Hyprland loads dreamcoder-colors. + + The loader belongs in custom.lua: Dreamcoder generates it and ML4W never + ships it, so ML4W upgrades that rewrite hyprland.lua cannot drop it. A + require injected into hyprland.lua is still accepted as a legacy layout. + """ + hypr = ch / "hypr" + for candidate in (hypr / "custom.lua", hypr / "hyprland.lua"): + if candidate.exists() and "dreamcoder-colors" in candidate.read_text(errors="ignore"): + return HealthCheck( + name="hyprland dreamcoder import", + status="ok", + detail=str(candidate), + repair="", + ) + return HealthCheck( + name="hyprland dreamcoder import", + status="warn", + detail=f"no dreamcoder-colors loader in {hypr / 'custom.lua'}", + repair="scripts/generate-custom-lua.sh (custom.lua loads dreamcoder-colors)", + ) + + def _check_ml4w_hooks(ch: Path) -> list[HealthCheck]: """Check ML4W integration hooks.""" ml4w_checks: list[HealthCheck] = [] - # Hyprland dreamcoder-colors import - hypr_lua = ch / "hypr" / "hyprland.lua" - dc_imported = False - if hypr_lua.exists(): - dc_imported = "dreamcoder-colors" in hypr_lua.read_text(errors="ignore") - ml4w_checks.append( - HealthCheck( - name="hyprland dreamcoder import", - status="ok" if dc_imported else "warn", - detail=str(hypr_lua) if hypr_lua.exists() else "missing hyprland.lua", - repair="Add require('dreamcoder-colors') after require('colors') in hyprland.lua", - ) - ) + ml4w_checks.append(_check_hypr_colors_loader(ch)) # Btop theme btop_theme = ch / "btop" / "themes" / "dreamcoder.theme" diff --git a/src/dreamcoder_theme/repair_engine.py b/src/dreamcoder_theme/repair_engine.py index 8fc1c71b..a777c98b 100644 --- a/src/dreamcoder_theme/repair_engine.py +++ b/src/dreamcoder_theme/repair_engine.py @@ -17,7 +17,7 @@ "restore-fish-config": "Restore missing Fish config from repo.", "restore-active-kitty-colors": "Restore active Kitty colors from repo.", "restore-btop-theme": "Deploy Btop theme from repo.", - "restore-hypr-import": "Add dreamcoder-colors import in hyprland.lua.", + "restore-hypr-import": "Regenerate custom.lua so it loads dreamcoder-colors.", "restore-bat-themes": "Deploy Bat themes from repo.", "enable-auto-timer": "Enable dreamcoder auto-theme timer via systemctl.", } diff --git a/tests/fixtures/ml4w/ml4w-wallpaper-2.16 b/tests/fixtures/ml4w/ml4w-wallpaper-2.16 new file mode 100644 index 00000000..d009e058 --- /dev/null +++ b/tests/fixtures/ml4w/ml4w-wallpaper-2.16 @@ -0,0 +1,259 @@ +#!/usr/bin/env bash + +# ============================================================ +# Variables +# ============================================================ +IMAGE_PATH="" +EFFECT="" +NOTIFICATIONS=false +SKIP_WALLPAPER=false +SKIP_THEMING=false +MONITOR_OUTPUT="" +AWWW_CROP_GRAVITY="center" +CACHE_FOLDER="$HOME/.cache/ml4w/hyprland-dotfiles" +CACHE_FILE="$CACHE_FOLDER/current_wallpaper" +DEFAULT_WALLPAPER="$HOME/.config/ml4w/wallpapers/default.jpg" +BLURRED_WALLPAPER="$CACHE_FOLDER/blurred_wallpaper.png" +SQUARE_WALLPAPER="$CACHE_FOLDER/square_wallpaper.png" +RASI_FILE="$CACHE_FOLDER/current_wallpaper.rasi" +SETTINGS_BLUR="$HOME/.config/ml4w/settings/blur.sh" +SETTINGS_WALLPAPER_FOLDER="$HOME/.config/ml4w/settings/wallpaper-folder" +SETTINGS_WALLPAPER_EFFECT="$HOME/.config/ml4w/settings/wallpaper-effect" +SETTINGS_TRANSITION_EFFECT=$(cat "$HOME/.config/ml4w/settings/wallpaper-transition-effect") +BLUR=$(cat "$SETTINGS_BLUR") + +# ============================================================ +# Logging +# ============================================================ +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' +info() { echo -e "${GREEN}[INFO]${NC} $1" >&2; } +warn() { echo -e "${YELLOW}[WARN]${NC} $1" >&2; } +error() { echo -e "${RED}[ERROR]${NC} $1" >&2; } + +# ============================================================ +# Functions +# ============================================================ + +show_help() { + echo "Usage: ml4w-wallpaper PATH_TO_IMAGE [OPTIONS]" + echo " DEFAULT: $DEFAULT_WALLPAPER" + echo "" + echo "Core Parameters:" + echo " PATH_TO_IMAGE Path to the image file to set as wallpaper." + echo "" + echo "Optional Parameters:" + echo " --effect NAME Apply a specific wallpaper effect." + echo " --random [FOLDER] Select a random image from FOLDER." + echo " --notifications Enable desktop notifications." + echo " --skip-wallpaper Will skip setting the wallpaper with awww." + echo " --skip-theming Will skip updating OS themes based on new wallpaper." + echo " --skip Alias for \"--skip-wallpaper\"." + echo " --monitor Specify which input to apply wallpaper change (default applies to all outputs)" + echo " --crop-gravity Specify where to anchor wallpaper image in case cropping is needed (values from 'awww img --crop-gravity; default is center)" + echo " --help Show this help message and exit." +} + +send_notification() { + [ "$NOTIFICATIONS" = true ] || return + notify-send -a "ml4w-wallpaper" -i "preferences-desktop-wallpaper-symbolic" "$1" "$2" +} + +apply_effect() { + [ -f "$SETTINGS_WALLPAPER_EFFECT" ] || return + EFFECT=$(cat "$SETTINGS_WALLPAPER_EFFECT") + [ "$EFFECT" = "off" ] && return + info "Applying wallpaper effect: $EFFECT" + local cached="$CACHE_FOLDER/$EFFECT-$(basename "$IMAGE_PATH")" + [ ! -f "$cached" ] && cp "$IMAGE_PATH" "$cached" + IMAGE_PATH="$cached" + source "$HOME/.config/hypr/effects/wallpaper/$EFFECT" +} + +wait_for_awww() { + local max=300 count=0 + until awww query >/dev/null 2>&1; do + (( count++ )) + if (( count >= max )); then + error "awww-daemon did not start within 30s" + exit 1 + fi + sleep 0.1 + done +} + +set_wallpaper() { + local awww_crop_gravity_args + if [ "$SKIP_WALLPAPER" = true ]; then + info "Wallpaper set skipped" + return + fi + read AWWW_MINOR AWWW_PATCH <<< $(awww --version | awk -F. '{print $2 " " $3}') + (( $AWWW_MINOR > 12 || $AWWW_MINOR == 12 && $AWWW_PATCH > 0 )) \ + && awww_crop_gravity_args="--resize crop --crop-gravity $AWWW_CROP_GRAVITY" \ + || awww_crop_gravity_args="" + info "Setting wallpaper: $IMAGE_PATH${MONITOR_OUTPUT:+ on monitor $MONITOR_OUTPUT}" + local max=30 count=0 + until awww img "$IMAGE_PATH" ${MONITOR_OUTPUT:+-o $MONITOR_OUTPUT} $awww_crop_gravity_args --transition-type "$SETTINGS_TRANSITION_EFFECT" 2>/dev/null; do + (( count++ )) + if (( count >= max )); then + error "Failed to set wallpaper after ${max}s (no valid outputs)" + return 1 + fi + sleep 1 + done +} + +run_matugen() { + local theme_pref + theme_pref=$(grep -E '^gtk-application-prefer-dark-theme=' "$HOME/.config/gtk-3.0/settings.ini" | awk -F'=' '{print $2}') + + local mode="light" + + case "$theme_pref" in + 1|true) + mode="dark" + ;; + esac + + local bin="matugen" + [ -f "$HOME/.cargo/bin/matugen" ] && bin="$HOME/.cargo/bin/matugen" + [ -f "$HOME/.local/bin/matugen" ] && bin="$HOME/.local/bin/matugen" + + info "Running matugen in $mode mode" + "$bin" image "$IMAGE_PATH" --source-color-index 0 -m "$mode" + info "Matugen updated" +} + +reload_waybar() { + if pgrep -x waybar >/dev/null; then + info "Waybar is currently running." + killall waybar || true + sleep 0.5 + else + info "Waybar not running" + fi + nohup bash -c "$HOME/.config/waybar/launch.sh" > /dev/null 2>&1 & + disown + info "Waybar restarted" +} + +reload_quickshell() { + if pgrep -x qs >/dev/null; then + qs ipc call theme-manager reload + qs -p "$HOME/.local/share/ml4w-dotfiles-settings/quickshell" ipc call theme-manager reload + info "Quickshell theme updated" + else + warn "Quickshell not running, skipping theme reload" + fi +} + +reload_pywalfox() { + type pywalfox >/dev/null 2>&1 || return + pywalfox update + info "Pywalfox updated" +} + +reload_swaync() { + sleep 0.1 + swaync-client -rs + info "SwayNC refreshed" +} + +generate_image_variants() { + magick "$IMAGE_PATH" -resize 75% "$BLURRED_WALLPAPER" + if [ "$BLUR" != "0x0" ]; then + magick "$BLURRED_WALLPAPER" -blur "$BLUR" "$BLURRED_WALLPAPER" + fi + info "Blurred wallpaper created" + magick "$IMAGE_PATH" -gravity Center -extent 1:1 "$SQUARE_WALLPAPER" + info "Square wallpaper created" + echo "* { current-image: url(\"$BLURRED_WALLPAPER\", height); }" > "$RASI_FILE" + info "Rasi file updated" +} + +# ============================================================ +# Parameter Parsing +# ============================================================ +[[ $# -eq 0 ]] && { show_help; exit 1; } + +while [[ $# -gt 0 ]]; do + case "$1" in + --skip|--skip-wallpaper) + SKIP_WALLPAPER=true; shift ;; + --skip-theming) + SKIP_THEMING=true; shift ;; + --effect) + EFFECT="$2"; shift 2 ;; + --random) + if [[ -n "$2" && "$2" != -* ]]; then + RANDOM_FOLDER="$2"; shift 2 + else + RAW_FOLDER=$(cat "$SETTINGS_WALLPAPER_FOLDER") + RANDOM_FOLDER="${RAW_FOLDER//\$HOME/$HOME}" + RANDOM_FOLDER="${RANDOM_FOLDER//\~/$HOME}" + shift 1 + fi + if [[ ! -d "$RANDOM_FOLDER" ]]; then + error "Directory does not exist -> $RANDOM_FOLDER"; exit 1 + fi + IMAGE_PATH=$(find "$RANDOM_FOLDER" -maxdepth 1 -type f \ + \( -iname "*.jpg" -o -iname "*.jpeg" -o -iname "*.png" \) | shuf -n 1) + [[ -z "$IMAGE_PATH" ]] && { error "No valid images found in -> $RANDOM_FOLDER"; exit 1; } + info "Random wallpaper selected: $IMAGE_PATH" ;; + --notifications) + NOTIFICATIONS=true; shift ;; + --monitor) + MONITOR_OUTPUT="$2"; shift 2 ;; + --crop-gravity) + AWWW_CROP_GRAVITY="$2"; shift 2 ;; + --help) + show_help; exit 0 ;; + -*) + error "Unknown option: $1"; show_help; exit 1 ;; + *) + if [[ -z "$IMAGE_PATH" ]]; then + IMAGE_PATH="$1" + else + error "Multiple image paths provided -> $1"; exit 1 + fi + shift ;; + esac +done + +# ============================================================ +# Validation +# ============================================================ +if [[ -z "$IMAGE_PATH" ]]; then + info "Using default wallpaper -> $DEFAULT_WALLPAPER" + IMAGE_PATH="$DEFAULT_WALLPAPER" +fi + +if [[ ! -f "$IMAGE_PATH" ]]; then + error "Image file does not exist -> $IMAGE_PATH"; exit 1 +fi + +mkdir -p "$CACHE_FOLDER" +echo "$IMAGE_PATH" > "$CACHE_FILE" +info "Cache updated: $IMAGE_PATH" + +# ============================================================ +# Main +# ============================================================ +apply_effect +wait_for_awww +set_wallpaper +if "$SKIP_THEMING"; then + info "Updating theming skipped" +else + run_matugen + reload_waybar + reload_quickshell + reload_pywalfox + reload_swaync + generate_image_variants +fi +send_notification "Wallpaper Updated" "$(basename "$IMAGE_PATH")${MONITOR_OUTPUT:+ on output $MONITOR_OUTPUT}" +info "Done" diff --git a/tests/ml4w/apply_ml4w_hooks.bats b/tests/ml4w/apply_ml4w_hooks.bats index 60a3f9e7..8e0797f2 100644 --- a/tests/ml4w/apply_ml4w_hooks.bats +++ b/tests/ml4w/apply_ml4w_hooks.bats @@ -2,9 +2,16 @@ # ============================================================================ # Tests for scripts/apply-ml4w-hooks.sh # ============================================================================ -# The waypaper injection goes through `sed`. In a sed replacement, `&` expands to -# the whole match, and the injected hook text contains `2>&1`, so an unescaped -# `&` re-inserts the matched line and corrupts the config with a nested copy. +# Primary target is ML4W's wallpaper runner. The fixture is ML4W tag 2.16's +# dotfiles/.config/ml4w/scripts/ml4w-wallpaper (upstream 3960570, trailing +# whitespace trimmed). ML4W upgrades overwrite that file, so the hook must be +# re-appliable: replaced on every run, never duplicated. +# +# The optional waypaper injection goes through `sed`. In a sed replacement, `&` +# expands to the whole match, and the injected hook text contains `2>&1`, so an +# unescaped `&` re-inserts the matched line and corrupts the config. + +RUNNER_FIXTURE="${BATS_TEST_DIRNAME}/../fixtures/ml4w/ml4w-wallpaper-2.16" setup() { TEST_DIR="$(mktemp -d)" @@ -15,9 +22,10 @@ setup() { printf '#!/usr/bin/env bash\nexit 0\n' >"${TEST_DIR}/scripts/wallpaper-hook.sh" chmod +x "${TEST_DIR}/scripts/"*.sh WAYPAPER_CONFIG="${TEST_DIR}/config.ini" - ML4W_WALLPAPER_SCRIPT="${TEST_DIR}/wallpaper.sh" + ML4W_WALLPAPER_SCRIPT="${TEST_DIR}/ml4w-wallpaper" printf '[Settings]\npost_command = ~/.config/ml4w/scripts/ml4w-wallpaper "$wallpaper" --skip > /dev/null 2>&1\n' >"${WAYPAPER_CONFIG}" - printf '#!/usr/bin/env bash\n' >"${ML4W_WALLPAPER_SCRIPT}" + cp "${RUNNER_FIXTURE}" "${ML4W_WALLPAPER_SCRIPT}" + chmod +x "${ML4W_WALLPAPER_SCRIPT}" } teardown() { @@ -35,36 +43,87 @@ count_in_post_command() { grep '^post_command' "${WAYPAPER_CONFIG}" | grep -o "$1" | wc -l | tr -d ' ' } -@test "apply-ml4w-hooks: the injected post_command stays a single well-formed line" { +count_blocks() { + grep -c '^# >>> Dreamcoder wallpaper hook >>>$' "${ML4W_WALLPAPER_SCRIPT}" || true +} + +# ── ML4W runner (primary) ──────────────────────────────────────────────────── + +@test "apply-ml4w-hooks: the 2.16 runner gets exactly one marked block" { run run_hooks [ "$status" -eq 0 ] - [ "$(count_in_post_command 'post_command =')" -eq 1 ] - [ "$(count_in_post_command 'wallpaper-hook.sh')" -eq 1 ] - grep -q 'wallpaper-hook.sh.*2>&1$' "${WAYPAPER_CONFIG}" + [ "$(count_blocks)" -eq 1 ] + grep -q 'wallpaper-hook.sh" "\$IMAGE_PATH"' "${ML4W_WALLPAPER_SCRIPT}" } -@test "apply-ml4w-hooks: running twice does not inject a second hook" { +@test "apply-ml4w-hooks: the hook runs after the runner's own work" { run_hooks + local done_line hook_line + done_line="$(grep -n '^info "Done"$' "${ML4W_WALLPAPER_SCRIPT}" | cut -d: -f1)" + hook_line="$(grep -n 'Dreamcoder wallpaper hook >>>' "${ML4W_WALLPAPER_SCRIPT}" | cut -d: -f1)" + [ "${hook_line}" -gt "${done_line}" ] +} + +@test "apply-ml4w-hooks: the hooked runner is still valid bash and executable" { run_hooks - [ "$(count_in_post_command 'wallpaper-hook.sh')" -eq 1 ] - [ "$(count_in_post_command 'post_command =')" -eq 1 ] + bash -n "${ML4W_WALLPAPER_SCRIPT}" + [ -x "${ML4W_WALLPAPER_SCRIPT}" ] +} + +@test "apply-ml4w-hooks: re-running leaves the runner byte-identical" { + run_hooks + cp "${ML4W_WALLPAPER_SCRIPT}" "${TEST_DIR}/first" + run run_hooks + [ "$status" -eq 0 ] + [[ "$output" == *"already current"* ]] + cmp -s "${TEST_DIR}/first" "${ML4W_WALLPAPER_SCRIPT}" } -@test "apply-ml4w-hooks: the ML4W wallpaper block is appended exactly once" { +@test "apply-ml4w-hooks: an ML4W upgrade that restores the runner is re-hooked" { run_hooks + cp "${RUNNER_FIXTURE}" "${ML4W_WALLPAPER_SCRIPT}" + [ "$(count_blocks)" -eq 0 ] run_hooks - [ "$(grep -c 'Dreamcoder final wallpaper/theme sync' "${ML4W_WALLPAPER_SCRIPT}")" -eq 1 ] + [ "$(count_blocks)" -eq 1 ] } -@test "apply-ml4w-hooks: the config is not left with an unescaped match copy" { +@test "apply-ml4w-hooks: a stale block is replaced, not duplicated" { run_hooks - # The corrupt form repeats the original assignment inside the line. - [ "$(count_in_post_command 'ml4w-wallpaper')" -eq 1 ] + sed -i 's|wallpaper-hook.sh|old-path/wallpaper-hook.sh|' "${ML4W_WALLPAPER_SCRIPT}" + run_hooks + [ "$(count_blocks)" -eq 1 ] + run grep -q 'old-path' "${ML4W_WALLPAPER_SCRIPT}" + [ "$status" -ne 0 ] } -@test "apply-ml4w-hooks: the appended block reads the live runner's variable" { +@test "apply-ml4w-hooks: the unmarked block of older releases is migrated" { + cat >>"${ML4W_WALLPAPER_SCRIPT}" <&1$' "${WAYPAPER_CONFIG}" +} + +@test "apply-ml4w-hooks: running twice does not inject a second waypaper hook" { + run_hooks + run_hooks + [ "$(count_in_post_command 'wallpaper-hook.sh')" -eq 1 ] + [ "$(count_in_post_command 'post_command =')" -eq 1 ] +} + +@test "apply-ml4w-hooks: the config is not left with an unescaped match copy" { + run_hooks + # The corrupt form repeats the original assignment inside the line. + [ "$(count_in_post_command 'ml4w-wallpaper')" -eq 1 ] +} diff --git a/tests/ml4w/generate_custom_lua.bats b/tests/ml4w/generate_custom_lua.bats index 5bb18543..d64a1f64 100644 --- a/tests/ml4w/generate_custom_lua.bats +++ b/tests/ml4w/generate_custom_lua.bats @@ -142,3 +142,18 @@ load '../helpers/setup' [ "$status" -eq 0 ] [[ "$output" == *"default.json"* ]] } + +@test "generate-custom-lua: custom.lua loads dreamcoder-colors exactly once, guarded" { + run bash "${DREAMCODER_DOTS_DIR}/scripts/generate-custom-lua.sh" \ + --profile asus-vivobook15 --dry-run + [ "$status" -eq 0 ] + # ML4W upgrades rewrite hyprland.lua, so the loader lives in custom.lua. + [ "$(grep -c 'require("dreamcoder-colors")' <<<"$output")" -eq 1 ] + [[ "$output" == *'io.open(os.getenv("HOME") .. "/.config/hypr/dreamcoder-colors.lua", "r")'* ]] +} + +@test "generate-custom-lua: regenerating keeps a single colour loader" { + bash "${DREAMCODER_DOTS_DIR}/scripts/generate-custom-lua.sh" --profile default >/dev/null + bash "${DREAMCODER_DOTS_DIR}/scripts/generate-custom-lua.sh" --profile default >/dev/null + [ "$(grep -c 'require("dreamcoder-colors")' "${HOME}/.config/hypr/custom.lua")" -eq 1 ] +} diff --git a/tests/ml4w/keybindings_variant.bats b/tests/ml4w/keybindings_variant.bats new file mode 100644 index 00000000..e8f5feee --- /dev/null +++ b/tests/ml4w/keybindings_variant.bats @@ -0,0 +1,57 @@ +# ============================================================================ +# BATS tests: ml4w_assets/hypr/conf/keybindings/dreamcoder.lua +# ============================================================================ +# The curated variant tracks upstream ML4W default.lua (tag 2.16) minus the +# binds the machine profile owns. These checks keep it aligned with the 2.16 +# layout and free of collisions with the profile. + +load '../helpers/setup' + +variant() { printf '%s' "${DREAMCODER_DOTS_DIR}/ml4w_assets/hypr/conf/keybindings/dreamcoder.lua"; } +profile() { printf '%s' "${DREAMCODER_DOTS_DIR}/DreamcoderProfiles/dreamcoder/asus-vivobook15.json"; } + +@test "keybindings variant: parses as Lua" { + command -v luac >/dev/null || skip "luac not installed" + run luac -p "$(variant)" + [ "$status" -eq 0 ] +} + +@test "keybindings variant: SUPER+Tab opens the 2.16 overview location" { + run grep -F 'qs -p ~/.local/share/quickshell-overview ipc call overview toggle' "$(variant)" + [ "$status" -eq 0 ] + run grep -F '.config/quickshell/overview' "$(variant)" + [ "$status" -ne 0 ] +} + +@test "keybindings variant: ports the 2.16 autohide toggles" { + run grep -F '" + ALT + B", hl.dsp.exec_cmd("~/.config/ml4w/scripts/ml4w-toggle-statusbar-autohide")' "$(variant)" + [ "$status" -eq 0 ] + run grep -F '" + ALT + D", hl.dsp.exec_cmd("~/.config/ml4w/scripts/ml4w-toggle-dock-autohide")' "$(variant)" + [ "$status" -eq 0 ] +} + +# Pre-existing overlap, not introduced by the 2.16 port: the variant resizes +# with SUPER + SHIFT + arrows (upstream default.lua) while the profile moves +# windows on the same combos. Resolving it is a product decision; listed here +# so any NEW collision still fails. +KNOWN_OVERLAPS='SUPER+SHIFT+DOWN +SUPER+SHIFT+LEFT +SUPER+SHIFT+RIGHT +SUPER+SHIFT+UP' + +@test "keybindings variant: no mainMod bind collides with the profile" { + command -v jq >/dev/null || skip "jq not installed" + # Profile combos, normalised to "MOD+MOD+KEY" in upper case. + jq -r '.keybindings.bindings[] | select((.mouse // false) | not) + | ((.mods // []) + [.key]) | join("+") | ascii_upcase' "$(profile)" \ + | sort -u >"${BATS_TEST_TMPDIR}/profile" + # Static variant combos: hl.bind(mainMod .. " + X + Y", ...). + grep -oE 'hl\.bind\(mainMod \.\. " \+ [^"]+"' "$(variant)" \ + | sed -E 's/.*" \+ ([^"]+)"/SUPER + \1/; s/ \+ /+/g' | tr '[:lower:]' '[:upper:]' \ + | sort -u >"${BATS_TEST_TMPDIR}/variant" + printf '%s\n' "${KNOWN_OVERLAPS}" | sort -u >"${BATS_TEST_TMPDIR}/known" + comm -12 "${BATS_TEST_TMPDIR}/profile" "${BATS_TEST_TMPDIR}/variant" >"${BATS_TEST_TMPDIR}/overlap" + run comm -23 "${BATS_TEST_TMPDIR}/overlap" "${BATS_TEST_TMPDIR}/known" + [ "$status" -eq 0 ] + [ -z "$output" ] +} diff --git a/tests/ml4w/ml4w_managed.bats b/tests/ml4w/ml4w_managed.bats index be645c3d..2eb52f0c 100644 --- a/tests/ml4w/ml4w_managed.bats +++ b/tests/ml4w/ml4w_managed.bats @@ -122,3 +122,81 @@ load_ml4w_lib() { [[ "${lines[0]}" == "flags:"* ]] [[ "${lines[0]#flags:}" != *e* ]] } + +# ── hypr_colors_is_dreamcoder ──────────────────────────────────────────────── +# ML4W 2.16 ships colors.lua / colors.conf as regular files and the theme sync +# writes Dreamcoder content through them, so a regular file is valid when its +# bytes match a DreamcoderThemes variant. + +@test "ml4w lib: regular colors.lua with Dreamcoder content is accepted" { + mkdir -p "${HOME}/.config/hypr" + cp "${DREAMCODER_DOTS_DIR}/DreamcoderThemes/dreamcoder/hypr-colors-dark.lua" "${HOME}/.config/hypr/colors.lua" + + load_ml4w_lib + run hypr_colors_is_dreamcoder "${HOME}/.config/hypr/colors.lua" + [ "$status" -eq 0 ] +} + +@test "ml4w lib: regular colors.conf with Dreamcoder light content is accepted" { + mkdir -p "${HOME}/.config/hypr" + cp "${DREAMCODER_DOTS_DIR}/DreamcoderThemes/dreamcoder/hypr-colors-light.conf" "${HOME}/.config/hypr/colors.conf" + + load_ml4w_lib + run hypr_colors_is_dreamcoder "${HOME}/.config/hypr/colors.conf" + [ "$status" -eq 0 ] +} + +@test "ml4w lib: regular colors.lua with foreign (Matugen) content is rejected" { + mkdir -p "${HOME}/.config/hypr" + printf 'primary = "rgba(ff0000ff)"\n' >"${HOME}/.config/hypr/colors.lua" + + load_ml4w_lib + run hypr_colors_is_dreamcoder "${HOME}/.config/hypr/colors.lua" + [ "$status" -ne 0 ] +} + +@test "ml4w lib: a symlink to a Dreamcoder variant is accepted" { + mkdir -p "${HOME}/.config/hypr" + ln -s "${DREAMCODER_DOTS_DIR}/DreamcoderThemes/dreamcoder/hypr-colors-night.lua" "${HOME}/.config/hypr/colors.lua" + + load_ml4w_lib + run hypr_colors_is_dreamcoder "${HOME}/.config/hypr/colors.lua" + [ "$status" -eq 0 ] +} + +@test "ml4w lib: a missing colour file is rejected" { + load_ml4w_lib + run hypr_colors_is_dreamcoder "${HOME}/.config/hypr/colors.lua" + [ "$status" -ne 0 ] +} + +# ── waybar_colors_is_dreamcoder ────────────────────────────────────────────── +# The theme sync renders waybar/colors.css in place with a generator header. + +@test "ml4w lib: sync-rendered waybar colors.css is accepted" { + mkdir -p "${HOME}/.config/waybar" + printf '/*\n * Dreamcoder colors for ML4W Waybar\n * Generated by Dreamcoder sync — only replaces Matugen color values.\n */\n@define-color background #000000;\n' >"${HOME}/.config/waybar/colors.css" + + load_ml4w_lib + run waybar_colors_is_dreamcoder "${HOME}/.config/waybar/colors.css" + [ "$status" -eq 0 ] +} + +@test "ml4w lib: Matugen waybar colors.css is rejected" { + mkdir -p "${HOME}/.config/waybar" + printf '@define-color background #1a1b26;\n' >"${HOME}/.config/waybar/colors.css" + + load_ml4w_lib + run waybar_colors_is_dreamcoder "${HOME}/.config/waybar/colors.css" + [ "$status" -ne 0 ] +} + +@test "ml4w lib: waybar colors.css symlink outside Dreamcoder is rejected" { + mkdir -p "${HOME}/.config/waybar" + printf '@define-color background #1a1b26;\n' >"${HOME}/.config/waybar/matugen.css" + ln -s matugen.css "${HOME}/.config/waybar/colors.css" + + load_ml4w_lib + run waybar_colors_is_dreamcoder "${HOME}/.config/waybar/colors.css" + [ "$status" -ne 0 ] +} diff --git a/tests/shell/test_dreamcoder_dispatch.bats b/tests/shell/test_dreamcoder_dispatch.bats index 4ca11c3b..6a42f3d7 100644 --- a/tests/shell/test_dreamcoder_dispatch.bats +++ b/tests/shell/test_dreamcoder_dispatch.bats @@ -49,3 +49,13 @@ assert report["upstreams"]["ml4w"]["mappings"] == [], report [[ "$output" == *"Usage: dreamcoder"* ]] [[ "$output" == *"upstream-diff"* ]] } + +@test "dreamcoder exposes src/ to Python routes without an installed package" { + mkdir -p "${TEST_DIR}/src/dreamcoder_theme" + printf 'def main():\n print("sync-ok")\n' > "${TEST_DIR}/src/dreamcoder_theme/sync.py" + : > "${TEST_DIR}/src/dreamcoder_theme/__init__.py" + cp scripts/sync-dreamcoder-theme.py "${TEST_DIR}/scripts/" + run env -u PYTHONPATH DREAMCODER_DOTS_DIR="${TEST_DIR}" bash "${TEST_DIR}/scripts/dreamcoder" sync + [ "$status" -eq 0 ] + [ "$output" = "sync-ok" ] +} diff --git a/tests/test_doctor_hypr_colors_loader.py b/tests/test_doctor_hypr_colors_loader.py new file mode 100644 index 00000000..dbe1445d --- /dev/null +++ b/tests/test_doctor_hypr_colors_loader.py @@ -0,0 +1,55 @@ +"""Doctor: where Hyprland loads dreamcoder-colors from. + +ML4W upgrades rewrite hyprland.lua, so the loader lives in the Dreamcoder-owned +custom.lua. A legacy require injected into hyprland.lua is still accepted. +""" + +from pathlib import Path + +from dreamcoder_theme.doctor import _check_hypr_colors_loader + +LOADER = 'require("dreamcoder-colors")\n' + + +def _hypr(tmp_path: Path) -> Path: + hypr = tmp_path / "hypr" + hypr.mkdir() + (hypr / "hyprland.lua").write_text('require("colors")\nrequire("custom")\n') + return hypr + + +def test_loader_in_custom_lua_is_ok(tmp_path: Path) -> None: + hypr = _hypr(tmp_path) + (hypr / "custom.lua").write_text(LOADER) + + check = _check_hypr_colors_loader(tmp_path) + + assert check.status == "ok" + assert check.detail == str(hypr / "custom.lua") + + +def test_legacy_loader_in_hyprland_lua_is_still_ok(tmp_path: Path) -> None: + hypr = _hypr(tmp_path) + (hypr / "hyprland.lua").write_text('require("colors")\n' + LOADER) + + check = _check_hypr_colors_loader(tmp_path) + + assert check.status == "ok" + assert check.detail == str(hypr / "hyprland.lua") + + +def test_missing_loader_warns_and_points_at_the_generator(tmp_path: Path) -> None: + hypr = _hypr(tmp_path) + (hypr / "custom.lua").write_text("-- keybindings only\n") + + check = _check_hypr_colors_loader(tmp_path) + + assert check.status == "warn" + assert "generate-custom-lua.sh" in check.repair + + +def test_missing_hypr_dir_warns(tmp_path: Path) -> None: + check = _check_hypr_colors_loader(tmp_path) + + assert check.status == "warn" + check.to_dict() diff --git a/tests/test_verify_repo_sync.py b/tests/test_verify_repo_sync.py index 9cc1b8c0..4663df9b 100644 --- a/tests/test_verify_repo_sync.py +++ b/tests/test_verify_repo_sync.py @@ -195,14 +195,14 @@ def _base_manifest() -> dict: "name": "ML4W (Hyprland desktop dotfiles)", "url": "https://github.com/mylinuxforwork/dotfiles.git", "status": "pinned", - "pinned_ref": "46f2ca7f73fe98b16ce4ab6433a9ac29fa9fd033", + "pinned_ref": "3960570f47f4f691c424ff46b387d389a8e69bca", "verified_on": "2026-08-10T01:27:49Z", }, "gentleman-dots": { "name": "Gentleman.Dots (shell / editor / terminal base configuration)", "url": "https://github.com/Gentleman-Programming/Gentleman.Dots.git", "status": "pinned", - "pinned_ref": "02584500de6378ff5f54d252dc28fce8424b088a", + "pinned_ref": "6f44b797b016aea92772d8a6d81a5f1bc53a84bb", "verified_on": "2026-08-10T01:27:49Z", }, }, @@ -328,7 +328,7 @@ def test_manifest_docs_mismatch_is_rejected( sources = tmp_path / "docs/sources.md" stale_ref = "1" * 40 sources.write_text( - sources.read_text().replace("46f2ca7f73fe98b16ce4ab6433a9ac29fa9fd033", stale_ref) + sources.read_text().replace("3960570f47f4f691c424ff46b387d389a8e69bca", stale_ref) ) code, output = _run(module, capsys)