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.
1106 lines
33 KiB
Python
Executable file
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())
|