Skip to content

Instantly share code, notes, and snippets.

@coleifer
Last active August 13, 2024 06:49
Show Gist options
  • Save coleifer/33484bff21c34644dae1 to your computer and use it in GitHub Desktop.
Save coleifer/33484bff21c34644dae1 to your computer and use it in GitHub Desktop.
#!/usr/bin/env python
"""
Theme generator/manager.
Can generate themes from:
- Xresources-style color files
- Sweyla's site, e.g. for http://sweyla.com/themes/seed/693812/ -> 693812
- Images, in which case it will use k-means to get colors
Requires:
- ~/.config/themer/templates/ directory w/one valid set of templates
Assumes:
- you're using "any color you like" icon set
- probably a lot of other things
"""
from collections import namedtuple
import colorsys
import itertools
import logging
import math
import optparse
import os
import random
import re
import shutil
import sys
import urllib2
import yaml
from jinja2 import Environment, FileSystemLoader
try:
import Image, ImageDraw
except ImportError:
from PIL import Image, ImageDraw
logger = logging.getLogger(__name__)
DEFAULT_CONTEXT_CONFIG = {
'primary': 'magenta',
'secondary': 'green',
'tertiary': 'blue',
}
CONFIG_DIR = os.getenv('XDG_CONFIG_HOME', os.path.join(os.getenv('HOME'), '.config'))
THEMER_ROOT = os.path.join(CONFIG_DIR, 'themer')
TEMPLATE_ROOT = os.path.join(THEMER_ROOT, 'templates')
def dict_update(parent, child):
"""Recursively update parent dict with child dict."""
for key, value in child.iteritems():
if key in parent and isinstance(parent[key], dict):
parent[key] = dict_update(parent[key], value)
else:
parent[key] = value
return parent
def read_config(config_file):
"""Read a YAML config file."""
logger.debug('Reading config file: %s' % config_file)
config_dir = os.path.dirname(config_file)
base_config = {}
with open(config_file) as fh:
data = yaml.load(fh)
if data.get('extends'):
parent_config = os.path.join(config_dir, data['extends'])
base_config = read_config(parent_config)
return dict_update(base_config, data)
def render_templates(template_dir, files, context):
"""Render templates from `template_dir`."""
env = Environment(loader=FileSystemLoader(template_dir))
logger.debug('Jinja environment configured for: %s' % template_dir)
for src, dest in files.items():
dir_name = os.path.dirname(dest)
if not os.path.exists(dir_name):
logger.debug('Creating directory %s' % dir_name)
os.makedirs(dir_name)
if src.endswith(('tpl', 'conf')):
logger.info('Writing %s -> %s' % (src, dest))
template = env.get_template(src)
with open(dest, 'w') as fh:
fh.write(template.render(**context).encode('utf-8'))
else:
logger.info('Copying %s -> %s' % (src, dest))
shutil.copy(os.path.join(template_dir, src), dest)
def munge_context(variables, colors):
context = {}
context.update(variables)
# Handle case when a variable may reference a color, e.g.
# `primary` = `alt_red`, then `primary` = `#fd1a2b`
for key, value in context.items():
if value in colors:
context[key] = colors[value]
context.update(colors)
for key, value in DEFAULT_CONTEXT_CONFIG.items():
if key not in context:
context[key] = context[value]
return context
def wallfix(directory, colors):
"""Look in `directory` for file named `wallpaper.xxx` and set it."""
wallpaper = None
for filename in os.listdir(directory):
if filename.startswith('wallpaper'):
wallpaper = filename
break
if not wallpaper:
logger.info('No wallpaper found, generating new one.')
wallpaper = create_wallpaper(colors, directory)
logger.info('Setting %s as wallpaper' % wallpaper)
path = os.path.join(directory, wallpaper)
os.system('wallfix %s' % path)
def hex_to_rgb(h):
h = h.lstrip('#')
return tuple(map(lambda n: int(n, 16), [h[i:i+2] for i in range(0, 6, 2)]))
def rgb_to_hex(rgb):
return '#%s' % ''.join(('%02x' % p for p in rgb))
def create_wallpaper(colors, template_dir, w=1920, h=1200, filename='wallpaper.png'):
rectangles = (
# x1, y1, x2, y2 -- in percents
('red', [0, 30.0, 3.125, 72.5]), # LEFT
('green', [50, 0, 76.5625, 12.5]), # TOP
('yellow', [96.875, 30.0, 100, 72.5]), # RIGHT
('magenta', [23.4375, 25.0, 50, 30.0]), # MID TOP LEFT
('white', [23.4375, 30.0, 50, 72.5]), # MID LEFT
('magenta', [50, 30.0, 76.5625, 72.5]), # MID RIGHT
('white', [50, 72.5, 76.5625, 87.5]), # MID BOTTOM RIGHT
)
def fix_coords(coords):
m = [w, h, w, h]
return [int(c * .01 * m[i]) for i, c in enumerate(coords)]
background = hex_to_rgb(colors['black'])
image = Image.new('RGB', (w, h), background)
draw = ImageDraw.Draw(image)
for color, coords in rectangles:
x1, y1, x2, y2 = fix_coords(coords)
draw.rectangle([(x1, y1), (x2, y2)], fill=hex_to_rgb(colors[color]))
image.save(os.path.join(template_dir, filename), 'PNG')
return filename
Point = namedtuple('Point', ('coords', 'ct'))
Cluster = namedtuple('Cluster', ('points', 'center'))
def symlink(theme_name):
"""Set up a symlink for the new theme."""
logger.info('Setting %s as current theme' % theme_name)
current = os.path.join(THEMER_ROOT, 'current')
if os.path.islink(current):
os.unlink(current)
os.symlink(os.path.join(THEMER_ROOT, theme_name), current)
def activate(theme_name):
"""Activate the given theme."""
symlink(theme_name)
dest = os.path.join(THEMER_ROOT, theme_name)
color_file = os.path.join(dest, 'colors.yaml')
colors = CachedColorParser(color_file).read()
wallfix(dest, colors)
IconUpdater(colors['primary'], colors['secondary']).update_icons()
os.system('xrdb -merge ~/.Xresources')
os.system('i3-msg -q restart')
def fetch_vim(color_file):
return urllib2.urlopen('http://sweyla.com/themes/vim/sweyla%s.vim' % color_file).read()
def generate(color_source, config_file, template_dir, theme_name):
"""Generate a new theme."""
destination = os.path.join(THEMER_ROOT, theme_name)
wallpaper = None
if color_source.isdigit() and not os.path.isfile(color_source):
colors = SweylaColorParser(color_source).read()
vim = fetch_vim(color_source)
elif color_source.lower().endswith(('.jpg', '.png', '.jpeg')):
colors = AutodetectColorParser(color_source).read()
wallpaper = color_source
vim = None
else:
colors = ColorParser(color_source).read()
vim = None
config = read_config(config_file)
context = munge_context(config['variables'], colors)
files = {
key: os.path.join(destination, value)
for key, value in config['files'].items()}
if wallpaper:
# Add wallpaper to the list of files to copy.
files[wallpaper] = os.path.join(
destination,
'wallpaper%s' % os.path.splitext(wallpaper)[1])
render_templates(template_dir, files, context)
# Save a copy of the colors in the generated theme folder.
with open(os.path.join(destination, 'colors.yaml'), 'w') as fh:
yaml.dump(context, fh, default_flow_style=False)
# Save the vim color scheme.
if vim:
logger.info('Saving vim colorscheme %s.vim' % theme_name)
filename = os.path.join(os.environ['HOME'], '.vim/colors/%s.vim' % theme_name)
with open(filename, 'w') as fh:
fh.write(vim)
class ColorParser(object):
# Colors look something like "*color0: #FF0d3c\n"
color_re = re.compile('.*?(color[^:]+|background|foreground):\s*(#[\da-z]{6})')
def __init__(self, color_file):
self.color_file = color_file
self.colors = {}
def mapping(self):
return {
'background': 'background',
'foreground': 'foreground',
'color0': 'black',
'color8': 'alt_black',
'color1': 'red',
'color9': 'alt_red',
'color2': 'green',
'color10': 'alt_green',
'color3': 'yellow',
'color11': 'alt_yellow',
'color4': 'blue',
'color12': 'alt_blue',
'color5': 'magenta',
'color13': 'alt_magenta',
'color6': 'cyan',
'color14': 'alt_cyan',
'color7': 'white',
'color15': 'alt_white',
'colorul': 'underline'}
def read(self):
color_mapping = self.mapping()
with open(self.color_file) as fh:
for line in fh.readlines():
if line.startswith('!'):
continue
match_obj = self.color_re.search(line.lower())
if match_obj:
var, color = match_obj.groups()
self.colors[color_mapping[var]] = color
if len(self.colors) < 16:
logger.warning(
'Error, only %s colors were read when loading color file "%s"'
% (len(self.colors), self.color_file))
return self.colors
class SweylaColorParser(ColorParser):
def mapping(self):
return {
'bg': ['background', 'black', 'alt_black'],
'fg': ['foreground', 'white'],
'nf': 'red', # name of function / method
'nd': 'alt_red', # decorator
'nc': 'green', # name of class
'nt': 'alt_green', # ???
'nb': 'yellow', # e.g., "object" or "open"
'c': 'alt_yellow', # comments
's': 'blue', # string
'mi': 'alt_blue', # e.g., a number
'k': 'magenta', # e.g., "class"
'o': 'alt_magenta', # operator, e.g "="
'bp': 'cyan', # e.g., "self" keyword
'si': 'alt_cyan', # e.g. "%d"
'se': 'alt_white',
'support_function': 'underline'}
def read(self):
mapping = self.mapping()
resp = urllib2.urlopen(
'http://sweyla.com/themes/textfile/sweyla%s.txt' % self.color_file)
contents = resp.read()
for line in contents.splitlines():
key, value = line.split(':\t')
if key in mapping:
colors = mapping[key]
if not isinstance(colors, list):
colors = [colors]
for color in colors:
self.colors[color] = value
return self.colors
class AutodetectColorParser(ColorParser):
def __init__(self, wallpaper_file, k=16, bg='#0e0e0e', fg='#ffffff'):
self.wallpaper_file = wallpaper_file
self.k = k
self.bg = bg
self.fg = fg
def _get_points_from_image(self, img):
points = []
w, h = img.size
for count, color in img.getcolors(w * h):
points.append(Point(color, count))
return points
def get_dominant_colors(self):
img = Image.open(self.wallpaper_file)
img.thumbnail((300, 300)) # Resize to speed up python loop.
width, height = img.size
points = self._get_points_from_image(img)
clusters = self.kmeans(points, self.k, 1)
rgbs = [map(int, c.center.coords) for c in clusters]
return map(rgb_to_hex, rgbs)
def _euclidean_dist(self, p1, p2):
return math.sqrt(
sum((p1.coords[i] - p2.coords[i]) ** 2 for i in range(3)))
def _calculate_center(self, points):
vals = [0.0 for i in range(3)]
plen = 0
for p in points:
plen += p.ct
for i in range(3):
vals[i] += (p.coords[i] * p.ct)
return Point([(v / plen) for v in vals], 1)
def kmeans(self, points, k, min_diff):
clusters = [Cluster([p], p) for p in random.sample(points, k)]
logger.info('Calculating %d dominant colors.' % k)
while True:
plists = [[] for i in range(k)]
for p in points:
smallest_distance = float('Inf')
for i in range(k):
distance = self._euclidean_dist(p, clusters[i].center)
if distance < smallest_distance:
smallest_distance = distance
idx = i
plists[idx].append(p)
diff = 0
for i in range(k):
old = clusters[i]
center = self._calculate_center(plists[i])
new = Cluster(plists[i], center)
clusters[i] = new
diff = max(diff, self._euclidean_dist(old.center, new.center))
logger.debug('Diff: %d' % diff)
if diff <= min_diff:
break
return clusters
def normalize(self, hexv, minv=128, maxv=256):
r, g, b = hex_to_rgb(hexv)
h, s, v = colorsys.rgb_to_hsv(r / 256.0, g / 256.0, b / 256.0)
minv = minv / 256.0
maxv = maxv / 256.0
if v < minv:
v = minv
if v > maxv:
v = maxv
rgb = colorsys.hsv_to_rgb(h, s, v)
return rgb_to_hex(map(lambda i: i * 256, rgb))
def read(self):
colors = self.get_dominant_colors()
color_dict = {
'background': self.bg,
'foreground': self.fg}
for i, color in enumerate(itertools.cycle(colors)):
if i == 0:
color = self.normalize(color, minv=0, maxv=32)
elif i == 8:
color = self.normalize(color, minv=128, maxv=192)
elif i < 8:
color = self.normalize(color, minv=160, maxv=224)
else:
color = self.normalize(color, minv=200, maxv=256)
color_dict['color%d' % i] = color
if i == 15:
break
mapping = self.mapping()
translated = {}
for k, v in color_dict.items():
translated[mapping[k]] = v
logger.debug(translated)
return translated
class CachedColorParser(ColorParser):
def read(self):
with open(self.color_file) as fh:
self.colors = yaml.load(fh)
return self.colors
class IconUpdater(object):
def __init__(self, primary_color, secondary_color):
self.primary_color = primary_color
self.secondary_color = secondary_color
def icon_path(self):
return os.path.join(os.environ['HOME'], '.icons/acyl')
def primary_icon(self):
return os.path.join(self.icon_path(), 'scalable/places/desktop.svg')
def secondary_icon(self):
return os.path.join(self.icon_path(), 'scalable/actions/add.svg')
def extract_color_svg(self, filename):
regex = re.compile('stop-color:(#[\da-zA-Z]{6})')
with open(filename, 'r') as fh:
for line in fh.readlines():
match_obj = regex.search(line)
if match_obj:
return match_obj.groups()[0]
raise ValueError('Unable to determine icon color.')
def update_icons(self):
# Introspect a couple icon files to determine what colors are being used
# currently.
old_primary = self.extract_color_svg(self.primary_icon())
old_secondary = self.extract_color_svg(self.secondary_icon())
logger.debug('Old icon colors: %s, %s' % (old_primary, old_secondary))
# Walk the icons, updating the colors in each svg file.
file_count = 0
for root, dirs, filenames in os.walk(self.icon_path()):
for filename in filenames:
if not filename.endswith('.svg'):
continue
path = os.path.join(root, filename)
with open(path, 'r+') as fh:
contents = fh.read()
contents = contents.replace(old_primary, self.primary_color)
contents = contents.replace(old_secondary, self.secondary_color)
fh.seek(0)
fh.write(contents)
file_count += 1
logger.info('Checked %d icon files' % file_count)
def get_parser():
parser = optparse.OptionParser(usage='usage: %prog [options] [list|activate|generate|current|delete] theme_name [color file]')
parser.add_option('-t', '--template', dest='template_dir', default='i3')
parser.add_option('-c', '--config', dest='config_file', default='config.yaml')
parser.add_option('-a', '--activate', dest='activate', action='store_true')
parser.add_option('-v', '--verbose', dest='verbose', action='store_true')
parser.add_option('-d', '--debug', dest='debug', action='store_true')
return parser
def panic(msg):
print >> sys.stderr, msg
sys.exit(1)
if __name__ == '__main__':
parser = get_parser()
options, args = parser.parse_args()
if not args:
panic(parser.get_usage())
action = args[0]
if action not in ('list', 'activate', 'generate', 'current', 'delete'):
panic('Unknown action "%s"' % action)
if action not in ('list', 'current') and len(args) == 1:
panic('Missing required argument "theme_name"')
elif action == 'list':
themes = [
t for t in os.listdir(THEMER_ROOT)
if t not in ('templates', 'current')]
print '\n'.join(sorted(themes))
sys.exit(0)
elif action == 'current':
current = os.path.join(THEMER_ROOT, 'current')
if not os.path.exists(current):
print 'No theme'
else:
print os.path.basename(os.path.realpath(
os.path.join(THEMER_ROOT, 'current')))
os.system('colortheme')
sys.exit(0)
theme_name = args[1]
# Add logging handlers.
if options.verbose or options.debug:
handler = logging.StreamHandler()
logger.addHandler(handler)
if options.debug:
logger.setLevel(logging.DEBUG)
if action == 'activate':
activate(theme_name)
elif action == 'delete':
shutil.rmtree(os.path.join(THEMER_ROOT, theme_name))
logger.info('Removed %s' % theme_name)
else:
# Find the appropriate yaml config file and load it.
template_dir = os.path.join(TEMPLATE_ROOT, options.template_dir)
config_file = os.path.join(template_dir, options.config_file)
if not os.path.exists(config_file):
panic('Unable to find file "%s"' % config_file)
if not len(args) == 3:
panic('Missing required color file')
else:
color_file = args[2]
generate(color_file, config_file, template_dir, theme_name)
if options.activate or raw_input('Activate now? yN ') == 'y':
activate(theme_name)
@tomvanderlee
Copy link

Hi!

Can you change line 1 to #!/usr/bin/env python2, since /usr/bin/env python now runs the python3 environment. This does not help because it is a pyton2 program.

@raffomania
Copy link

Hey, I'd +1 phantom94's request. This breaks the inofficial package for your script on arch linux :(

@tomvanderlee
Copy link

@raffomania, I added a patch to the aur-package.

Copy link

ghost commented Feb 14, 2016

aur-package dead?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment