Last active
December 7, 2023 15:21
-
-
Save FelisDiligens/a182bf3e178887b1b4e7cf620bbec613 to your computer and use it in GitHub Desktop.
Converts theme files between different formats (Windows Terminal, mintty, iTerm)
This file contains 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 | |
import json | |
import configparser | |
import xml.etree.ElementTree as ET | |
from pathlib import Path | |
import sys | |
import argparse | |
def clamp(val, min_val, max_val): | |
return max(min_val, min(val, max_val)) | |
class Color: | |
"""Holds a RGB color. Can convert to and from various notations.""" | |
type_rgb_int = "rgb_int" | |
type_rgb_str = "rgb_str" | |
type_rgb_real = "rgb_real" | |
type_hex = "hex" | |
def __init__(self, color, format="rgb_int"): | |
""" | |
Create a Color object. | |
Arguments: | |
color -- either a tuple or a str, depending on type. | |
format -- one of the Color.type_* fields | |
""" | |
if format == Color.type_rgb_int: | |
if type(color) is not tuple or len(color) != 3: | |
raise ValueError( | |
f"Invalid 'color' argument. Expected tuple with 3 elements, got '{color}'" | |
) | |
self.r = color[0] | |
self.g = color[1] | |
self.b = color[2] | |
elif format == Color.type_rgb_str: | |
if type(color) is not str: | |
raise ValueError( | |
f"Invalid 'color' argument. Expected string, got '{color}'" | |
) | |
rgb = tuple((int(s) for s in color.split(","))) | |
if len(rgb) != 3: | |
raise ValueError( | |
f"Invalid 'color' argument. Expected string with three commas, got '{color}'" | |
) | |
self.r = int(rgb[0]) | |
self.g = int(rgb[1]) | |
self.b = int(rgb[2]) | |
elif format == Color.type_rgb_real: | |
if type(color) is not tuple or len(color) != 3: | |
raise ValueError( | |
f"Invalid 'color' argument. Expected tuple with 3 elements, got '{color}'" | |
) | |
self.r = clamp(int(color[0] * 255), 0, 255) | |
self.g = clamp(int(color[1] * 255), 0, 255) | |
self.b = clamp(int(color[2] * 255), 0, 255) | |
elif format == Color.type_hex: | |
if type(color) is not str: | |
raise ValueError( | |
f"Invalid 'color' argument. Expected string, got '{color}'" | |
) | |
hex = color.lstrip("#") | |
rgb = tuple(int(hex[i : i + 2], 16) for i in (0, 2, 4)) | |
self.r = rgb[0] | |
self.g = rgb[1] | |
self.b = rgb[2] | |
else: | |
raise ValueError(f"Unknown type '{format}'") | |
def get_rgb_real(self) -> tuple[float, float, float]: | |
return (self.r / 255.0, self.g / 255.0, self.b / 255.0) | |
def get_rgb_int(self) -> tuple[int, int, int]: | |
return (self.r, self.g, self.b) | |
def get_rgb_str(self): | |
return "%i,%i,%i" % (self.r, self.g, self.b) | |
def get_hex(self): | |
return ("#%02x%02x%02x" % (self.r, self.g, self.b)).upper() | |
class Theme: | |
def __init__(self): | |
self.name = "Unnamed" | |
self.background_color = Color("#FFFFFF", format=Color.type_hex) | |
self.foreground_color = Color("#FFFFFF", format=Color.type_hex) | |
self.cursor_color = Color("#FFFFFF", format=Color.type_hex) | |
self.selection_background = Color("#FFFFFF", format=Color.type_hex) | |
self.black = Color("#FFFFFF", format=Color.type_hex) | |
self.bright_black = Color("#FFFFFF", format=Color.type_hex) | |
self.red = Color("#FFFFFF", format=Color.type_hex) | |
self.bright_red = Color("#FFFFFF", format=Color.type_hex) | |
self.green = Color("#FFFFFF", format=Color.type_hex) | |
self.bright_green = Color("#FFFFFF", format=Color.type_hex) | |
self.yellow = Color("#FFFFFF", format=Color.type_hex) | |
self.bright_yellow = Color("#FFFFFF", format=Color.type_hex) | |
self.blue = Color("#FFFFFF", format=Color.type_hex) | |
self.bright_blue = Color("#FFFFFF", format=Color.type_hex) | |
self.purple = Color("#FFFFFF", format=Color.type_hex) | |
self.bright_purple = Color("#FFFFFF", format=Color.type_hex) | |
self.cyan = Color("#FFFFFF", format=Color.type_hex) | |
self.bright_cyan = Color("#FFFFFF", format=Color.type_hex) | |
self.white = Color("#FFFFFF", format=Color.type_hex) | |
self.bright_white = Color("#FFFFFF", format=Color.type_hex) | |
def from_wt(self, theme): | |
json_parsed = json.loads(theme) | |
self.name = json_parsed["name"] | |
self.background_color = Color(json_parsed["background"], format=Color.type_hex) | |
self.foreground_color = Color(json_parsed["foreground"], format=Color.type_hex) | |
self.cursor_color = Color(json_parsed["cursorColor"], format=Color.type_hex) | |
self.selection_background = Color( | |
json_parsed["selectionBackground"], format=Color.type_hex | |
) | |
self.black = Color(json_parsed["black"], format=Color.type_hex) | |
self.bright_black = Color(json_parsed["brightBlack"], format=Color.type_hex) | |
self.red = Color(json_parsed["red"], format=Color.type_hex) | |
self.bright_red = Color(json_parsed["brightRed"], format=Color.type_hex) | |
self.green = Color(json_parsed["green"], format=Color.type_hex) | |
self.bright_green = Color(json_parsed["brightGreen"], format=Color.type_hex) | |
self.yellow = Color(json_parsed["yellow"], format=Color.type_hex) | |
self.bright_yellow = Color(json_parsed["brightYellow"], format=Color.type_hex) | |
self.blue = Color(json_parsed["blue"], format=Color.type_hex) | |
self.bright_blue = Color(json_parsed["brightBlue"], format=Color.type_hex) | |
self.purple = Color(json_parsed["purple"], format=Color.type_hex) | |
self.bright_purple = Color(json_parsed["brightPurple"], format=Color.type_hex) | |
self.cyan = Color(json_parsed["cyan"], format=Color.type_hex) | |
self.bright_cyan = Color(json_parsed["brightCyan"], format=Color.type_hex) | |
self.white = Color(json_parsed["white"], format=Color.type_hex) | |
self.bright_white = Color(json_parsed["brightWhite"], format=Color.type_hex) | |
return self | |
def from_mintty(self, theme): | |
section = "SECTION" | |
parser = configparser.ConfigParser() | |
parser.read_string(f"[{section}]\n" + theme) | |
for key in parser[section]: | |
color = Color(parser[section][key], format=Color.type_rgb_str) | |
if key.lower() == "backgroundcolour": | |
self.background_color = color | |
elif key.lower() == "foregroundcolour": | |
self.foreground_color = color | |
elif key.lower() == "cursorcolour": | |
self.cursor_color = color | |
elif key.lower() == "black": | |
self.black = color | |
elif key.lower() == "boldblack": | |
self.bright_black = color | |
elif key.lower() == "red": | |
self.red = color | |
elif key.lower() == "boldred": | |
self.bright_red = color | |
elif key.lower() == "green": | |
self.green = color | |
elif key.lower() == "boldgreen": | |
self.bright_green = color | |
elif key.lower() == "yellow": | |
self.yellow = color | |
elif key.lower() == "boldyellow": | |
self.bright_yellow = color | |
elif key.lower() == "blue": | |
self.blue = color | |
elif key.lower() == "boldblue": | |
self.bright_blue = color | |
elif key.lower() == "magenta": | |
self.purple = color | |
elif key.lower() == "boldmagenta": | |
self.bright_purple = color | |
elif key.lower() == "cyan": | |
self.cyan = color | |
elif key.lower() == "boldcyan": | |
self.bright_cyan = color | |
elif key.lower() == "white": | |
self.white = color | |
elif key.lower() == "boldwhite": | |
self.bright_white = color | |
return self | |
def from_iterm(self, theme): | |
# Keys | |
# ---- | |
# Ansi 0 Color = black | |
# Ansi 1 Color = red | |
# Ansi 2 Color = green | |
# Ansi 3 Color = yellow | |
# Ansi 4 Color = blue | |
# Ansi 5 Color = purple | |
# Ansi 6 Color = cyan | |
# Ansi 7 Color = white | |
# Ansi 8 Color = bright_black | |
# Ansi 9 Color = bright_red | |
# Ansi 10 Color = bright_green | |
# Ansi 11 Color = bright_yellow | |
# Ansi 12 Color = bright_blue | |
# Ansi 13 Color = bright_purple | |
# Ansi 14 Color = bright_cyan | |
# Ansi 15 Color = bright_white | |
# Background Color = background_color | |
# Badge Color = ? | |
# Bold Color = ? | |
# Cursor Color = cursor_color | |
# Cursor Guide Color = ? | |
# Cursor Text Color = ? | |
# Foreground Color = foreground_color | |
# Link Color = ? | |
# Selected Text Color = ? | |
# Selection Color = selection_background | |
root = ET.fromstring(theme) | |
if not root.tag == "plist" or root.attrib["version"] != "1.0": | |
raise ValueError( | |
'ITerm XML version unknown, expected <plist version="1.0">' | |
) | |
if not root[0].tag == "dict": | |
raise ValueError("<plist> does not contain <dict>") | |
key = None | |
for child in root[0]: | |
if child.tag == "key": | |
key = child.text | |
elif child.tag == "dict": | |
rgb_dict = {"red": 0.0, "green": 0.0, "blue": 0.0} | |
rgb_component = None | |
for rgb in child: | |
if rgb.tag == "key": | |
if rgb.text == "Blue Component": | |
rgb_component = "blue" | |
elif rgb.text == "Green Component": | |
rgb_component = "green" | |
elif rgb.text == "Red Component": | |
rgb_component = "red" | |
else: | |
rgb_component = None | |
elif rgb.tag == "real" and rgb_component: | |
rgb_dict[rgb_component] = float(rgb.text) | |
color = Color( | |
(rgb_dict["red"], rgb_dict["green"], rgb_dict["blue"]), | |
format=Color.type_rgb_real, | |
) | |
if key == "Ansi 0 Color": | |
self.black = color | |
elif key == "Ansi 1 Color": | |
self.red = color | |
elif key == "Ansi 2 Color": | |
self.green = color | |
elif key == "Ansi 3 Color": | |
self.yellow = color | |
elif key == "Ansi 4 Color": | |
self.blue = color | |
elif key == "Ansi 5 Color": | |
self.purple = color | |
elif key == "Ansi 6 Color": | |
self.cyan = color | |
elif key == "Ansi 7 Color": | |
self.white = color | |
elif key == "Ansi 8 Color": | |
self.bright_black = color | |
elif key == "Ansi 9 Color": | |
self.bright_red = color | |
elif key == "Ansi 10 Color": | |
self.bright_green = color | |
elif key == "Ansi 11 Color": | |
self.bright_yellow = color | |
elif key == "Ansi 12 Color": | |
self.bright_blue = color | |
elif key == "Ansi 13 Color": | |
self.bright_purple = color | |
elif key == "Ansi 14 Color": | |
self.bright_cyan = color | |
elif key == "Ansi 15 Color": | |
self.bright_white = color | |
elif key == "Background Color": | |
self.background_color = color | |
elif key == "Cursor Color": | |
self.cursor_color = color | |
elif key == "Foreground Color": | |
self.foreground_color = color | |
elif key == "Selection Color": | |
self.selection_background = color | |
return self | |
def to_wt(self): | |
return json.dumps( | |
{ | |
"name": self.name, | |
"background": self.background_color.get_hex(), | |
"foreground": self.foreground_color.get_hex(), | |
"cursorColor": self.cursor_color.get_hex(), | |
"selectionBackground": self.selection_background.get_hex(), | |
"black": self.black.get_hex(), | |
"brightBlack": self.bright_black.get_hex(), | |
"blue": self.blue.get_hex(), | |
"brightBlue": self.bright_blue.get_hex(), | |
"cyan": self.bright_cyan.get_hex(), | |
"brightCyan": self.bright_cyan.get_hex(), | |
"green": self.green.get_hex(), | |
"brightGreen": self.bright_green.get_hex(), | |
"purple": self.purple.get_hex(), | |
"brightPurple": self.bright_purple.get_hex(), | |
"red": self.red.get_hex(), | |
"brightRed": self.bright_red.get_hex(), | |
"white": self.white.get_hex(), | |
"brightWhite": self.bright_white.get_hex(), | |
"yellow": self.yellow.get_hex(), | |
"brightYellow": self.bright_yellow.get_hex(), | |
}, | |
indent=4, | |
) | |
def to_mintty(self): | |
result = ["# Theme: %s" % self.name, ""] | |
# Write as key/value pairs separated by '=' | |
result.append(f"BackgroundColour={self.background_color.get_rgb_str()}") | |
result.append(f"ForegroundColour={self.foreground_color.get_rgb_str()}") | |
result.append(f"CursorColour={self.cursor_color.get_rgb_str()}") | |
result.append( | |
f"# SelectionBackground={self.selection_background.get_rgb_str()}" | |
) | |
result.append(f"Black={self.black.get_rgb_str()}") | |
result.append(f"BoldBlack={self.bright_black.get_rgb_str()}") | |
result.append(f"Blue={self.blue.get_rgb_str()}") | |
result.append(f"BoldBlue={self.bright_blue.get_rgb_str()}") | |
result.append(f"Cyan={self.cyan.get_rgb_str()}") | |
result.append(f"BoldCyan={self.bright_cyan.get_rgb_str()}") | |
result.append(f"Green={self.green.get_rgb_str()}") | |
result.append(f"BoldGreen={self.bright_green.get_rgb_str()}") | |
result.append(f"Magenta={self.purple.get_rgb_str()}") | |
result.append(f"BoldMagenta={self.bright_purple.get_rgb_str()}") | |
result.append(f"Red={self.red.get_rgb_str()}") | |
result.append(f"BoldRed={self.bright_red.get_rgb_str()}") | |
result.append(f"White={self.white.get_rgb_str()}") | |
result.append(f"BoldWhite={self.bright_white.get_rgb_str()}") | |
result.append(f"Yellow={self.yellow.get_rgb_str()}") | |
result.append(f"BoldYellow={self.bright_yellow.get_rgb_str()}") | |
return "\n".join(result) | |
def to_xterm(self): | |
result = [ | |
"! Theme: %s" % self.name, | |
"", | |
"! special", | |
f"XTerm*foreground: {self.foreground_color.get_hex()}", | |
f"XTerm*background: {self.background_color.get_hex()}", | |
f"XTerm*cursorColor: {self.cursor_color.get_hex()}", | |
"", | |
"! black", | |
f"XTerm*color0: {self.black.get_hex()}", | |
f"XTerm*color8: {self.bright_black.get_hex()}", | |
"", | |
"! red", | |
f"XTerm*color1: {self.red.get_hex()}", | |
f"XTerm*color9: {self.bright_red.get_hex()}", | |
"", | |
"! green", | |
f"XTerm*color2: {self.green.get_hex()}", | |
f"XTerm*color10: {self.bright_green.get_hex()}", | |
"", | |
"! yellow", | |
f"XTerm*color3: {self.yellow.get_hex()}", | |
f"XTerm*color11: {self.bright_yellow.get_hex()}" "", | |
"! blue", | |
f"XTerm*color4: {self.blue.get_hex()}", | |
f"XTerm*color12: {self.bright_blue.get_hex()}", | |
"", | |
"! magenta", | |
f"XTerm*color5: {self.purple.get_hex()}", | |
f"XTerm*color13: {self.bright_purple.get_hex()}", | |
"", | |
"! cyan", | |
f"XTerm*color6: {self.cyan.get_hex()}", | |
f"XTerm*color14: {self.bright_cyan.get_hex()}", | |
"", | |
"! white", | |
f"XTerm*color7: {self.white.get_hex()}", | |
f"XTerm*color15: {self.bright_white.get_hex()}", | |
] | |
return "\n".join(result) | |
if __name__ == "__main__": | |
parser = argparse.ArgumentParser( | |
description="Converts theme files between different formats" | |
) | |
parser.add_argument("filename", help='path to file or "-" to read from STDIN') | |
parser.add_argument( | |
"-i", | |
"--input", | |
help="set format of input theme (wt, mintty, iterm)", | |
required=True, | |
type=str, | |
choices=["wt", "mintty", "iterm"], | |
) | |
parser.add_argument( | |
"-o", | |
"--output", | |
help="set format of output theme (wt, mintty, xterm)", | |
required=True, | |
type=str, | |
choices=["wt", "mintty", "xterm"], | |
) | |
args = parser.parse_args() | |
file_contents = "" | |
if args.filename == "-": | |
file_contents = sys.stdin.read() | |
else: | |
file_path = Path(args.filename).absolute() | |
if not args.filename or not file_path.exists() or not file_path.is_file(): | |
print("Please provide a valid path to your theme file.") | |
sys.exit(1) | |
with open(args.filename, "r", encoding="utf-8") as f: | |
file_contents = f.read() | |
theme = None | |
if args.input == "wt": | |
theme = Theme().from_wt(file_contents) | |
elif args.input == "mintty": | |
theme = Theme().from_mintty(file_contents) | |
theme.name = file_path.stem | |
elif args.input == "iterm": | |
theme = Theme().from_iterm(file_contents) | |
theme.name = file_path.stem | |
else: | |
print(f"Unknown input format: '{args.input}'") | |
sys.exit(1) | |
if args.output == "wt": | |
print(theme.to_wt()) | |
elif args.output == "mintty": | |
print(theme.to_mintty()) | |
elif args.output == "xterm": | |
print(theme.to_xterm()) | |
else: | |
print(f"Unknown output format: '{args.output}'") | |
sys.exit(1) |
Author
FelisDiligens
commented
Jan 20, 2023
•
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment