dotfiles/.local/bin/color-palette
Alexander Hess 86cce2d9cf
Remove the forge "unfocused-border" patch
The patch rewrote forge's window.js with `sed`/`awk` anchored on
exact upstream lines, making it the most fragile piece of the
repository, for a purely cosmetic hairline on unfocused windows.

This also simplifies a future migration away from forge.
2026-10-06 13:36:43 +02:00

1106 lines
33 KiB
Python
Executable file

#!/usr/bin/env python3
# This file's name matches the CLI convention in ~/.local/bin
# pylint: disable=invalid-name
# Templates for six consumers live here
# => Length of file is managable
# pylint: disable=too-many-lines
"""Derive a color palette from GNOME's accent color.
References to GNOME mean references to libadwaita to be precise.
"""
import argparse
import math
import os
import pathlib
import subprocess
import sys
import tempfile
import textwrap
from collections.abc import Container
import coloraide # pylint: disable=import-error
# Number of equally spaced stops between the pinned
# `LIGHTNESS_DARK_END` and `LIGHTNESS_LIGHT_END`
N_MAJOR_STOPS = 7
# Roles as (dark, light) indexes into the `ramp`
# => Initialized as mirrors (i.e., `(k, `-(k + 1)`)
ROLES = {
"base": (0, -1),
"surface-dim": (1, -2),
"emphasis-dim": (2, -3),
"accent-area": (3, -4),
"active-border": (4, -5),
"chrome-quiet": (5, -6),
"text": (6, -7),
}
# We pin the ends of the lightness grid,
# independent of the anchor's own lightness:
# - The light end is a tinted near-white, far enough from white so that
# the accent's tint stays visible,
# themed surfaces remain distinguishable from GNOME's white surfaces (~0.97),
# and light text on dark surfaces does not glare
# - The dark end is a deep base, clearly lighter than pure black,
# which stays reserved for the screensaver and shadows,
# and darker than GNOME's dark chrome (~0.32) so that
# bars and menus visibly float above the desktop and terminal base
LIGHTNESS_DARK_END = 0.200
LIGHTNESS_LIGHT_END = 0.950
# Chroma (i.e., "How colorful is a color?"; 0 => "gray") of the extreme stops
# => We use a `bell()` to vary the chroma across all stops so that
# the accent is the most colorful
CHROMA_DARK_END = 0.055 # Higher because hue is hard to see in dark colors
CHROMA_LIGHT_END = 0.022 # Lower because a subtle tint is already visible near white
# Lightness distance between the normal and the bright ANSI shade,
# as (dark, light) pairs
# => Smaller in light mode, where "bright" must go darker for contrast
# and the emphasis is carried by extra chroma instead (see below)
ANSI_BRIGHT_LIFT = (0.10, 0.05)
# Chroma multiplier for the bright ANSI shades, as (dark, light) pairs
# => In light mode "brighter" reads as "more vivid" rather than "darker"
ANSI_BRIGHT_CHROMA_FACTOR = (1.0, 1.35)
# Cap for the ANSI accents' chroma so that the palette stays overall calm
ANSI_CHROMA_MAX = {
"red": 0.14,
"yellow": 0.10,
"green": 0.11,
"cyan": 0.10,
"blue": 0.15,
"magenta": 0.12,
}
# WCAG 2.1 contrast the ANSI accents must reach against the `base`
# => Blue-heavy hues get a higher floor so they survive night light,
# which attenuates the blue channel screen-wide
ANSI_CONTRAST = {
"red": 4.5,
"yellow": 4.5,
"green": 4.5,
"cyan": 4.5,
"blue": 5.5,
"magenta": 5.0,
}
# Hue angles (in "oklch": degrees on the color wheel)
# for the terminal's semantic ANSI colors
# => Fixed taste, deliberately not influenced by the anchor,
# except when the anchor's hue is within `ANSI_HUE_REUSE_MAX`,
# in which case the anchor's hue is used instead
ANSI_HUES = {
"red": 25,
"yellow": 95,
"green": 145,
"cyan": 195,
"blue": 260,
"magenta": 330,
}
# Maximum circular hue distance (in degrees) for re-using the anchor's hue
ANSI_HUE_REUSE_MAX = 20
_ANSI_ORDER = [
"black",
"red",
"green",
"yellow",
"blue",
"magenta",
"cyan",
"white",
]
# GNOME's nine built-in accents, used as anchor for the palette to be created
# (See https://gnome.pages.gitlab.gnome.org/libadwaita/doc/main/css-variables.html#accent-colors)
GNOME_ACCENTS = {
"blue": "#3584e4",
"green": "#3a944a",
"orange": "#ed5b00",
"pink": "#d56199",
"purple": "#9141ac",
"red": "#e62d42",
"slate": "#6f8396",
"teal": "#2190a4",
"yellow": "#c88800",
}
DEFAULT_GNOME_ACCENT = "orange"
# GNOME's built-in neutral surfaces as (dark, light) pairs,
# adopted verbatim so that our themed elements sit naturally next them
GNOME_NEUTRALS = {
"chrome": ("#2e2e32", "#ebebed"), # --headerbar or --sidebar-bg-color
"chrome-2": ("#28282c", "#f3f3f5"), # --secondary-sidebar-bg-color
"popover": ("#36363a", "#ffffff"), # --popover-bg-color
"view": ("#1d1d20", "#ffffff"), # --view-bg-color
"window": ("#222226", "#fafafb"), # --window-bg-color
}
_LIGHTNESS_RESOLUTION = 0.001
# Corner geometry:
# - Windows are clipped to `WINDOW_RADIUS`
# by the rounded-window-corners-reborn extension
# - forge's rings sit slightly outside the window frame
# => The ring radius adds 1px so the curves stay concentric
# (forge's stock inset is actually 3; see window.js)
WINDOW_RADIUS = 12
# Template for the stylesheet used by the GNOME shell extension `forge`
FORGE_CSS = textwrap.dedent("""\
.color-palette-metadata {{
/* Written by ~/.local/bin/color-palette */
/* accent={accent_name} scheme={scheme_name} */
}}
.tiled {{
color: {text};
opacity: 1;
border-width: 3px;
}}
.split {{
color: {emphasis_dim};
opacity: 1;
border-width: 3px;
}}
.stacked {{
color: {emphasis_dim};
opacity: 1;
border-width: 3px;
}}
.tabbed {{
color: {emphasis_dim};
opacity: 1;
border-width: 3px;
}}
.floated {{
color: {emphasis_dim};
border-width: 3px;
opacity: 1;
}}
.window-tiled-border {{
/* The focused tiled ring is the one place besides space-bar's */
/* active workspace that carries the full accent; all other */
/* focused rings (split, stacked, tabbed, floated) use */
/* emphasis-dim instead */
border-width: 4px;
border-color: {accent};
border-style: solid;
border-radius: {ring_radius};
}}
.window-split-border {{
border-width: 4px;
border-color: {emphasis_dim};
border-style: solid;
border-radius: {ring_radius};
}}
.window-split-horizontal {{
border-left-width: 0;
border-top-width: 0;
border-bottom-width: 0;
}}
.window-split-vertical {{
border-left-width: 0;
border-top-width: 0;
border-right-width: 0;
}}
.window-stacked-border {{
border-width: 4px;
border-color: {emphasis_dim};
border-style: solid;
border-radius: {ring_radius};
}}
.window-tabbed-border {{
border-width: 4px;
border-color: {emphasis_dim};
border-style: solid;
border-radius: {ring_radius};
}}
.window-tabbed-bg {{
border-radius: 12px;
}}
.window-tabbed-tab {{
background-color: {surface_dim};
border-color: {surface_dim_a60};
border-width: 1px;
border-radius: 12px;
color: {chrome_quiet};
margin: 1px;
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.2);
}}
.window-tabbed-tab-active {{
background-color: {emphasis_dim};
color: {text};
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.2);
}}
.window-tabbed-tab-close {{
padding: 3px;
margin: 4px;
border-radius: 12px;
width: 12px;
}}
.window-tabbed-tab-icon {{
margin: 3px;
}}
.window-floated-border {{
border-width: 4px;
border-color: {emphasis_dim};
border-style: solid;
border-radius: {ring_radius};
}}
.window-tilepreview-tiled {{
border-width: 1px;
border-color: {accent_a40};
border-style: solid;
border-radius: {ring_radius};
background-color: {accent_a30};
}}
.window-tilepreview-stacked {{
border-width: 1px;
border-color: {emphasis_dim_a40};
border-style: solid;
border-radius: {ring_radius};
background-color: {emphasis_dim_a30};
}}
.window-tilepreview-swap {{
border-width: 1px;
border-color: {active_border_a40};
border-style: solid;
border-radius: {ring_radius};
background-color: {active_border_a40};
}}
.window-tilepreview-tabbed {{
border-width: 1px;
border-color: {emphasis_dim_a40};
border-style: solid;
border-radius: {ring_radius};
}}
""")
def as_ansi24(hex_value: str) -> str:
"""Format a hex color as a 24-bit ANSI code ("38;2;R;G;B")."""
red, green, blue = (int(hex_value[i : i + 2], 16) for i in (1, 3, 5))
return f"38;2;{red};{green};{blue}"
def as_hex(hex_value: str) -> str:
"""Format a hex color as itself.
This function is useful as a named dummy where a callback is needed.
"""
return hex_value
def as_rgb(hex_value: str) -> str:
"""Format a hex color as an "rgb(r,g,b)" string.
Example: rgb(33,144,164)
"""
red, green, blue = (int(hex_value[i : i + 2], 16) for i in (1, 3, 5))
return f"rgb({red},{green},{blue})"
def as_rgba(hex_value: str, *, alpha: float) -> str:
"""Format a hex color as an "rgba(r, g, b, a)" string."""
red, green, blue = (int(hex_value[i : i + 2], 16) for i in (1, 3, 5))
return f"rgba({red}, {green}, {blue}, {alpha})"
# Consumers of the palette, keyed by `dconf` path
# => Each entry maps a dconf key to a (role, formatter) pair
CONSUMERS = {
"org/gnome/desktop/background": {
"primary-color": ("base", as_hex),
"secondary-color": ("base", as_hex),
},
"org/gnome/desktop/screensaver": {
"primary-color": ("black", as_hex),
"secondary-color": ("black", as_hex),
},
"org/gnome/shell/extensions/space-bar/appearance": {
"active-workspace-background-color": ("accent", as_rgb),
"active-workspace-border-color": ("active-border", as_rgb),
"active-workspace-text-color": ("text", as_rgb),
"empty-workspace-background-color": ("chrome", as_rgb),
"empty-workspace-border-color": ("chrome", as_rgb),
"empty-workspace-text-color": ("emphasis-dim", as_rgb),
"inactive-workspace-background-color": ("chrome", as_rgb),
"inactive-workspace-border-color": ("chrome", as_rgb),
"inactive-workspace-text-color": ("chrome-quiet", as_rgb),
},
"org/gnome/terminal/legacy/profiles:/:12345678-1234-5678-9000-123456789000": {
"background-color": ("base", as_rgb),
"foreground-color": ("text", as_rgb),
},
}
def bell(
lightness: float,
*,
peak_lightness: float,
peak_chroma: float,
) -> float:
"""Return the chroma at the `lightness`.
We make chroma follow a parabola over lightness for our palette:
It peaks at `peak_chroma` (i.e., at the anchor's lightness)
and falls off to the tails at the grid's ends.
So, every stop is a shade of the `accent`,
and none is more colorful than the `accent` itself.
Beyond the tails (e.g., for extra dark surface stops),
the chroma is held at the tail values.
"""
if lightness >= peak_lightness:
end = CHROMA_LIGHT_END
span = LIGHTNESS_LIGHT_END - peak_lightness
else:
end = CHROMA_DARK_END
span = peak_lightness - LIGHTNESS_DARK_END
# If the `anchor` is at or beyond a pinned end,
# we have no falloff to compute
if span <= 0:
return end
# `t` is the position between the peak (`t=0`) and an end (`t=1`),
# clamped at `1` so lightnesses beyond the end keep the tail chroma
t = min(abs(lightness - peak_lightness) / span, 1.0)
return peak_chroma - (peak_chroma - end) * (t**2)
def hexify(
*,
lightness: float,
chroma: float,
hue: float,
) -> str:
"""Convert an "oklch" color to a lower-case sRGB hex string.
OKLCH can describe colors that sRGB cannot display,
mostly dark ones with high chroma. Such colors are made displayable
by reducing only their chroma (i.e., toward gray) until they
just fit into sRGB, keeping lightness and hue as requested.
So, a fitted stop is exactly as dark and exactly
in the anchor's hue as designed, just a little less colorful.
"""
return (
coloraide.Color("oklch", (lightness, chroma, hue))
.convert("srgb")
.fit(method="oklch-chroma")
.to_string(hex=True, upper=False)
)
def hue_distance(angle_a: float, angle_b: float) -> float:
"""Return the circular distance between two hue angles in degrees."""
angle_distance = abs(angle_a - angle_b) % 360
return min(angle_distance, 360 - angle_distance)
def lift(
*,
lightness: float,
chroma: float,
hue: float,
against: str,
target_contrast: float,
) -> float:
"""Return the `lightness` at which `hue` reads on `against`.
Starting from the given `lightness` (i.e., the anchor's lightness),
move away from the `against` color until the `target_contrast`
is reached. In simple terms, we make a color lighter on a dark
surface and darker on a light one.
The minimal such lightness is found via bisection, exploiting that
WCAG contrast grows monotonically when moving away from the surface.
"""
surface = coloraide.Color(against)
surface_lightness = surface.convert("oklch")["lightness"]
def contrast_at(candidate: float) -> float:
color = coloraide.Color("oklch", (candidate, chroma, hue))
color.fit("srgb", method="oklch-chroma")
return color.contrast(surface, method="wcag21")
# Already good at the lightness of the `anchor`
if contrast_at(lightness) >= target_contrast:
return lightness
# `far` is the extreme lightness away from the surface
far = 1.0 if surface_lightness < lightness else 0.0
# Even the extreme cannot reach the target contrast
if contrast_at(far) < target_contrast:
return far
# Bisect between "not enough contrast" (low) and "enough" (high)
low, high = lightness, far
# Halving the interval below the `_LIGHTNESS_RESOLUTION` suffices
# because one 8-bit sRGB channel step spans roughly 0.002-0.004 in OKLCH
# lightness; so, finer results cannot change the final hex anymore
n_bisect_steps = math.ceil(math.log2(1 / _LIGHTNESS_RESOLUTION)) # => 10
for _ in range(n_bisect_steps):
mid = (low + high) / 2
if contrast_at(mid) < target_contrast:
low = mid
else:
high = mid
# `high` is on the "enough" side => Contrast is guaranteed
return high
def read_gnome(
key: str,
*,
valid_values: Container[str],
default: str = "",
section: str = "org.gnome.desktop.interface",
) -> str:
"""Read the live GNOME setting `key`, restricted to `valid_values`.
Reads a key from the [section] via `gsettings` and
returns `default` if reading fails or yields an unknown value.
"""
try:
out = subprocess.run(
["gsettings", "get", section, key],
capture_output=True,
text=True,
check=True,
).stdout
except (OSError, subprocess.CalledProcessError):
return default
value = out.strip().strip("'\"").lower()
if value not in valid_values:
print(
f"WARN: {key} is {value!r} => Falling back to {default!r}",
file=sys.stderr,
)
return default
return value
def stop(
lightness: float,
*,
anchor_lightness: float,
anchor_chroma: float,
anchor_hue: float,
) -> str:
"""Compute the hex value of the grid stop at `lightness`."""
return hexify(
lightness=lightness,
chroma=bell(
lightness,
peak_lightness=anchor_lightness,
peak_chroma=anchor_chroma,
),
hue=anchor_hue,
)
def derive_ansi_colors(
*,
anchor: coloraide.Color,
base: str,
is_dark: bool,
) -> dict[str, str]:
"""Derive the terminal's semantic ANSI colors.
The colors are solved against the `base` so that
they reach `ANSI_CONTRAST` in the current scheme.
"""
colors = {}
# The (dark, light) constants, resolved for the current scheme
mode = 0 if is_dark else 1
bright_lift = ANSI_BRIGHT_LIFT[mode]
for name, ansi_hue in ANSI_HUES.items():
ansi_chroma = ANSI_CHROMA_MAX[name]
bright_chroma = ansi_chroma * ANSI_BRIGHT_CHROMA_FACTOR[mode]
# Re-use the anchor's hue if it is close enough
# => One color, two roles
if hue_distance(ansi_hue, anchor["hue"]) <= ANSI_HUE_REUSE_MAX:
ansi_hue = anchor["hue"]
normal_lightness = lift(
lightness=anchor["lightness"],
chroma=ansi_chroma,
hue=ansi_hue,
against=base,
target_contrast=ANSI_CONTRAST[name],
)
bright_lightness = normal_lightness + (bright_lift if is_dark else -bright_lift)
colors[f"{name}-normal"] = hexify(
lightness=normal_lightness,
chroma=ansi_chroma,
hue=ansi_hue,
)
colors[f"{name}-bright"] = hexify(
lightness=bright_lightness,
chroma=bright_chroma,
hue=ansi_hue,
)
return colors
def derive_colors(*, scheme_override: str = "") -> dict[str, str]:
"""Derive all palette colors from the live GNOME settings."""
colors = {
"black": "#000000", # Screensaver, shadows
"white": "#ffffff",
}
accent = read_gnome(
"accent-color",
valid_values=set(GNOME_ACCENTS),
default=DEFAULT_GNOME_ACCENT,
)
if scheme_override:
scheme = f"prefer-{scheme_override}"
else:
scheme = read_gnome(
"color-scheme",
valid_values={
"default", # ~ "prefer-light"
"prefer-light",
"prefer-dark",
},
default="prefer-dark",
)
is_dark = scheme == "prefer-dark"
# The `anchor` seeds the palette:
# - its "hue" is used for all stops, and
# - its "chroma" becomes the `peak_chroma` for the `bell()`
anchor = coloraide.Color(GNOME_ACCENTS[accent]).convert("oklch")
# Built up the `colors` palette
# 0) The chosen GNOME accent itself
colors["accent"] = GNOME_ACCENTS[accent]
# 1) GNOME's built-in colors
for neutral, (dark_hex, light_hex) in GNOME_NEUTRALS.items():
colors[neutral] = dark_hex if is_dark else light_hex
# 2) The `ramp`, equally spaced stops between the pinned ends,
# ordered from dark (=> 0) to light (=> `N_MAJOR_STOPS` - 1)
ramp = [
stop(
LIGHTNESS_DARK_END
+ k * (LIGHTNESS_LIGHT_END - LIGHTNESS_DARK_END) / (N_MAJOR_STOPS - 1),
anchor_lightness=anchor["lightness"],
anchor_chroma=anchor["chroma"],
anchor_hue=anchor["hue"],
)
for k in range(N_MAJOR_STOPS)
]
for role, (dark_index, light_index) in ROLES.items():
colors[role] = ramp[dark_index if is_dark else light_index]
# 3) The terminal's semantic ANSI colors, solved against the current `base`
colors.update(
derive_ansi_colors(
anchor=anchor,
base=colors["base"],
is_dark=is_dark,
)
)
# The non-hue ANSI slots, aliased to `ramp` roles
# => One color, two roles
colors["black-normal"] = colors["chrome"]
colors["black-bright"] = colors["emphasis-dim"]
colors["white-normal"] = colors["text"]
colors["white-bright"] = colors["text"]
# 4) Expose some metadata for `main()`'s summary line
colors["_accent"] = accent
colors["_scheme"] = scheme
return colors
def render_alacritty(colors: dict[str, str]) -> str:
"""Render the colors imported by ~/.config/alacritty/alacritty.toml."""
lines = [
"# Written by ~/.local/bin/color-palette",
f"# accent={colors['_accent']} scheme={colors['_scheme']}",
"",
"[colors]",
"",
"draw_bold_text_with_bright_colors = false",
"",
"[colors.primary]",
f'background = "{colors["base"]}"',
f'foreground = "{colors["text"]}"',
"",
"[colors.selection]",
f'background = "{colors["surface-dim"]}"',
f'text = "{colors["text"]}"',
"",
"[colors.cursor]",
f'text = "{colors["base"]}"',
f'cursor = "{colors["text"]}"',
]
for shade in ("normal", "bright"):
lines += ["", f"[colors.{shade}]"]
for name in _ANSI_ORDER:
lines.append(f'{name:<7} = "{colors[f"{name}-{shade}"]}"')
lines += ["", "[bell]", f'color = "{colors["emphasis-dim"]}"']
return "\n".join(lines) + "\n"
def render_dconf(colors: dict[str, str]) -> str:
"""Render the `dconf` overrides for ~/.config/gnome-settings/local/."""
lines = [
"# Written by ~/.local/bin/color-palette",
f"# accent={colors['_accent']} scheme={colors['_scheme']}",
"",
"",
"[org/gnome/desktop/interface]",
"",
f"accent-color='{colors['_accent']}'",
f"color-scheme='{colors['_scheme']}'",
]
for dconf_path, keys in CONSUMERS.items():
lines += ["", "", f"[{dconf_path}]", ""]
for key, (role, formatter) in keys.items():
lines.append(f"{key}='{formatter(colors[role])}'")
# The terminal's 16-slot ANSI palette
if "terminal" in dconf_path:
slots = [
f"'{as_rgb(colors[f'{name}-normal'])}'" for name in _ANSI_ORDER
] + [f"'{as_rgb(colors[f'{name}-bright'])}'" for name in _ANSI_ORDER]
lines.append(f"palette=[{', '.join(slots)}]")
lines.append("")
return "\n".join(lines)
def render_fastfetch(colors: dict[str, str]) -> str:
"""Render ~/.config/fastfetch/config.jsonc."""
return textwrap.dedent(f"""\
// Written by ~/.local/bin/color-palette
// accent={colors['_accent']} scheme={colors['_scheme']}
//
// See: https://github.com/fastfetch-cli/fastfetch
{{
"$schema": "https://github.com/fastfetch-cli/fastfetch/raw/dev/doc/json_schema.json",
"logo": {{
"type": "small",
"color": {{
"1": "{as_ansi24(colors['accent'])}",
"2": "{as_ansi24(colors['active-border'])}"
}},
"padding": {{
"top": 1,
"right": 4
}}
}},
"display": {{
"separator": " ",
"color": {{
"keys": "{as_ansi24(colors['accent'])}",
"title": "{as_ansi24(colors['text'])}",
"separator": "{as_ansi24(colors['surface-dim'])}"
}},
"key": {{
"width": 12
}}
}},
"modules": [
"title",
"separator",
"os",
"kernel",
"uptime",
"packages",
"shell",
"break",
"cpu",
"memory",
{{
"type": "disk",
"folders": "/"
}},
"localip",
"break",
"colors"
]
}}
""")
def render_forge_css(colors: dict[str, str]) -> str:
"""Render ~/.config/forge/stylesheet/forge/stylesheet.css."""
return FORGE_CSS.format(
accent=as_rgb(colors["accent"]),
accent_a30=as_rgba(colors["accent"], alpha=0.3),
accent_a40=as_rgba(colors["accent"], alpha=0.4),
accent_name=colors["_accent"],
active_border_a40=as_rgba(colors["active-border"], alpha=0.4),
chrome_quiet=as_rgb(colors["chrome-quiet"]),
emphasis_dim=as_rgb(colors["emphasis-dim"]),
emphasis_dim_a30=as_rgba(colors["emphasis-dim"], alpha=0.3),
emphasis_dim_a40=as_rgba(colors["emphasis-dim"], alpha=0.4),
ring_radius=f"{WINDOW_RADIUS + 1}px",
scheme_name=colors["_scheme"],
surface_dim=as_rgb(colors["surface-dim"]),
surface_dim_a60=as_rgba(colors["surface-dim"], alpha=0.6),
text=as_rgb(colors["text"]),
)
def render_colors_md(colors: dict[str, str]) -> str:
"""Render ~/.config/gnome-settings/COLORS.md."""
header = textwrap.dedent(f"""\
<!-- Written by ~/.local/bin/color-palette -->
# Color Palette
All colors below are derived from GNOME's live settings
by [`color-palette`](../../.local/bin/color-palette).
Current inputs: accent=**{colors['_accent']}** scheme=**{colors['_scheme']}**
The `roles` form a ramp of {N_MAJOR_STOPS} stops in the accent's hue,
uniform in perceived lightness (i.e., OKLCH),
reading light-to-dark in dark mode and dark-to-light in light mode.
The GNOME neutrals are adopted verbatim from `libadwaita`.
The ANSI accents are solved to at least WCAG {min(ANSI_CONTRAST.values())}:1
against `base` (blue: {ANSI_CONTRAST["blue"]}:1, magenta: {ANSI_CONTRAST["magenta"]}:1).
## Roles
| Role | Hex | RGB |
|------|-----|-----|
""")
lines = []
for role in [*ROLES, "accent", "chrome", "black"]:
lines.append(f"| {role} | `{colors[role]}` | {as_rgb(colors[role])} |")
lines += [
"",
"",
"## Terminal ANSI Colors",
"",
"| ANSI | Normal | Bright |",
"|------|--------|--------|",
]
for name in _ANSI_ORDER:
lines.append(
f"| {name} | `{colors[f'{name}-normal']}` | `{colors[f'{name}-bright']}` |"
)
lines.append("")
return header + "\n".join(lines)
def render_rofi(colors: dict[str, str]) -> str:
"""Render the local override imported at the end of theme.rasi."""
return textwrap.dedent(f"""\
/* Written by ~/.local/bin/color-palette */
/* accent={colors['_accent']} scheme={colors['_scheme']} */
* {{
accent-c: {colors['accent']};
bg-dark: {colors['base']};
bg: {colors['chrome']};
border-c: {colors['active-border']};
emphasis-c: {colors['emphasis-dim']};
fg: {colors['text']};
fg-a12: {as_rgba(colors['text'], alpha=0.12)};
fg-dim: {colors['chrome-quiet']};
}}
""")
def show_colors(colors: dict[str, str]) -> None:
"""Print all derived colors of this run for debugging."""
print(
f"Palette: accent={colors['_accent']} scheme={colors['_scheme']}",
file=sys.stderr,
)
for name, hex_value in sorted(colors.items()):
if not name.startswith("_"):
print(f" {name:<16} {hex_value}", file=sys.stderr)
def show_swatches(colors: dict[str, str]) -> None: # pylint: disable=too-many-locals
"""Show all colors as truecolor swatches, grouped and in ramp order."""
base = colors["base"]
surface = coloraide.Color(base)
def rgb_of(hex_value: str) -> tuple[int, int, int]:
return tuple(int(hex_value[i : i + 2], 16) for i in (1, 3, 5))
def block(hex_value: str) -> str:
red, green, blue = rgb_of(hex_value)
return f"\033[48;2;{red};{green};{blue}m \033[0m"
def sample(fg_hex: str, bg_hex: str) -> str:
red, green, blue = rgb_of(fg_hex)
b_red, b_green, b_blue = rgb_of(bg_hex)
return (
f"\033[38;2;{red};{green};{blue}m"
f"\033[48;2;{b_red};{b_green};{b_blue}m Sample \033[0m"
)
def ratio(hex_value: str, *, against: coloraide.Color = surface) -> float:
return coloraide.Color(hex_value).contrast(against, method="wcag21")
def flag(value: float, *, target_contrast: float) -> str:
return " !" if value < (target_contrast - 0.05) else " "
print(f"Palette: accent={colors['_accent']} scheme={colors['_scheme']}")
# 1) The `ramp` roles, printed dark-to-light as in the ramp itself
# => Surfaces are judged by "does `text` read on them",
# foreground-ish roles by "do they read on `base`"
print("\n Ramp (on base, ratio of `text` on it)")
for role in ROLES:
hex_value = colors[role]
text_on_it = ratio(colors["text"], against=coloraide.Color(hex_value))
print(
f" {role:<16} {hex_value} {block(hex_value)}"
f" {sample(hex_value, base)}"
f" {ratio(hex_value):4.1f}:1 text:{text_on_it:4.1f}:1"
)
# 2) GNOME's neutrals
print("\n GNOME neutrals")
for name in [*GNOME_NEUTRALS, "black", "white", "accent"]:
hex_value = colors[name]
print(f" {name:<16} {hex_value} {block(hex_value)}")
# 3) The ANSI accents, normal and bright side by side
# => "black" and "white" alias ramp roles and are exempt
# from the contrast target that `lift()` solves for
print("\n ANSI (normal | bright, on base)")
for name in _ANSI_ORDER:
normal, bright = colors[f"{name}-normal"], colors[f"{name}-bright"]
r_normal, r_bright = ratio(normal), ratio(bright)
if name in ("black", "white"):
f_normal = f_bright = " "
else:
f_normal, f_bright = (
flag(r_normal, target_contrast=ANSI_CONTRAST[name]),
flag(r_bright, target_contrast=ANSI_CONTRAST[name]),
)
print(
f" {name:<8}"
f" {block(normal)} {sample(normal, base)} {r_normal:4.1f}:1{f_normal}"
f" | {block(bright)} {sample(bright, base)} {r_bright:4.1f}:1{f_bright}"
)
def write_consumers(colors: dict[str, str]) -> int:
"""Write the `colors` into all consumer files."""
config_home = pathlib.Path(
os.environ.get("XDG_CONFIG_HOME", pathlib.Path.home() / ".config")
)
results = (
write_file(
content=render_alacritty(colors),
target=config_home / "alacritty" / "colors.toml",
),
write_file(
content=render_colors_md(colors),
target=config_home / "gnome-settings" / "COLORS.md",
),
write_file(
content=render_dconf(colors),
target=config_home / "gnome-settings" / "local" / "palette.ini",
),
write_file(
content=render_fastfetch(colors),
target=config_home / "fastfetch" / "config.jsonc",
),
write_file(
content=render_forge_css(colors),
target=config_home / "forge" / "stylesheet" / "forge" / "stylesheet.css",
),
write_file(
content=render_rofi(colors),
target=config_home / "rofi" / "colors.rasi",
),
)
return 0 if all(results) else 1
def write_file(*, content: str, target: pathlib.Path) -> bool:
"""Write the `content` to the `target`, if it differs.
The write is done via a temporary file in the same directory
so that a failing run does not leave a truncated target behind.
Returns:
status: If the write succeeded
"""
try:
if target.exists() and target.read_text(encoding="utf-8") == content:
print(f" unchanged {target}", file=sys.stderr)
return True
target.parent.mkdir(parents=True, exist_ok=True)
fd, tmp_path = tempfile.mkstemp(
dir=target.parent,
prefix=".palette-",
suffix=".tmp",
)
try:
with os.fdopen(fd, "w", encoding="utf-8") as tmp:
tmp.write(content)
os.chmod(tmp_path, 0o644)
os.replace(tmp_path, target)
except BaseException:
try:
os.unlink(tmp_path)
except OSError:
pass
raise
except (OSError, UnicodeDecodeError) as error:
print(f" FAILED {target} ({error})", file=sys.stderr)
return False
print(f" written {target}", file=sys.stderr)
return True
def main() -> int:
"""Process the arguments and derive a color palette."""
# Keep the `argparse` flags and descriptions below in sync
# with the tab completions in ~/.bash_completion and ~/.zshrc
parser = argparse.ArgumentParser(
description="Derive a color palette from GNOME's accent color"
)
parser.add_argument(
# Used by ~/.local/bin/dark-scheme-toggle to speed up the change
"--scheme",
choices=["dark", "light"],
default=None,
help="Override the live GNOME color-scheme",
)
parser.add_argument(
"--show",
action="store_true",
help="Show the derived colors as swatches in the terminal",
)
parser.add_argument(
"--write",
action="store_true",
help="Write the derived color palette into all consumer files",
)
args = parser.parse_args()
colors = derive_colors(scheme_override=args.scheme)
if args.show:
show_swatches(colors)
else:
show_colors(colors)
if not args.write:
return 0
return write_consumers(colors)
if __name__ == "__main__":
sys.exit(main())