Repository: ManiacalLabs/BiblioPixel
Pull request #1068 (2018-11-21 17:07:28)

User color tables
User palettes have been sufficiently successful that I added user colors too.

Colors go off before palettes so you can use your custom colors in your custom palettes...


Patch:
diff --git a/bibliopixel/colors/names.py b/bibliopixel/colors/names.py
index 93931b1..8c8d63f 100644
--- a/bibliopixel/colors/names.py
+++ b/bibliopixel/colors/names.py
@@ -2,8 +2,9 @@
 Convert back and forth between colors and string names.
 """
 
-import numbers, re
-from . import juce, classic
+import numbers
+from . import tables
+from . tables import COLOR_DICT
 
 
 def name_to_color(name):
@@ -26,9 +27,12 @@ def name_to_color(name):
         try:
             n = _from_number(name)
         except:
-            return TO_COLOR[name.replace(' ', '').lower()]
+            color = tables.get_color(name)
+            if color:
+                return color
+            raise ValueError
 
-        return _to_triplet(n)
+        return tables.to_triplet(n)
 
     try:
         color = to_color(name)
@@ -57,10 +61,7 @@ def color_to_name(color, use_hex=False):
     if use_hex:
         return '#%02x%02x%02x' % color
 
-    try:
-        return TO_NAME[color]
-    except:
-        return str(color)
+    return tables.get_name(color) or str(color)
 
 
 def toggle(s):
@@ -96,24 +97,6 @@ class _Colors(object):
         return color_to_name(color)
 
 
-def _to_triplet(color):
-    rg, b = color // 256, color % 256
-    r, g = rg // 256, rg % 256
-    return r, g, b
-
-
-def _classic_pairs():
-    find = re.compile(r'[A-Z][a-z0-9]+').finditer
-
-    for name in dir(classic):
-        if name == 'Green_HTML':
-            yield 'green html', classic.Green_HTML
-
-        elif len(name) > 1 and name[0].isupper() and name[1].islower():
-            key = ' '.join(i.group(0).lower() for i in find(name))
-            yield key, getattr(classic, name)
-
-
 def _from_number(s):
     s = s.strip()
     for prefix in '0x', '#':
@@ -170,18 +153,3 @@ To get a name from a color, use
     COLOR((0, 255, 255))
 """
 COLORS = _Colors()
-
-
-_JUCE_COLORS = {k: _to_triplet(v) for k, v in juce.COLORS.items()}
-_CLASSIC_COLORS = dict(_classic_pairs())
-
-"""
-A dictionary of every color by name.
-"""
-
-COLOR_DICT = dict(_CLASSIC_COLORS, **_JUCE_COLORS)
-
-# TODO: integrate this better with COLOR
-_SECONDARY_NAMES = juce.SECONDARY_NAMES.union({'off', 'on'})
-TO_NAME = {v: k for k, v in COLOR_DICT.items() if k not in _SECONDARY_NAMES}
-TO_COLOR = {k.replace(' ', ''): v for k, v in COLOR_DICT.items()}
diff --git a/bibliopixel/colors/tables.py b/bibliopixel/colors/tables.py
new file mode 100644
index 0000000..b2d3a55
--- /dev/null
+++ b/bibliopixel/colors/tables.py
@@ -0,0 +1,65 @@
+"""
+Tables of named colors
+"""
+
+import re
+from . import juce, classic
+
+
+def canonical_name(name):
+    return name.replace(' ', '').lower()
+
+
+def to_triplet(color):
+    rg, b = color // 256, color % 256
+    r, g = rg // 256, rg % 256
+    return r, g, b
+
+
+def set_user_colors(colors):
+    from . import names
+
+    _TO_NAME_USER.clear()
+    _TO_COLOR_USER.clear()
+
+    for name, color in colors.items():
+        color = names.to_color(color)
+        _TO_NAME_USER[color] = name
+        _TO_COLOR_USER[canonical_name(name)] = color
+
+
+def get_color(name):
+    name = canonical_name(name)
+    return _TO_COLOR_USER.get(name) or _TO_COLOR.get(name)
+
+
+def get_name(color):
+    return _TO_NAME_USER.get(color) or _TO_NAME.get(color)
+
+
+def _classic_pairs():
+    find = re.compile(r'[A-Z][a-z0-9]+').finditer
+
+    for name in dir(classic):
+        if name == 'Green_HTML':
+            yield 'green html', classic.Green_HTML
+
+        elif len(name) > 1 and name[0].isupper() and name[1].islower():
+            key = ' '.join(i.group(0).lower() for i in find(name))
+            yield key, getattr(classic, name)
+
+
+_JUCE_COLORS = {k: to_triplet(v) for k, v in juce.COLORS.items()}
+_CLASSIC_COLORS = dict(_classic_pairs())
+
+"""
+A dictionary of every color by name.
+"""
+
+COLOR_DICT = dict(_CLASSIC_COLORS, **_JUCE_COLORS)
+
+_SECONDARY_NAMES = juce.SECONDARY_NAMES.union({'off', 'on'})
+_TO_NAME = {v: k for k, v in COLOR_DICT.items() if k not in _SECONDARY_NAMES}
+_TO_COLOR = {k.replace(' ', ''): v for k, v in COLOR_DICT.items()}
+_TO_NAME_USER = {}
+_TO_COLOR_USER = {}
diff --git a/bibliopixel/project/fill.py b/bibliopixel/project/fill.py
index 19a00fe..abffaf3 100644
--- a/bibliopixel/project/fill.py
+++ b/bibliopixel/project/fill.py
@@ -7,7 +7,7 @@ import copy
 from . import aliases, alias_lists, construct, merge
 from .. import layout
 from .. util import deprecated, exception, log
