Last active
February 23, 2026 16:07
-
-
Save jake-stewart/1533b798cffcc08508803bdba8b9e878 to your computer and use it in GitHub Desktop.
Detect and handle 256-color themes
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python3 | |
| """ | |
| Detects whether the terminal is using a light or dark theme and automatically | |
| adjusts 256-color palette indices so that colors render consistently regardless | |
| of the active theme. This is especially useful when a terminal (e.g. Ghostty) | |
| generates its 256-color palette to match the current theme — or when it doesn't, | |
| and we need to compensate by flipping the indices ourselves. | |
| """ | |
| import os | |
| import sys | |
| import termios | |
| import tty | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| # Terminal Color Querying | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| # Uses OSC (Operating System Command) escape sequences to ask the terminal | |
| # for its current color values. | |
| # | |
| # References: | |
| # - OSC 4 (palette colors) : https://ghostty.org/docs/vt/osc/4 | |
| # - OSC 10 (foreground) : https://ghostty.org/docs/vt/osc/1x | |
| # - OSC 11 (background) : https://ghostty.org/docs/vt/osc/1x | |
| def query_colors(codes): | |
| """ | |
| Query the terminal for one or more color values via OSC sequences. | |
| Each code can be: | |
| - "10" → foreground color | |
| - "11" → background color | |
| - "4;N" → palette color N (0–255) | |
| Returns a list of (R, G, B) tuples with 8-bit channel values (0–255). | |
| """ | |
| fd = sys.stdin.fileno() | |
| old = termios.tcgetattr(fd) | |
| tty.setraw(fd) | |
| # Send an OSC query for each requested color code. | |
| for code in codes: | |
| os.write(fd, f"\x1b]{code};?\x1b\\".encode()) | |
| # Send a Device Attributes request (DA1) as a sentinel — the terminal's | |
| # response to this tells us that all prior OSC replies have been flushed. | |
| os.write(fd, "\x1b[c".encode()) | |
| # Read until we see the DA1 response (starts with "\x1b[?"). | |
| buf = b"" | |
| while b"\x1b[?" not in buf: | |
| buf += os.read(fd, 4096) | |
| termios.tcsetattr(fd, termios.TCSADRAIN, old) | |
| # Parse each "rgb:RR/GG/BB" (or "rgb:RRRR/GGGG/BBBB") payload from the | |
| # collected responses, normalising every channel to 8 bits. | |
| results = [] | |
| for part in buf.decode().split("rgb:")[1:]: | |
| channels = part.split("\x1b")[0].split("/") | |
| rgb = [] | |
| for c in channels: | |
| if len(c) <= 2: | |
| # 8-bit: pad single-digit values by repeating the digit. | |
| rgb.append(int(c.rjust(2, c[0]), 16)) | |
| else: | |
| # 16-bit (or wider): take the high-order byte. | |
| rgb.append(int(c[:2], 16)) | |
| results.append(tuple(rgb)) | |
| return results | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| # Theme Detection | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| # We detect two things independently: | |
| # | |
| # 1. Terminal theme — is the terminal background lighter than its foreground? | |
| # 2. Palette theme — is palette entry 16 (the "black" end of the 6×6×6 cube) | |
| # lighter than entry 231 (the "white" end)? | |
| # | |
| # When both agree we call the setup "harmonious": the 256-color cube already | |
| # matches the terminal's appearance and indices can be used as-is. When they | |
| # disagree we flip / offset the indices so that logical colours still look | |
| # correct. | |
| def lightness(r, g, b): | |
| """Perceived luminance (ITU-R BT.709 luma coefficients).""" | |
| return 0.2126 * r + 0.7152 * g + 0.0722 * b | |
| # Query the four colours we need for detection. | |
| fg, bg, c16, c231 = query_colors(["10", "11", "4;16", "4;231"]) | |
| # Whether a light theme was detected (terminal bg is brighter than fg). | |
| is_term_light_theme = lightness(*bg) > lightness(*fg) | |
| print(f"Terminal light theme: {is_term_light_theme}") | |
| # Whether the 256 palette is light-oriented (entry 16 brighter than 231). | |
| is_palette_light_theme = lightness(*c16) > lightness(*c231) | |
| print(f"Palette light theme: {is_palette_light_theme}") | |
| # The colours are harmonious when the terminal theme matches the palette theme. | |
| is_harmonious = is_term_light_theme == is_palette_light_theme | |
| print(f"Harmonious: {is_harmonious}") | |
| # The palette is *generated* (and thus pretty) if terminal fg/bg matches the | |
| # palette endpoints exactly — meaning the terminal built the 256 palette from | |
| # its own theme colours. | |
| is_generated = bg == c16 and fg == c231 | |
| print(f"Generated: {is_generated}") | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| # Theme-Aware Color Helpers | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| # When the palette and terminal theme are *not* harmonious (e.g. a dark palette | |
| # on a light terminal), raw 256-colour indices produce jarring results. The | |
| # helpers below flip the indices so that: | |
| # | |
| # • grey256(0) → always closest to the terminal background | |
| # • grey256(23) → always closest to the terminal foreground | |
| # • color256(0,0,0) → always closest to the terminal background | |
| # • color256(5,5,5) → always closest to the terminal foreground | |
| def grey256(n): | |
| """ | |
| Return a 256-colour grey-ramp index (232–255) that respects the | |
| current theme. `n` ranges from 0 (darkest) to 23 (lightest). | |
| When the palette is non-harmonious the ramp direction is reversed. | |
| """ | |
| return 232 + (n if is_harmonious else 23 - n) | |
| def color256(r, g, b): | |
| """ | |
| Return a 256-colour cube index (16–231) for the given `(r, g, b)` | |
| components, each in 0–5. When the palette is non-harmonious the | |
| values are reflected so that the visual result matches what a | |
| harmonious palette would produce. | |
| """ | |
| if not is_harmonious: | |
| offset = 5 - max(r, g, b) - min(r, g, b) | |
| r, g, b = r + offset, g + offset, b + offset | |
| return 16 + r * 36 + g * 6 + b | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| # Demo | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| def colorise_bg(text, palette_index): | |
| """Wrap `text` in ANSI escape codes to set its background colour.""" | |
| return f"\x1b[48;5;{palette_index}m{text}\x1b[0m" | |
| if __name__ == "__main__": | |
| print() | |
| # Subtle (low-saturation) colours | |
| print(colorise_bg(" subtle red ", color256(1, 0, 0))) | |
| print(colorise_bg(" subtle green ", color256(0, 1, 0))) | |
| print(colorise_bg(" subtle blue ", color256(0, 0, 1))) | |
| print(colorise_bg(" subtle cyan ", color256(0, 1, 1))) | |
| print(colorise_bg(" subtle magenta ", color256(1, 0, 1))) | |
| print(colorise_bg(" subtle yellow ", color256(1, 1, 0))) | |
| print() | |
| # Subtle (low-saturation) colours | |
| print(colorise_bg(" medium red ", color256(3, 0, 0))) | |
| print(colorise_bg(" medium green ", color256(0, 3, 0))) | |
| print(colorise_bg(" medium blue ", color256(0, 0, 3))) | |
| print(colorise_bg(" medium cyan ", color256(0, 3, 3))) | |
| print(colorise_bg(" medium magenta ", color256(3, 0, 3))) | |
| print(colorise_bg(" medium yellow ", color256(3, 3, 0))) | |
| print() | |
| # Full-saturation colours | |
| print(colorise_bg(" full red ", color256(5, 0, 0))) | |
| print(colorise_bg(" full green ", color256(0, 5, 0))) | |
| print(colorise_bg(" full blue ", color256(0, 0, 5))) | |
| print(colorise_bg(" full cyan ", color256(0, 5, 5))) | |
| print(colorise_bg(" full magenta ", color256(5, 0, 5))) | |
| print(colorise_bg(" full yellow ", color256(5, 5, 0))) | |
| print() | |
| # Neutrals | |
| print(colorise_bg(" background ", color256(0, 0, 0))) | |
| print(colorise_bg(" grey ", color256(3, 3, 3))) | |
| print(colorise_bg(" foreground ", color256(5, 5, 5))) | |
| print(colorise_bg(" subtle grey ", grey256(2))) | |
| print(colorise_bg(" strong grey ", grey256(6))) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment