@sebastienrousseau/dotfiles 0.2.520 → 0.2.521
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +18 -0
- package/LICENSE-APACHE +190 -0
- package/LICENSE-MIT +21 -0
- package/README.md +43 -37
- package/install.sh +76 -10
- package/package.json +7 -7
- package/tools/README.md +49 -0
- package/tools/ci/install-chezmoi-verified.sh +68 -0
- package/docs/.vitepress/reports/localization-readability-audit.md +0 -73
- package/docs/AI.md +0 -179
- package/docs/ARCHITECTURE.md +0 -117
- package/docs/CNAME +0 -1
- package/docs/CONFIG_STRATEGY.md +0 -124
- package/docs/COPYRIGHT +0 -7
- package/docs/ECOSYSTEM.md +0 -220
- package/docs/GOLD-STANDARD-AUDIT.md +0 -352
- package/docs/GOVERNANCE.md +0 -98
- package/docs/MAINTAINERS.md +0 -41
- package/docs/MINIMUM-TOOLCHAIN.md +0 -100
- package/docs/NAMING_CONVENTIONS.md +0 -102
- package/docs/OPENCODE.md +0 -127
- package/docs/README.md +0 -84
- package/docs/STRUCTURE.md +0 -102
- package/docs/adr/ADR-001-ci-cd-pipeline.md +0 -118
- package/docs/adr/ADR-002-shell-performance.md +0 -130
- package/docs/adr/ADR-003-security-first.md +0 -158
- package/docs/adr/ADR-004-cli-architecture.md +0 -171
- package/docs/adr/ADR-005-chezmoi-choice.md +0 -99
- package/docs/adr/ADR-006-shell-selection.md +0 -124
- package/docs/adr/ADR-007-multi-shell-parity.md +0 -62
- package/docs/adr/ADR-008-alias-system-architecture.md +0 -95
- package/docs/adr/ADR-009-wallpaper-driven-theming.md +0 -131
- package/docs/adr/ADR-010-starship-transient-prompt.md +0 -144
- package/docs/adr/ADR-011-nushell-tier3-keep.md +0 -144
- package/docs/adr/ADR-012-ai-fleet-local-proxy.md +0 -79
- package/docs/adr/README.md +0 -40
- package/docs/architecture/AI_COST_OPTIMIZATION.md +0 -144
- package/docs/architecture/ARCHITECTURE.md +0 -20
- package/docs/architecture/INTEROP.md +0 -44
- package/docs/architecture/REPO_LAYOUT.md +0 -241
- package/docs/architecture/WALKTHROUGH.md +0 -86
- package/docs/architecture/fleet-deployment.md +0 -77
- package/docs/archive/EUXIS_2026_REVIEW.md +0 -127
- package/docs/archive/LEGACY_ROADMAP.md +0 -6
- package/docs/archive/MILESTONE_v0.2.493.md +0 -47
- package/docs/archive/PLAN.md +0 -199
- package/docs/archive/REPO_AUDIT.md +0 -31
- package/docs/articles/.pages +0 -6
- package/docs/articles/2026-07-05-custom-mkdocs-material-dark-theme.md +0 -216
- package/docs/articles/2026-07-05-fish-startup-abbr.md +0 -153
- package/docs/articles/2026-07-05-master-to-main-rename-runbook.md +0 -128
- package/docs/articles/index.md +0 -36
- package/docs/guides/INSTALL.md +0 -144
- package/docs/guides/MACOS_ICLOUD_SYMLINKS.md +0 -121
- package/docs/guides/NEOVIM_IDE_GUIDE.md +0 -61
- package/docs/guides/THEMING.md +0 -230
- package/docs/guides/TROUBLESHOOTING.md +0 -176
- package/docs/guides/WSL2_NIX_TROUBLESHOOTING.md +0 -792
- package/docs/index.md +0 -132
- package/docs/interop/A2A.md +0 -39
- package/docs/interop/POWERSHELL.md +0 -102
- package/docs/manual/00-introduction.md +0 -89
- package/docs/manual/01-concepts/01-architecture.md +0 -138
- package/docs/manual/01-concepts/02-trust-model.md +0 -183
- package/docs/manual/01-concepts/03-theme-engine.md +0 -186
- package/docs/manual/01-concepts/04-fleet.md +0 -148
- package/docs/manual/01-concepts/05-self-healing.md +0 -204
- package/docs/manual/02-tutorials/01-first-install.md +0 -197
- package/docs/manual/02-tutorials/02-add-wallpaper.md +0 -216
- package/docs/manual/02-tutorials/03-create-profile.md +0 -244
- package/docs/manual/02-tutorials/04-encrypt-secret.md +0 -281
- package/docs/manual/02-tutorials/05-deploy-fleet.md +0 -283
- package/docs/manual/03-reference/01-dot-cli.md +0 -475
- package/docs/manual/03-reference/02-config-files.md +0 -265
- package/docs/manual/03-reference/03-environment.md +0 -124
- package/docs/manual/03-reference/04-templates.md +0 -190
- package/docs/manual/03-reference/05-feature-flags.md +0 -187
- package/docs/manual/04-cookbook/01-recipes.md +0 -285
- package/docs/manual/04-cookbook/02-troubleshooting.md +0 -351
- package/docs/manual/04-cookbook/03-faq.md +0 -175
- package/docs/manual/05-appendices/A-platform-matrix.md +0 -101
- package/docs/manual/05-appendices/B-security-checklist.md +0 -85
- package/docs/manual/05-appendices/C-glossary.md +0 -40
- package/docs/manual/05-appendices/D-bibliography.md +0 -58
- package/docs/manual/05-appendices/E-license.md +0 -38
- package/docs/manual/_toc.yml +0 -58
- package/docs/manual/command-index.md +0 -175
- package/docs/manual/concept-index.md +0 -170
- package/docs/manual/index.md +0 -66
- package/docs/migration/README.md +0 -81
- package/docs/migration/from-bare-git-repo.md +0 -156
- package/docs/migration/from-gnu-stow.md +0 -165
- package/docs/migration/from-plain-chezmoi.md +0 -148
- package/docs/migration/from-yadm.md +0 -187
- package/docs/operations/ARCHITECTURE_ROADMAP.md +0 -7
- package/docs/operations/ATTESTATION.md +0 -44
- package/docs/operations/CI_CADENCE.md +0 -107
- package/docs/operations/CI_COMPOSITES.md +0 -156
- package/docs/operations/COMPLETIONS.md +0 -123
- package/docs/operations/COVERAGE.md +0 -204
- package/docs/operations/DRIFT.md +0 -107
- package/docs/operations/HARD_AUDIT_2026.md +0 -631
- package/docs/operations/MAINTENANCE.md +0 -63
- package/docs/operations/MANIFEST.md +0 -127
- package/docs/operations/MIGRATION.md +0 -109
- package/docs/operations/OPERATIONS.md +0 -188
- package/docs/operations/PERFORMANCE.md +0 -133
- package/docs/operations/PERFORMANCE_BUDGETS.md +0 -196
- package/docs/operations/REGISTRY.md +0 -90
- package/docs/operations/RELEASE_PIPELINE.md +0 -128
- package/docs/operations/RELIABILITY.md +0 -122
- package/docs/operations/RFC_v0_2_503_reorganization.md +0 -280
- package/docs/operations/ROADMAP.md +0 -10
- package/docs/operations/ROADMAP_2026.md +0 -7
- package/docs/operations/ROADMAP_V0_2_503.md +0 -10
- package/docs/operations/TESTING.md +0 -216
- package/docs/operations/TRACEABILITY.md +0 -44
- package/docs/operations/TRUSTED_AGENT_WORKSTATION.md +0 -65
- package/docs/operations/VERSION_SYNC.md +0 -393
- package/docs/packaging.md +0 -222
- package/docs/reference/ALIASES.md +0 -131
- package/docs/reference/ALIASES_CHEATSHEET.md +0 -32
- package/docs/reference/ALIASES_DEPRECATIONS.md +0 -13
- package/docs/reference/FEATURE-MATRIX.md +0 -646
- package/docs/reference/FEATURES.md +0 -66
- package/docs/reference/FONTS.md +0 -112
- package/docs/reference/POWERSHELL_PARITY.md +0 -82
- package/docs/reference/PROFILES.md +0 -69
- package/docs/reference/SCREENSHOTS.md +0 -121
- package/docs/reference/SCRIPTS.md +0 -71
- package/docs/reference/SUPPORT_MATRIX.md +0 -80
- package/docs/reference/THEMES.md +0 -117
- package/docs/reference/TOOLS.md +0 -110
- package/docs/reference/UTILS.md +0 -243
- package/docs/registry.json +0 -6
- package/docs/schema/dot-env-v1.json +0 -110
- package/docs/schema/dot-registry-v1.json +0 -33
- package/docs/security/AI_ACT_COMPLIANCE.md +0 -94
- package/docs/security/AUDIT_BYPASS.md +0 -103
- package/docs/security/AUTOMATION_SECRETS.md +0 -26
- package/docs/security/CI_EGRESS_ALLOWLIST.md +0 -127
- package/docs/security/CI_PINNING.md +0 -129
- package/docs/security/COMMIT_SIGNING.md +0 -138
- package/docs/security/COMPLIANCE.md +0 -458
- package/docs/security/DEPS_DEV_EXCEPTIONS.md +0 -86
- package/docs/security/DISCLOSURE.md +0 -130
- package/docs/security/ENCRYPTION.md +0 -57
- package/docs/security/FMEA.md +0 -159
- package/docs/security/FUZZING.md +0 -209
- package/docs/security/HISTORY_FILTERING.md +0 -132
- package/docs/security/INCIDENT_RESPONSE.md +0 -579
- package/docs/security/INSTALL_VERIFICATION.md +0 -122
- package/docs/security/KEYS.md +0 -49
- package/docs/security/KEY_ROTATION.md +0 -303
- package/docs/security/MCP_POLICY.md +0 -78
- package/docs/security/POLICY_RELEASES.md +0 -37
- package/docs/security/README.md +0 -28
- package/docs/security/SCORECARD.md +0 -195
- package/docs/security/SECRETS.md +0 -158
- package/docs/security/SECURITY.md +0 -45
- package/docs/security/SECURITY_CHECKLIST.md +0 -55
- package/docs/security/SHELL_EXEMPTIONS.md +0 -145
- package/docs/security/SOUP_REGISTER.md +0 -36
- package/docs/security/THREAT_MODEL.md +0 -130
- package/docs/security/VERIFICATION_VALIDATION.md +0 -228
- package/docs/security/VERIFY_RELEASE.md +0 -201
- package/docs/security/security-pubkey.asc +0 -15
- package/docs/stylesheets/extra.css +0 -444
- package/docs/themes/README.md +0 -10
- package/docs/themes/VISUAL_INTEGRITY_REPORT.md +0 -30
- package/docs/themes/hero-shot.svg +0 -78
- package/scripts/README.md +0 -123
- package/scripts/ci/check-copyright-headers.sh +0 -8
- package/scripts/ci/check-shell-preamble.sh +0 -8
- package/scripts/ci/guard-gitleaks-checkout.sh +0 -8
- package/scripts/demo/record.sh +0 -43
- package/scripts/diagnostics/a2a-conformance.sh +0 -163
- package/scripts/diagnostics/alias-governance.sh +0 -165
- package/scripts/diagnostics/aliases-cheatsheet.sh +0 -74
- package/scripts/diagnostics/aliases-manifest.sh +0 -77
- package/scripts/diagnostics/attest-verify.sh +0 -147
- package/scripts/diagnostics/benchmark.sh +0 -408
- package/scripts/diagnostics/conflicts.sh +0 -73
- package/scripts/diagnostics/doctor-unified.sh +0 -43
- package/scripts/diagnostics/doctor.sh +0 -797
- package/scripts/diagnostics/drift-dashboard.sh +0 -203
- package/scripts/diagnostics/health.sh +0 -656
- package/scripts/diagnostics/history-analysis.sh +0 -86
- package/scripts/diagnostics/mcp-doctor.sh +0 -582
- package/scripts/diagnostics/perf.sh +0 -453
- package/scripts/diagnostics/scorecard.sh +0 -120
- package/scripts/diagnostics/secret-governance.sh +0 -65
- package/scripts/diagnostics/security-score.sh +0 -467
- package/scripts/diagnostics/smoke-test.sh +0 -88
- package/scripts/diagnostics/snapshot.sh +0 -90
- package/scripts/diagnostics/verify.sh +0 -108
- package/scripts/diagnostics/verify_state.sh +0 -73
- package/scripts/diagnostics/version-locks.sh +0 -94
- package/scripts/diagnostics/workstation-attestation.sh +0 -212
- package/scripts/dot/commands/agent.sh +0 -535
- package/scripts/dot/commands/agents.sh +0 -352
- package/scripts/dot/commands/ai.sh +0 -600
- package/scripts/dot/commands/aliases.sh +0 -277
- package/scripts/dot/commands/appearance.sh +0 -110
- package/scripts/dot/commands/completion.sh +0 -171
- package/scripts/dot/commands/core.sh +0 -217
- package/scripts/dot/commands/diagnostics.sh +0 -265
- package/scripts/dot/commands/env-emit.sh +0 -203
- package/scripts/dot/commands/fleet.sh +0 -711
- package/scripts/dot/commands/init.sh +0 -185
- package/scripts/dot/commands/lint.sh +0 -208
- package/scripts/dot/commands/manual.sh +0 -169
- package/scripts/dot/commands/meta.sh +0 -438
- package/scripts/dot/commands/patterns.sh +0 -55
- package/scripts/dot/commands/registry.sh +0 -455
- package/scripts/dot/commands/restore.sh +0 -232
- package/scripts/dot/commands/secrets.sh +0 -296
- package/scripts/dot/commands/security.sh +0 -102
- package/scripts/dot/commands/tools.sh +0 -570
- package/scripts/dot/data/alias-deprecations.tsv +0 -2
- package/scripts/dot/powershell/Dot.psm1 +0 -319
- package/scripts/fonts/install-nerd-fonts.sh +0 -75
- package/scripts/fonts/patch-fonts.sh +0 -36
- package/scripts/git-hooks/install.sh +0 -12
- package/scripts/git-hooks/pre-commit +0 -12
- package/scripts/git-hooks/pre-commit-audit.sh +0 -146
- package/scripts/git-hooks/pre-push +0 -105
- package/scripts/git-hooks/prepare-commit-msg +0 -29
- package/scripts/lib/secrets_provider.sh +0 -200
- package/scripts/nvim/headless-upgrade.lua +0 -81
- package/scripts/ops/ai-setup.sh +0 -71
- package/scripts/ops/bundle.sh +0 -104
- package/scripts/ops/chaos.sh +0 -50
- package/scripts/ops/chezmoi-apply.sh +0 -333
- package/scripts/ops/chezmoi-diff.sh +0 -16
- package/scripts/ops/chezmoi-remove.sh +0 -46
- package/scripts/ops/chezmoi-update.sh +0 -67
- package/scripts/ops/heal-chezmoi.sh +0 -87
- package/scripts/ops/heal-system.sh +0 -129
- package/scripts/ops/heal-tools.sh +0 -297
- package/scripts/ops/heal.sh +0 -223
- package/scripts/ops/post-apply-repair.sh +0 -107
- package/scripts/ops/prewarm.sh +0 -128
- package/scripts/ops/release.sh +0 -262
- package/scripts/ops/rollback.sh +0 -613
- package/scripts/ops/setup.sh +0 -138
- package/scripts/ops/teleport.sh +0 -34
- package/scripts/qa/check-feature-matrix.sh +0 -296
- package/scripts/qa/check-version-consistency.sh +0 -12
- package/scripts/qa/coverage-baseline.sh +0 -61
- package/scripts/qa/docs-coverage.sh +0 -118
- package/scripts/qa/examples-coverage.sh +0 -94
- package/scripts/qa/powershell-contract.ps1 +0 -95
- package/scripts/qa/reliability-audit.sh +0 -139
- package/scripts/qa/scorecard-snapshot.sh +0 -128
- package/scripts/qa/traceability-coverage.sh +0 -124
- package/scripts/qa/validate-examples.sh +0 -90
- package/scripts/qa/wsl-contract.sh +0 -12
- package/scripts/secrets/age-init.sh +0 -82
- package/scripts/secrets/create-secrets-file.sh +0 -46
- package/scripts/secrets/encrypt-ssh-key.sh +0 -44
- package/scripts/security/backup.sh +0 -58
- package/scripts/security/check-disclosure-key-expiry.sh +0 -111
- package/scripts/security/dns-doh.sh +0 -52
- package/scripts/security/encryption-check.sh +0 -55
- package/scripts/security/enforce-policies.sh +0 -552
- package/scripts/security/firewall.sh +0 -91
- package/scripts/security/lock-configs.sh +0 -67
- package/scripts/security/lock-screen.sh +0 -56
- package/scripts/security/manage-secrets.sh +0 -429
- package/scripts/security/ssh-cert.sh +0 -204
- package/scripts/security/telemetry-kill.sh +0 -51
- package/scripts/security/usb-safety.sh +0 -52
- package/scripts/theme/apply-gnome-theme.sh +0 -333
- package/scripts/theme/extract-heic-frames.sh +0 -115
- package/scripts/theme/extract-theme.py +0 -1020
- package/scripts/theme/install-boot-logo.sh +0 -63
- package/scripts/theme/install-catppuccin-themes.sh +0 -371
- package/scripts/theme/install-cursors.sh +0 -26
- package/scripts/theme/install-file-icons.sh +0 -27
- package/scripts/theme/install-grub-theme.sh +0 -62
- package/scripts/theme/install-lock-icon.sh +0 -31
- package/scripts/theme/merge-wallpaper.sh +0 -146
- package/scripts/theme/rebuild-themes.sh +0 -603
- package/scripts/theme/switch.sh +0 -476
- package/scripts/theme/wallpaper-rotate.sh +0 -137
- package/scripts/theme/wallpaper-sync.sh +0 -690
- package/scripts/tools/cmatrix.sh +0 -22
- package/scripts/tools/detect-collisions.py +0 -103
- package/scripts/tools/emoji-picker.sh +0 -49
- package/scripts/tools/figlet-banner.sh +0 -19
- package/scripts/tools/log-rotate.sh +0 -31
- package/scripts/tools/lolcat-wrap.sh +0 -20
- package/scripts/tools/pipes.sh +0 -49
- package/scripts/tuning/linux.sh +0 -186
- package/scripts/tuning/macos.sh +0 -56
- package/scripts/uninstall.sh +0 -86
- package/scripts/verify-release-versions +0 -156
- package/scripts/version-sync.sh +0 -714
- package/templates/chezmoi-data/geekom-a9.toml.example +0 -21
- package/templates/chezmoi-data/mac-m1.toml.example +0 -16
- package/templates/chezmoi-data/mac-t2-linux.toml.example +0 -21
- package/templates/chezmoi-data/surface-pro-7p.toml.example +0 -21
- package/templates/projects/go/.github/workflows/ci.yml +0 -31
- package/templates/projects/go/README.md +0 -7
- package/templates/projects/go/cmd/__PROJECT_NAME__/main.go +0 -8
- package/templates/projects/go/go.mod +0 -3
- package/templates/projects/go/go.sum +0 -0
- package/templates/projects/molecule/README.md +0 -7
- package/templates/projects/molecule/converge.yml +0 -7
- package/templates/projects/molecule/molecule.yml +0 -16
- package/templates/projects/node/.github/workflows/ci.yml +0 -30
- package/templates/projects/node/README.md +0 -7
- package/templates/projects/node/package-lock.json +0 -12
- package/templates/projects/node/package.json +0 -10
- package/templates/projects/node/src/index.js +0 -3
- package/templates/projects/packer/README.md +0 -15
- package/templates/projects/packer/main.pkr.hcl +0 -15
- package/templates/projects/python/.github/workflows/ci.yml +0 -34
- package/templates/projects/python/README.md +0 -7
- package/templates/projects/python/pyproject.toml +0 -25
- package/templates/projects/python/src/__PROJECT_NAME__/__init__.py +0 -2
- package/templates/projects/python/tests/test_basic.py +0 -3
|
@@ -1,1020 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
# SPDX-License-Identifier: Apache-2.0 OR MIT
|
|
3
|
-
# Copyright (c) 2015-2026 Sebastien Rousseau
|
|
4
|
-
# extract-theme.py — Generate a terminal theme from wallpaper dominant colors.
|
|
5
|
-
#
|
|
6
|
-
# Uses K-Means clustering in CIELAB color space for perceptually accurate
|
|
7
|
-
# dominant color extraction. No external dependencies — stdlib only.
|
|
8
|
-
# ImageMagick is used for image downsampling and pixel extraction.
|
|
9
|
-
#
|
|
10
|
-
# Usage:
|
|
11
|
-
# python3 extract-theme.py <image-path> [--name <theme-name>] [--format toml|json]
|
|
12
|
-
# python3 extract-theme.py /System/Library/Desktop\ Pictures/Sonoma.heic
|
|
13
|
-
# python3 extract-theme.py ~/Pictures/Wallpapers/macos-tahoe-dark.heic --name macos-tahoe-dark
|
|
14
|
-
|
|
15
|
-
import sys
|
|
16
|
-
import subprocess # nosec B404 — used only with fixed magick command, no shell
|
|
17
|
-
import math
|
|
18
|
-
import random # nosec B311 — used for K-Means seeding, not security
|
|
19
|
-
import json
|
|
20
|
-
import os
|
|
21
|
-
from typing import List, Tuple, Dict
|
|
22
|
-
|
|
23
|
-
# ---------------------------------------------------------------------------
|
|
24
|
-
# Color space conversions (RGB ↔ XYZ ↔ CIELAB)
|
|
25
|
-
# ---------------------------------------------------------------------------
|
|
26
|
-
|
|
27
|
-
def srgb_to_linear(c: float) -> float:
|
|
28
|
-
"""Linearize an sRGB component (0-1)."""
|
|
29
|
-
return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
def linear_to_srgb(c: float) -> float:
|
|
33
|
-
"""Gamma-compress a linear component to sRGB (0-1)."""
|
|
34
|
-
c = max(0.0, min(1.0, c))
|
|
35
|
-
return 12.92 * c if c <= 0.0031308 else 1.055 * (c ** (1.0 / 2.4)) - 0.055
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
def rgb_to_xyz(r: int, g: int, b: int) -> Tuple[float, float, float]:
|
|
39
|
-
"""Convert sRGB (0-255) to CIE XYZ (D65 illuminant)."""
|
|
40
|
-
rl = srgb_to_linear(r / 255.0)
|
|
41
|
-
gl = srgb_to_linear(g / 255.0)
|
|
42
|
-
bl = srgb_to_linear(b / 255.0)
|
|
43
|
-
x = 0.4124564 * rl + 0.3575761 * gl + 0.1804375 * bl
|
|
44
|
-
y = 0.2126729 * rl + 0.7151522 * gl + 0.0721750 * bl
|
|
45
|
-
z = 0.0193339 * rl + 0.1191920 * gl + 0.9503041 * bl
|
|
46
|
-
return x, y, z
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
def xyz_to_lab(x: float, y: float, z: float) -> Tuple[float, float, float]:
|
|
50
|
-
"""Convert CIE XYZ to CIELAB (D65 reference white)."""
|
|
51
|
-
xn, yn, zn = 0.95047, 1.00000, 1.08883
|
|
52
|
-
|
|
53
|
-
def f(t):
|
|
54
|
-
return t ** (1.0 / 3.0) if t > 0.008856 else 7.787 * t + 16.0 / 116.0
|
|
55
|
-
|
|
56
|
-
fx, fy, fz = f(x / xn), f(y / yn), f(z / zn)
|
|
57
|
-
L = 116.0 * fy - 16.0
|
|
58
|
-
a = 500.0 * (fx - fy)
|
|
59
|
-
b = 200.0 * (fy - fz)
|
|
60
|
-
return L, a, b
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
def lab_to_xyz(L: float, a: float, b: float) -> Tuple[float, float, float]:
|
|
64
|
-
"""Convert CIELAB to CIE XYZ (D65 reference white)."""
|
|
65
|
-
xn, yn, zn = 0.95047, 1.00000, 1.08883
|
|
66
|
-
fy = (L + 16.0) / 116.0
|
|
67
|
-
fx = a / 500.0 + fy
|
|
68
|
-
fz = fy - b / 200.0
|
|
69
|
-
|
|
70
|
-
def inv_f(t):
|
|
71
|
-
return t ** 3 if t ** 3 > 0.008856 else (t - 16.0 / 116.0) / 7.787
|
|
72
|
-
|
|
73
|
-
return inv_f(fx) * xn, inv_f(fy) * yn, inv_f(fz) * zn
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
def xyz_to_rgb(x: float, y: float, z: float) -> Tuple[int, int, int]:
|
|
77
|
-
"""Convert CIE XYZ to sRGB (0-255)."""
|
|
78
|
-
rl = 3.2404542 * x - 1.5371385 * y - 0.4985314 * z
|
|
79
|
-
gl = -0.9692660 * x + 1.8760108 * y + 0.0415561 * z
|
|
80
|
-
bl = 0.0556434 * x - 0.2040259 * y + 1.0572252 * z
|
|
81
|
-
r = int(round(linear_to_srgb(rl) * 255))
|
|
82
|
-
g = int(round(linear_to_srgb(gl) * 255))
|
|
83
|
-
b = int(round(linear_to_srgb(bl) * 255))
|
|
84
|
-
return max(0, min(255, r)), max(0, min(255, g)), max(0, min(255, b))
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
def rgb_to_lab(r: int, g: int, b: int) -> Tuple[float, float, float]:
|
|
88
|
-
return xyz_to_lab(*rgb_to_xyz(r, g, b))
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
def lab_to_rgb(L: float, a: float, b: float) -> Tuple[int, int, int]:
|
|
92
|
-
return xyz_to_rgb(*lab_to_xyz(L, a, b))
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
def rgb_to_hex(r: int, g: int, b: int) -> str:
|
|
96
|
-
return f"#{r:02x}{g:02x}{b:02x}"
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
def hex_to_rgb(h: str) -> Tuple[int, int, int]:
|
|
100
|
-
h = h.lstrip("#")
|
|
101
|
-
return int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
# ---------------------------------------------------------------------------
|
|
105
|
-
# Perceptual utilities
|
|
106
|
-
# ---------------------------------------------------------------------------
|
|
107
|
-
|
|
108
|
-
def lab_distance(a: Tuple[float, ...], b: Tuple[float, ...]) -> float:
|
|
109
|
-
"""Euclidean distance in CIELAB (ΔE*ab)."""
|
|
110
|
-
return math.sqrt(sum((x - y) ** 2 for x, y in zip(a, b)))
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
def relative_luminance(r: int, g: int, b: int) -> float:
|
|
114
|
-
"""WCAG relative luminance from sRGB."""
|
|
115
|
-
def ch(c):
|
|
116
|
-
s = c / 255.0
|
|
117
|
-
return s / 12.92 if s <= 0.03928 else ((s + 0.055) / 1.055) ** 2.4
|
|
118
|
-
return 0.2126 * ch(r) + 0.7152 * ch(g) + 0.0722 * ch(b)
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
def contrast_ratio(c1: Tuple[int, int, int], c2: Tuple[int, int, int]) -> float:
|
|
122
|
-
l1 = relative_luminance(*c1)
|
|
123
|
-
l2 = relative_luminance(*c2)
|
|
124
|
-
return (max(l1, l2) + 0.05) / (min(l1, l2) + 0.05)
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
def lab_chroma(L: float, a: float, b: float) -> float:
|
|
128
|
-
"""Chroma (saturation) in CIELAB."""
|
|
129
|
-
return math.sqrt(a * a + b * b)
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
def lab_hue(L: float, a: float, b: float) -> float:
|
|
133
|
-
"""Hue angle in CIELAB (0-360)."""
|
|
134
|
-
h = math.degrees(math.atan2(b, a))
|
|
135
|
-
return h if h >= 0 else h + 360.0
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
# ---------------------------------------------------------------------------
|
|
139
|
-
# K-Means clustering in CIELAB
|
|
140
|
-
# ---------------------------------------------------------------------------
|
|
141
|
-
|
|
142
|
-
def _kmeans_init(pixels, k, rng):
|
|
143
|
-
"""Initialize k centroids using K-Means++ seeding."""
|
|
144
|
-
n = len(pixels)
|
|
145
|
-
centroids = [pixels[rng.randint(0, n - 1)]]
|
|
146
|
-
for _ in range(1, k):
|
|
147
|
-
dists = [min(lab_distance(p, c) ** 2 for c in centroids) for p in pixels]
|
|
148
|
-
total = sum(dists)
|
|
149
|
-
if total == 0:
|
|
150
|
-
centroids.append(pixels[rng.randint(0, n - 1)])
|
|
151
|
-
continue
|
|
152
|
-
r = rng.random() * total
|
|
153
|
-
cumulative = 0.0
|
|
154
|
-
for i, d in enumerate(dists):
|
|
155
|
-
cumulative += d
|
|
156
|
-
if cumulative >= r:
|
|
157
|
-
centroids.append(pixels[i])
|
|
158
|
-
break
|
|
159
|
-
return centroids
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
def _assign_labels(pixels, centroids, labels):
|
|
163
|
-
"""Assign each pixel to its nearest centroid. Returns number of changed labels."""
|
|
164
|
-
changed = 0
|
|
165
|
-
for i, p in enumerate(pixels):
|
|
166
|
-
best_j = 0
|
|
167
|
-
best_d = lab_distance(p, centroids[0])
|
|
168
|
-
for j in range(1, len(centroids)):
|
|
169
|
-
d = lab_distance(p, centroids[j])
|
|
170
|
-
if d < best_d:
|
|
171
|
-
best_d = d
|
|
172
|
-
best_j = j
|
|
173
|
-
if labels[i] != best_j:
|
|
174
|
-
changed += 1
|
|
175
|
-
labels[i] = best_j
|
|
176
|
-
return changed
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
def _update_centroids(pixels, labels, centroids):
|
|
180
|
-
"""Recompute centroids as the mean of assigned pixels."""
|
|
181
|
-
n = len(pixels)
|
|
182
|
-
for j, _ in enumerate(centroids):
|
|
183
|
-
members = [pixels[i] for i in range(n) if labels[i] == j]
|
|
184
|
-
if members:
|
|
185
|
-
centroids[j] = tuple(
|
|
186
|
-
sum(m[d] for m in members) / len(members) for d in range(3)
|
|
187
|
-
)
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
def _kmeans_single_run(pixels, k, max_iter, rng):
|
|
191
|
-
"""Run one K-Means iteration loop. Returns (centroids, labels, inertia)."""
|
|
192
|
-
centroids = _kmeans_init(pixels, k, rng)
|
|
193
|
-
labels = [0] * len(pixels)
|
|
194
|
-
for _ in range(max_iter):
|
|
195
|
-
changed = _assign_labels(pixels, centroids, labels)
|
|
196
|
-
if changed == 0:
|
|
197
|
-
break
|
|
198
|
-
_update_centroids(pixels, labels, centroids)
|
|
199
|
-
inertia = sum(
|
|
200
|
-
lab_distance(pixels[i], centroids[labels[i]]) ** 2 for i in range(len(pixels))
|
|
201
|
-
)
|
|
202
|
-
return centroids, labels, inertia
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
def kmeans_lab(
|
|
206
|
-
pixels: List[Tuple[float, float, float]],
|
|
207
|
-
k: int = 8,
|
|
208
|
-
max_iter: int = 20,
|
|
209
|
-
runs: int = 3,
|
|
210
|
-
) -> List[Tuple[Tuple[float, float, float], int]]:
|
|
211
|
-
"""Run K-Means clustering in CIELAB space with best-of-N initialization."""
|
|
212
|
-
n = len(pixels)
|
|
213
|
-
if n == 0:
|
|
214
|
-
return []
|
|
215
|
-
|
|
216
|
-
best_centroids = None
|
|
217
|
-
best_labels = None
|
|
218
|
-
best_inertia = float("inf")
|
|
219
|
-
|
|
220
|
-
for run_idx in range(runs):
|
|
221
|
-
rng = random.Random(run_idx * 42 + 7) # nosec B311
|
|
222
|
-
centroids, labels, inertia = _kmeans_single_run(pixels, k, max_iter, rng)
|
|
223
|
-
if inertia < best_inertia:
|
|
224
|
-
best_inertia = inertia
|
|
225
|
-
best_centroids = centroids[:]
|
|
226
|
-
best_labels = labels[:]
|
|
227
|
-
|
|
228
|
-
populations = [0] * k
|
|
229
|
-
for label in best_labels:
|
|
230
|
-
populations[label] += 1
|
|
231
|
-
|
|
232
|
-
result = [(best_centroids[j], populations[j]) for j in range(k)]
|
|
233
|
-
result.sort(key=lambda x: x[1], reverse=True)
|
|
234
|
-
return result
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
# ---------------------------------------------------------------------------
|
|
238
|
-
# Pixel extraction via ImageMagick
|
|
239
|
-
# ---------------------------------------------------------------------------
|
|
240
|
-
|
|
241
|
-
def extract_pixels(image_path: str, max_dim: int = 80) -> List[Tuple[int, int, int]]:
|
|
242
|
-
"""Downsample image and extract RGB pixels using ImageMagick."""
|
|
243
|
-
# Validate path contains no shell metacharacters (defense in depth)
|
|
244
|
-
base_path = image_path.split("[")[0] if "[" in image_path else image_path
|
|
245
|
-
if not os.path.isfile(base_path):
|
|
246
|
-
raise FileNotFoundError(f"Image not found: {base_path}")
|
|
247
|
-
cmd = [
|
|
248
|
-
"magick", image_path,
|
|
249
|
-
"-resize", f"{max_dim}x{max_dim}>",
|
|
250
|
-
"-depth", "8",
|
|
251
|
-
"txt:-",
|
|
252
|
-
]
|
|
253
|
-
# cmd is a fixed list with validated image_path, shell=False by default
|
|
254
|
-
result = subprocess.run( # nosec B603
|
|
255
|
-
cmd, capture_output=True, text=True, timeout=30, check=False
|
|
256
|
-
)
|
|
257
|
-
if result.returncode != 0:
|
|
258
|
-
raise RuntimeError(f"ImageMagick failed: {result.stderr}")
|
|
259
|
-
|
|
260
|
-
pixels = []
|
|
261
|
-
for line in result.stdout.splitlines():
|
|
262
|
-
if line.startswith("#"):
|
|
263
|
-
continue
|
|
264
|
-
# Format: "x,y: (R,G,B) #RRGGBB srgb(R,G,B)" or with alpha
|
|
265
|
-
parts = line.split("(")
|
|
266
|
-
if len(parts) < 2:
|
|
267
|
-
continue
|
|
268
|
-
color_part = parts[1].split(")")[0]
|
|
269
|
-
components = [c.strip() for c in color_part.split(",")]
|
|
270
|
-
if len(components) >= 3:
|
|
271
|
-
try:
|
|
272
|
-
r, g, b = int(components[0]), int(components[1]), int(components[2])
|
|
273
|
-
pixels.append((r, g, b))
|
|
274
|
-
except ValueError:
|
|
275
|
-
continue
|
|
276
|
-
return pixels
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
# ---------------------------------------------------------------------------
|
|
280
|
-
# Theme generation from dominant colors
|
|
281
|
-
# ---------------------------------------------------------------------------
|
|
282
|
-
|
|
283
|
-
# ANSI hue targets in CIELAB hue angle
|
|
284
|
-
ANSI_HUES = {
|
|
285
|
-
"red": 30.0,
|
|
286
|
-
"green": 145.0,
|
|
287
|
-
"yellow": 95.0,
|
|
288
|
-
"blue": 275.0,
|
|
289
|
-
"magenta": 330.0,
|
|
290
|
-
"cyan": 210.0,
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
def find_nearest_hue(hue: float) -> str:
|
|
295
|
-
"""Map a CIELAB hue angle to the nearest ANSI color name."""
|
|
296
|
-
best_name = "red"
|
|
297
|
-
best_dist = 360.0
|
|
298
|
-
for name, target in ANSI_HUES.items():
|
|
299
|
-
dist = min(abs(hue - target), 360 - abs(hue - target))
|
|
300
|
-
if dist < best_dist:
|
|
301
|
-
best_dist = dist
|
|
302
|
-
best_name = name
|
|
303
|
-
return best_name
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
def adjust_lightness(lab: Tuple[float, float, float], target_L: float) -> Tuple[float, float, float]:
|
|
307
|
-
"""Adjust L* while preserving hue and chroma."""
|
|
308
|
-
L, a, b = lab
|
|
309
|
-
if L == 0:
|
|
310
|
-
return (target_L, a, b)
|
|
311
|
-
scale = target_L / L
|
|
312
|
-
return (target_L, a * min(scale, 1.5), b * min(scale, 1.5))
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
def ensure_contrast(
|
|
316
|
-
fg_rgb: Tuple[int, int, int],
|
|
317
|
-
bg_rgb: Tuple[int, int, int],
|
|
318
|
-
min_ratio: float,
|
|
319
|
-
is_dark: bool,
|
|
320
|
-
) -> Tuple[int, int, int]:
|
|
321
|
-
"""Adjust fg lightness until contrast ratio meets min_ratio against bg."""
|
|
322
|
-
fl, fa, fb = rgb_to_lab(*fg_rgb)
|
|
323
|
-
for _ in range(80):
|
|
324
|
-
cr = contrast_ratio(lab_to_rgb(fl, fa, fb), bg_rgb)
|
|
325
|
-
if cr >= min_ratio:
|
|
326
|
-
break
|
|
327
|
-
fl += 2.0 if is_dark else -2.0
|
|
328
|
-
fl = max(0.0, min(100.0, fl))
|
|
329
|
-
return lab_to_rgb(fl, fa, fb)
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
# VS Code theming — Catppuccin only.
|
|
333
|
-
#
|
|
334
|
-
# The label must match `contributes.themes[].label` in the extension's
|
|
335
|
-
# package.json exactly, and the icon id must match `contributes.iconThemes[].id`.
|
|
336
|
-
# VS Code cannot resolve an unknown name: it keeps whatever is active and logs
|
|
337
|
-
# an error the user never sees, which is how three wrong values here survived.
|
|
338
|
-
#
|
|
339
|
-
# Verified against catppuccin.catppuccin-vsc 3.19.0 and
|
|
340
|
-
# catppuccin.catppuccin-vsc-icons 1.26.0:
|
|
341
|
-
# themes Catppuccin Mocha | Macchiato | Frappe | Latte
|
|
342
|
-
# iconThemes catppuccin-mocha | -macchiato | -frappe | -latte
|
|
343
|
-
# productIconThemes (none — the extension registers none at all)
|
|
344
|
-
#
|
|
345
|
-
# Neovim still picks per-family (tokyonight/everforest/catppuccin) from the
|
|
346
|
-
# wallpaper hue; only the VS Code surface is pinned, because a single editor
|
|
347
|
-
# theme that is always installed beats three that mostly are not.
|
|
348
|
-
VSCODE_DARK = "Catppuccin Mocha"
|
|
349
|
-
VSCODE_LIGHT = "Catppuccin Latte"
|
|
350
|
-
VSCODE_ICONS_DARK = "catppuccin-mocha"
|
|
351
|
-
VSCODE_ICONS_LIGHT = "catppuccin-latte"
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
def _nvim_from_hue(hue: float, is_dark: bool) -> Tuple[str, str]:
|
|
355
|
-
"""Map accent hue angle to nearest Neovim colorscheme."""
|
|
356
|
-
if 60 <= hue < 150:
|
|
357
|
-
return ("everforest", "hard" if is_dark else "soft")
|
|
358
|
-
if 210 <= hue < 270:
|
|
359
|
-
return ("tokyonight", "night" if is_dark else "day")
|
|
360
|
-
return ("catppuccin", "mocha" if is_dark else "latte")
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
def _macos_accent_from_hue(hue: float) -> int:
|
|
364
|
-
"""Map a CIELAB hue angle to a macOS accent-colour integer.
|
|
365
|
-
|
|
366
|
-
Boundaries are calibrated to the CIELAB hues of Apple's own accent
|
|
367
|
-
swatches (not HSL): red≈36°, orange≈67°, yellow≈87°, green≈144°,
|
|
368
|
-
blue≈287°, purple≈317°. The previous HSL-style cutoffs pushed blue
|
|
369
|
-
accents (~283°) into Purple and purple into Pink.
|
|
370
|
-
"""
|
|
371
|
-
hue = hue % 360
|
|
372
|
-
if hue < 15 or hue >= 345:
|
|
373
|
-
return 6 # Pink / magenta
|
|
374
|
-
if hue < 50:
|
|
375
|
-
return 0 # Red
|
|
376
|
-
if hue < 77:
|
|
377
|
-
return 1 # Orange
|
|
378
|
-
if hue < 110:
|
|
379
|
-
return 2 # Yellow
|
|
380
|
-
if hue < 200:
|
|
381
|
-
return 3 # Green
|
|
382
|
-
if hue < 300:
|
|
383
|
-
return 4 # Blue (incl. teal-blue and navy)
|
|
384
|
-
return 5 # Purple / indigo (300–345)
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
def _env_hex(key: str, fallback: str) -> str:
|
|
388
|
-
"""A #rrggbb value from env, or the fallback when unset/invalid."""
|
|
389
|
-
v = os.environ.get(key, "")
|
|
390
|
-
if len(v) == 7 and v[0] == "#":
|
|
391
|
-
try:
|
|
392
|
-
hex_to_rgb(v)
|
|
393
|
-
return v
|
|
394
|
-
except ValueError:
|
|
395
|
-
pass
|
|
396
|
-
return fallback
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
def _compute_bg_fg(clusters, is_dark):
|
|
400
|
-
"""Terminal background/foreground — fixed, engineered neutrals per mode.
|
|
401
|
-
|
|
402
|
-
Deliberately NOT derived from the wallpaper: a terminal wants a
|
|
403
|
-
high-contrast, low-eye-strain surface that stays stable across themes.
|
|
404
|
-
Dark mode uses a deep neutral grey with off-white text; light mode a warm
|
|
405
|
-
paper cream with charcoal text. The wallpaper still drives the accent and
|
|
406
|
-
the 16 ANSI colours (which are computed against this bg for contrast).
|
|
407
|
-
Override per mode via DOTFILES_TERM_BG_DARK / _FG_DARK / _BG_LIGHT /
|
|
408
|
-
_FG_LIGHT.
|
|
409
|
-
"""
|
|
410
|
-
if is_dark:
|
|
411
|
-
bg_rgb = hex_to_rgb(_env_hex("DOTFILES_TERM_BG_DARK", "#1e1e2e"))
|
|
412
|
-
fg_rgb = hex_to_rgb(_env_hex("DOTFILES_TERM_FG_DARK", "#d4d4d4"))
|
|
413
|
-
else:
|
|
414
|
-
bg_rgb = hex_to_rgb(_env_hex("DOTFILES_TERM_BG_LIGHT", "#fbf1c7"))
|
|
415
|
-
fg_rgb = hex_to_rgb(_env_hex("DOTFILES_TERM_FG_LIGHT", "#3c3836"))
|
|
416
|
-
|
|
417
|
-
# Tint the engineered neutral toward the wallpaper's dominant hue.
|
|
418
|
-
#
|
|
419
|
-
# The lightness stays exactly where it was — that is what the docstring
|
|
420
|
-
# above is protecting, and it is what keeps contrast and eye strain
|
|
421
|
-
# predictable. Only a*/b* move, and only to TINT_CHROMA, which is small
|
|
422
|
-
# enough to read as "this terminal belongs to that wallpaper" rather than
|
|
423
|
-
# as a coloured background.
|
|
424
|
-
#
|
|
425
|
-
# Without this, term.bg had exactly two values across 228 themes and every
|
|
426
|
-
# theme looked identical apart from one accent. Set DOTFILES_TERM_TINT=0
|
|
427
|
-
# for the previous flat neutrals.
|
|
428
|
-
if _env_hex("DOTFILES_TERM_TINT", "1") != "0":
|
|
429
|
-
ranked = sorted(clusters, key=lambda c: c[1] * lab_chroma(*c[0]), reverse=True)
|
|
430
|
-
src = ranked[0][0] if ranked and lab_chroma(*ranked[0][0]) >= 5.0 else None
|
|
431
|
-
if src is not None:
|
|
432
|
-
hue_c = lab_chroma(*src)
|
|
433
|
-
if hue_c > 0:
|
|
434
|
-
# Unit vector along the wallpaper's hue, scaled to a fixed,
|
|
435
|
-
# deliberately low chroma. Dark surfaces take a touch more:
|
|
436
|
-
# the same chroma reads weaker against a low-lightness ground.
|
|
437
|
-
bg_chroma = 4.5 if is_dark else 3.0
|
|
438
|
-
fg_chroma = 2.5 if is_dark else 2.0
|
|
439
|
-
ua, ub = src[1] / hue_c, src[2] / hue_c
|
|
440
|
-
bl = rgb_to_lab(*bg_rgb)[0]
|
|
441
|
-
fl = rgb_to_lab(*fg_rgb)[0]
|
|
442
|
-
bg_rgb = lab_to_rgb(bl, ua * bg_chroma, ub * bg_chroma)
|
|
443
|
-
fg_rgb = lab_to_rgb(fl, ua * fg_chroma, ub * fg_chroma)
|
|
444
|
-
|
|
445
|
-
bg_lab = rgb_to_lab(*bg_rgb)
|
|
446
|
-
return bg_lab, bg_rgb, fg_rgb
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
def _compute_accent(clusters, is_dark):
|
|
450
|
-
"""Select the accent from the wallpaper's DOMINANT chromatic colour.
|
|
451
|
-
|
|
452
|
-
Rank clusters by population x chroma so the accent follows the *main*
|
|
453
|
-
colour of the wallpaper (a large, colourful region) rather than the
|
|
454
|
-
single most-saturated cluster — which is often a tiny vivid splash
|
|
455
|
-
that doesn't represent the image. Near-neutral clusters score ~0 and
|
|
456
|
-
are skipped; if the whole image is neutral we fall back to the most
|
|
457
|
-
saturated cluster so the accent still carries a hue. The selected
|
|
458
|
-
colour is then AAA-darkened below (ADR-009 contrast requirement)."""
|
|
459
|
-
ranked = sorted(clusters, key=lambda c: c[1] * lab_chroma(*c[0]), reverse=True)
|
|
460
|
-
accent_lab = ranked[0][0]
|
|
461
|
-
if lab_chroma(*accent_lab) < 5.0:
|
|
462
|
-
accent_lab = max(clusters, key=lambda c: lab_chroma(*c[0]))[0]
|
|
463
|
-
if is_dark:
|
|
464
|
-
accent_lab = (max(accent_lab[0], 35.0), accent_lab[1], accent_lab[2])
|
|
465
|
-
else:
|
|
466
|
-
accent_lab = (min(accent_lab[0], 45.0), accent_lab[1], accent_lab[2])
|
|
467
|
-
# Darken until white text has 7:1 contrast (AAA)
|
|
468
|
-
al, aa, ab = accent_lab
|
|
469
|
-
for _ in range(80):
|
|
470
|
-
if contrast_ratio((255, 255, 255), lab_to_rgb(al, aa, ab)) >= 7.0:
|
|
471
|
-
break
|
|
472
|
-
al = max(0.0, al - 2.0)
|
|
473
|
-
return (al, aa, ab), lab_to_rgb(al, aa, ab)
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
# A support colour that has to fall below this lightness to satisfy the 7:1
|
|
477
|
-
# white-on-block requirement is not carrying colour any more. See the
|
|
478
|
-
# rejection note in _compute_support_colours.
|
|
479
|
-
SUPPORT_MIN_L = 25.0
|
|
480
|
-
|
|
481
|
-
# Minimum perceptual distance between the three support colours, as dE*ab.
|
|
482
|
-
#
|
|
483
|
-
# This replaced a 12-degree hue-angle check, which let near-identical colours
|
|
484
|
-
# through: hue angle is meaningless at low chroma, so three slate blues at
|
|
485
|
-
# chroma ~13 could sit 12 degrees apart and still be the same colour to look
|
|
486
|
-
# at. forest-dark's accent/secondary/tertiary measured dE 3.6 — against ~2.3
|
|
487
|
-
# for "just noticeable" — while passing the hue rule comfortably.
|
|
488
|
-
#
|
|
489
|
-
# 10 is roughly where two colours read as clearly different rather than as
|
|
490
|
-
# shades of one. Measured over the library, 68 of 228 themes had at least one
|
|
491
|
-
# pair below it; those now reach further down the cluster ranking for a
|
|
492
|
-
# candidate that is actually distinct, and only fall back to a synthetic
|
|
493
|
-
# rotation when the wallpaper genuinely has nothing else to offer.
|
|
494
|
-
SUPPORT_MIN_DE = 10.0
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
def _aaa_darkened(lab, is_dark):
|
|
498
|
-
"""Clamp lightness per mode, then darken until white sits on it at 7:1.
|
|
499
|
-
|
|
500
|
-
Shared by the selection filter and the final emission so a candidate is
|
|
501
|
-
judged on exactly the colour it will become, not on the one it started
|
|
502
|
-
as — checking the input and emitting the output was how a colour that
|
|
503
|
-
passed the chroma filter still arrived at L*=14.8.
|
|
504
|
-
|
|
505
|
-
A candidate that is ALREADY darker than SUPPORT_MIN_L is then lifted back
|
|
506
|
-
toward it, one step at a time, stopping the moment another step would
|
|
507
|
-
cost the 7:1 guarantee. Two different things can leave a support colour
|
|
508
|
-
too dark: the darkening loop travelling a long way (a low-chroma
|
|
509
|
-
candidate), and the cluster simply starting dark (dune-light's whole
|
|
510
|
-
palette sits near L*17 and holds 14.3:1, so the loop never ran). The
|
|
511
|
-
first is handled by rejecting the candidate; only the second can be
|
|
512
|
-
lifted, and only where there is contrast headroom to pay for it.
|
|
513
|
-
"""
|
|
514
|
-
L = max(lab[0], 35.0) if is_dark else min(lab[0], 45.0)
|
|
515
|
-
a, b = lab[1], lab[2]
|
|
516
|
-
for _ in range(80):
|
|
517
|
-
if contrast_ratio((255, 255, 255), lab_to_rgb(L, a, b)) >= 7.0:
|
|
518
|
-
break
|
|
519
|
-
L = max(0.0, L - 2.0)
|
|
520
|
-
while L < SUPPORT_MIN_L:
|
|
521
|
-
nxt = min(SUPPORT_MIN_L, L + 1.0)
|
|
522
|
-
if contrast_ratio((255, 255, 255), lab_to_rgb(nxt, a, b)) < 7.0:
|
|
523
|
-
break
|
|
524
|
-
L = nxt
|
|
525
|
-
return (L, a, b)
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
def _displayed(lab, is_dark):
|
|
529
|
-
"""The LAB of the colour that will actually be SHOWN.
|
|
530
|
-
|
|
531
|
-
Cluster centroids routinely land outside sRGB — Tang's top three sit at
|
|
532
|
-
chroma 197, 119 and 98, where sRGB tops out near 130 — and `lab_to_rgb`
|
|
533
|
-
clamps them. Comparing the raw LAB values said those three were dE 79 and
|
|
534
|
-
49 apart; after clamping they were #a80000 and #b00000, dE 3.3. The
|
|
535
|
-
distance test has to run on the clamped colour or it is measuring
|
|
536
|
-
something the user never sees.
|
|
537
|
-
"""
|
|
538
|
-
return rgb_to_lab(*lab_to_rgb(*_aaa_darkened(lab, is_dark)))
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
def _compute_support_colours(clusters, accent_lab, is_dark):
|
|
542
|
-
"""The wallpaper's SECOND and THIRD chromatic colours, as UI accents.
|
|
543
|
-
|
|
544
|
-
`_compute_accent` takes the top of the population x chroma ranking and
|
|
545
|
-
throws the rest away, so a wallpaper contributed exactly one colour to
|
|
546
|
-
the UI and everything else — status bars, separators, inactive states —
|
|
547
|
-
fell back to fixed neutrals. These are the next two ranked clusters,
|
|
548
|
-
put through the same AAA-darkening as the accent so white text sits on
|
|
549
|
-
them at 7:1.
|
|
550
|
-
|
|
551
|
-
Clusters within 12 degrees of a colour already chosen are skipped: two
|
|
552
|
-
near-identical blues give no more information than one, and the point of
|
|
553
|
-
a secondary is that it reads as different.
|
|
554
|
-
"""
|
|
555
|
-
ranked = [c for c in sorted(clusters, key=lambda c: c[1] * lab_chroma(*c[0]), reverse=True)
|
|
556
|
-
if lab_chroma(*c[0]) >= 5.0]
|
|
557
|
-
# Compare the colours as they will be SHOWN, not as the clusters arrived:
|
|
558
|
-
# the AAA-darkening moves lightness, which moves perceptual distance too.
|
|
559
|
-
picked, chosen = [], [rgb_to_lab(*lab_to_rgb(*accent_lab))]
|
|
560
|
-
for lab, _pop in ranked:
|
|
561
|
-
final = _displayed(lab, is_dark)
|
|
562
|
-
if any(lab_distance(final, o) < SUPPORT_MIN_DE for o in chosen):
|
|
563
|
-
continue
|
|
564
|
-
# Reject a candidate that only reaches 7:1 by going almost black.
|
|
565
|
-
#
|
|
566
|
-
# The AAA-darkening below drives lightness down until white sits on
|
|
567
|
-
# the colour at 7:1. A low-chroma candidate has to travel a long way
|
|
568
|
-
# to get there: bauhaus-light's second cluster landed at L*=14.8,
|
|
569
|
-
# which reads as another shade of dark rather than as a colour, even
|
|
570
|
-
# though its chroma of 8.0 passed the filter above.
|
|
571
|
-
#
|
|
572
|
-
# Measured across the library this affected 7 themes, all light
|
|
573
|
-
# variants, where the surface is pale and the colours must darken to
|
|
574
|
-
# sit on it. Raising the chroma floor instead would have cost 34
|
|
575
|
-
# themes their support colours and fixed only one of these seven, so
|
|
576
|
-
# the constraint belongs on the OUTCOME, not on the input.
|
|
577
|
-
if final[0] < SUPPORT_MIN_L:
|
|
578
|
-
continue
|
|
579
|
-
picked.append(lab)
|
|
580
|
-
chosen.append(final)
|
|
581
|
-
if len(picked) == 2:
|
|
582
|
-
break
|
|
583
|
-
# A wallpaper with only one usable hue still needs two support colours, so
|
|
584
|
-
# the accent's hue is rotated to invent them. Rotating blindly does not
|
|
585
|
-
# work: a rotated hue at high chroma often falls outside sRGB, and
|
|
586
|
-
# lab_to_rgb clamps it. Two different angles then clamp to nearly the same
|
|
587
|
-
# colour — tang-light produced #695600 and #685600, dE 0.5 apart, both at
|
|
588
|
-
# chroma 45. The fallback was the one path not checking its own output.
|
|
589
|
-
#
|
|
590
|
-
# So search: walk angles away from the accent, and drop chroma until the
|
|
591
|
-
# result survives a round trip through sRGB (proof it is in gamut) and is
|
|
592
|
-
# far enough from everything already chosen.
|
|
593
|
-
while len(picked) < 2:
|
|
594
|
-
base = picked[-1] if picked else accent_lab
|
|
595
|
-
best = None
|
|
596
|
-
for deg in (120, 90, 150, 60, 180, 40, 210, 30, 240, 270, 300):
|
|
597
|
-
for scale in (1.0, 0.85, 0.7, 0.55, 0.4):
|
|
598
|
-
c = lab_chroma(*base) * scale
|
|
599
|
-
rad = math.radians((lab_hue(*base) + deg) % 360.0)
|
|
600
|
-
cand_lab = (base[0], math.cos(rad) * c, math.sin(rad) * c)
|
|
601
|
-
final = _displayed(cand_lab, is_dark)
|
|
602
|
-
rgb = lab_to_rgb(*_aaa_darkened(cand_lab, is_dark))
|
|
603
|
-
# Round trip: if the colour was out of gamut it was clamped,
|
|
604
|
-
# and the clamped value will not convert back to what we asked
|
|
605
|
-
# for. That is exactly how the duplicates were produced.
|
|
606
|
-
if lab_distance(rgb_to_lab(*rgb), final) > 3.0:
|
|
607
|
-
continue
|
|
608
|
-
if final[0] < SUPPORT_MIN_L:
|
|
609
|
-
continue
|
|
610
|
-
d = min(lab_distance(final, o) for o in chosen)
|
|
611
|
-
if d >= SUPPORT_MIN_DE:
|
|
612
|
-
best = cand_lab
|
|
613
|
-
break
|
|
614
|
-
if best is None or d > min(lab_distance(_displayed(best, is_dark), o) for o in chosen):
|
|
615
|
-
best = cand_lab
|
|
616
|
-
if best is not None and min(
|
|
617
|
-
lab_distance(_displayed(best, is_dark), o) for o in chosen
|
|
618
|
-
) >= SUPPORT_MIN_DE:
|
|
619
|
-
break
|
|
620
|
-
if best is None:
|
|
621
|
-
best = (base[0], -base[1], -base[2]) # last resort: opposite hue
|
|
622
|
-
picked.append(best)
|
|
623
|
-
chosen.append(_displayed(best, is_dark))
|
|
624
|
-
|
|
625
|
-
return (lab_to_rgb(*_aaa_darkened(picked[0], is_dark)),
|
|
626
|
-
lab_to_rgb(*_aaa_darkened(picked[1], is_dark)))
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
def _on_dark(lab, surfaces, min_ratio=4.5):
|
|
630
|
-
"""The same hue, light enough to be TEXT on a dark surface.
|
|
631
|
-
|
|
632
|
-
`accent`, `secondary` and `tertiary` are darkened until white sits on
|
|
633
|
-
them at 7:1 — they are background colours by construction, and painting
|
|
634
|
-
them as text on the dark status bar measured as low as 1.67:1.
|
|
635
|
-
|
|
636
|
-
This keeps the hue and chroma and walks lightness up instead, until the
|
|
637
|
-
ratio holds against every surface the text can land on. Without it there
|
|
638
|
-
is no way to show a wallpaper colour AS text: the choice would be
|
|
639
|
-
between wallpaper colour and legibility.
|
|
640
|
-
"""
|
|
641
|
-
L, a, b = lab
|
|
642
|
-
for _ in range(120):
|
|
643
|
-
rgb = lab_to_rgb(L, a, b)
|
|
644
|
-
if all(contrast_ratio(rgb, s) >= min_ratio for s in surfaces):
|
|
645
|
-
return rgb
|
|
646
|
-
if L >= 100.0:
|
|
647
|
-
break
|
|
648
|
-
L = min(100.0, L + 1.5)
|
|
649
|
-
return lab_to_rgb(L, a, b)
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
def _muted_text(panel_rgb, border_rgb, bg_lab, is_dark):
|
|
653
|
-
"""Readable de-emphasised text — 4.5:1 against every surface it lands on.
|
|
654
|
-
|
|
655
|
-
Not `term.c8`. c8 is ANSI bright-black and is supposed to be dim; its
|
|
656
|
-
floor is 2.5:1 against bg, and tmux was painting the clock and the
|
|
657
|
-
inactive window names with it at 2.12:1 and 2.45:1. Raising c8 would
|
|
658
|
-
have made every terminal's dim colour less dim in order to fix a status
|
|
659
|
-
bar, so this is a separate slot.
|
|
660
|
-
|
|
661
|
-
Floored against BOTH panel and border, not just panel: the two differ in
|
|
662
|
-
lightness, tmux draws muted text on each of them, and flooring against
|
|
663
|
-
only the friendlier one left the other at 4.40:1 — a near miss is still
|
|
664
|
-
a miss.
|
|
665
|
-
"""
|
|
666
|
-
start = 62.0 if is_dark else 42.0
|
|
667
|
-
seed = lab_to_rgb(start, bg_lab[1] * 0.5, bg_lab[2] * 0.5)
|
|
668
|
-
out = seed
|
|
669
|
-
for surface in (panel_rgb, border_rgb):
|
|
670
|
-
out = ensure_contrast(out, surface, 4.5, is_dark)
|
|
671
|
-
# ensure_contrast against the second surface can walk back toward the
|
|
672
|
-
# first, so confirm rather than assume, and step until both hold.
|
|
673
|
-
for _ in range(80):
|
|
674
|
-
if all(contrast_ratio(out, s) >= 4.5 for s in (panel_rgb, border_rgb)):
|
|
675
|
-
break
|
|
676
|
-
l, a, b = rgb_to_lab(*out)
|
|
677
|
-
l = min(100.0, l + 2.0) if is_dark else max(0.0, l - 2.0)
|
|
678
|
-
out = lab_to_rgb(l, a, b)
|
|
679
|
-
return out
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
def _compute_panel_border(bg_lab, bg_rgb, is_dark):
|
|
683
|
-
"""Compute panel and border with enforced contrast ranges against bg."""
|
|
684
|
-
if is_dark:
|
|
685
|
-
panel_lab = (min(bg_lab[0] + 3, 100), bg_lab[1], bg_lab[2])
|
|
686
|
-
border_lab = (bg_lab[0] + 8, bg_lab[1] * 0.5, bg_lab[2] * 0.5)
|
|
687
|
-
else:
|
|
688
|
-
panel_lab = (max(bg_lab[0] - 3, 0), bg_lab[1], bg_lab[2])
|
|
689
|
-
border_lab = (bg_lab[0] - 6, bg_lab[1] * 0.3, bg_lab[2] * 0.3)
|
|
690
|
-
panel_rgb = lab_to_rgb(*panel_lab)
|
|
691
|
-
for _ in range(20):
|
|
692
|
-
pr = contrast_ratio(panel_rgb, bg_rgb)
|
|
693
|
-
if 1.03 <= pr <= 2.0:
|
|
694
|
-
break
|
|
695
|
-
if pr < 1.03:
|
|
696
|
-
panel_lab = (panel_lab[0] + (2 if is_dark else -2), panel_lab[1], panel_lab[2])
|
|
697
|
-
else:
|
|
698
|
-
panel_lab = (panel_lab[0] + (-1 if is_dark else 1), panel_lab[1], panel_lab[2])
|
|
699
|
-
panel_lab = (max(0, min(100, panel_lab[0])), panel_lab[1], panel_lab[2])
|
|
700
|
-
panel_rgb = lab_to_rgb(*panel_lab)
|
|
701
|
-
return panel_rgb, lab_to_rgb(*border_lab)
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
def _build_ansi_color(base_lab, accent_lab, bg_rgb, is_dark):
|
|
705
|
-
"""Build normal + bright ANSI variant from a base Lab color."""
|
|
706
|
-
normal_L = (
|
|
707
|
-
max(55.0, min(75.0, base_lab[0])) if is_dark
|
|
708
|
-
else max(30.0, min(50.0, base_lab[0]))
|
|
709
|
-
)
|
|
710
|
-
normal = adjust_lightness(base_lab, normal_L)
|
|
711
|
-
if is_dark:
|
|
712
|
-
# Dark bg: the bright variant pops by getting lighter.
|
|
713
|
-
bright = adjust_lightness(base_lab, normal_L + 12)
|
|
714
|
-
bright_min = 4.5
|
|
715
|
-
else:
|
|
716
|
-
# Light bg: a lighter bright would wash out against near-white, so
|
|
717
|
-
# brighten by vividness at equal lightness instead — the bright is
|
|
718
|
-
# never darker than the normal (matches Apple's light ANSI ramp,
|
|
719
|
-
# where brights read as more saturated, not muddier).
|
|
720
|
-
bright = (normal[0], normal[1] * 1.25, normal[2] * 1.25)
|
|
721
|
-
bright_min = 7.0
|
|
722
|
-
# WCAG AAA (7:1) for the chromatic slots on a light bg so coloured text
|
|
723
|
-
# (paths, syntax) is unambiguously legible — AA (4.5:1) still read as washed
|
|
724
|
-
# out on cream. Brights stay AAA too and differ from normals by saturation
|
|
725
|
-
# (Apple's light ramp), not lightness. Dark mode keeps its lower floor
|
|
726
|
-
# (light text on dark reads comfortably at a lower ratio).
|
|
727
|
-
normal_min = 3.0 if is_dark else 7.0
|
|
728
|
-
normal_rgb = ensure_contrast(lab_to_rgb(*normal), bg_rgb, normal_min, is_dark)
|
|
729
|
-
bright_rgb = ensure_contrast(lab_to_rgb(*bright), bg_rgb, bright_min, is_dark)
|
|
730
|
-
return normal_rgb, bright_rgb
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
def _ansi_palette(clusters, accent_lab, bg_rgb, is_dark):
|
|
734
|
-
"""Generate the 6 chromatic ANSI hues (red, green, yellow, blue, magenta, cyan)."""
|
|
735
|
-
chromatic = [(c, pop) for c, pop in clusters if lab_chroma(*c) > 10]
|
|
736
|
-
hue_assignments: Dict[str, List[Tuple[float, float, float]]] = {h: [] for h in ANSI_HUES}
|
|
737
|
-
for c_lab, _ in chromatic:
|
|
738
|
-
hue_assignments[find_nearest_hue(lab_hue(*c_lab))].append(c_lab)
|
|
739
|
-
|
|
740
|
-
accent_chroma = lab_chroma(*accent_lab)
|
|
741
|
-
ansi = {}
|
|
742
|
-
for hue_name, target_hue in ANSI_HUES.items():
|
|
743
|
-
candidates = hue_assignments[hue_name]
|
|
744
|
-
if candidates:
|
|
745
|
-
base = max(candidates, key=lambda c: lab_chroma(*c))
|
|
746
|
-
else:
|
|
747
|
-
new_a = accent_chroma * math.cos(math.radians(target_hue))
|
|
748
|
-
new_b = accent_chroma * math.sin(math.radians(target_hue))
|
|
749
|
-
base = (50.0, new_a, new_b)
|
|
750
|
-
ansi[hue_name] = _build_ansi_color(base, accent_lab, bg_rgb, is_dark)
|
|
751
|
-
return ansi
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
def _structural_colors(bg_lab, bg_rgb, is_dark):
|
|
755
|
-
"""Compute c0, c7, c8, c15 structural ANSI colors."""
|
|
756
|
-
if is_dark:
|
|
757
|
-
return (
|
|
758
|
-
ensure_contrast(lab_to_rgb(bg_lab[0] + 10, bg_lab[1], bg_lab[2]), bg_rgb, 1.5, True),
|
|
759
|
-
ensure_contrast(lab_to_rgb(75.0, bg_lab[1] * 0.1, bg_lab[2] * 0.1), bg_rgb, 5.0, True),
|
|
760
|
-
ensure_contrast(lab_to_rgb(bg_lab[0] + 25, bg_lab[1], bg_lab[2]), bg_rgb, 2.5, True),
|
|
761
|
-
ensure_contrast(lab_to_rgb(90.0, bg_lab[1] * 0.05, bg_lab[2] * 0.05), bg_rgb, 7.0, True),
|
|
762
|
-
)
|
|
763
|
-
# Light bg: the neutral ramp increases in lightness (c0 < c8 < c7 < c15).
|
|
764
|
-
# c0/c8/c7 stay readable (>= AA); c15 ("bright white") is the LIGHTEST tone,
|
|
765
|
-
# strictly lighter than c7 — bright-white text legibility is gated by fg, not
|
|
766
|
-
# c15, and forcing c15 to the same floor as c7 made them converge (c15 ended
|
|
767
|
-
# a hair darker, breaking the ramp). Start c15 well above c7 with only a mild
|
|
768
|
-
# floor so it stays the lightest. (Terminals render the dark palette; this
|
|
769
|
-
# ramp only feeds non-terminal light-palette consumers.)
|
|
770
|
-
return (
|
|
771
|
-
ensure_contrast(lab_to_rgb(18.0, bg_lab[1] * 0.2, bg_lab[2] * 0.2), bg_rgb, 7.0, False),
|
|
772
|
-
ensure_contrast(lab_to_rgb(50.0, bg_lab[1] * 0.15, bg_lab[2] * 0.15), bg_rgb, 4.5, False),
|
|
773
|
-
ensure_contrast(lab_to_rgb(35.0, bg_lab[1] * 0.2, bg_lab[2] * 0.2), bg_rgb, 4.5, False),
|
|
774
|
-
ensure_contrast(lab_to_rgb(64.0, bg_lab[1] * 0.08, bg_lab[2] * 0.08), bg_rgb, 2.5, False),
|
|
775
|
-
)
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
def generate_theme(
|
|
779
|
-
clusters: List[Tuple[Tuple[float, float, float], int]],
|
|
780
|
-
name: str,
|
|
781
|
-
is_dark: bool,
|
|
782
|
-
) -> Dict:
|
|
783
|
-
"""Generate a full theme definition from clustered dominant colors."""
|
|
784
|
-
bg_lab, bg_rgb, fg_rgb = _compute_bg_fg(clusters, is_dark)
|
|
785
|
-
accent_lab, accent_rgb = _compute_accent(clusters, is_dark)
|
|
786
|
-
accent_text = (255, 255, 255)
|
|
787
|
-
cursor_rgb = accent_rgb
|
|
788
|
-
|
|
789
|
-
# Selection background
|
|
790
|
-
if is_dark:
|
|
791
|
-
sel_lab = (bg_lab[0] + 15, accent_lab[1] * 0.4, accent_lab[2] * 0.4)
|
|
792
|
-
else:
|
|
793
|
-
sel_lab = (bg_lab[0] - 12, accent_lab[1] * 0.3, accent_lab[2] * 0.3)
|
|
794
|
-
sel_rgb = lab_to_rgb(*sel_lab)
|
|
795
|
-
|
|
796
|
-
panel_rgb, border_rgb = _compute_panel_border(bg_lab, bg_rgb, is_dark)
|
|
797
|
-
secondary_rgb, tertiary_rgb = _compute_support_colours(clusters, accent_lab, is_dark)
|
|
798
|
-
muted_rgb = _muted_text(panel_rgb, border_rgb, bg_lab, is_dark)
|
|
799
|
-
# Text-safe versions of the three chromatic colours, for consumers that
|
|
800
|
-
# paint them as foreground on panel/border rather than as a block.
|
|
801
|
-
_surfaces = (panel_rgb, border_rgb, bg_rgb)
|
|
802
|
-
accent_on_rgb = _on_dark(rgb_to_lab(*accent_rgb), _surfaces)
|
|
803
|
-
secondary_on_rgb = _on_dark(rgb_to_lab(*secondary_rgb), _surfaces)
|
|
804
|
-
tertiary_on_rgb = _on_dark(rgb_to_lab(*tertiary_rgb), _surfaces)
|
|
805
|
-
ansi = _ansi_palette(clusters, accent_lab, bg_rgb, is_dark)
|
|
806
|
-
c0_rgb, c7_rgb, c8_rgb, c15_rgb = _structural_colors(bg_lab, bg_rgb, is_dark)
|
|
807
|
-
|
|
808
|
-
accent_hue = lab_hue(*accent_lab)
|
|
809
|
-
nvim_theme = _nvim_from_hue(accent_hue, is_dark)
|
|
810
|
-
macos_accent = _macos_accent_from_hue(accent_hue)
|
|
811
|
-
|
|
812
|
-
mode = "dark" if is_dark else "light"
|
|
813
|
-
|
|
814
|
-
return {
|
|
815
|
-
"name": name,
|
|
816
|
-
"mode": mode,
|
|
817
|
-
"family": name.rsplit("-", 1)[0] if name.endswith(f"-{mode}") else name,
|
|
818
|
-
"macos_accent": macos_accent,
|
|
819
|
-
"wallpaper": "", # Set by caller
|
|
820
|
-
"source": "custom", # Set by caller
|
|
821
|
-
"term": {
|
|
822
|
-
"bg": rgb_to_hex(*bg_rgb),
|
|
823
|
-
"fg": rgb_to_hex(*fg_rgb),
|
|
824
|
-
"cursor": rgb_to_hex(*cursor_rgb),
|
|
825
|
-
"cursor_text": rgb_to_hex(*bg_rgb),
|
|
826
|
-
"sel_bg": rgb_to_hex(*sel_rgb),
|
|
827
|
-
"sel_fg": rgb_to_hex(*fg_rgb),
|
|
828
|
-
"c0": rgb_to_hex(*c0_rgb),
|
|
829
|
-
"c1": rgb_to_hex(*ansi["red"][0]),
|
|
830
|
-
"c2": rgb_to_hex(*ansi["green"][0]),
|
|
831
|
-
"c3": rgb_to_hex(*ansi["yellow"][0]),
|
|
832
|
-
"c4": rgb_to_hex(*ansi["blue"][0]),
|
|
833
|
-
"c5": rgb_to_hex(*ansi["magenta"][0]),
|
|
834
|
-
"c6": rgb_to_hex(*ansi["cyan"][0]),
|
|
835
|
-
"c7": rgb_to_hex(*c7_rgb),
|
|
836
|
-
"c8": rgb_to_hex(*c8_rgb),
|
|
837
|
-
"c9": rgb_to_hex(*ansi["red"][1]),
|
|
838
|
-
"c10": rgb_to_hex(*ansi["green"][1]),
|
|
839
|
-
"c11": rgb_to_hex(*ansi["yellow"][1]),
|
|
840
|
-
"c12": rgb_to_hex(*ansi["blue"][1]),
|
|
841
|
-
"c13": rgb_to_hex(*ansi["magenta"][1]),
|
|
842
|
-
"c14": rgb_to_hex(*ansi["cyan"][1]),
|
|
843
|
-
"c15": rgb_to_hex(*c15_rgb),
|
|
844
|
-
},
|
|
845
|
-
"ui": {
|
|
846
|
-
"accent": rgb_to_hex(*accent_rgb),
|
|
847
|
-
"accent_text": rgb_to_hex(*accent_text),
|
|
848
|
-
"error": rgb_to_hex(*ansi["red"][0]),
|
|
849
|
-
"warning": rgb_to_hex(*ansi["yellow"][0]),
|
|
850
|
-
"success": rgb_to_hex(*ansi["green"][0]),
|
|
851
|
-
"info": rgb_to_hex(*ansi["blue"][0]),
|
|
852
|
-
"panel": rgb_to_hex(*panel_rgb),
|
|
853
|
-
"border": rgb_to_hex(*border_rgb),
|
|
854
|
-
# The wallpaper's 2nd and 3rd chromatic colours. White sits on
|
|
855
|
-
# either at 7:1, same as accent.
|
|
856
|
-
"secondary": rgb_to_hex(*secondary_rgb),
|
|
857
|
-
"tertiary": rgb_to_hex(*tertiary_rgb),
|
|
858
|
-
# De-emphasised text that is still text: 4.5:1 against `panel`.
|
|
859
|
-
"text_muted": rgb_to_hex(*muted_rgb),
|
|
860
|
-
# Same hues, lightened until they are legible AS TEXT on panel,
|
|
861
|
-
# border and bg (>= 4.5:1 on all three).
|
|
862
|
-
"accent_on_surface": rgb_to_hex(*accent_on_rgb),
|
|
863
|
-
"secondary_on_surface": rgb_to_hex(*secondary_on_rgb),
|
|
864
|
-
"tertiary_on_surface": rgb_to_hex(*tertiary_on_rgb),
|
|
865
|
-
},
|
|
866
|
-
"app": {
|
|
867
|
-
"nvim": nvim_theme[0],
|
|
868
|
-
"nvim_style": nvim_theme[1],
|
|
869
|
-
"lualine": nvim_theme[0],
|
|
870
|
-
"gtk_theme": "Adwaita-dark" if is_dark else "Adwaita",
|
|
871
|
-
"gtk_icon": "Papirus-Dark" if is_dark else "Papirus-Light",
|
|
872
|
-
"gnome_shell": "",
|
|
873
|
-
"gnome_gtk": "Adwaita-dark" if is_dark else "Adwaita",
|
|
874
|
-
"vscode": VSCODE_DARK if is_dark else VSCODE_LIGHT,
|
|
875
|
-
"vscode_dark": VSCODE_DARK,
|
|
876
|
-
"vscode_light": VSCODE_LIGHT,
|
|
877
|
-
"vscode_icons": VSCODE_ICONS_DARK if is_dark else VSCODE_ICONS_LIGHT,
|
|
878
|
-
"cat_wallpaper": "",
|
|
879
|
-
"starship_palette": f"catppuccin_{'mocha' if is_dark else 'latte'}",
|
|
880
|
-
},
|
|
881
|
-
}
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
# ---------------------------------------------------------------------------
|
|
885
|
-
# TOML output
|
|
886
|
-
# ---------------------------------------------------------------------------
|
|
887
|
-
|
|
888
|
-
def theme_to_toml(theme: Dict) -> str:
|
|
889
|
-
"""Render a theme dict as TOML sections."""
|
|
890
|
-
name = theme["name"]
|
|
891
|
-
lines = []
|
|
892
|
-
lines.append(f'[themes.{name}]')
|
|
893
|
-
lines.append(f'mode = "{theme["mode"]}"')
|
|
894
|
-
lines.append(f'family = "{theme["family"]}"')
|
|
895
|
-
lines.append(f'macos_accent = {theme["macos_accent"]}')
|
|
896
|
-
lines.append(f'wallpaper = "{theme["wallpaper"]}"')
|
|
897
|
-
lines.append(f'source = "{theme["source"]}"')
|
|
898
|
-
lines.append("")
|
|
899
|
-
|
|
900
|
-
lines.append(f"[themes.{name}.term]")
|
|
901
|
-
for key in ["bg", "fg", "cursor", "cursor_text", "sel_bg", "sel_fg"]:
|
|
902
|
-
lines.append(f'{key} = "{theme["term"][key]}"')
|
|
903
|
-
for i in range(16):
|
|
904
|
-
key = f"c{i}"
|
|
905
|
-
pad = " " * (4 - len(key))
|
|
906
|
-
lines.append(f'{key}{pad}= "{theme["term"][key]}"')
|
|
907
|
-
lines.append("")
|
|
908
|
-
|
|
909
|
-
lines.append(f"[themes.{name}.ui]")
|
|
910
|
-
for key in ["accent", "accent_text", "secondary", "tertiary", "text_muted",
|
|
911
|
-
"accent_on_surface", "secondary_on_surface", "tertiary_on_surface",
|
|
912
|
-
"error", "warning", "success", "info", "panel", "border"]:
|
|
913
|
-
lines.append(f'{key} = "{theme["ui"][key]}"')
|
|
914
|
-
lines.append("")
|
|
915
|
-
|
|
916
|
-
lines.append(f"[themes.{name}.app]")
|
|
917
|
-
for key, val in theme["app"].items():
|
|
918
|
-
lines.append(f'{key} = "{val}"')
|
|
919
|
-
|
|
920
|
-
return "\n".join(lines)
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
# ---------------------------------------------------------------------------
|
|
924
|
-
# Main
|
|
925
|
-
# ---------------------------------------------------------------------------
|
|
926
|
-
|
|
927
|
-
def determine_mode(pixels: List[Tuple[int, int, int]]) -> bool:
|
|
928
|
-
"""Determine if image is dark (True) or light (False) from average luminance."""
|
|
929
|
-
if not pixels:
|
|
930
|
-
return True
|
|
931
|
-
avg_lum = sum(relative_luminance(*p) for p in pixels) / len(pixels)
|
|
932
|
-
return avg_lum < 0.35
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
def derive_name(image_path: str) -> str:
|
|
936
|
-
"""Derive a theme name from the image filename."""
|
|
937
|
-
base = os.path.splitext(os.path.basename(image_path))[0]
|
|
938
|
-
# Normalize: lowercase, replace spaces with hyphens, strip non-alphanum
|
|
939
|
-
name = base.lower().replace(" ", "-").replace("_", "-")
|
|
940
|
-
name = "".join(c for c in name if c.isalnum() or c == "-")
|
|
941
|
-
# Remove consecutive hyphens
|
|
942
|
-
while "--" in name:
|
|
943
|
-
name = name.replace("--", "-")
|
|
944
|
-
return name.strip("-")
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
def main():
|
|
948
|
-
import argparse
|
|
949
|
-
|
|
950
|
-
parser = argparse.ArgumentParser(
|
|
951
|
-
description="Extract a terminal theme from a wallpaper image."
|
|
952
|
-
)
|
|
953
|
-
parser.add_argument("image", help="Path to wallpaper image")
|
|
954
|
-
parser.add_argument("--name", help="Theme name (default: derived from filename)")
|
|
955
|
-
parser.add_argument("--format", choices=["toml", "json"], default="toml")
|
|
956
|
-
parser.add_argument("--clusters", type=int, default=8, help="Number of K-Means clusters")
|
|
957
|
-
parser.add_argument("--source", choices=["system", "custom"], default="custom", help="Wallpaper source type")
|
|
958
|
-
args = parser.parse_args()
|
|
959
|
-
|
|
960
|
-
# Extract pixels
|
|
961
|
-
try:
|
|
962
|
-
pixels = extract_pixels(args.image)
|
|
963
|
-
except FileNotFoundError as exc:
|
|
964
|
-
print(f"Error: {exc}", file=sys.stderr)
|
|
965
|
-
sys.exit(1)
|
|
966
|
-
if not pixels:
|
|
967
|
-
print("Error: no pixels extracted", file=sys.stderr)
|
|
968
|
-
sys.exit(1)
|
|
969
|
-
|
|
970
|
-
# Derive name
|
|
971
|
-
name = args.name or derive_name(args.image)
|
|
972
|
-
|
|
973
|
-
# Determine dark/light: trust name suffix if present, else detect from pixels
|
|
974
|
-
if name.endswith("-dark"):
|
|
975
|
-
is_dark = True
|
|
976
|
-
elif name.endswith("-light"):
|
|
977
|
-
is_dark = False
|
|
978
|
-
else:
|
|
979
|
-
is_dark = determine_mode(pixels)
|
|
980
|
-
name += "-dark" if is_dark else "-light"
|
|
981
|
-
|
|
982
|
-
# Subsample for speed — 2000 pixels is enough for accurate K-Means
|
|
983
|
-
if len(pixels) > 2000:
|
|
984
|
-
rng = random.Random(42) # nosec B311 — deterministic sampling
|
|
985
|
-
pixels = rng.sample(pixels, 2000)
|
|
986
|
-
|
|
987
|
-
# Convert to CIELAB
|
|
988
|
-
lab_pixels = [rgb_to_lab(*p) for p in pixels]
|
|
989
|
-
|
|
990
|
-
# Filter near-black and near-white to avoid muddy palettes
|
|
991
|
-
filtered = [p for p in lab_pixels if 5 < p[0] < 95]
|
|
992
|
-
if len(filtered) < len(lab_pixels) * 0.1:
|
|
993
|
-
filtered = lab_pixels # Image is mostly black/white, use all
|
|
994
|
-
|
|
995
|
-
# K-Means clustering
|
|
996
|
-
clusters = kmeans_lab(filtered, k=args.clusters, runs=3)
|
|
997
|
-
|
|
998
|
-
# Generate theme
|
|
999
|
-
theme = generate_theme(clusters, name, is_dark)
|
|
1000
|
-
wp_base = args.image.split("[")[0] if "[" in args.image else args.image
|
|
1001
|
-
wp_abs = os.path.abspath(wp_base)
|
|
1002
|
-
# Store home-relative with a `~` so the committed themes.toml is not tied to
|
|
1003
|
-
# one machine's username. Consumers (wallpaper-sync.sh) expand `~/`. System
|
|
1004
|
-
# wallpapers (/System/..., /usr/share/...) stay absolute — identical on
|
|
1005
|
-
# every host anyway.
|
|
1006
|
-
home = os.path.expanduser("~")
|
|
1007
|
-
if wp_abs == home or wp_abs.startswith(home + os.sep):
|
|
1008
|
-
wp_abs = "~" + wp_abs[len(home):]
|
|
1009
|
-
theme["wallpaper"] = wp_abs
|
|
1010
|
-
theme["source"] = args.source
|
|
1011
|
-
|
|
1012
|
-
# Output
|
|
1013
|
-
if args.format == "json":
|
|
1014
|
-
print(json.dumps(theme, indent=2))
|
|
1015
|
-
else:
|
|
1016
|
-
print(theme_to_toml(theme))
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
if __name__ == "__main__":
|
|
1020
|
-
main()
|