-from .. colors import make, palettes
+from .. colors import make, palettes, tables
 from .. animation.strip import Strip
 
 DEFAULT_DRIVERS = [construct.to_type('simpixel')]
@@ -15,6 +15,7 @@ DEFAULT_DRIVERS = [construct.to_type('simpixel')]
 
 def fill(desc):
     desc = _fill_aliases(desc)
+    desc = _fill_colors(desc)
     desc = _fill_palettes(desc)
     desc = _fill_animation(desc)
     desc = _fill_drivers(desc)
@@ -48,6 +49,11 @@ def _fill_aliases(desc):
     return desc
 
 
+def _fill_colors(desc):
+    tables.set_user_colors(desc.pop('colors', {}))
+    return desc
+
+
 def _fill_palettes(desc):
     p = desc.pop('palettes', None)
     if p:
diff --git a/bibliopixel/project/merge.py b/bibliopixel/project/merge.py
index 2f51cd6..52018c2 100644
--- a/bibliopixel/project/merge.py
+++ b/bibliopixel/project/merge.py
@@ -7,6 +7,7 @@ from . import construct, load
 # layout, which depend on drivers, and everything depends on path and typename.
 DEFAULT_PROJECT = collections.OrderedDict((
     ('aliases', {'bpa': 'BiblioPixelAnimations'}),
+    ('colors', {}),
     ('palettes', {}),
     ('path', ''),
     ('typename', 'bibliopixel.project.project.Project'),
diff --git a/doc/tutorial/projects/project-fields.rst b/doc/tutorial/projects/project-fields.rst
index ba1e0ed..ac957af 100644
--- a/doc/tutorial/projects/project-fields.rst
+++ b/doc/tutorial/projects/project-fields.rst
@@ -12,9 +12,9 @@ The Project files above contain three Sections: ``shape``, ``run``, and
 ``animation``, and the ``animation`` Section contains two Fields, ``typename``
 and ``colors``.
 
-The Project Sections are ``aliases``, ``animation``, ``controls``, ``driver``,
-``drivers``, ``layout``, ``numbers``, ``path``, ``run``, and ``shape``... but
-you will probably never use many of these.
+The Project Sections are ``aliases``, ``animation``, ``colors``, ``controls``,
+``driver``, ``drivers``, ``layout``, ``numbers``, ``palettes``, ``path``,
+``run``, and ``shape``... but you might never use many of these.
 
 Sections can have Fields - for example, the ``run`` Section above has the Field
 ``fps: 60``.
@@ -41,8 +41,8 @@ Sections naturally fall into two categories.
 *Value Sections* are fairly simple things like strings, numbers, lists, or
 dictionaries.
 
-The Value Sections are ``aliases``, ``numbers``, ``palettes``, ``path``,
-``run``, and ``shape``.
+The Value Sections are ``aliases``, ``colors``, ``numbers``, ``palettes``,
+``path``, ``run``, and ``shape``.
 
 *Class Sections* represent Python objects - actual programs. Nearly all the
 excitement in BiblioPixel is in the Class Sections!
diff --git a/doc/tutorial/projects/project-sections.rst b/doc/tutorial/projects/project-sections.rst
index 6412581..f11d317 100644
--- a/doc/tutorial/projects/project-sections.rst
+++ b/doc/tutorial/projects/project-sections.rst
@@ -31,6 +31,10 @@ Value Sections
     A dictionary of aliases that are expanded in ``typename`` fields
     to save repetition in Project files.
 
+``colors``
+    A dictionary of named Colors.  Colors can be named in many ways - see the
+    section on Color Fields later.
+
 ``numbers``
     Selects between plain old Python lists and faster, more powerful ``numpy``
     lists.


Test patch:
diff --git a/test/bibliopixel/util/colors/closest_colors_test.py b/test/bibliopixel/colors/closest_colors_test.py
similarity index 100%
rename from test/bibliopixel/util/colors/closest_colors_test.py
rename to test/bibliopixel/colors/closest_colors_test.py
diff --git a/test/bibliopixel/colors/color_test.py b/test/bibliopixel/colors/color_test.py
index 4eab84c..9ab52a4 100644
--- a/test/bibliopixel/colors/color_test.py
+++ b/test/bibliopixel/colors/color_test.py
@@ -1,6 +1,6 @@
 import unittest
 
-from bibliopixel.colors import COLORS, arithmetic, classic, names
+from bibliopixel.colors import COLORS, arithmetic, classic, names, tables
 
 
 class ArithmeticTest(unittest.TestCase):
@@ -23,8 +23,8 @@ class ArithmeticTest(unittest.TestCase):
 class ClassicTest(unittest.TestCase):
     def test_classic(self):
         # Make sure that the classic and new color names are the same.
-        cc = names._CLASSIC_COLORS
-        jc = names._JUCE_COLORS
+        cc = tables._CLASSIC_COLORS
+        jc = tables._JUCE_COLORS
         for name, color in cc.items():
             self.assertEqual(color, jc.get(name, color), name)
 
diff --git a/test/bibliopixel/util/colors/legacy_palette_test.py b/test/bibliopixel/colors/legacy_palette_test.py
similarity index 100%
rename from test/bibliopixel/util/colors/legacy_palette_test.py
rename to test/bibliopixel/colors/legacy_palette_test.py
diff --git a/test/bibliopixel/util/colors/names_test.py b/test/bibliopixel/colors/names_test.py
similarity index 100%
rename from test/bibliopixel/util/colors/names_test.py
rename to test/bibliopixel/colors/names_test.py
diff --git a/test/bibliopixel/util/colors/palette_test.py b/test/bibliopixel/colors/palette_test.py
similarity index 100%
rename from test/bibliopixel/util/colors/palette_test.py
rename to test/bibliopixel/colors/palette_test.py
diff --git a/test/bibliopixel/util/colors/palettes_test.py b/test/bibliopixel/colors/palettes_test.py
similarity index 100%
rename from test/bibliopixel/util/colors/palettes_test.py
rename to test/bibliopixel/colors/palettes_test.py
diff --git a/test/bibliopixel/colors/tables_test.py b/test/bibliopixel/colors/tables_test.py
new file mode 100644
index 0000000..abe6179
--- /dev/null
+++ b/test/bibliopixel/colors/tables_test.py
@@ -0,0 +1,41 @@
+import unittest
+from bibliopixel.colors import tables
+
+
+class TablesTest(unittest.TestCase):
+    def test_simple(self):
+        self.assertEqual(tables.get_color('RED'), (255, 0, 0))
+        self.assertEqual(tables.get_name((255, 0, 0)), 'red')
+        self.assertIs(tables.get_color('rod'), None)
+
+    def test_new_colors(self):
+        self.assertIs(tables.get_color('frog'), None)
+
+        frog = 1, 255, 2
+        tables.set_user_colors({'Frog': frog})
+        try:
+            self.assertEqual(tables.get_color('RED'), (255, 0, 0))
+            self.assertEqual(tables.get_name((255, 0, 0)), 'red')
+            self.assertEqual(tables.get_color('frog'), frog)
+            self.assertEqual(tables.get_name(frog), 'Frog')
+        finally:
+            tables.set_user_colors({})
+
+        self.assertIs(tables.get_color('frog'), None)
+
+    def test_override_colors(self):
+        self.assertEqual(tables.get_color('RED'), (255, 0, 0))
+        self.assertEqual(tables.get_name((255, 0, 0)), 'red')
+
+        wrong_red = 6, 6, 6
+        tables.set_user_colors({'red': wrong_red})
+        try:
+            self.assertEqual(tables.get_color('RED'), wrong_red)
+            self.assertEqual(tables.get_name(wrong_red), 'red')
+            # Ooops! but what else to do?
+            self.assertEqual(tables.get_name((255, 0, 0)), 'red')
+        finally:
+            tables.set_user_colors({})
+
+        self.assertEqual(tables.get_color('RED'), (255, 0, 0))
+        self.assertEqual(tables.get_name((255, 0, 0)), 'red')
diff --git a/test/bibliopixel/project/fill_test.py b/test/bibliopixel/project/fill_test.py
index 3be67af..7fa26f7 100644
--- a/test/bibliopixel/project/fill_test.py
+++ b/test/bibliopixel/project/fill_test.py
@@ -67,6 +67,7 @@ class FillTest(unittest.TestCase):
                 'datatype': matrix.Matrix,
             },
             'numbers': 'python',
+            'colors': {},
             'palettes': {},
             'run': {},
         }
