1# This file is part of MyPaint.
2# Copyright (C) 2013-2018 by the MyPaint Development Team
3#
4# This program is free software; you can redistribute it and/or modify
5# it under the terms of the GNU General Public License as published by
6# the Free Software Foundation; either version 2 of the License, or
7# (at your option) any later version.
8
9
10"""Palette: user-defined lists of color swatches"""
11
12# TODO: Make palettes part of the model, save as part of ORA documents.
13
14
15## Imports
16
17from __future__ import division, print_function
18
19import re
20from copy import copy
21import logging
22
23from lib.helpers import clamp
24from lib.observable import event
25from lib.color import RGBColor
26from lib.color import YCbCrColor
27from lib.color import UIColor  # noqa
28from lib.pycompat import unicode
29from lib.pycompat import xrange
30from lib.pycompat import PY3
31from io import open
32
33logger = logging.getLogger(__name__)
34
35## Class and function defs
36
37
38class Palette (object):
39    """A flat list of color swatches, compatible with the GIMP
40
41    As a (sideways-compatible) extension to the GIMP's format, MyPaint supports
42    empty slots in the palette. These slots are represented by pure black
43    swatches with the name ``__NONE__``.
44
45    Palette objects expose the position within the palette of a current color
46    match, which can be declared to be approximate or exact. This is used for
47    highlighting the user concept of the "current color" in the GUI.
48
49    Palette objects can be serialized in the GIMP's file format (the regular
50    `unicode()` function on a Palette will do this too), or converted to and
51    from a simpler JSON-ready representation for storing in the MyPaint prefs.
52    Support for loading and saving via modal dialogs is defined here too.
53
54    """
55
56    ## Class-level constants
57    _EMPTY_SLOT_ITEM = RGBColor(-1, -1, -1)
58    _EMPTY_SLOT_NAME = "__NONE__"
59
60    ## Construction, loading and saving
61
62    def __init__(self, filehandle=None, filename=None, colors=None):
63        """Instantiate, from a file or a sequence of colors
64
65        :param filehandle: Filehandle to load.
66        :param filename: Name of a file to load.
67        :param colors: Iterable sequence of colors (lib.color.UIColor).
68
69        The constructor arguments are mutually exclusive.  With no args
70        specified, you get an empty palette.
71
72          >>> Palette()
73          <Palette colors=0, columns=0, name=None>
74
75        Palettes can be generated from interpolations, which is handy for
76        testing, at least.
77
78          >>> cols = RGBColor(1,1,0).interpolate(RGBColor(1,0,1), 10)
79          >>> Palette(colors=cols)
80          <Palette colors=10, columns=0, name=None>
81
82        """
83        super(Palette, self).__init__()
84
85        #: Number of columns. 0 means "natural flow"
86        self._columns = 0
87        #: List of named colors
88        self._colors = []
89        #: Name of the palette as a Unicode string, or None
90        self._name = None
91        #: Current position in the palette. None=no match; integer=index.
92        self._match_position = None
93        #: True if the current match is approximate
94        self._match_is_approx = False
95
96        # Clear and initialize
97        self.clear(silent=True)
98        if colors:
99            for col in colors:
100                col = self._copy_color_in(col)
101                self._colors.append(col)
102        elif filehandle:
103            self.load(filehandle, silent=True)
104        elif filename:
105            with open(filename, "r", encoding="utf-8", errors="replace") as fp:
106                self.load(fp, silent=True)
107
108    def clear(self, silent=False):
109        """Resets the palette to its initial state.
110
111          >>> grey16 = RGBColor(1,1,1).interpolate(RGBColor(0,0,0), 16)
112          >>> p = Palette(colors=grey16)
113          >>> p.name = "Greyscale"
114          >>> p.columns = 3
115          >>> p                               # doctest: +ELLIPSIS
116          <Palette colors=16, columns=3, name=...'Greyscale'>
117          >>> p.clear()
118          >>> p
119          <Palette colors=0, columns=0, name=None>
120
121        Fires the `info_changed()`, `sequence_changed()`, and `match_changed()`
122        events, unless the `silent` parameter tests true.
123        """
124        self._colors = []
125        self._columns = 0
126        self._name = None
127        self._match_position = None
128        self._match_is_approx = False
129        if not silent:
130            self.info_changed()
131            self.sequence_changed()
132            self.match_changed()
133
134    def load(self, filehandle, silent=False):
135        """Load contents from a file handle containing a GIMP palette.
136
137        :param filehandle: File-like object (.readline, line iteration)
138        :param bool silent: If true, don't emit any events.
139
140        >>> pal = Palette()
141        >>> with open("palettes/MyPaint_Default.gpl", "r") as fp:
142        ...     pal.load(fp)
143        >>> len(pal) > 1
144        True
145
146        If the file format is incorrect, a RuntimeError will be raised.
147
148        """
149        comment_line_re = re.compile(r'^#')
150        field_line_re = re.compile(r'^(\w+)\s*:\s*(.*)$')
151        color_line_re = re.compile(r'^(\d+)\s+(\d+)\s+(\d+)\s*(?:\b(.*))$')
152        fp = filehandle
153        self.clear(silent=True)   # method fires events itself
154        line = fp.readline()
155        if line.strip() != "GIMP Palette":
156            raise RuntimeError("Not a valid GIMP Palette")
157        header_done = False
158        line_num = 0
159        for line in fp:
160            line = line.strip()
161            line_num += 1
162            if line == '':
163                continue
164            if comment_line_re.match(line):
165                continue
166            if not header_done:
167                match = field_line_re.match(line)
168                if match:
169                    key, value = match.groups()
170                    key = key.lower()
171                    if key == 'name':
172                        self._name = value.strip()
173                    elif key == 'columns':
174                        self._columns = int(value)
175                    else:
176                        logger.warning("Unknown 'key:value' pair %r", line)
177                    continue
178                else:
179                    header_done = True
180            match = color_line_re.match(line)
181            if not match:
182                logger.warning("Expected 'R G B [Name]', not %r", line)
183                continue
184            r, g, b, col_name = match.groups()
185            col_name = col_name.strip()
186            r = clamp(int(r), 0, 0xff) / 0xff
187            g = clamp(int(g), 0, 0xff) / 0xff
188            b = clamp(int(b), 0, 0xff) / 0xff
189            if r == g == b == 0 and col_name == self._EMPTY_SLOT_NAME:
190                self.append(None)
191            else:
192                col = RGBColor(r, g, b)
193                col.__name = col_name
194                self._colors.append(col)
195        if not silent:
196            self.info_changed()
197            self.sequence_changed()
198            self.match_changed()
199
200    def save(self, filehandle):
201        """Saves the palette to an open file handle.
202
203        :param filehandle: File-like object (.write suffices)
204
205        >>> from lib.pycompat import PY3
206        >>> if PY3:
207        ...     from io import StringIO
208        ... else:
209        ...     from cStringIO import StringIO
210        >>> fp = StringIO()
211        >>> cols = RGBColor(1,.7,0).interpolate(RGBColor(.1,.1,.5), 16)
212        >>> pal = Palette(colors=cols)
213        >>> pal.save(fp)
214        >>> fp.getvalue() == unicode(pal)
215        True
216
217        The file handle is not flushed, and is left open after the
218        write.
219
220        >>> fp.flush()
221        >>> fp.close()
222
223        """
224        filehandle.write(unicode(self))
225
226    def update(self, other):
227        """Updates all details of this palette from another palette.
228
229        Fires the `info_changed()`, `sequence_changed()`, and `match_changed()`
230        events.
231        """
232        self.clear(silent=True)
233        for col in other._colors:
234            col = self._copy_color_in(col)
235            self._colors.append(col)
236        self._name = other._name
237        self._columns = other._columns
238        self.info_changed()
239        self.sequence_changed()
240        self.match_changed()
241
242    ## Palette size and metadata
243
244    def get_columns(self):
245        """Get the number of columns (0 means unspecified)."""
246        return self._columns
247
248    def set_columns(self, n):
249        """Set the number of columns (0 means unspecified)."""
250        self._columns = int(n)
251        self.info_changed()
252
253    def get_name(self):
254        """Gets the palette's name."""
255        return self._name
256
257    def set_name(self, name):
258        """Sets the palette's name."""
259        if name is not None:
260            name = unicode(name)
261        self._name = name
262        self.info_changed()
263
264    def __bool__(self):
265        """Palettes never test false, regardless of their length.
266
267        >>> p = Palette()
268        >>> bool(p)
269        True
270
271        """
272        return True
273
274    def __len__(self):
275        """Palette length is the number of color slots within it."""
276        return len(self._colors)
277
278    ## PY2/PY3 compat
279
280    __nonzero__ = __bool__
281
282    ## Match position marker
283
284    def get_match_position(self):
285        """Return the position of the current match (int or None)"""
286        return self._match_position
287
288    def set_match_position(self, i):
289        """Sets the position of the current match (int or None)
290
291        Fires `match_changed()` if the value is changed."""
292        if i is not None:
293            i = int(i)
294            if i < 0 or i >= len(self):
295                i = None
296        if i != self._match_position:
297            self._match_position = i
298            self.match_changed()
299
300    def get_match_is_approx(self):
301        """Returns whether the current match is approximate."""
302        return self._match_is_approx
303
304    def set_match_is_approx(self, approx):
305        """Sets whether the current match is approximate
306
307        Fires match_changed() if the boolean value changes."""
308        approx = bool(approx)
309        if approx != self._match_is_approx:
310            self._match_is_approx = approx
311            self.match_changed()
312
313    def match_color(self, col, exact=False, order=None):
314        """Moves the match position to the color closest to the argument.
315
316        :param col: The color to match.
317        :type col: lib.color.UIColor
318        :param exact: Only consider exact matches, and not near-exact or
319                approximate matches.
320        :type exact: bool
321        :param order: a search order to use. Default is outwards from the
322                match position, or in order if the match is unset.
323        :type order: sequence or iterator of integer color indices.
324        :returns: Whether the match succeeded.
325        :rtype: bool
326
327        By default, the matching algorithm favours exact or near-exact matches
328        which are close to the current position. If the current position is
329        unset, this search starts at 0. If there are no exact or near-exact
330        matches, a looser approximate match will be used, again favouring
331        matches with nearby positions.
332
333          >>> red2blue = RGBColor(1, 0, 0).interpolate(RGBColor(0, 1, 1), 5)
334          >>> p = Palette(colors=red2blue)
335          >>> p.match_color(RGBColor(0.45, 0.45, 0.45))
336          True
337          >>> p.match_position
338          2
339          >>> p.match_is_approx
340          True
341          >>> p[p.match_position]
342          <RGBColor r=0.5000, g=0.5000, b=0.5000>
343          >>> p.match_color(RGBColor(0.5, 0.5, 0.5))
344          True
345          >>> p.match_is_approx
346          False
347          >>> p.match_color(RGBColor(0.45, 0.45, 0.45), exact=True)
348          False
349          >>> p.match_color(RGBColor(0.5, 0.5, 0.5), exact=True)
350          True
351
352        Fires the ``match_changed()`` event when changes happen.
353        """
354        if order is not None:
355            search_order = order
356        elif self.match_position is not None:
357            search_order = _outwards_from(len(self), self.match_position)
358        else:
359            search_order = xrange(len(self))
360        bestmatch_i = None
361        bestmatch_d = None
362        is_approx = True
363        for i in search_order:
364            c = self._colors[i]
365            if c is self._EMPTY_SLOT_ITEM:
366                continue
367            # Closest exact or near-exact match by index distance (according to
368            # the search_order). Considering near-exact matches as equivalent
369            # to exact matches improves the feel of PaletteNext and
370            # PalettePrev.
371            if exact:
372                if c == col:
373                    bestmatch_i = i
374                    is_approx = False
375                    break
376            else:
377                d = _color_distance(col, c)
378                if c == col or d < 0.06:
379                    bestmatch_i = i
380                    is_approx = False
381                    break
382                if bestmatch_d is None or d < bestmatch_d:
383                    bestmatch_i = i
384                    bestmatch_d = d
385            # Measuring over a blend into solid equiluminant 0-chroma
386            # grey for the orange #DA5D2E with an opaque but feathered
387            # brush made huge, and picking just inside the point where the
388            # palette widget begins to call it approximate:
389            #
390            # 0.05 is a difference only discernible (to me) by tilting LCD
391            # 0.066 to 0.075 appears slightly greyer for large areas
392            # 0.1 and above is very clearly distinct
393
394        # If there are no exact or near-exact matches, choose the most similar
395        # color anywhere in the palette.
396        if bestmatch_i is not None:
397            self._match_position = bestmatch_i
398            self._match_is_approx = is_approx
399            self.match_changed()
400            return True
401        return False
402
403    def move_match_position(self, direction, refcol):
404        """Move the match position in steps, matching first if needed.
405
406        :param direction: Direction for moving, positive or negative
407        :type direction: int:, ``1`` or ``-1``
408        :param refcol: Reference color, used for initial matching when needed.
409        :type refcol: UIColor
410        :returns: the color newly matched, if the match position has changed
411        :rtype: UIColor|NoneType
412
413        Invoking this method when there's no current match position will select
414        the color that's closest to the reference color, just like
415        `match_color()`
416
417        >>> greys = RGBColor(1,1,1).interpolate(RGBColor(0,0,0), 16)
418        >>> pal = Palette(colors=greys)
419        >>> refcol = RGBColor(0.5, 0.55, 0.45)
420        >>> pal.move_match_position(-1, refcol)
421        >>> pal.match_position
422        7
423        >>> pal.match_is_approx
424        True
425
426        When the current match is defined, but only an approximate match, this
427        method converts it to an exact match but does not change its position.
428
429          >>> pal.move_match_position(-1, refcol) is None
430          False
431          >>> pal.match_position
432          7
433          >>> pal.match_is_approx
434          False
435
436        When the match is initially exact, its position is stepped in the
437        direction indicated, either by +1 or -1. Blank palette entries are
438        skipped.
439
440          >>> pal.move_match_position(-1, refcol) is None
441          False
442          >>> pal.match_position
443          6
444          >>> pal.match_is_approx
445          False
446
447        Fires ``match_position_changed()`` and ``match_is_approx_changed()`` as
448        appropriate.  The return value is the newly matched color whenever this
449        method produces a new exact match.
450
451        """
452        # Normalize direction
453        direction = int(direction)
454        if direction < 0:
455            direction = -1
456        elif direction > 0:
457            direction = 1
458        else:
459            return None
460        # If nothing is selected, pick the closest match without changing
461        # the managed color.
462        old_pos = self._match_position
463        if old_pos is None:
464            self.match_color(refcol)
465            return None
466        # Otherwise, refine the match, or step it in the requested direction.
467        new_pos = None
468        if self._match_is_approx:
469            # Make an existing approximate match concrete.
470            new_pos = old_pos
471        else:
472            # Index reflects a close or identical match.
473            # Seek in the requested direction, skipping empty entries.
474            pos = old_pos
475            assert direction != 0
476            pos += direction
477            while pos < len(self._colors) and pos >= 0:
478                if self._colors[pos] is not self._EMPTY_SLOT_ITEM:
479                    new_pos = pos
480                    break
481                pos += direction
482        # Update the palette index and the managed color.
483        result = None
484        if new_pos is not None:
485            col = self._colors[new_pos]
486            if col is not self._EMPTY_SLOT_ITEM:
487                result = self._copy_color_out(col)
488            self.set_match_position(new_pos)
489            self.set_match_is_approx(False)
490        return result
491
492    ## Property-style access for setters and getters
493
494    columns = property(get_columns, set_columns)
495    name = property(get_name, set_name)
496    match_position = property(get_match_position, set_match_position)
497    match_is_approx = property(get_match_is_approx, set_match_is_approx)
498
499    ## Color access
500
501    def _copy_color_out(self, col):
502        if col is self._EMPTY_SLOT_ITEM:
503            return None
504        result = RGBColor(color=col)
505        result.__name = col.__name
506        return result
507
508    def _copy_color_in(self, col, name=None):
509        if col is self._EMPTY_SLOT_ITEM or col is None:
510            result = self._EMPTY_SLOT_ITEM
511        else:
512            if name is None:
513                try:
514                    name = col.__name
515                except AttributeError:
516                    pass
517            if name is not None:
518                name = unicode(name)
519            result = RGBColor(color=col)
520            result.__name = name
521        return result
522
523    def append(self, col, name=None, unique=False, match=False):
524        """Appends a color, optionally setting a name for it.
525
526        :param col: The color to append.
527        :param name: Name of the color to insert.
528        :param unique: If true, don't append if the color already exists
529                in the palette. Only exact matches count.
530        :param match: If true, set the match position to the
531                appropriate palette entry.
532        """
533        col = self._copy_color_in(col, name)
534        if unique:
535            # Find the final exact match, if one is present
536            for i in xrange(len(self._colors)-1, -1, -1):
537                if col == self._colors[i]:
538                    if match:
539                        self._match_position = i
540                        self._match_is_approx = False
541                        self.match_changed()
542                    return
543        # Append new color, and select it if requested
544        end_i = len(self._colors)
545        self._colors.append(col)
546        if match:
547            self._match_position = end_i
548            self._match_is_approx = False
549            self.match_changed()
550        self.sequence_changed()
551
552    def insert(self, i, col, name=None):
553        """Inserts a color, setting an optional name for it.
554
555        :param i: Target index. `None` indicates appending a color.
556        :param col: Color to insert. `None` indicates an empty slot.
557        :param name: Name of the color to insert.
558
559          >>> grey16 = RGBColor(1, 1, 1).interpolate(RGBColor(0, 0, 0), 16)
560          >>> p = Palette(colors=grey16)
561          >>> p.insert(5, RGBColor(1, 0, 0), name="red")
562          >>> p
563          <Palette colors=17, columns=0, name=None>
564          >>> p[5]
565          <RGBColor r=1.0000, g=0.0000, b=0.0000>
566
567        Fires the `sequence_changed()` event. If the match position changes as
568        a result, `match_changed()` is fired too.
569
570        """
571        col = self._copy_color_in(col, name)
572        if i is None:
573            self._colors.append(col)
574        else:
575            self._colors.insert(i, col)
576            if self.match_position is not None:
577                if self.match_position >= i:
578                    self.match_position += 1
579        self.sequence_changed()
580
581    def reposition(self, src_i, targ_i):
582        """Moves a color, or copies it to empty slots, or moves it the end.
583
584        :param src_i: Source color index.
585        :param targ_i: Source color index, or None to indicate the end.
586
587        This operation performs a copy if the target is an empty slot, and a
588        remove followed by an insert if the target slot contains a color.
589
590          >>> grey16 = RGBColor(1, 1, 1).interpolate(RGBColor(0, 0, 0), 16)
591          >>> p = Palette(colors=grey16)
592          >>> p[5] = None           # creates an empty slot
593          >>> p.match_position = 8
594          >>> p[5] == p[0]
595          False
596          >>> p.reposition(0, 5)
597          >>> p[5] == p[0]
598          True
599          >>> p.match_position
600          8
601          >>> p[5] = RGBColor(1, 0, 0)
602          >>> p.reposition(14, 5)
603          >>> p.match_position     # continues pointing to the same color
604          9
605          >>> len(p)       # repositioning doesn't change the length
606          16
607
608        Fires the `color_changed()` event for copies to empty slots, or
609        `sequence_changed()` for moves. If the match position changes as a
610        result, `match_changed()` is fired too.
611
612        """
613        assert src_i is not None
614        if src_i == targ_i:
615            return
616        try:
617            col = self._colors[src_i]
618            assert col is not None  # just in case we change the internal repr
619        except IndexError:
620            return
621
622        # Special case: just copy if the target is an empty slot
623        match_pos = self.match_position
624        if targ_i is not None:
625            targ = self._colors[targ_i]
626            if targ is self._EMPTY_SLOT_ITEM:
627                self._colors[targ_i] = self._copy_color_in(col)
628                self.color_changed(targ_i)
629                # Copying from the matched color moves the match position.
630                # Copying to the match position clears the match.
631                if match_pos == src_i:
632                    self.match_position = targ_i
633                elif match_pos == targ_i:
634                    self.match_position = None
635                return
636
637        # Normal case. Remove...
638        self._colors.pop(src_i)
639        moving_match = False
640        updated_match = False
641        if match_pos is not None:
642            # Moving rightwards. Adjust for the pop().
643            if targ_i is not None and targ_i > src_i:
644                targ_i -= 1
645            # Similar logic for the match position, but allow it to follow
646            # the move if it started at the src position.
647            if match_pos == src_i:
648                match_pos = None
649                moving_match = True
650                updated_match = True
651            elif match_pos > src_i:
652                match_pos -= 1
653                updated_match = True
654        # ... then append or insert.
655        if targ_i is None:
656            self._colors.append(col)
657            if moving_match:
658                match_pos = len(self._colors) - 1
659                updated_match = True
660        else:
661            self._colors.insert(targ_i, col)
662            if match_pos is not None:
663                if moving_match:
664                    match_pos = targ_i
665                    updated_match = True
666                elif match_pos >= targ_i:
667                    match_pos += 1
668                    updated_match = True
669        # Announce changes
670        self.sequence_changed()
671        if updated_match:
672            self.match_position = match_pos
673            self.match_changed()
674
675    def pop(self, i):
676        """Removes a color, returning it.
677
678        Fires the `match_changed()` event if the match index changes as a
679        result of the removal, and `sequence_changed()` if a color was removed,
680        prior to its return.
681        """
682        i = int(i)
683        try:
684            col = self._colors.pop(i)
685        except IndexError:
686            return
687        if self.match_position == i:
688            self.match_position = None
689        elif self.match_position > i:
690            self.match_position -= 1
691        self.sequence_changed()
692        return self._copy_color_out(col)
693
694    def get_color(self, i):
695        """Looks up a color by its list index."""
696        if i is None:
697            return None
698        try:
699            col = self._colors[i]
700            return self._copy_color_out(col)
701        except IndexError:
702            return None
703
704    def __getitem__(self, i):
705        return self.get_color(i)
706
707    def __setitem__(self, i, col):
708        self._colors[i] = self._copy_color_in(col, None)
709        self.color_changed(i)
710
711    ## Color name access
712
713    def get_color_name(self, i):
714        """Looks up a color's name by its list index."""
715        try:
716            col = self._colors[i]
717        except IndexError:
718            return
719        if col is self._EMPTY_SLOT_ITEM:
720            return
721        return col.__name
722
723    def set_color_name(self, i, name):
724        """Sets a color's name by its list index."""
725        try:
726            col = self._colors[i]
727        except IndexError:
728            return
729        if col is self._EMPTY_SLOT_ITEM:
730            return
731        col.__name = name
732        self.color_changed(i)
733
734    def get_color_by_name(self, name):
735        """Looks up the first color with the given name.
736
737          >>> pltt = Palette()
738          >>> pltt.append(RGBColor(1,0,1), "Magenta")
739          >>> pltt.get_color_by_name("Magenta")
740          <RGBColor r=1.0000, g=0.0000, b=1.0000>
741
742        """
743        for col in self:
744            if col.__name == name:
745                return RGBColor(color=col)
746
747    def __iter__(self):
748        return self.iter_colors()
749
750    def iter_colors(self):
751        """Iterates across the palette's colors."""
752        for col in self._colors:
753            if col is self._EMPTY_SLOT_ITEM:
754                yield None
755            else:
756                yield col
757
758    ## Observable events
759
760    @event
761    def info_changed(self):
762        """Event: palette name, or number of columns was changed."""
763
764    @event
765    def match_changed(self):
766        """Event: either match position or match_is_approx was updated."""
767
768    @event
769    def sequence_changed(self):
770        """Event: the color ordering or palette length was changed."""
771
772    @event
773    def color_changed(self, i):
774        """Event: the color in the given slot, or its name, was modified."""
775
776    ## Dumping and cloning
777
778    def __unicode__(self):
779        """Py2-era serialization as a Unicode string.
780
781        Used by the Py3 __str__() while we are in transition.
782
783        """
784        result = u"GIMP Palette\n"
785        if self._name is not None:
786            result += u"Name: %s\n" % self._name
787        if self._columns > 0:
788            result += u"Columns: %d\n" % self._columns
789        result += u"#\n"
790        for col in self._colors:
791            if col is self._EMPTY_SLOT_ITEM:
792                col_name = self._EMPTY_SLOT_NAME
793                r = g = b = 0
794            else:
795                col_name = col.__name
796                r, g, b = [clamp(int(c*0xff), 0, 0xff) for c in col.get_rgb()]
797            if col_name is None:
798                result += u"%d %d %d\n" % (r, g, b)
799            else:
800                result += u"%d %d %d    %s\n" % (r, g, b, col_name)
801        return result
802
803    def __str__(self):
804        """Py3: serialize as str (=Unicode). Py2: as bytes (lossy!)."""
805        s = self.__unicode__()
806        if not PY3:
807            s = s.encode("utf-8", errors="replace")
808        return s
809
810    def __copy__(self):
811        clone = Palette()
812        clone.set_name(self.get_name())
813        clone.set_columns(self.get_columns())
814        for col in self._colors:
815            if col is self._EMPTY_SLOT_ITEM:
816                clone.append(None)
817            else:
818                clone.append(copy(col), col.__name)
819        return clone
820
821    def __deepcopy__(self, memo):
822        return self.__copy__()
823
824    def __repr__(self):
825        return "<Palette colors=%d, columns=%d, name=%r>" % (
826            len(self._colors),
827            self._columns,
828            self._name,
829        )
830
831    ## Conversion to/from simple dict representation
832
833    def to_simple_dict(self):
834        """Converts the palette to a simple dict form used in the prefs."""
835        simple = {}
836        simple["name"] = self.get_name()
837        simple["columns"] = self.get_columns()
838        entries = []
839        for col in self.iter_colors():
840            if col is None:
841                entries.append(None)
842            else:
843                name = col.__name
844                entries.append((col.to_hex_str(), name))
845        simple["entries"] = entries
846        return simple
847
848    @classmethod
849    def new_from_simple_dict(cls, simple):
850        """Constructs and returns a palette from the simple dict form."""
851        pal = cls()
852        pal.set_name(simple.get("name", None))
853        pal.set_columns(simple.get("columns", None))
854        for entry in simple.get("entries", []):
855            if entry is None:
856                pal.append(None)
857            else:
858                s, name = entry
859                col = RGBColor.new_from_hex_str(s)
860                pal.append(col, name)
861        return pal
862
863
864## Helper functions
865
866def _outwards_from(n, i):
867    """Search order within the palette, outwards from a given index.
868
869    Defined for a sequence of len() `n`, outwards from index `i`.
870    """
871    assert i < n and i >= 0
872    yield i
873    for j in xrange(n):
874        exhausted = True
875        if i - j >= 0:
876            yield i - j
877            exhausted = False
878        if i + j < n:
879            yield i + j
880            exhausted = False
881        if exhausted:
882            break
883
884
885def _color_distance(c1, c2):
886    """Distance metric for color matching in the palette.
887
888    Use a geometric YCbCr distance, as recommended by Graphics Programming with
889    Perl, chapter 1, Martien Verbruggen. If we want to give the chrominance
890    dimensions a different weighting to luma, we can.
891
892    """
893    c1 = YCbCrColor(color=c1)
894    c2 = YCbCrColor(color=c2)
895    d_cb = c1.Cb - c2.Cb
896    d_cr = c1.Cr - c2.Cr
897    d_y = c1.Y - c2.Y
898    return ((d_cb**2) + (d_cr**2) + (d_y)**2) ** (1.0/3)
899
900
901## Module testing
902
903
904if __name__ == '__main__':
905    logging.basicConfig(level=logging.DEBUG)
906    import doctest
907    doctest.testmod()
908