Advertisement
Not a member of Pastebin yet?
Sign Up,
it unlocks many cool features!
- #!/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)
Advertisement
Add Comment
Please, Sign In to add comment
Advertisement