diff --git a/test/bibliopixel/project/merge_test.py b/test/bibliopixel/project/merge_test.py
index e356a68..81b9ffc 100644
--- a/test/bibliopixel/project/merge_test.py
+++ b/test/bibliopixel/project/merge_test.py
@@ -41,6 +41,7 @@ class MergeTest(unittest.TestCase):
                 'numpy_dtype': 'float',
                 'typename': 'bibliopixel.project.data_maker.Maker'
             },
+            'colors': {},
             'palettes': {},
             'path': 'path/to/dir',
             'run': {},
diff --git a/test/bibliopixel/project/project_test.py b/test/bibliopixel/project/project_test.py
index 016e9e5..cae5aa6 100644
--- a/test/bibliopixel/project/project_test.py
+++ b/test/bibliopixel/project/project_test.py
@@ -3,7 +3,7 @@ from unittest import mock
 
 from . make import make
 from bibliopixel.animation import animation
-from bibliopixel.colors import gamma
+from bibliopixel.colors import gamma, tables
 from bibliopixel.drivers.ledtype import LEDTYPE
 
 BAD_JSON_ERROR = """
@@ -108,6 +108,16 @@ class ProjectTest(unittest.TestCase):
     def test_nested_sequence(self):
         make(PROJECT_NESTED_SEQUENCE, run_start=False)
 
+    def test_project_colors(self):
+        try:
+            make(PROJECT_COLORS, run_start=False)
+            self.assertEqual(tables.get_color('bland'), (1, 2, 3))
+            self.assertEqual(tables.get_name((3, 2, 1)), 'exciting!!')
+            self.assertIs(tables.get_color('exciting'), None)
+        finally:
+            tables.set_user_colors({})
+        self.assertEqual(tables.get_color('bland'), None)
+
 
 PROJECT = """
 {
@@ -368,6 +378,14 @@ animation:
         - $bpa.matrix.bloom
 """
 
+PROJECT_COLORS = """
+shape: 32
+animation: .tests.StripChannelTest
+colors:
+  bland: [1, 2, 3]
+  'exciting!!': [3, 2, 1]
+"""
+
 
 class AnimationTest(animation.Animation):
     PROJECT = {