├── bin ├── publish.sh ├── test.sh └── build.sh ├── examples ├── 01-regular-print.py ├── 04-header-one.py ├── 02-autoformat-wrapped-print.py ├── 05-superimpose-styles.py ├── 08-fix-all.py ├── 07-fix-text.py ├── 03-id-deterministic-color.py ├── 06-select-palette.py ├── 09-basic-palette.py ├── 10-restricted-color-palette.py ├── four-color-themes.py └── bash-prompt.py ├── REFERENCES.md ├── src └── autopalette │ ├── __init__.py │ ├── colormatch.py │ ├── theme.py │ ├── render.py │ ├── utils.py │ ├── autoformat.py │ ├── palette.py │ └── colortrans.py ├── LICENSE ├── .gitignore ├── setup.py ├── README.md └── README.rst /bin/publish.sh: -------------------------------------------------------------------------------- 1 | twine upload dist/* -------------------------------------------------------------------------------- /examples/01-regular-print.py: -------------------------------------------------------------------------------- 1 | print("Tring!") -------------------------------------------------------------------------------- /bin/test.sh: -------------------------------------------------------------------------------- 1 | twine upload --repository-url https://test.pypi.org/legacy/ dist/* -------------------------------------------------------------------------------- /examples/04-header-one.py: -------------------------------------------------------------------------------- 1 | from autopalette import af 2 | 3 | print(af("Hello again!").h1) -------------------------------------------------------------------------------- /examples/02-autoformat-wrapped-print.py: -------------------------------------------------------------------------------- 1 | from autopalette import af 2 | 3 | print(af("Tring!")) 4 | -------------------------------------------------------------------------------- /examples/05-superimpose-styles.py: -------------------------------------------------------------------------------- 1 | from autopalette import af 2 | 3 | print(af("Hey! We've met before!?").info.b) -------------------------------------------------------------------------------- /examples/08-fix-all.py: -------------------------------------------------------------------------------- 1 | from autopalette import af 2 | 3 | af.init(fix_all=True) 4 | 5 | print(af("I 💛 Unicode!")) -------------------------------------------------------------------------------- /examples/07-fix-text.py: -------------------------------------------------------------------------------- 1 | from autopalette import af 2 | 3 | af.init(fix_text=True) 4 | 5 | print(af("¯\\_(ã\x83\x84)_/¯").info) -------------------------------------------------------------------------------- /bin/build.sh: -------------------------------------------------------------------------------- 1 | rm -r dist 2 | rm -r build 3 | pandoc --from=markdown --to=rst README.md > README.rst && \ 4 | python setup.py sdist bdist_wheel 5 | -------------------------------------------------------------------------------- /examples/03-id-deterministic-color.py: -------------------------------------------------------------------------------- 1 | from autopalette import af 2 | 3 | print(af("Hello, world!").id) 4 | print(af("Hello, world!").id256) 5 | -------------------------------------------------------------------------------- /examples/06-select-palette.py: -------------------------------------------------------------------------------- 1 | from autopalette import af, GameBoyGreenPalette 2 | 3 | af.init(palette=GameBoyGreenPalette) 4 | 5 | print(af("There you are!").h1) 6 | -------------------------------------------------------------------------------- /REFERENCES.md: -------------------------------------------------------------------------------- 1 | - https://en.wikipedia.org/wiki/ANSI_escape_code#Colors 2 | - https://gist.github.com/chrisopedia/8754917 3 | - https://gist.github.com/XVilka/8346728 4 | - https://no-color.org/ 5 | - http://mkweb.bcgsc.ca/colorblind/ 6 | - http://www.calmar.ws/vim/256-xterm-24bit-rgb-color-chart.html 7 | - http://safecolours.rigdenage.com/palettefiles.html 8 | -------------------------------------------------------------------------------- /examples/09-basic-palette.py: -------------------------------------------------------------------------------- 1 | from autopalette import af 2 | 3 | print(af("No formatting.")) 4 | 5 | print(af("Hello, world!").id) 6 | print(af("Hello, world!").id256) 7 | 8 | print(af("Plain text.").p) 9 | print(af("Light text.").light) 10 | print(af("Dark text.").dark) 11 | 12 | print(af("Header One").h1) 13 | print(af("Header Two").h2.b) 14 | print(af("Header Three").h3.i) 15 | print(af("Header Four").h4.u) 16 | 17 | print(af("List element").li) 18 | 19 | print(af("An error!").err) 20 | print(af("A warning.").warn) 21 | print(af("Some information.").info) 22 | print(af("All is good.").ok) 23 | 24 | print(af("Bold").b) 25 | print(af("Muted").m) 26 | print(af("Italic").i) 27 | print(af("Reversed").r) 28 | print(af("Underline").u) 29 | -------------------------------------------------------------------------------- /examples/10-restricted-color-palette.py: -------------------------------------------------------------------------------- 1 | from autopalette import af, DutronPalette 2 | 3 | af.init() 4 | af.init(palette=DutronPalette) 5 | 6 | print(af("No formatting.")) 7 | 8 | print(af("Hashed color.").id) 9 | 10 | print(af("Plain text, colored within palette.").p) 11 | print(af("Light text.").light) 12 | print(af("Dark text.").dark) 13 | 14 | print(af("Header One").h1) 15 | print(af("Header Two").h2) 16 | print(af("Header Three").h3) 17 | print(af("Header Four").h4) 18 | 19 | print(af("List element").li) 20 | 21 | print(af("An error!").err) 22 | print(af("A warning.").warn) 23 | print(af("Some information.").info) 24 | print(af("All is good.").ok) 25 | 26 | print(af("Bold").b) 27 | print(af("Muted").m) 28 | print(af("Italic").i) 29 | print(af("Underline").u) 30 | print(af("Reversed").r) 31 | -------------------------------------------------------------------------------- /src/autopalette/__init__.py: -------------------------------------------------------------------------------- 1 | from .palette import ( 2 | AutoPalette, 3 | Gray4Palette, 4 | GameBoyGreenPalette, 5 | GameBoyChocolatePalette, 6 | Oil6Palette, 7 | ColorsCCPalette, 8 | DutronPalette, 9 | ) 10 | from .theme import ( 11 | Theme, 12 | ThemeColor, 13 | BasicTheme, 14 | FourColorTheme, 15 | ) 16 | from .render import ( 17 | AnsiTruecolorRenderer, 18 | Ansi256Renderer, 19 | Ansi16Renderer, 20 | Ansi8Renderer, 21 | AnsiNoColorRenderer, 22 | ) 23 | from .autoformat import AutoFormat 24 | 25 | af = AutoFormat() 26 | ap = af 27 | 28 | __all__ = [ 29 | 'af', 30 | 'ap', 31 | 'AnsiTruecolorRenderer', 32 | 'Ansi256Renderer', 33 | 'Ansi16Renderer', 34 | 'Ansi8Renderer', 35 | 'AnsiNoColorRenderer', 36 | 'AutoFormat', 37 | 'AutoPalette', 38 | 'Theme', 39 | 'ThemeColor', 40 | 'BasicTheme', 41 | 'FourColorTheme', 42 | 'Gray4Palette', 43 | 'GameBoyGreenPalette', 44 | 'GameBoyChocolatePalette', 45 | 'Oil6Palette', 46 | ] 47 | -------------------------------------------------------------------------------- /examples/four-color-themes.py: -------------------------------------------------------------------------------- 1 | from autopalette import ( 2 | 3 | # Renderers 4 | AnsiTruecolorRenderer, 5 | Ansi256Renderer, 6 | Ansi16Renderer, 7 | Ansi8Renderer, 8 | AnsiNoColorRenderer, 9 | 10 | # Palettes 11 | FourColorTheme, 12 | Gray4Palette, 13 | GameBoyGreenPalette, 14 | GameBoyChocolatePalette, 15 | Oil6Palette, 16 | ) 17 | 18 | for palette in [Gray4Palette, 19 | GameBoyGreenPalette, 20 | GameBoyChocolatePalette, 21 | Oil6Palette]: 22 | print() 23 | print('Using palette: ', palette.__name__) 24 | 25 | for renderer in [AnsiTruecolorRenderer, 26 | Ansi256Renderer, 27 | Ansi16Renderer, 28 | Ansi8Renderer, 29 | AnsiNoColorRenderer]: 30 | print('\twith renderer: ', renderer.__name__, end='\n\t\t') 31 | 32 | th = FourColorTheme( 33 | palette=palette, 34 | renderer=renderer, 35 | ) 36 | 37 | print(th.base('base'), end=' ') 38 | print(th.light('light'), end=' ') 39 | print(th.dark('dark'), end=' ') 40 | print(th.h1('header1'), end=' ') 41 | print(th.h2('header2'), end=' ') 42 | print(th.h3('header3'), end=' ') 43 | print('\n') 44 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 2-Clause License 2 | 3 | Copyright (c) 2018, Harshad Sharma 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | * Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | * Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 17 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 18 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 19 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 20 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 21 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 22 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 23 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 24 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 25 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | .pytest_cache/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | db.sqlite3 58 | 59 | # Flask stuff: 60 | instance/ 61 | .webassets-cache 62 | 63 | # Scrapy stuff: 64 | .scrapy 65 | 66 | # Sphinx documentation 67 | docs/_build/ 68 | 69 | # PyBuilder 70 | target/ 71 | 72 | # Jupyter Notebook 73 | .ipynb_checkpoints 74 | 75 | # pyenv 76 | .python-version 77 | 78 | # celery beat schedule file 79 | celerybeat-schedule 80 | 81 | # SageMath parsed files 82 | *.sage.py 83 | 84 | # Environments 85 | .env 86 | .venv 87 | env/ 88 | venv/ 89 | ENV/ 90 | env.bak/ 91 | venv.bak/ 92 | 93 | # Spyder project settings 94 | .spyderproject 95 | .spyproject 96 | 97 | # Rope project settings 98 | .ropeproject 99 | 100 | # mkdocs documentation 101 | /site 102 | 103 | # mypy 104 | .mypy_cache/ 105 | .idea 106 | -------------------------------------------------------------------------------- /examples/bash-prompt.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import unicodedata 3 | import grapheme 4 | from autopalette import af 5 | 6 | af.init(term_colors=256) 7 | 8 | 9 | def save_cursor(): 10 | return f'\x1b[s' 11 | 12 | 13 | def restore_cursor(): 14 | return f'\x1b[u' 15 | 16 | 17 | def move_cursor_left(n): 18 | return f'\x1b[{n}D' 19 | 20 | 21 | def adjust_spaces(text): 22 | fixed_length = len(unicodedata.normalize('NFKD', text)) 23 | unicode_length = grapheme.length(text) 24 | if unicode_length != fixed_length: 25 | _text = ' ' * fixed_length 26 | _text += save_cursor() 27 | _text += move_cursor_left(fixed_length) 28 | _text += unicodedata.normalize('NFKD', text) 29 | _text += restore_cursor() 30 | return _text 31 | return text 32 | 33 | 34 | # --- default (fast), render colors once and let bash render prompt variables. 35 | ## 36 | ### export PS1="$(~/bin/bash-prompt.py)" 37 | 38 | username = r'\u' 39 | hostname = r'\H' 40 | local_time = r'\A ' 41 | utc_time = r'$( date -u "+%H:%M" ) ' 42 | working_dir = r'\w' 43 | 44 | # --- alternative (slow), render full prompt via Python every time. 45 | ## 46 | ### export PS1="\$(~/bin/bash-prompt.py)" 47 | # 48 | # import datetime 49 | # import getpass 50 | # import os 51 | # import platform 52 | # 53 | # username = getpass.getuser() 54 | # hostname = platform.node() 55 | # local_time = datetime.datetime.now().strftime('%H:%M ') 56 | # utc_time = datetime.datetime.utcnow().strftime('%H:%M ') 57 | # working_dir = os.getcwd().replace(os.path.expanduser('~'), '~') 58 | 59 | prompt = r'' 60 | prompt += adjust_spaces("🇮🇳") 61 | prompt += local_time 62 | prompt += adjust_spaces("🌍") 63 | prompt += utc_time 64 | prompt += af(username).id256 65 | prompt += '@' 66 | prompt += af(hostname).id256 67 | prompt += af(':' + working_dir).dark 68 | prompt += af('\n$ ') 69 | 70 | print(prompt) 71 | -------------------------------------------------------------------------------- /src/autopalette/colormatch.py: -------------------------------------------------------------------------------- 1 | from typing import Sequence, Union, Tuple 2 | 3 | import kdtree 4 | 5 | from colour import Color 6 | 7 | AnsiCodeType = Union[str, int, Tuple[int, int, int]] 8 | 9 | 10 | class ColorPoint(object): 11 | def __init__(self, source: Color, target: Color, 12 | ansi: AnsiCodeType) -> None: 13 | """ 14 | Map source color to target color, stores target 15 | ansi color ans a single int, a sequence of RGB as ints 16 | or markup string. 17 | """ 18 | self.source = source 19 | self.target = target 20 | self.ansi = ansi 21 | 22 | def __len__(self) -> int: 23 | """ 24 | >>> cp = ColorPoint(Color('black'), Color('white'), '') 25 | >>> len(cp) == 3 26 | True 27 | """ 28 | return 3 29 | 30 | def __getitem__(self, item) -> float: 31 | """ 32 | >>> cp = ColorPoint(Color('#880073'), Color('white'), '') 33 | >>> cp[0] # hue 34 | 0.8590686274509803 35 | >>> cp[1] # saturation 36 | 1.0 37 | >>> cp[2] # luminance 38 | 0.26666666666666666 39 | """ 40 | return self.source.hsl[item] 41 | 42 | def __repr__(self) -> str: 43 | return 'ColorPoint({!r} => {!r})'.format(self.source, self.target) 44 | 45 | 46 | class ColorMatch(object): 47 | def __init__(self) -> None: 48 | self.tree = kdtree.create(dimensions=3) 49 | 50 | def add(self, source: Color, target: Color, ansi: AnsiCodeType) -> None: 51 | point = ColorPoint(source, target, ansi) 52 | self.tree.add(point) 53 | 54 | def match(self, color: Color) -> ColorPoint: 55 | """ 56 | >>> cm = ColorMatch() 57 | >>> cm.add(Color('red'), Color('white'), '') 58 | >>> cm.add(Color('blue'), Color('white'), '') 59 | >>> cm.match(Color('yellow')) 60 | ColorPoint( => ) 61 | """ 62 | results = self.tree.search_nn(color.hsl) 63 | if not results: 64 | raise KeyError('No match found for color: {}'.format(color)) 65 | return results[0].data 66 | 67 | 68 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- encoding: utf-8 -*- 3 | from __future__ import ( 4 | absolute_import, 5 | print_function, 6 | ) 7 | 8 | import io 9 | from glob import glob 10 | from os.path import ( 11 | basename, 12 | dirname, 13 | join, 14 | splitext, 15 | ) 16 | 17 | from setuptools import find_packages, setup 18 | 19 | 20 | def read(*names, **kwargs): 21 | return io.open( 22 | join(dirname(__file__), *names), 23 | encoding=kwargs.get('encoding', 'utf8') 24 | ).read() 25 | 26 | 27 | setup( 28 | name='autopalette', 29 | version='0.1.0_2', 30 | license='BSD License', 31 | description='Terminal palettes and themes, without tears.', 32 | long_description=read('README.rst'), 33 | author='Harshad Sharma', 34 | author_email='harshad@sharma.io', 35 | url='https://github.com/hiway/autopalette', 36 | packages=find_packages('src'), 37 | package_dir={'': 'src'}, 38 | py_modules=[splitext(basename(path))[0] for path in glob('src/*.py')], 39 | include_package_data=True, 40 | zip_safe=False, 41 | classifiers=[ 42 | # complete classifier list: 43 | # http://pypi.python.org/pypi?%3Aaction=list_classifiers 44 | 'Environment :: Console', 45 | 'Intended Audience :: Developers', 46 | 'License :: OSI Approved :: BSD License', 47 | 'Operating System :: POSIX', 48 | 'Operating System :: POSIX :: BSD', 49 | 'Operating System :: POSIX :: BSD :: FreeBSD', 50 | 'Operating System :: POSIX :: Linux', 51 | 'Operating System :: Unix', 52 | 'Programming Language :: Python', 53 | 'Programming Language :: Python :: 3.6', 54 | 'Programming Language :: Python :: 3.7', 55 | 'Programming Language :: Python :: 3 :: Only', 56 | 'Programming Language :: Python :: Implementation :: CPython', 57 | 'Topic :: Terminals', 58 | 'Topic :: Software Development', 59 | 'Topic :: Software Development :: User Interfaces', 60 | 'Topic :: Utilities', 61 | ], 62 | keywords=[ 63 | 'terminal', 64 | 'color', 65 | 'theme', 66 | 'palette', 67 | ], 68 | install_requires=[ 69 | # MIT/ Felix Krull 70 | # https://pypi.org/project/colorhash/ 71 | 'colorhash>=1.0.2', 72 | 73 | # BSD License/ Valentin LAB 74 | # https://pypi.org/project/colour/ 75 | 'colour>=0.1.5', 76 | 77 | # ISCL/ Stefan Kögl 78 | # https://pypi.org/project/kdtree/ 79 | 'kdtree>=0.16', 80 | 81 | # Apache 2.0/ Felix Meyer-Wolters 82 | # https://pypi.org/project/sty/ 83 | 'sty>=1.0.0b6', 84 | 85 | # MIT/ Sam CB 86 | # https://pypi.org/project/emoji2text/ 87 | 'emoji2text', 88 | 89 | # MIT/ Rob Speer ( Luminoso ) 90 | # https://pypi.org/project/ftfy/ 91 | 'ftfy', 92 | 93 | # WTFPL/ Micah Elliott 94 | # https://gist.github.com/MicahElliott/719710/ 95 | # 'colortrans>=0.1', 96 | # ^^ disabled since source is included with autopalette. 97 | ], 98 | ) 99 | -------------------------------------------------------------------------------- /src/autopalette/theme.py: -------------------------------------------------------------------------------- 1 | import sty 2 | from colour import Color 3 | 4 | from autopalette.colormatch import AnsiCodeType 5 | from autopalette.palette import Ansi256Palette 6 | from autopalette.render import OptionalPalette, OptionalRenderer, Ansi256Renderer 7 | 8 | 9 | class ThemeColor(object): 10 | """ 11 | Stores edits applied in definition of Palette. 12 | 13 | >>> base = ThemeColor('white').set_luminance(.8) 14 | >>> base._edits 15 | [('set_luminance', [0.8])] 16 | >>> base._color 17 | 'white' 18 | """ 19 | 20 | def __init__(self, color: str = None, ansi: AnsiCodeType = None): 21 | self._color = color 22 | self._edits = [] 23 | self._ansi = ansi 24 | self.ansi_reset = False 25 | 26 | def apply(self, color: Color): 27 | for method, args in self._edits: 28 | if method == 'reset': 29 | self.ansi_reset = True 30 | # todo 31 | continue 32 | getattr(color, method)(*args) 33 | return self 34 | 35 | def reset(self): 36 | self._edits.append(('reset', [True])) 37 | return self 38 | 39 | def set_hue(self, value): 40 | self._edits.append(('set_hue', [value])) 41 | return self 42 | 43 | def set_saturation(self, value): 44 | self._edits.append(('set_saturation', [value])) 45 | return self 46 | 47 | def set_luminance(self, value): 48 | self._edits.append(('set_luminance', [value])) 49 | return self 50 | 51 | def __call__(self, *args, **kwargs): 52 | """Exists to prevent linter warnings.""" 53 | raise NotImplementedError() 54 | 55 | 56 | class ThemeStyle(object): 57 | """ 58 | Style holds foreground and background colors 59 | for a single style in a palette. 60 | 61 | style = Style(fg=Color(), bg=Color()) 62 | 63 | style("text") 64 | """ 65 | 66 | def __init__(self, fg, bg, renderer, ansi_reset): 67 | self.fg = fg 68 | self.bg = bg 69 | self._renderer = renderer 70 | self._ansi_reset = ansi_reset 71 | 72 | def __repr__(self): 73 | return 'Style(fg={}, bg={})'.format(self.fg, self.bg) 74 | 75 | def __call__(self, text): 76 | return self._renderer.render(text, fg=self.fg, bg=self.bg, ansi_reset=self._ansi_reset) 77 | 78 | @property 79 | def renderer(self): 80 | return self._renderer 81 | 82 | 83 | class Theme(object): 84 | def __init__(self, palette: OptionalPalette = None, renderer: OptionalRenderer = None): 85 | self.palette = palette() if palette else Ansi256Palette() 86 | renderer = renderer(palette=palette) if renderer else Ansi256Renderer(palette=palette) 87 | renderer.palette = self.palette 88 | self.renderer = renderer 89 | for name, attr in self.__class__.__dict__.items(): 90 | if not isinstance(attr, ThemeColor): 91 | continue 92 | if name.startswith('_'): 93 | if hasattr(self, name[1:]): 94 | continue 95 | else: 96 | raise ValueError('Background set without foreground: {}'.format(name)) 97 | match = self.palette.match(Color(attr._color)) 98 | fg = Color(match.target.hex_l) 99 | attr.apply(fg) 100 | bg = None 101 | if hasattr(self, '_' + name): 102 | bgattr = getattr(self, '_' + name) 103 | bgmatch = self.palette.match(Color(bgattr._color)) 104 | bg = Color(bgmatch.target.hex_l) 105 | bgattr.apply(bg) 106 | setattr(self, name, 107 | ThemeStyle(fg, bg=bg, 108 | renderer=self.renderer, 109 | ansi_reset=attr.ansi_reset)) 110 | 111 | 112 | class BasicTheme(Theme): 113 | base = ThemeColor('white').reset() 114 | light = ThemeColor('silver') 115 | dark = ThemeColor('purple').set_luminance(.2) 116 | 117 | h1 = ThemeColor('black') 118 | _h1 = ThemeColor('yellow') 119 | 120 | h2 = ThemeColor('white') 121 | _h2 = ThemeColor('blue') 122 | 123 | h3 = ThemeColor('black') 124 | _h3 = ThemeColor('green').set_luminance(.8) 125 | 126 | h4 = ThemeColor('white').set_luminance(1) 127 | _h4 = ThemeColor('purple').set_luminance(.2) 128 | 129 | error = ThemeColor('white') 130 | _error = ThemeColor('red') 131 | 132 | warning = ThemeColor('red') 133 | 134 | info = ThemeColor('lightblue').set_luminance(.5).set_saturation(.7) 135 | ok = ThemeColor('green') 136 | 137 | 138 | class FourColorTheme(Theme): 139 | base = ThemeColor('black').set_luminance(.7) 140 | light = ThemeColor('black').set_luminance(.9) 141 | dark = ThemeColor('black').set_luminance(.4) 142 | 143 | h1 = ThemeColor('white') 144 | _h1 = ThemeColor('silver').set_luminance(.5) 145 | 146 | h2 = ThemeColor('white') 147 | _h2 = ThemeColor('gray') 148 | 149 | h3 = ThemeColor('white') 150 | _h3 = ThemeColor('black') 151 | 152 | h4 = ThemeColor('white') 153 | _h4 = ThemeColor('gray') 154 | 155 | error = ThemeColor('white').set_luminance(.5) 156 | _error = ThemeColor('black').set_luminance(.5).set_saturation(.5) 157 | 158 | warning = ThemeColor('gray') 159 | _warning = ThemeColor('black').set_luminance(.5).set_saturation(.5) 160 | 161 | info = ThemeColor('gray').set_luminance(.5).set_saturation(.7) 162 | ok = ThemeColor('white').set_luminance(.5).set_saturation(.7) 163 | -------------------------------------------------------------------------------- /src/autopalette/render.py: -------------------------------------------------------------------------------- 1 | from typing import Union, ClassVar 2 | 3 | import sty 4 | from colour import Color 5 | 6 | from autopalette.colormatch import ColorPoint, AnsiCodeType 7 | from autopalette.palette import Ansi256Palette, Ansi16Palette, Ansi8Palette 8 | from autopalette.utils import rgb_to_RGB255 9 | 10 | OptionalColor = Union['Color', None] 11 | OptionalPalette = ClassVar['BasePalette'] 12 | OptionalRenderer = ClassVar['Renderer'] 13 | 14 | 15 | class BaseRenderer(object): 16 | def __init__(self, 17 | palette: OptionalPalette = None, 18 | fallback: OptionalPalette = None) -> None: 19 | self.palette = palette if palette else Ansi256Palette() 20 | self.fallback = fallback if fallback else Ansi256Palette() 21 | 22 | def render(self, text, fg: Color, bg: OptionalColor = None): 23 | raise NotImplementedError() 24 | 25 | def is_bright(self, color: Color): 26 | if color.get_saturation() == 0 \ 27 | and color.get_luminance() == 1: 28 | return True 29 | if color.get_luminance() > 0.7: 30 | return True 31 | if color.get_saturation() >= 0.3 \ 32 | and color.get_luminance() >= 0.3: 33 | return True 34 | return False 35 | 36 | 37 | class Ansi256Renderer(BaseRenderer): 38 | def render(self, text, fg: Color, bg: OptionalColor = None, ansi_reset=False): 39 | if ansi_reset: 40 | return text 41 | fg = self.palette.match(fg, ansi=True) 42 | if fg.ansi == '' or fg.ansi is None: 43 | fg = self.fallback.match(fg.target, ansi=True) 44 | if bg: 45 | bg = self.palette.match(bg, ansi=True) 46 | if bg.ansi == '' or bg.ansi is None: 47 | bg = self.fallback.match(bg.target, ansi=True) 48 | return self._render(text, fg=fg, bg=bg) 49 | 50 | def _render(self, text, fg: ColorPoint, bg: ColorPoint = None): 51 | out = '' 52 | out += sty.fg(fg.ansi) 53 | if bg: 54 | out += sty.bg(bg.ansi) 55 | out += text 56 | out += sty.rs.all 57 | return out 58 | 59 | def is_bright(self, color: Color): 60 | ansi = self.palette.match(color).ansi 61 | if ansi < 16: 62 | if ansi in (0, 1, 2, 3, 4, 5, 6, 8): 63 | return False 64 | return True 65 | return super().is_bright(color) 66 | 67 | def bg(self, color: Color) -> str: 68 | return sty.bg(self.palette.match(color, ansi=True).ansi) 69 | 70 | def fg(self, color: Color) -> str: 71 | return sty.fg(self.palette.match(color, ansi=True).ansi) 72 | 73 | @property 74 | def rs(self): 75 | return sty.rs 76 | 77 | @property 78 | def ef(self): 79 | return sty.ef 80 | 81 | 82 | class AnsiNoColorRenderer(Ansi256Renderer): 83 | def render(self, text, fg: Color, bg: OptionalColor = None, ansi_reset=False): 84 | return text 85 | 86 | 87 | class Ansi16Renderer(Ansi256Renderer): 88 | 89 | def __init__(self, 90 | palette: OptionalPalette = None, 91 | fallback: OptionalPalette = None) -> None: 92 | super().__init__(palette=Ansi16Palette, 93 | fallback=fallback) 94 | 95 | def render(self, text, fg: Color, bg: OptionalColor = None, ansi_reset=False): 96 | # todo: downsample 256 to 16 colors 97 | return super().render(text, fg=fg, bg=bg, ansi_reset=False) 98 | 99 | 100 | class Ansi8Renderer(Ansi256Renderer): 101 | 102 | def __init__(self, 103 | palette: OptionalPalette = None, 104 | fallback: OptionalPalette = None) -> None: 105 | super().__init__(palette=Ansi8Palette, 106 | fallback=fallback) 107 | 108 | def render(self, text, fg: Color, bg: OptionalColor = None, ansi_reset=False): 109 | # todo: downsample 256 to 8 colors 110 | return super().render(text, fg=fg, bg=bg, ansi_reset=False) 111 | 112 | 113 | class AnsiTruecolorRenderer(BaseRenderer): 114 | def match(self, color: Color) -> ColorPoint: 115 | ansi = rgb_to_RGB255(color.rgb) 116 | return ColorPoint(color, color, ansi=ansi) 117 | 118 | def render(self, text, fg: Color, bg: OptionalColor = None, ansi_reset=False): 119 | if ansi_reset: 120 | return text 121 | fg = self.palette.match(fg) 122 | if bg: 123 | bg = self.palette.match(bg) 124 | return self._render(text, fg=fg, bg=bg) 125 | 126 | def _render(self, text, fg: ColorPoint, bg: ColorPoint = None): 127 | rgb = rgb_to_RGB255(fg.target.rgb) 128 | out = '' 129 | out += sty.fg(*rgb) 130 | if bg: 131 | bgrgb = rgb_to_RGB255(bg.target.rgb) 132 | out += sty.bg(*bgrgb) 133 | out += text 134 | out += sty.rs.all 135 | return out 136 | 137 | def bg(self, color: Color) -> str: 138 | bg = self.palette.match(color) 139 | rgb = rgb_to_RGB255(bg.target.rgb) 140 | return sty.fg(*rgb) 141 | 142 | def fg(self, color: Color) -> str: 143 | fg = self.palette.match(color) 144 | rgb = rgb_to_RGB255(fg.target.rgb) 145 | return sty.fg(*rgb) 146 | 147 | @property 148 | def rs(self): 149 | return sty.rs 150 | 151 | @property 152 | def ef(self): 153 | return sty.ef 154 | 155 | 156 | render_map = { 157 | '-1': AnsiTruecolorRenderer, 158 | '0': AnsiNoColorRenderer, 159 | '8': Ansi8Renderer, 160 | '16': Ansi16Renderer, 161 | '88': Ansi256Renderer, 162 | '256': Ansi256Renderer, 163 | 'ansi': Ansi256Renderer, 164 | 'rgb': AnsiTruecolorRenderer, 165 | 'truecolor': AnsiTruecolorRenderer, 166 | '24bit': AnsiTruecolorRenderer, 167 | 'vt100': AnsiNoColorRenderer, 168 | 'vt200': AnsiNoColorRenderer, 169 | 'vt220': AnsiNoColorRenderer, 170 | 'rxvt': Ansi16Renderer, 171 | 'rxvt-88color': Ansi256Renderer, 172 | 'xterm': Ansi16Renderer, 173 | 'xterm-color': Ansi16Renderer, 174 | 'xterm-256color': Ansi256Renderer, 175 | 176 | } 177 | -------------------------------------------------------------------------------- /src/autopalette/utils.py: -------------------------------------------------------------------------------- 1 | from typing import Union, Tuple, TypeVar 2 | 3 | import sys 4 | import platform 5 | 6 | import os 7 | from colorhash import ColorHash 8 | from colour import Color, COLOR_NAME_TO_RGB 9 | 10 | IntervalValue = Union[int, float] 11 | RGB255Tuple = Tuple[int, ...] 12 | RGBTuple = Tuple[float, ...] 13 | 14 | hex_characters = {c for c in '#0123456789abcdef'} 15 | 16 | 17 | def map_interval(from_start: IntervalValue, 18 | from_end: IntervalValue, 19 | to_start: IntervalValue, 20 | to_end: IntervalValue, 21 | value: IntervalValue) -> IntervalValue: 22 | """ 23 | Map numbers from an interval to another. 24 | 25 | >>> map_interval(0, 1, 0, 255, 0.5) 26 | 127.5 27 | 28 | >>> map_interval(0, 255, 0, 1, 128) # doctest: +ELLIPSIS 29 | 0.50... 30 | 31 | :param from_start: lower bound of source interval. 32 | :param from_end: upper bound of source interval. 33 | :param to_start: lower bound of target interval. 34 | :param to_end: upper bound of target interval. 35 | :param value: source value to map to target interval. 36 | :return: value in target interval. 37 | """ 38 | return ((value - from_start) * (to_end - to_start) / 39 | (from_end - from_start) + to_start) 40 | 41 | 42 | def rgb_to_RGB255(rgb: RGBTuple) -> RGB255Tuple: 43 | """ 44 | Convert from Color.rgb's 0-1 range to ANSI RGB (0-255) range. 45 | 46 | >>> rgb_to_RGB255((1, 0.5, 0)) 47 | (255, 128, 0) 48 | """ 49 | return tuple([int(round(map_interval(0, 1, 0, 255, c))) for c in rgb]) 50 | 51 | 52 | def RGB255_to_rgb(rgb: RGB255Tuple) -> RGBTuple: 53 | """ 54 | Convert from ANSI RGB (0-255) range to Color.rgb (0-1) range. 55 | 56 | >>> RGB255_to_rgb((0, 128, 255)) # doctest: +ELLIPSIS 57 | (0.0, 0.50..., 1.0) 58 | """ 59 | return tuple(map_interval(0, 255, 0, 1, c) for c in rgb) 60 | 61 | 62 | def parse_color(color: str) -> Color: 63 | """ 64 | Parse a string into a Color object. 65 | 66 | Supports strings of format: 67 | - "ffffff" 68 | - "#ffffff" 69 | - "orange" - names in CSS colors list. 70 | - any other string is hashed to a deterministic color. 71 | 72 | >>> c = parse_color('red') 73 | >>> c.get_hue() == Color('red').get_hue() 74 | True 75 | 76 | >>> c = parse_color('#00ff00') 77 | >>> c.get_hue() == Color('lime').get_hue() 78 | True 79 | 80 | >>> c = parse_color('test') 81 | >>> c.get_hue() # doctest: +ELLIPSIS 82 | 0.4419... 83 | """ 84 | try: 85 | if isinstance(color, str): 86 | if len(color) == 6 and not set(color) - hex_characters: 87 | color = '#' + color 88 | elif (color.startswith('#') and len(color) == 7 and 89 | not set(color.lower()) - hex_characters): 90 | pass 91 | elif color in COLOR_NAME_TO_RGB: 92 | pass 93 | else: 94 | return Color(ColorHash(color).hex) 95 | return Color(color) 96 | else: 97 | raise ValueError() 98 | except: 99 | raise ValueError('Cannot parse color: {!r},' 100 | 'expected a hex color, a color name,' 101 | 'or a tuple of 0-255 RGB values.'.format(color)) 102 | 103 | 104 | def terminal_colors(stream=sys.stdout) -> int: 105 | """ 106 | Get number of supported ANSI colors for a stream. 107 | Defaults to sys.stdout. 108 | 109 | https://gist.github.com/XVilka/8346728 110 | 111 | >>> terminal_colors(sys.stderr) 112 | 0 113 | """ 114 | colors = 0 115 | if stream.isatty(): 116 | if platform.system() == 'Windows': 117 | # colorama supports 8 ANSI colors 118 | # (and dim is same as normal) 119 | colors = 8 120 | elif os.environ.get('NO_COLOR', None) is not None: 121 | colors = 0 122 | elif os.environ.get('COLORTERM', '').lower() in ['truecolor', '24bit']: 123 | colors = -1 124 | else: 125 | # curses is used to autodetect terminal colors on *nix. 126 | try: 127 | from curses import setupterm, tigetnum 128 | 129 | setupterm() 130 | colors = max(0, tigetnum('colors')) 131 | except ImportError: 132 | pass 133 | except: 134 | pass 135 | return colors 136 | 137 | 138 | def read_config(filename: os.PathLike = '~/.autopalette'): 139 | filename = os.environ.get('AUTOPALETTE_CONFIG', filename) 140 | filename = os.path.expanduser(filename) 141 | config = {} 142 | try: 143 | with open(filename, 'r') as infile: 144 | for line in infile.readlines(): 145 | if line.strip().startswith('#'): 146 | continue 147 | try: 148 | k, v = line.split('=') 149 | config.update({k.strip().lower(): v.strip().lower()}) 150 | except: 151 | raise ValueError('Cannot parse: {!r}'.format(line)) 152 | except FileNotFoundError: 153 | pass 154 | return config 155 | 156 | 157 | def select_palette(hint: Union[int, str]): 158 | from autopalette.palette import palette_map 159 | if hint == 0 or os.environ.get('NO_COLOR', None) is not None: 160 | return palette_map['0'] 161 | config = read_config() 162 | palette = config.get('palette', hint) 163 | palette = os.environ.get('AUTOPALETTE', palette) 164 | palette = str(palette).lower() 165 | return palette_map[palette] 166 | 167 | 168 | def select_render_engine(hint: Union[int, str]): 169 | """ 170 | Automatically select appropriate render engine, 171 | hint is number of colors or value of $TERM. 172 | """ 173 | from autopalette.render import render_map 174 | if hint == 0 or os.environ.get('NO_COLOR', None) is not None: 175 | return render_map['0'] 176 | config = read_config() 177 | renderer = os.environ.get('TERM', hint) 178 | if os.environ.get('COLORTERM', renderer): 179 | renderer = os.environ.get('COLORTERM', renderer) 180 | renderer = config.get('renderer', renderer) 181 | renderer = os.environ.get('AUTOPALETTE_RENDERER', renderer) 182 | renderer = str(renderer).lower() 183 | return render_map[renderer] 184 | -------------------------------------------------------------------------------- /src/autopalette/autoformat.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | import os 4 | import sty 5 | 6 | from autopalette import BasicTheme 7 | from autopalette.colormatch import ColorPoint 8 | from autopalette.colortrans import rgb2short 9 | from autopalette.utils import ( 10 | terminal_colors, 11 | select_render_engine, 12 | parse_color, 13 | select_palette, 14 | ) 15 | 16 | 17 | class ColoredString(str): 18 | def __new__(cls, body, theme, key='', term_colors=0): 19 | return super().__new__(cls, body) 20 | 21 | def __init__(self, body, theme, key='', term_colors=0): 22 | super().__init__() 23 | self.theme = theme 24 | self.key = key 25 | self.render = self.theme.renderer.render 26 | self._render = self.theme.renderer._render 27 | self.term_colors = term_colors 28 | 29 | @property 30 | def _raw(self): 31 | return super().__repr__() 32 | 33 | @property 34 | def _body(self): 35 | return super().__str__() 36 | 37 | def copy(self, body): 38 | return ColoredString(body, theme=self.theme) 39 | 40 | @property 41 | def id(self): 42 | if self.key: 43 | color = parse_color(self.key) 44 | else: 45 | color = parse_color(self._body) 46 | text = self.render(self._body, fg=color) 47 | return self.copy(text) 48 | 49 | @property 50 | def id256(self): 51 | if self.term_colors == 0: 52 | return self.copy(self._body) 53 | if self.key: 54 | color = parse_color(self.key) 55 | else: 56 | color = parse_color(self._body) 57 | ansi = int(rgb2short(color.hex_l)[0]) 58 | match = ColorPoint(source=color, target=color, ansi=ansi) 59 | text = self._render(self._body, fg=match) 60 | return self.copy(text) 61 | 62 | @property 63 | def p(self): 64 | text = self.theme.base(self._body) 65 | return self.copy(text) 66 | 67 | @property 68 | def light(self): 69 | text = self.theme.light(self._body) 70 | return self.copy(text) 71 | 72 | @property 73 | def dark(self): 74 | text = self.theme.dark(self._body) 75 | return self.copy(text) 76 | 77 | @property 78 | def h1(self): 79 | text = self.theme.h1(self._body) 80 | return self.copy(text) 81 | 82 | @property 83 | def h2(self): 84 | text = self.theme.h2(self._body) 85 | return self.copy(text) 86 | 87 | @property 88 | def h3(self): 89 | text = self.theme.h3(self._body) 90 | return self.copy(text) 91 | 92 | @property 93 | def h4(self): 94 | text = self.theme.h4(self._body) 95 | return self.copy(text) 96 | 97 | @property 98 | def li(self): 99 | text = self.theme.light('- ' + self._body) 100 | return self.copy(text) 101 | 102 | @property 103 | def err(self): 104 | text = self.theme.error(self._body) 105 | return self.copy(text) 106 | 107 | @property 108 | def warn(self): 109 | text = self.theme.warning(self._body) 110 | return self.copy(text) 111 | 112 | @property 113 | def info(self): 114 | text = self.theme.info(self._body) 115 | return self.copy(text) 116 | 117 | @property 118 | def ok(self): 119 | text = self.theme.ok(self._body) 120 | return self.copy(text) 121 | 122 | @property 123 | def b(self): 124 | if terminal_colors() == 0: 125 | return self.copy(self._body) 126 | text = self._body 127 | text = sty.ef.bold + text + sty.rs.all 128 | return self.copy(text) 129 | 130 | @property 131 | def i(self): 132 | if terminal_colors() == 0: 133 | return self.copy(self._body) 134 | text = self._body 135 | text = sty.ef.italic + text + sty.rs.all 136 | return self.copy(text) 137 | 138 | @property 139 | def u(self): 140 | if terminal_colors() == 0: 141 | return self.copy(self._body) 142 | text = self._body 143 | text = sty.ef.underl + text + sty.rs.all 144 | return self.copy(text) 145 | 146 | @property 147 | def r(self): 148 | if terminal_colors() == 0: 149 | return self.copy(self._body) 150 | text = self._body 151 | text = sty.ef.inverse + text + sty.rs.all 152 | return self.copy(text) 153 | 154 | @property 155 | def m(self): 156 | if terminal_colors() == 0: 157 | return self.copy(self._body) 158 | text = self._body 159 | text = sty.ef.dim + text + sty.rs.all 160 | return self.copy(text) 161 | 162 | @property 163 | def raw(self): 164 | return repr(self) 165 | 166 | 167 | class AutoFormat(object): 168 | def __init__(self, term_colors=0, 169 | renderer=None, palette=None, 170 | theme=None): 171 | self.init(term_colors=term_colors, 172 | renderer=renderer, 173 | palette=palette, 174 | theme=theme) 175 | 176 | def init(self, 177 | term_colors=0, 178 | renderer=None, 179 | palette=None, 180 | theme=None, 181 | fix_all=False, 182 | fix_text=False): 183 | self.term_colors = term_colors or terminal_colors(sys.stdout) 184 | self.renderer = renderer or select_render_engine(self.term_colors) 185 | self.palette = palette or select_palette(self.term_colors) 186 | self.theme = theme(palette=self.palette, 187 | renderer=self.renderer) if theme else BasicTheme( 188 | palette=self.palette, 189 | renderer=self.renderer) 190 | self._need_text_fix = self.need_text_fix() 191 | self._need_emoji_fix = self.need_emoji_fix() 192 | if fix_all and self._need_emoji_fix: 193 | fix_text = True 194 | try: 195 | from emoji2text import emoji2text 196 | self.fix_emoji = emoji2text 197 | except ImportError: 198 | raise ImportError('Please install python package: emoji2text') 199 | if fix_text and self._need_text_fix: 200 | try: 201 | from ftfy import fix_text 202 | self.fix_text = fix_text 203 | except ImportError: 204 | raise ImportError('Please install python package: ftfy') 205 | 206 | def need_text_fix(self): 207 | if 0 <= self.term_colors <= 16: 208 | return True 209 | return False 210 | 211 | def need_emoji_fix(self): 212 | if 0 <= self.term_colors <= 16: 213 | return True 214 | return False 215 | 216 | def fix_text(self, text): 217 | return text 218 | 219 | def fix_emoji(self, text, sep): 220 | return text 221 | 222 | def __call__(self, content, *, key=''): 223 | if self._need_text_fix: 224 | content = self.fix_text(content) 225 | if self._need_emoji_fix: 226 | content = self.fix_emoji(content, ':') 227 | return ColoredString(content, theme=self.theme, key=key, term_colors=self.term_colors) 228 | -------------------------------------------------------------------------------- /src/autopalette/palette.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | from colour import Color 4 | 5 | from autopalette.colormatch import ColorPoint, ColorMatch, AnsiCodeType 6 | from autopalette.utils import parse_color, map_interval 7 | from autopalette.colortrans import rgb2short, short2rgb 8 | 9 | 10 | class BasePalette(object): 11 | def match(self, color: Color) -> ColorPoint: 12 | raise NotImplementedError() 13 | 14 | 15 | class Ansi256Palette(BasePalette): 16 | def match(self, color: Color, ansi=False) -> ColorPoint: 17 | ansi = int(rgb2short(color.hex_l)[0]) 18 | target = Color('#' + short2rgb(str(ansi))) 19 | return ColorPoint(color, target, ansi=ansi) 20 | 21 | 22 | class AutoPalette(BasePalette): 23 | def __init__(self, colors: List[dict] = None): 24 | self.tree = ColorMatch() 25 | if not colors: 26 | colors = getattr(self, 'colors', {}) 27 | if isinstance(colors, dict): 28 | self.colors = self.colors_from_dict(colors) 29 | else: 30 | raise ValueError('Expected a dict for colors with the format => ' 31 | 'source-color: (target-color, target-ansi-code). ' 32 | 'Got: {}'.format(colors)) 33 | for color, data in self.colors.items(): 34 | self.add_color(source=data['source'], 35 | target=data['target'], 36 | ansi=data['ansi']) 37 | 38 | def colors_from_dict(self, colors: dict) -> dict: 39 | new_colors = {} 40 | for clr_name, payload in colors.items(): 41 | if not 'source' in payload: 42 | color = parse_color(clr_name) 43 | color_hex = color.hex_l 44 | target = parse_color(payload[0]) 45 | ansi = payload[1] 46 | new_colors.update({color_hex: {'source': color, 47 | 'target': target, 48 | 'ansi': ansi}}) 49 | else: 50 | color_hex = payload['source'].hex_l 51 | new_colors.update({color_hex: {'source': payload['source'], 52 | 'target': payload['target'].hex_l, 53 | 'ansi': payload['ansi']}}) 54 | return new_colors 55 | 56 | def add_color(self, source: Color, target: Color, ansi: AnsiCodeType): 57 | self.tree.add(source, target, ansi) 58 | 59 | def match(self, color: Color, ansi=False) -> ColorPoint: 60 | match = self.tree.match(color) 61 | return match 62 | 63 | 64 | class Ansi8Palette(AutoPalette): 65 | colors = { 66 | 'black': ('#000000', 0), 67 | 'red': ('#ff0000', 1), 68 | 'lime': ('#00ff00', 2), 69 | 'yellow': ('#ffff00', 3), 70 | 'blue': ('#0000ff', 4), 71 | 'magenta': ('#ff00ff', 5), 72 | 'cyan': ('#00ffff', 6), 73 | 'white': ('#ffffff', 7), 74 | } 75 | # ^^ source ^^ target ^^ ansi-code 76 | 77 | 78 | class Ansi16Palette(AutoPalette): 79 | colors = { 80 | 'black': ('black', 0), 81 | 'darkred': ('darkred', 1), 82 | 'green': ('darkgreen', 2), 83 | 'orange': ('orange', 3), 84 | 'darkblue': ('darkblue', 4), 85 | 'darkmagenta': ('darkmagenta', 5), 86 | 'darkcyan': ('darkcyan', 6), 87 | 'silver': ('silver', 7), 88 | 'gray': ('gray', 8), 89 | 'red': ('red', 9), 90 | 'lime': ('green', 10), 91 | 'yellow': ('yellow', 11), 92 | 'blue': ('blue', 12), 93 | 'magenta': ('magenta', 13), 94 | 'cyan': ('cyan', 14), 95 | 'white': ('white', 15), 96 | } 97 | # ^^ source ^^ target ^^ ansi-code 98 | 99 | 100 | class Gray4Palette(AutoPalette): 101 | """ 102 | A 4-bit grayscale palette, desaturates and maps all colors 103 | to one of four target shades. 104 | """ 105 | colors = { 106 | 'black': ('#000000', 0), 107 | 'silver': ('#676767', 8), 108 | 'blue': ('#b6b6b6', 7), 109 | 'yellow': ('#b6b6b6', 0), 110 | 'white': ('#fafafa', 15), 111 | } 112 | 113 | # ^^ source ^^ target ^^ ansi-code 114 | 115 | def match(self, color: Color, ansi=False) -> ColorPoint: 116 | if not ansi: 117 | return super().match(color) 118 | color.set_saturation(0.3) 119 | return super(Gray4Palette, self).match(color) 120 | 121 | 122 | class Oil6Palette(AutoPalette): 123 | """ 124 | Source: https://lospec.com/palette-list/oil-6 125 | """ 126 | colors = { 127 | 'white': ('#fbf5ef', 91), 128 | 'silver': ('#f2d3ab', 11), 129 | 'lightgray': ('#c69fa5', 139), 130 | 'gray': ('#8b6d9c', 5), 131 | 'darkgray': ('#494d7e', 98), 132 | 'black': ('#272744', 0), 133 | } 134 | 135 | # ^^ source ^^ target ^^ ansi-code 136 | 137 | def match(self, color: Color, ansi=False) -> ColorPoint: 138 | lum = map_interval(0, 1, .3, .9, color.get_luminance()) 139 | color.set_luminance(lum) 140 | sat = map_interval(0, 1, .2, .9, color.get_saturation()) 141 | color.set_saturation(sat) 142 | return super().match(color) 143 | 144 | 145 | class GameBoyChocolatePalette(AutoPalette): 146 | """ 147 | Source: https://lospec.com/palette-list/gb-chocolate 148 | """ 149 | colors = { 150 | 'white': ('#ffe4c2', 15), 151 | 'silver': ('#dca456', 3), 152 | 'gray': ('#a9604c', 1), 153 | 'black': ('#422936', 0), 154 | } 155 | 156 | # ^^ source ^^ target ^^ ansi-code 157 | 158 | def match(self, color: Color, ansi=False) -> ColorPoint: 159 | lum = map_interval(0, 1, .2, .9, color.get_luminance()) 160 | color.set_luminance(lum) 161 | return super().match(color) 162 | 163 | 164 | class GameBoyGreenPalette(AutoPalette): 165 | """ 166 | Source: https://www.designpieces.com/palette/ \ 167 | game-boy-original-color-palette-hex-and-rgb/ 168 | """ 169 | colors = { 170 | 'white': ('green', 10), # help auto conversion with a brighter color. 171 | 'yellow': ('#9bbc0f', 15), 172 | 'green': ('#8bac0f', 10), 173 | 'gray': ('#306230', 2), 174 | 'black': ('#0f380f', 0), 175 | } 176 | 177 | # ^^ source ^^ target ^^ ansi-code 178 | 179 | def match(self, color: Color, ansi=False) -> ColorPoint: 180 | lum = map_interval(0, 1, .3, .85, color.get_luminance()) 181 | color.set_luminance(lum) 182 | return super().match(color) 183 | 184 | 185 | class ColorsCCPalette(AutoPalette): 186 | """ 187 | Source: https://clrs.cc/ 188 | Also see: https://clrs.cc/a11y/ 189 | """ 190 | colors = { 191 | 'navy': ('#001f3f', 4), 192 | 'blue': ('#0074D9', 12), 193 | 'aqua': ('#7FDBFF', 14), 194 | 'teal': ('#39CCCC', 6), 195 | 'olive': ('#3D9970', 3), 196 | 'green': ('#2ECC40', 2), 197 | 'lime': ('#01FF70', 10), 198 | 'yellow': ('#FFDC00', 11), 199 | 'orange': ('#FF851B', 3), 200 | 'red': ('#FF4136', 9), 201 | 'maroon': ('#85144b', 1), 202 | 'fuchsia': ('#F012BE', 13), 203 | 'purple': ('#B10DC9', 5), 204 | 'black': ('#111111', 0), 205 | 'gray': ('#AAAAAA', 8), 206 | 'silver': ('#DDDDDD', 7), 207 | 'white': ('#FFFFFF', 15), 208 | } 209 | # ^^ source ^^ target ^^ ansi-code 210 | 211 | 212 | class BasicPalette(AutoPalette): 213 | colors = { 214 | 'black': ('#000000', 0), 215 | 'silver': ('#eaebbc', 7), 216 | 'gray': ('#286c80', 8), 217 | 'purple': ('#8339a0', 5), 218 | 'blue': ('#0059c8', 4), 219 | 'lightblue': ('#2f9ffa', 12), 220 | 'magenta': ('#ea75f9', 13), 221 | 'red': ('#ed452f', 9), 222 | 'orange': ('#ec835b', 3), 223 | 'yellow': ('#f3e945', 11), 224 | 'lime': ('#9df381', 10), 225 | 'green': ('#47AA49', 2), 226 | 'white': ('#ffffff', 15), 227 | } 228 | 229 | # ^^ source ^^ target ^^ ansi-code 230 | 231 | def match(self, color: Color, ansi=False) -> ColorPoint: 232 | lum = map_interval(0, 1, .2, 1, color.get_luminance()) 233 | color.set_luminance(lum) 234 | return super().match(color) 235 | 236 | 237 | class DutronPalette(AutoPalette): 238 | colors = { 239 | 'black': ('#000000', 0), 240 | 'silver': ('#94945d', 7), 241 | 'gray': ('#595d82', 8), 242 | 'purple': ('#415ac4', 4), 243 | 'blue': ('#7e8ff8', 4), 244 | 'lightblue': ('#8384fb', 12), 245 | 'magenta': ('#a2adf4', 12), 246 | 'red': ('#585539', 3), 247 | 'orange': ('#a7a24f', 3), 248 | 'yellow': ('#f2e745', 11), 249 | 'lime': ('#e4dd88', 11), 250 | 'green': ('#82844B', 3), 251 | 'white': ('#ffffff', 15), 252 | } 253 | # ^^ source ^^ target ^^ ansi-code 254 | 255 | 256 | palette_map = { 257 | '-1': BasicPalette, 258 | '0': Ansi8Palette, 259 | '8': Ansi8Palette, 260 | '16': Ansi16Palette, 261 | '88': Ansi256Palette, 262 | '256': Ansi256Palette, 263 | 'ansi': Ansi256Palette, 264 | 'rgb': Ansi256Palette, 265 | 'truecolor': Ansi256Palette, 266 | 'vt100': Ansi8Palette, 267 | 'vt200': Ansi8Palette, 268 | 'vt220': Ansi8Palette, 269 | 'rxvt': Ansi8Palette, 270 | 'rxvt-88color': Ansi256Palette, 271 | 'xterm': Ansi8Palette, 272 | 'xterm-color': Ansi16Palette, 273 | 'xterm-256color': Ansi256Palette, 274 | 'dutron': DutronPalette, 275 | 'gameboychocolate': GameBoyChocolatePalette, 276 | 'gameboygreen': GameBoyGreenPalette, 277 | 'gray4': Gray4Palette, 278 | 'oil6': Oil6Palette, 279 | 'basic': BasicPalette, 280 | } 281 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # autopalette 2 | 3 | Terminal palettes and themes, without tears. 4 | 5 | ``` 6 | pip install autopalette 7 | ``` 8 | 9 | **Status: Alpha; being developed.** 10 | 11 | 12 | Do you write python scripts that `print()` text 13 | on the command-line terminal? 14 | Do you feel the interface could convey a bit more meaning, 15 | but the effort needed to get it right has kept you away 16 | from using ANSI colors? 17 | These things should be easy, right? 18 | 19 | Here's a regular Python program that prints a word: 20 | 21 | ```python 22 | print("Tring!") 23 | ``` 24 | 25 | ![01-regular-print](https://user-images.githubusercontent.com/23116/40859649-0da89ab0-65d2-11e8-8026-19ba6a2ad003.png) 26 | 27 | 28 | Here is what it looks like with autopalette, 29 | using a shortcut called `AutoFormat` or in short: `af`. 30 | 31 | ```python 32 | from autopalette import af 33 | 34 | print(af("Tring!")) 35 | ``` 36 | 37 | ![02-autoformat-wrapped-print](https://user-images.githubusercontent.com/23116/40859706-3b61f3c0-65d2-11e8-996b-da4e218e192c.png) 38 | 39 | 40 | We added one line to import, 41 | and four characters around the string. 42 | 43 | And it does nothing - autopalette is non-intrusive that way. 44 | You can leave your `af`-wrapped strings around 45 | and they will not run unnecessary code until you ask for more. 46 | 47 | What's more? 48 | 49 | ```python 50 | from autopalette import af 51 | 52 | print(af("Hello, world!").id) 53 | print(af("Hello, world!").id256) 54 | ``` 55 | 56 | ![03-id-deterministic-color](https://user-images.githubusercontent.com/23116/40859765-63bec9b0-65d2-11e8-886c-82011ea96f8b.png) 57 | 58 | If your terminal / emulator reports that it supports color, 59 | you should see the second line formatted in fuschia/ magenta. 60 | Try changing the text and observe that 61 | the color changes when the text changes, 62 | but it stays fixed for the same text. 63 | Across function calls, across program runs, 64 | across machines, across time itself! 65 | Okay maybe that was too dramatic, 66 | but it is kind of true because, mathematics. 67 | 68 | Autopalette's `id` feature hashes the supplied text and generates 69 | a color unique to the text within 70 | the range of colors reported by the terminal. 71 | `id256` generates a color within the ANSI 256 palette. 72 | `id256` is not portable, but feel free to use it 73 | for your personal scripts where color limits are known. 74 | 75 | Why is this useful? 76 | 77 | It helps to identify unique names 78 | that your program may output, such as: 79 | 80 | - hostnames, when working with remote machines. 81 | - usernames, for logs of multi-user environments. 82 | - you know better what matters to your program's output :) 83 | 84 | Sometimes you want a little more... 85 | 86 | ```python 87 | from autopalette import af 88 | 89 | print(af("Hello again!").h1) 90 | ``` 91 | 92 | ![04-header-one](https://user-images.githubusercontent.com/23116/40859801-858c3ef6-65d2-11e8-90d7-69a80fc57c57.png) 93 | 94 | 95 | And we have a nicely decorated header, just like that. 96 | You can use one of the several pre-defined styles, 97 | or read further below how you can design your own. 98 | 99 | Here are the various styles built into autopalette. 100 | 101 | - `p`: plain-text, or paragraph - as you like to read it. 102 | - `light`: where color range allows it, lighter text. 103 | - `dark`: darker text if terminal supports enough colors within palette. 104 | - `h1`: highlighted text style 1, or header-1. 105 | - `h2`: 106 | - `h3` 107 | - `h4` 108 | - `li`: list element. 109 | - `err`: an error 110 | - `warn`: a warning 111 | - `info`: a warning 112 | - `ok`: a warning 113 | - `b`: bold. 114 | - `i`: italic. 115 | - `u`: underline. 116 | - `r`: reversed colors. 117 | - `raw`: useful to debug, displays the ANSI code instead of applying it. 118 | 119 | Let us try superimposing two styles. 120 | 121 | ```python 122 | from autopalette import af 123 | 124 | print(af("Hey! We've met before!?").info.b) 125 | ``` 126 | 127 | ![05-superimpose-styles](https://user-images.githubusercontent.com/23116/40859850-abe90afc-65d2-11e8-905d-d8a875d0f021.png) 128 | 129 | 130 | You get the idea, tack the names of styles you want 131 | at the end-bracket of the call to `af`. 132 | 133 | If you are wondering, "Wait, what's with that weird syntax?", 134 | in Python's spirit of quick protoyping, 135 | autopalette encourages experimenting with 136 | minimal mental and physical effort to tweak knobs. 137 | Your program's actual task matters more, 138 | but you care enough about your future self and users using the app 139 | to style it well and be a delight to use. 140 | Autopalette's syntax is an expriment to help manage this dilemma. 141 | 142 | While you compose and read your code, 143 | this syntax separates the styling from rest of the function calls. 144 | You don't have to think about styling unless you want to, 145 | and when you do, which is often as you look at the string 146 | you just put together to print - assuming you started with `af("`, 147 | close the quote and bracket, type out a style shortcut 148 | and you are done. 149 | 150 | Although, few times you want a bit more than that... 151 | 152 | ```python 153 | from autopalette import af, GameBoyGreenPalette 154 | 155 | af.init(palette=GameBoyGreenPalette) 156 | 157 | print(af("There you are!").h1) 158 | ``` 159 | 160 | ![06-select-palette](https://user-images.githubusercontent.com/23116/40860027-550d2046-65d3-11e8-9fbe-b0ecdf3ec50c.png) 161 | 162 | Look at that! Yummy. 163 | 164 | Autopalette goes the length to support a handful of palettes. 165 | 166 | - GameBoyChocolate 167 | - GameBoyOriginal 168 | - Grayscale 169 | - Oil 170 | - Arcade 171 | - CLRS 172 | 173 | If this is exciting to you too, read further below how to create your own! 174 | 175 | How does this look on a terminal with only 16 colors? 176 | 177 | ![06-select-palette-16-color](https://user-images.githubusercontent.com/23116/40860055-74e898aa-65d3-11e8-8bfc-3873c1ea4a4b.png) 178 | 179 | Not too shabby, eh? 180 | 181 | How do you test how your app will look on terminals with limited colors? 182 | Try these as prefix to your script invocation for a temporary change: 183 | 184 | - `env TERM=vt100` 185 | - `env TERM=rxvt` 186 | - `env TERM=xterm` 187 | - `env TERM=xterm-256color` 188 | - `env COLORTERM=truecolor` 189 | - `env NO_COLOR` 190 | 191 | like so: 192 | 193 | `$ env TERM=xterm-256color python app.py` 194 | 195 | To save a setting permanently, put `export TERM=...` 196 | in your `~/.bash_profile` or your default shell's configuration. 197 | 198 | If the environment variable NO_COLOR is set, 199 | autopalette honors the configuration and disables all color. 200 | Same with redirected output and pipes - 201 | autopalette will handle it fully automatically, 202 | if it fails to do so, please open an issue in the tracker 203 | and I'll do my best to fix it. 204 | In case you can fix the issue yourself, a pull request will be awesome! 205 | 206 | And we would be essentially done, except, 207 | there's this little voice in the head that's saying something mojibªke something, 208 | but it's all garbled up. 209 | 210 | ```python 211 | from autopalette import af 212 | 213 | af.init(fix_text=True) 214 | 215 | print(af("¯\\_(ã\x83\x84)_/¯").info) 216 | ``` 217 | 218 | ![07-fix-text](https://user-images.githubusercontent.com/23116/40860106-abf343f4-65d3-11e8-9272-89733b0790bd.png) 219 | 220 | 221 | Neat, with the `fix_text` option set, 222 | autopalette transparently passes your text 223 | through `ftfy`'s `fix_text()` function call, 224 | ensuring your application does not output garbage 225 | when badly encoded strings find their way 226 | to your app's print statement. 227 | 228 | There's more, not all terminal and emulators support unicode, 229 | and will still produce garbage if we feed them strings 230 | that they do not know how to display. Use the `fix_all` option 231 | to let autopalette and the terminal it is running on figure out the rest. 232 | 233 | ```python 234 | from autopalette import af 235 | 236 | af.init(fix_all=True) 237 | 238 | print(af("I 💛 Unicode!")) 239 | ``` 240 | 241 | Try this example with `env TERM=vt100` for the full cleanup! 242 | 243 | ![08-fix-all](https://user-images.githubusercontent.com/23116/40860125-c4f0343e-65d3-11e8-9bfe-d92f177c5852.png) 244 | 245 | Note that fixing text and emoji requires additional libraries 246 | to be loaded and can slow down startup time. 247 | If your program does not output strings generated by other programs, 248 | (which includes strings received from http APIs!) 249 | and the program is invoked repeatedly instead of running for a while, 250 | you may want to skip `fix_...` options. 251 | 252 | And that's about it for three-line examples! 253 | 254 | You can start your scripts with `af.init(fix_all=True)` 255 | and use `af()` to wrap your strings, even if you ignore colors and styles, 256 | your program will display text correctly 257 | on most popular (and many obscure) terminals. 258 | 259 | Here's the basic theme: 260 | 261 | ![09-basic-palette](https://user-images.githubusercontent.com/23116/40860445-e69d057a-65d4-11e8-9926-228beaf3c429.png) 262 | 263 | But there's more! 264 | 265 | Your users have the ability to define their own themes, 266 | and autopalette will automatically* recolor your application 267 | to their preferences or needs. 268 | (*mostly automatically, or with a little help.) 269 | 270 | ```text 271 | # ~/.autopalette 272 | 273 | palette = Dutron 274 | render = Truecolor 275 | ``` 276 | 277 | ![10-restricted-color-palette](https://user-images.githubusercontent.com/23116/40860487-0589dd50-65d5-11e8-9360-2fb29a2d213e.png) 278 | 279 | 280 | Your terminal applications look beautiful as you intend, 281 | to everyone, as they expect. 282 | 283 | It is almost two decades since Y2K! 284 | And with over 50 years of the terminal technology behind us, 285 | this should be a thing we expect as a norm. 286 | 287 | Autopalette is another attempt at fixing some of these gaps 288 | by making it near trivial to style terminal apps 289 | and do the right thing for the various terminals it runs on... 290 | without the complexity often involved as a result 291 | of the rich legacy of the technology. 292 | 293 | Autopalette would not dare exist without the libraries 294 | published by these generous individuals who made it possible 295 | to think and write code in simple mental models 296 | that are just right for the task: 297 | 298 | - `colorhash`: Felix Krull (https://pypi.org/project/colorhash/) 299 | - `colortrans.py`: Micah Elliott (https://gist.github.com/MicahElliott/719710/) 300 | - `colour`: Valentin LAB (https://pypi.org/project/colour/) 301 | - `emoji2text`: Sam CB (https://pypi.org/project/emoji2text/) 302 | - `ftfy`: Rob Speer / Luminoso (https://pypi.org/project/ftfy/) 303 | - `kdtree`: Stefan Kögl (https://pypi.org/project/kdtree/) 304 | - `sty`: Felix Meyer-Wolters (https://pypi.org/project/sty/) 305 | 306 | -------------------------------------------------------------------------------- /src/autopalette/colortrans.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python 2 | 3 | """ Convert values between RGB hex codes and xterm-256 color codes. 4 | 5 | Nice long listing of all 256 colors and their codes. Useful for 6 | developing console color themes, or even script output schemes. 7 | 8 | Resources: 9 | * http://en.wikipedia.org/wiki/8-bit_color 10 | * http://en.wikipedia.org/wiki/ANSI_escape_code 11 | * /usr/share/X11/rgb.txt 12 | 13 | I'm not sure where this script was inspired from. I think I must have 14 | written it from scratch, though it's been several years now. 15 | 16 | Source: https://gist.github.com/MicahElliott/719710 17 | """ 18 | 19 | __author__ = 'Micah Elliott http://MicahElliott.com' 20 | __version__ = '0.1' 21 | __copyright__ = 'Copyright (C) 2011 Micah Elliott. All rights reserved.' 22 | __license__ = 'WTFPL http://sam.zoy.org/wtfpl/' 23 | 24 | # --------------------------------------------------------------------- 25 | 26 | import sys, re 27 | 28 | CLUT = [ # color look-up table 29 | # 8-bit, RGB hex 30 | 31 | # Primary 3-bit (8 colors). Unique representation! 32 | ('00', '000000'), 33 | ('01', '800000'), 34 | ('02', '008000'), 35 | ('03', '808000'), 36 | ('04', '000080'), 37 | ('05', '800080'), 38 | ('06', '008080'), 39 | ('07', 'c0c0c0'), 40 | 41 | # Equivalent "bright" versions of original 8 colors. 42 | ('08', '808080'), 43 | ('09', 'ff0000'), 44 | ('10', '00ff00'), 45 | ('11', 'ffff00'), 46 | ('12', '0000ff'), 47 | ('13', 'ff00ff'), 48 | ('14', '00ffff'), 49 | ('15', 'ffffff'), 50 | 51 | # Strictly ascending. 52 | ('16', '000000'), 53 | ('17', '00005f'), 54 | ('18', '000087'), 55 | ('19', '0000af'), 56 | ('20', '0000d7'), 57 | ('21', '0000ff'), 58 | ('22', '005f00'), 59 | ('23', '005f5f'), 60 | ('24', '005f87'), 61 | ('25', '005faf'), 62 | ('26', '005fd7'), 63 | ('27', '005fff'), 64 | ('28', '008700'), 65 | ('29', '00875f'), 66 | ('30', '008787'), 67 | ('31', '0087af'), 68 | ('32', '0087d7'), 69 | ('33', '0087ff'), 70 | ('34', '00af00'), 71 | ('35', '00af5f'), 72 | ('36', '00af87'), 73 | ('37', '00afaf'), 74 | ('38', '00afd7'), 75 | ('39', '00afff'), 76 | ('40', '00d700'), 77 | ('41', '00d75f'), 78 | ('42', '00d787'), 79 | ('43', '00d7af'), 80 | ('44', '00d7d7'), 81 | ('45', '00d7ff'), 82 | ('46', '00ff00'), 83 | ('47', '00ff5f'), 84 | ('48', '00ff87'), 85 | ('49', '00ffaf'), 86 | ('50', '00ffd7'), 87 | ('51', '00ffff'), 88 | ('52', '5f0000'), 89 | ('53', '5f005f'), 90 | ('54', '5f0087'), 91 | ('55', '5f00af'), 92 | ('56', '5f00d7'), 93 | ('57', '5f00ff'), 94 | ('58', '5f5f00'), 95 | ('59', '5f5f5f'), 96 | ('60', '5f5f87'), 97 | ('61', '5f5faf'), 98 | ('62', '5f5fd7'), 99 | ('63', '5f5fff'), 100 | ('64', '5f8700'), 101 | ('65', '5f875f'), 102 | ('66', '5f8787'), 103 | ('67', '5f87af'), 104 | ('68', '5f87d7'), 105 | ('69', '5f87ff'), 106 | ('70', '5faf00'), 107 | ('71', '5faf5f'), 108 | ('72', '5faf87'), 109 | ('73', '5fafaf'), 110 | ('74', '5fafd7'), 111 | ('75', '5fafff'), 112 | ('76', '5fd700'), 113 | ('77', '5fd75f'), 114 | ('78', '5fd787'), 115 | ('79', '5fd7af'), 116 | ('80', '5fd7d7'), 117 | ('81', '5fd7ff'), 118 | ('82', '5fff00'), 119 | ('83', '5fff5f'), 120 | ('84', '5fff87'), 121 | ('85', '5fffaf'), 122 | ('86', '5fffd7'), 123 | ('87', '5fffff'), 124 | ('88', '870000'), 125 | ('89', '87005f'), 126 | ('90', '870087'), 127 | ('91', '8700af'), 128 | ('92', '8700d7'), 129 | ('93', '8700ff'), 130 | ('94', '875f00'), 131 | ('95', '875f5f'), 132 | ('96', '875f87'), 133 | ('97', '875faf'), 134 | ('98', '875fd7'), 135 | ('99', '875fff'), 136 | ('100', '878700'), 137 | ('101', '87875f'), 138 | ('102', '878787'), 139 | ('103', '8787af'), 140 | ('104', '8787d7'), 141 | ('105', '8787ff'), 142 | ('106', '87af00'), 143 | ('107', '87af5f'), 144 | ('108', '87af87'), 145 | ('109', '87afaf'), 146 | ('110', '87afd7'), 147 | ('111', '87afff'), 148 | ('112', '87d700'), 149 | ('113', '87d75f'), 150 | ('114', '87d787'), 151 | ('115', '87d7af'), 152 | ('116', '87d7d7'), 153 | ('117', '87d7ff'), 154 | ('118', '87ff00'), 155 | ('119', '87ff5f'), 156 | ('120', '87ff87'), 157 | ('121', '87ffaf'), 158 | ('122', '87ffd7'), 159 | ('123', '87ffff'), 160 | ('124', 'af0000'), 161 | ('125', 'af005f'), 162 | ('126', 'af0087'), 163 | ('127', 'af00af'), 164 | ('128', 'af00d7'), 165 | ('129', 'af00ff'), 166 | ('130', 'af5f00'), 167 | ('131', 'af5f5f'), 168 | ('132', 'af5f87'), 169 | ('133', 'af5faf'), 170 | ('134', 'af5fd7'), 171 | ('135', 'af5fff'), 172 | ('136', 'af8700'), 173 | ('137', 'af875f'), 174 | ('138', 'af8787'), 175 | ('139', 'af87af'), 176 | ('140', 'af87d7'), 177 | ('141', 'af87ff'), 178 | ('142', 'afaf00'), 179 | ('143', 'afaf5f'), 180 | ('144', 'afaf87'), 181 | ('145', 'afafaf'), 182 | ('146', 'afafd7'), 183 | ('147', 'afafff'), 184 | ('148', 'afd700'), 185 | ('149', 'afd75f'), 186 | ('150', 'afd787'), 187 | ('151', 'afd7af'), 188 | ('152', 'afd7d7'), 189 | ('153', 'afd7ff'), 190 | ('154', 'afff00'), 191 | ('155', 'afff5f'), 192 | ('156', 'afff87'), 193 | ('157', 'afffaf'), 194 | ('158', 'afffd7'), 195 | ('159', 'afffff'), 196 | ('160', 'd70000'), 197 | ('161', 'd7005f'), 198 | ('162', 'd70087'), 199 | ('163', 'd700af'), 200 | ('164', 'd700d7'), 201 | ('165', 'd700ff'), 202 | ('166', 'd75f00'), 203 | ('167', 'd75f5f'), 204 | ('168', 'd75f87'), 205 | ('169', 'd75faf'), 206 | ('170', 'd75fd7'), 207 | ('171', 'd75fff'), 208 | ('172', 'd78700'), 209 | ('173', 'd7875f'), 210 | ('174', 'd78787'), 211 | ('175', 'd787af'), 212 | ('176', 'd787d7'), 213 | ('177', 'd787ff'), 214 | ('178', 'd7af00'), 215 | ('179', 'd7af5f'), 216 | ('180', 'd7af87'), 217 | ('181', 'd7afaf'), 218 | ('182', 'd7afd7'), 219 | ('183', 'd7afff'), 220 | ('184', 'd7d700'), 221 | ('185', 'd7d75f'), 222 | ('186', 'd7d787'), 223 | ('187', 'd7d7af'), 224 | ('188', 'd7d7d7'), 225 | ('189', 'd7d7ff'), 226 | ('190', 'd7ff00'), 227 | ('191', 'd7ff5f'), 228 | ('192', 'd7ff87'), 229 | ('193', 'd7ffaf'), 230 | ('194', 'd7ffd7'), 231 | ('195', 'd7ffff'), 232 | ('196', 'ff0000'), 233 | ('197', 'ff005f'), 234 | ('198', 'ff0087'), 235 | ('199', 'ff00af'), 236 | ('200', 'ff00d7'), 237 | ('201', 'ff00ff'), 238 | ('202', 'ff5f00'), 239 | ('203', 'ff5f5f'), 240 | ('204', 'ff5f87'), 241 | ('205', 'ff5faf'), 242 | ('206', 'ff5fd7'), 243 | ('207', 'ff5fff'), 244 | ('208', 'ff8700'), 245 | ('209', 'ff875f'), 246 | ('210', 'ff8787'), 247 | ('211', 'ff87af'), 248 | ('212', 'ff87d7'), 249 | ('213', 'ff87ff'), 250 | ('214', 'ffaf00'), 251 | ('215', 'ffaf5f'), 252 | ('216', 'ffaf87'), 253 | ('217', 'ffafaf'), 254 | ('218', 'ffafd7'), 255 | ('219', 'ffafff'), 256 | ('220', 'ffd700'), 257 | ('221', 'ffd75f'), 258 | ('222', 'ffd787'), 259 | ('223', 'ffd7af'), 260 | ('224', 'ffd7d7'), 261 | ('225', 'ffd7ff'), 262 | ('226', 'ffff00'), 263 | ('227', 'ffff5f'), 264 | ('228', 'ffff87'), 265 | ('229', 'ffffaf'), 266 | ('230', 'ffffd7'), 267 | ('231', 'ffffff'), 268 | 269 | # Gray-scale range. 270 | ('232', '080808'), 271 | ('233', '121212'), 272 | ('234', '1c1c1c'), 273 | ('235', '262626'), 274 | ('236', '303030'), 275 | ('237', '3a3a3a'), 276 | ('238', '444444'), 277 | ('239', '4e4e4e'), 278 | ('240', '585858'), 279 | ('241', '626262'), 280 | ('242', '6c6c6c'), 281 | ('243', '767676'), 282 | ('244', '808080'), 283 | ('245', '8a8a8a'), 284 | ('246', '949494'), 285 | ('247', '9e9e9e'), 286 | ('248', 'a8a8a8'), 287 | ('249', 'b2b2b2'), 288 | ('250', 'bcbcbc'), 289 | ('251', 'c6c6c6'), 290 | ('252', 'd0d0d0'), 291 | ('253', 'dadada'), 292 | ('254', 'e4e4e4'), 293 | ('255', 'eeeeee'), 294 | ] 295 | 296 | 297 | def _str2hex(hexstr): 298 | return int(hexstr, 16) 299 | 300 | 301 | def _strip_hash(rgb): 302 | # Strip leading `#` if exists. 303 | if rgb.startswith('#'): 304 | rgb = rgb.lstrip('#') 305 | return rgb 306 | 307 | 308 | def _create_dicts(): 309 | short2rgb_dict = dict(CLUT) 310 | rgb2short_dict = {} 311 | for k, v in short2rgb_dict.items(): 312 | rgb2short_dict[v] = k 313 | return rgb2short_dict, short2rgb_dict 314 | 315 | 316 | def short2rgb(short): 317 | return SHORT2RGB_DICT[short] 318 | 319 | 320 | def print_all(): 321 | """ Print all 256 xterm color codes. 322 | """ 323 | for short, rgb in CLUT: 324 | sys.stdout.write('\033[48;5;%sm%s:%s' % (short, short, rgb)) 325 | sys.stdout.write("\033[0m ") 326 | sys.stdout.write('\033[38;5;%sm%s:%s' % (short, short, rgb)) 327 | sys.stdout.write("\033[0m\n") 328 | print 329 | "Printed all codes." 330 | print 331 | "You can translate a hex or 0-255 code by providing an argument." 332 | 333 | 334 | def rgb2short(rgb): 335 | """ Find the closest xterm-256 approximation to the given RGB value. 336 | @param rgb: Hex code representing an RGB value, eg, 'abcdef' 337 | @returns: String between 0 and 255, compatible with xterm. 338 | >>> rgb2short('123456') 339 | ('23', '005f5f') 340 | >>> rgb2short('ffffff') 341 | ('231', 'ffffff') 342 | >>> rgb2short('0DADD6') # vimeo logo 343 | ('38', '00afd7') 344 | """ 345 | rgb = _strip_hash(rgb) 346 | incs = (0x00, 0x5f, 0x87, 0xaf, 0xd7, 0xff) 347 | # Break 6-char RGB code into 3 integer vals. 348 | parts = [int(h, 16) for h in re.split(r'(..)(..)(..)', rgb)[1:4]] 349 | res = [] 350 | for part in parts: 351 | i = 0 352 | while i < len(incs) - 1: 353 | s, b = incs[i], incs[i + 1] # smaller, bigger 354 | if s <= part <= b: 355 | s1 = abs(s - part) 356 | b1 = abs(b - part) 357 | if s1 < b1: 358 | closest = s 359 | else: 360 | closest = b 361 | res.append(closest) 362 | break 363 | i += 1 364 | # print('***', rgb, res) 365 | res = ''.join([('%02.x' % i) for i in res]) 366 | equiv = RGB2SHORT_DICT[res] 367 | # print('***', res, equiv) 368 | return equiv, res 369 | 370 | 371 | RGB2SHORT_DICT, SHORT2RGB_DICT = _create_dicts() 372 | 373 | # --------------------------------------------------------------------- 374 | 375 | if __name__ == '__main__': 376 | import doctest 377 | 378 | doctest.testmod() 379 | if len(sys.argv) == 1: 380 | print_all() 381 | raise SystemExit 382 | arg = sys.argv[1] 383 | if len(arg) < 4 and int(arg) < 256: 384 | rgb = short2rgb(arg) 385 | sys.stdout.write( 386 | 'xterm color \033[38;5;%sm%s\033[0m -> RGB exact \033[38;5;%sm%s\033[0m' % (arg, arg, arg, rgb)) 387 | sys.stdout.write("\033[0m\n") 388 | else: 389 | short, rgb = rgb2short(arg) 390 | sys.stdout.write('RGB %s -> xterm color approx \033[38;5;%sm%s (%s)' % (arg, short, short, rgb)) 391 | sys.stdout.write("\033[0m\n") 392 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | autopalette 2 | =========== 3 | 4 | Terminal palettes and themes, without tears. 5 | 6 | :: 7 | 8 | pip install autopalette 9 | 10 | **Status: Alpha; being developed.** 11 | 12 | Do you write python scripts that ``print()`` text on the command-line 13 | terminal? Do you feel the interface could convey a bit more meaning, but 14 | the effort needed to get it right has kept you away from using ANSI 15 | colors? These things should be easy, right? 16 | 17 | Here’s a regular Python program that prints a word: 18 | 19 | .. code:: python 20 | 21 | print("Tring!") 22 | 23 | .. figure:: https://user-images.githubusercontent.com/23116/40859649-0da89ab0-65d2-11e8-8026-19ba6a2ad003.png 24 | :alt: 01-regular-print 25 | 26 | 01-regular-print 27 | 28 | Here is what it looks like with autopalette, using a shortcut called 29 | ``AutoFormat`` or in short: ``af``. 30 | 31 | .. code:: python 32 | 33 | from autopalette import af 34 | 35 | print(af("Tring!")) 36 | 37 | .. figure:: https://user-images.githubusercontent.com/23116/40859706-3b61f3c0-65d2-11e8-996b-da4e218e192c.png 38 | :alt: 02-autoformat-wrapped-print 39 | 40 | 02-autoformat-wrapped-print 41 | 42 | We added one line to import, and four characters around the string. 43 | 44 | And it does nothing - autopalette is non-intrusive that way. You can 45 | leave your ``af``-wrapped strings around and they will not run 46 | unnecessary code until you ask for more. 47 | 48 | What’s more? 49 | 50 | .. code:: python 51 | 52 | from autopalette import af 53 | 54 | print(af("Hello, world!").id) 55 | print(af("Hello, world!").id256) 56 | 57 | .. figure:: https://user-images.githubusercontent.com/23116/40859765-63bec9b0-65d2-11e8-886c-82011ea96f8b.png 58 | :alt: 03-id-deterministic-color 59 | 60 | 03-id-deterministic-color 61 | 62 | If your terminal / emulator reports that it supports color, you should 63 | see the second line formatted in fuschia/ magenta. Try changing the text 64 | and observe that the color changes when the text changes, but it stays 65 | fixed for the same text. Across function calls, across program runs, 66 | across machines, across time itself! Okay maybe that was too dramatic, 67 | but it is kind of true because, mathematics. 68 | 69 | Autopalette’s ``id`` feature hashes the supplied text and generates a 70 | color unique to the text within the range of colors reported by the 71 | terminal. ``id256`` generates a color within the ANSI 256 palette. 72 | ``id256`` is not portable, but feel free to use it for your personal 73 | scripts where color limits are known. 74 | 75 | Why is this useful? 76 | 77 | It helps to identify unique names that your program may output, such as: 78 | 79 | - hostnames, when working with remote machines. 80 | - usernames, for logs of multi-user environments. 81 | - you know better what matters to your program’s output :) 82 | 83 | Sometimes you want a little more… 84 | 85 | .. code:: python 86 | 87 | from autopalette import af 88 | 89 | print(af("Hello again!").h1) 90 | 91 | .. figure:: https://user-images.githubusercontent.com/23116/40859801-858c3ef6-65d2-11e8-90d7-69a80fc57c57.png 92 | :alt: 04-header-one 93 | 94 | 04-header-one 95 | 96 | And we have a nicely decorated header, just like that. You can use one 97 | of the several pre-defined styles, or read further below how you can 98 | design your own. 99 | 100 | Here are the various styles built into autopalette. 101 | 102 | - ``p``: plain-text, or paragraph - as you like to read it. 103 | - ``light``: where color range allows it, lighter text. 104 | - ``dark``: darker text if terminal supports enough colors within 105 | palette. 106 | - ``h1``: highlighted text style 1, or header-1. 107 | - ``h2``: 108 | - ``h3`` 109 | - ``h4`` 110 | - ``li``: list element. 111 | - ``err``: an error 112 | - ``warn``: a warning 113 | - ``info``: a warning 114 | - ``ok``: a warning 115 | - ``b``: bold. 116 | - ``i``: italic. 117 | - ``u``: underline. 118 | - ``r``: reversed colors. 119 | - ``raw``: useful to debug, displays the ANSI code instead of applying 120 | it. 121 | 122 | Let us try superimposing two styles. 123 | 124 | .. code:: python 125 | 126 | from autopalette import af 127 | 128 | print(af("Hey! We've met before!?").info.b) 129 | 130 | .. figure:: https://user-images.githubusercontent.com/23116/40859850-abe90afc-65d2-11e8-905d-d8a875d0f021.png 131 | :alt: 05-superimpose-styles 132 | 133 | 05-superimpose-styles 134 | 135 | You get the idea, tack the names of styles you want at the end-bracket 136 | of the call to ``af``. 137 | 138 | If you are wondering, “Wait, what’s with that weird syntax?”, in 139 | Python’s spirit of quick protoyping, autopalette encourages 140 | experimenting with minimal mental and physical effort to tweak knobs. 141 | Your program’s actual task matters more, but you care enough about your 142 | future self and users using the app to style it well and be a delight to 143 | use. Autopalette’s syntax is an expriment to help manage this dilemma. 144 | 145 | While you compose and read your code, this syntax separates the styling 146 | from rest of the function calls. You don’t have to think about styling 147 | unless you want to, and when you do, which is often as you look at the 148 | string you just put together to print - assuming you started with 149 | ``af("``, close the quote and bracket, type out a style shortcut and you 150 | are done. 151 | 152 | Although, few times you want a bit more than that… 153 | 154 | .. code:: python 155 | 156 | from autopalette import af, GameBoyGreenPalette 157 | 158 | af.init(palette=GameBoyGreenPalette) 159 | 160 | print(af("There you are!").h1) 161 | 162 | .. figure:: https://user-images.githubusercontent.com/23116/40860027-550d2046-65d3-11e8-9fbe-b0ecdf3ec50c.png 163 | :alt: 06-select-palette 164 | 165 | 06-select-palette 166 | 167 | Look at that! Yummy. 168 | 169 | Autopalette goes the length to support a handful of palettes. 170 | 171 | - GameBoyChocolate 172 | - GameBoyOriginal 173 | - Grayscale 174 | - Oil 175 | - Arcade 176 | - CLRS 177 | 178 | If this is exciting to you too, read further below how to create your 179 | own! 180 | 181 | How does this look on a terminal with only 16 colors? 182 | 183 | .. figure:: https://user-images.githubusercontent.com/23116/40860055-74e898aa-65d3-11e8-8bfc-3873c1ea4a4b.png 184 | :alt: 06-select-palette-16-color 185 | 186 | 06-select-palette-16-color 187 | 188 | Not too shabby, eh? 189 | 190 | How do you test how your app will look on terminals with limited colors? 191 | Try these as prefix to your script invocation for a temporary change: 192 | 193 | - ``env TERM=vt100`` 194 | - ``env TERM=rxvt`` 195 | - ``env TERM=xterm`` 196 | - ``env TERM=xterm-256color`` 197 | - ``env COLORTERM=truecolor`` 198 | - ``env NO_COLOR`` 199 | 200 | like so: 201 | 202 | ``$ env TERM=xterm-256color python app.py`` 203 | 204 | To save a setting permanently, put ``export TERM=...`` in your 205 | ``~/.bash_profile`` or your default shell’s configuration. 206 | 207 | If the environment variable NO_COLOR is set, autopalette honors the 208 | configuration and disables all color. Same with redirected output and 209 | pipes - autopalette will handle it fully automatically, if it fails to 210 | do so, please open an issue in the tracker and I’ll do my best to fix 211 | it. In case you can fix the issue yourself, a pull request will be 212 | awesome! 213 | 214 | And we would be essentially done, except, there’s this little voice in 215 | the head that’s saying something mojibªke something, but it’s all 216 | garbled up. 217 | 218 | .. code:: python 219 | 220 | from autopalette import af 221 | 222 | af.init(fix_text=True) 223 | 224 | print(af("¯\\_(ã\x83\x84)_/¯").info) 225 | 226 | .. figure:: https://user-images.githubusercontent.com/23116/40860106-abf343f4-65d3-11e8-9272-89733b0790bd.png 227 | :alt: 07-fix-text 228 | 229 | 07-fix-text 230 | 231 | Neat, with the ``fix_text`` option set, autopalette transparently passes 232 | your text through ``ftfy``\ ’s ``fix_text()`` function call, ensuring 233 | your application does not output garbage when badly encoded strings find 234 | their way to your app’s print statement. 235 | 236 | There’s more, not all terminal and emulators support unicode, and will 237 | still produce garbage if we feed them strings that they do not know how 238 | to display. Use the ``fix_all`` option to let autopalette and the 239 | terminal it is running on figure out the rest. 240 | 241 | .. code:: python 242 | 243 | from autopalette import af 244 | 245 | af.init(fix_all=True) 246 | 247 | print(af("I 💛 Unicode!")) 248 | 249 | Try this example with ``env TERM=vt100`` for the full cleanup! 250 | 251 | .. figure:: https://user-images.githubusercontent.com/23116/40860125-c4f0343e-65d3-11e8-9bfe-d92f177c5852.png 252 | :alt: 08-fix-all 253 | 254 | 08-fix-all 255 | 256 | Note that fixing text and emoji requires additional libraries to be 257 | loaded and can slow down startup time. If your program does not output 258 | strings generated by other programs, (which includes strings received 259 | from http APIs!) and the program is invoked repeatedly instead of 260 | running for a while, you may want to skip ``fix_...`` options. 261 | 262 | And that’s about it for three-line examples! 263 | 264 | You can start your scripts with ``af.init(fix_all=True)`` and use 265 | ``af()`` to wrap your strings, even if you ignore colors and styles, 266 | your program will display text correctly on most popular (and many 267 | obscure) terminals. 268 | 269 | Here’s the basic theme: 270 | 271 | .. figure:: https://user-images.githubusercontent.com/23116/40860445-e69d057a-65d4-11e8-9926-228beaf3c429.png 272 | :alt: 09-basic-palette 273 | 274 | 09-basic-palette 275 | 276 | But there’s more! 277 | 278 | Your users have the ability to define their own themes, and autopalette 279 | will automatically\* recolor your application to their preferences or 280 | needs. (*mostly automatically, or with a little help.) 281 | 282 | .. code:: text 283 | 284 | # ~/.autopalette 285 | 286 | palette = Dutron 287 | render = Truecolor 288 | 289 | .. figure:: https://user-images.githubusercontent.com/23116/40860487-0589dd50-65d5-11e8-9360-2fb29a2d213e.png 290 | :alt: 10-restricted-color-palette 291 | 292 | 10-restricted-color-palette 293 | 294 | Your terminal applications look beautiful as you intend, to everyone, as 295 | they expect. 296 | 297 | It is almost two decades since Y2K! And with over 50 years of the 298 | terminal technology behind us, this should be a thing we expect as a 299 | norm. 300 | 301 | Autopalette is another attempt at fixing some of these gaps by making it 302 | near trivial to style terminal apps and do the right thing for the 303 | various terminals it runs on… without the complexity often involved as a 304 | result of the rich legacy of the technology. 305 | 306 | Autopalette would not dare exist without the libraries published by 307 | these generous individuals who made it possible to think and write code 308 | in simple mental models that are just right for the task: 309 | 310 | - ``colorhash``: Felix Krull (https://pypi.org/project/colorhash/) 311 | - ``colortrans.py``: Micah Elliott 312 | (https://gist.github.com/MicahElliott/719710/) 313 | - ``colour``: Valentin LAB (https://pypi.org/project/colour/) 314 | - ``emoji2text``: Sam CB (https://pypi.org/project/emoji2text/) 315 | - ``ftfy``: Rob Speer / Luminoso (https://pypi.org/project/ftfy/) 316 | - ``kdtree``: Stefan Kögl (https://pypi.org/project/kdtree/) 317 | - ``sty``: Felix Meyer-Wolters (https://pypi.org/project/sty/) 318 | --------------------------------------------------------------------------------