diff --git a/docs/source/trait_types.rst b/docs/source/trait_types.rst new file mode 100644 index 000000000..e4045c655 --- /dev/null +++ b/docs/source/trait_types.rst @@ -0,0 +1,158 @@ +=========== +Trait Types +=========== + +Pyface defines a number of custom Trait types that represent quantities and +objects that are useful in the context of graphical user interfaces. + +Colors +====== + +When working with user interfaces, it is common to want to be able to specify +the color to use in part of the UI. Each toolkit usually has its own way of +representing colors, and so the ability to specify a color in a +toolkit-independent way that can be converted to a toolkit-specific +representation is important. This is particularly so when you want to allow +the user to specify a color. + +Pyface provides a |Color| class and a corresponding |PyfaceColor| trait-type +that allows this sort of representation. Internally, the |Color| class +stores colors as a tuple of red, green, blue and alpha values which range +from 0.0 through to 1.0, inclusive. Helper properties allow the user to +specify individual channel values, as well as specify colors in alternate +color spaces, such as HSV or HLS:: + + Color(rgba=(0.4, 0.2, 0.6, 0.8)) + Color(red=0.4, green=0.2, blue=0.6, alpha=0.8) + Color(hls=(0.2, 0.5, 0.8)) + +|Color| instances can also be created via the |Color.from_str| method +which allow specification of colors via CSS-style color strings, such as:: + + Color.from_str("aquamarine") + Color.from_str("#624") + Color.from_str("hls(0.2, 0.5, 0.8)") + +All CSS color representations should work, and the method tries to be +flexible in what it will accept. + +|Color| instances are mutable, as their intended use is as values stored +in |PyfaceColor| trait classes which can be modified and listened to. The +|PyfaceColor| validator understands string descriptions of colors, and will +accept them as values when initializing or modifying the trait:: + + class Style(HasStrictTraits): + + color = PyfaceColor("#442266FF") + + @observe('color.rgba') + def color_changed(self, event): + print('The color has changed to {}'.format(self.color)) + + shape = Style(color='orange') + shape.color.blue = 0.8 + shape.color = "hsva(0.502, 0.502, 0.0, 0.8)" + +For interactions with the toolkit, the |Color.from_toolkit| and +|Color.to_toolkit| methods allow conversion to and from the appropriate +toolkit color objects, such as Qt's :py:class:`QColor` or +:py:class:`wx.Colour`. These are most likely to be needed by internal +Pyface functionality, and should not be needed by developers who are +building applications on top of Pyface. + + +Fonts +===== + +When working with fonts, there is the frequent difficulty that the desired +font may or may not be present in the environment where the application is +being run. Additionally, the way that fonts are specified in different +toolkits are, clearly, different, although there is a lot of commonality. +Finally, while it is clearly preferable to specify fonts via a concrete +and well-defined API, it is common for developers to specify fonts via +strings like '12pt "Comic Sans"' or "bold italic 24 Arial, Helvetica, Sans". + +Pyface defines a |Font| class that defines a toolkit-independent abstraction +of a font specification. This is not a particular, concrete font that can +be used to render text, but instead a specification for what font the +developer or user would like to use, but allowing for the backend toolkit to +be able to fall-back to other options, or substitute similar font faces +where it cannot exactly match the requirements. The attributes of the +font specification correspond closely to those that are described by CSS +fonts. In particular, a |Font| instance has the following traits: + +``family`` + A list of font family names in order of preference, such as "Helvetica" + or "Comic Sans". There are several generic font family names that can + be used as fall-backs in case all preferred fonts are unavailable. In + the case of a font that has been selected by the toolkit this list will + have one value which is the actual font family name. + +``weight`` + How thick or dark the font glyphs are. This value is specified by a + string, either of the form "100", "200", ..., "1000" or a number of + synonyms such as 'light' and 'bold' available for those values. + This is a mapped trait where ``weight_`` holds the corresponding + numerical value. + +``stretch`` + The amount of horizontal compression or expansion to apply to the glyphs. + These given by names such as 'condensed' and 'expanded', each of which is + mapped to a number between 100 and 900, available in the ``stretch_`` + mapped value. + +``style`` + This selects either 'oblique' or 'italic' variants typefaces of the given + font family. If neither is wanted, the value is 'normal'. + +``size`` + The overall size of the glyphs. This can be expressed either as the + numeric size in points, or as a string such as "small" or "large". + +``variants`` + A set of additional font style specifiers, such as "small-caps", + "strikethrough", "underline" or "overline", where supported by the + underlying toolkit. + +A |Font| object can be created in the usual way, by passing trait values as +keyword arguments, but there are classmethods :py:meth:`~!Font.from_toolkit` +and :py:meth:`~!Font.from_description` that create a |Font| from a toolkit +font specification object or a string description, respectively. + +The string specification follows CSS conventions: fonts are specfied by a +string which specifies the weight, stretch, style and variants by text +synonyms (in any order), followed by size in points and font family +preferences (quoted if not a single word) and separated by commas. +Where the value is "normal" it can be omitted from the description. + +For example:: + + 'italic bold 14pt Helvetica, Arial, sans-serif' + '36pt "Comic Sans"' + +are valid font descriptions, but "Helvetica bold 12pt" is not because the +order of elements is wrong. + +The |Font| object also has a method :py:meth:`~!Font.to_toolkit` that +produces a toolkit font specification, which is usually what controls and +other toolkit-specific code excpect to be given. + +While classes could simply use ``Instance(Font)`` whenever they want a +font specification, Pyface also provides a |PyfaceFont| trait type that +accepts either a |Font|, or a font description string. The value held +is always a |Font| object. This allows users to write code like:: + + class Style(HasStrictTraits): + font = PyfaceFont() + + style = Style(font='bold 10pt "Comic Sans"') + style.font = "italic 12pt Arial, Helvetic, sans-serif" + + +.. |Color| replace:: :py:class:`~pyface.color.Color` +.. |Color.from_str| replace:: :py:meth:`~pyface.color.Color.from_str` +.. |Color.from_toolkit| replace:: :py:meth:`~pyface.color.Color.from_toolkit` +.. |Color.to_toolkit| replace:: :py:meth:`~pyface.color.Color.to_toolkit` +.. |Font| replace:: :py:class:`~pyface.font.Font` +.. |PyfaceColor| replace:: :py:class:`~pyface.color.PyfaceColor` +.. |PyfaceFont| replace:: :py:class:`~pyface.font.PyfaceFont` diff --git a/pyface/api.py b/pyface/api.py index f4768d057..81c5b9897 100644 --- a/pyface/api.py +++ b/pyface/api.py @@ -14,11 +14,15 @@ from .application_window import ApplicationWindow from .beep import beep from .clipboard import clipboard, Clipboard +from .color import Color, PyfaceColor +from .color_dialog import ColorDialog, get_color from .confirmation_dialog import confirm, ConfirmationDialog from .constant import OK, CANCEL, YES, NO from .dialog import Dialog from .directory_dialog import DirectoryDialog from .file_dialog import FileDialog +from .font import Font, PyfaceFont +from .font_dialog import FontDialog, get_font from .filter import Filter from .gui import GUI from .gui_application import GUIApplication diff --git a/pyface/color.py b/pyface/color.py new file mode 100644 index 000000000..ebbf07a26 --- /dev/null +++ b/pyface/color.py @@ -0,0 +1,740 @@ +# (C) Copyright 2005-2020 Enthought, Inc., Austin, TX +# All rights reserved. +# +# This software is provided without warranty under the terms of the BSD +# license included in LICENSE.txt and may be redistributed only under +# the conditions described in the aforementioned license. The license +# is also available online at http://www.enthought.com/licenses/BSD.txt +# +# Thanks for using Enthought open source! + +""" Color classes and corresponding trait types for Pyface. + +The base Color class holds red, green, blue and alpha channel values as +a tuple of normalized values from 0.0 to 1.0. Various property traits +pull out the individual channel values and supply values for the HSV +and HSL colour spaces (with and without alpha). + +The ``from_str`` classmethod allows the creation of Color objects from +CSS-style strings, including all the CSS names colours, hex strings starting +with "#", and ``rgba()``-style functional notations. + +The ``from_toolkit`` and ``to_toolkit`` methods allow conversion to and +from native toolkit color objects. + +The PyfaceColor trait is a trait which holds a Color instance. The traits +also validate strings that are accepted by the ``from_str`` method and +Sequences or numpy arrays of values between 0.0 and 1.0 of length 3 or 4. + +A dictionary of named colours is available as the color_table module-level +dictionary. This dictionary holds all CSS colour names, plus a number of +other names such as "transparent". Additional names that give colours +useful for painting in widgets are also injected by the toolkits when they +are initialized. +""" + +from collections.abc import Sequence +import colorsys +import re + +try: + import numpy as np +except ImportError: + np = None + +from traits.api import ( + DefaultValue, HasStrictTraits, Property, Range, TraitType, Tuple, + cached_property +) + + +#: A dictionary mapping known color names to rgba tuples. +color_table = { + "aliceblue": (0.941, 0.973, 1.000, 1.0), + "antiquewhite": (0.980, 0.922, 0.843, 1.0), + "aqua": (0.000, 1.000, 1.000, 1.0), + "aquamarine": (0.498, 1.000, 0.831, 1.0), + "azure": (0.941, 1.000, 1.000, 1.0), + "beige": (0.961, 0.961, 0.863, 1.0), + "bisque": (1.000, 0.894, 0.769, 1.0), + "black": (0.000, 0.000, 0.000, 1.0), + "blanchedalmond": (1.000, 0.922, 0.804, 1.0), + "blue": (0.000, 0.000, 1.000, 1.0), + "blueviolet": (0.541, 0.169, 0.886, 1.0), + "brown": (0.647, 0.165, 0.165, 1.0), + "burlywood": (0.871, 0.722, 0.529, 1.0), + "cadetblue": (0.373, 0.620, 0.627, 1.0), + "chartreuse": (0.498, 1.000, 0.000, 1.0), + "chocolate": (0.824, 0.412, 0.118, 1.0), + "coral": (1.000, 0.498, 0.314, 1.0), + "cornflowerblue": (0.392, 0.584, 0.929, 1.0), + "cornsilk": (1.000, 0.973, 0.863, 1.0), + "crimson": (0.863, 0.078, 0.235, 1.0), + "cyan": (0.000, 1.000, 1.000, 1.0), + "darkblue": (0.000, 0.000, 0.545, 1.0), + "darkcyan": (0.000, 0.545, 0.545, 1.0), + "darkgoldenrod": (0.722, 0.525, 0.043, 1.0), + "darkgray": (0.663, 0.663, 0.663, 1.0), + "darkgreen": (0.000, 0.392, 0.000, 1.0), + "darkgrey": (0.663, 0.663, 0.663, 1.0), + "darkkhaki": (0.741, 0.718, 0.420, 1.0), + "darkmagenta": (0.545, 0.000, 0.545, 1.0), + "darkolivegreen": (0.333, 0.420, 0.184, 1.0), + "darkorange": (1.000, 0.549, 0.000, 1.0), + "darkorchid": (0.600, 0.196, 0.800, 1.0), + "darkred": (0.545, 0.000, 0.000, 1.0), + "darksalmon": (0.914, 0.588, 0.478, 1.0), + "darkseagreen": (0.561, 0.737, 0.561, 1.0), + "darkslateblue": (0.282, 0.239, 0.545, 1.0), + "darkslategray": (0.184, 0.310, 0.310, 1.0), + "darkslategrey": (0.184, 0.310, 0.310, 1.0), + "darkturquoise": (0.000, 0.808, 0.820, 1.0), + "darkviolet": (0.580, 0.000, 0.827, 1.0), + "deeppink": (1.000, 0.078, 0.576, 1.0), + "deepskyblue": (0.000, 0.749, 1.000, 1.0), + "dimgray": (0.412, 0.412, 0.412, 1.0), + "dimgrey": (0.412, 0.412, 0.412, 1.0), + "dodgerblue": (0.118, 0.565, 1.000, 1.0), + "firebrick": (0.698, 0.133, 0.133, 1.0), + "floralwhite": (1.000, 0.980, 0.941, 1.0), + "forestgreen": (0.133, 0.545, 0.133, 1.0), + "fuchsia": (1.000, 0.000, 1.000, 1.0), + "gainsboro": (0.863, 0.863, 0.863, 1.0), + "ghostwhite": (0.973, 0.973, 1.000, 1.0), + "gold": (1.000, 0.843, 0.000, 1.0), + "goldenrod": (0.855, 0.647, 0.125, 1.0), + "gray": (0.502, 0.502, 0.502, 1.0), + "green": (0.000, 0.502, 0.000, 1.0), + "greenyellow": (0.678, 1.000, 0.184, 1.0), + "grey": (0.502, 0.502, 0.502, 1.0), + "honeydew": (0.941, 1.000, 0.941, 1.0), + "hotpink": (1.000, 0.412, 0.706, 1.0), + "indianred": (0.804, 0.361, 0.361, 1.0), + "indigo": (0.294, 0.000, 0.510, 1.0), + "ivory": (1.000, 1.000, 0.941, 1.0), + "khaki": (0.941, 0.902, 0.549, 1.0), + "lavender": (0.902, 0.902, 0.980, 1.0), + "lavenderblush": (1.000, 0.941, 0.961, 1.0), + "lawngreen": (0.486, 0.988, 0.000, 1.0), + "lemonchiffon": (1.000, 0.980, 0.804, 1.0), + "lightblue": (0.678, 0.847, 0.902, 1.0), + "lightcoral": (0.941, 0.502, 0.502, 1.0), + "lightcyan": (0.878, 1.000, 1.000, 1.0), + "lightgoldenrodyellow": (0.980, 0.980, 0.824, 1.0), + "lightgray": (0.827, 0.827, 0.827, 1.0), + "lightgreen": (0.565, 0.933, 0.565, 1.0), + "lightgrey": (0.827, 0.827, 0.827, 1.0), + "lightpink": (1.000, 0.714, 0.757, 1.0), + "lightsalmon": (1.000, 0.627, 0.478, 1.0), + "lightseagreen": (0.125, 0.698, 0.667, 1.0), + "lightskyblue": (0.529, 0.808, 0.980, 1.0), + "lightslategray": (0.467, 0.533, 0.600, 1.0), + "lightslategrey": (0.467, 0.533, 0.600, 1.0), + "lightsteelblue": (0.690, 0.769, 0.871, 1.0), + "lightyellow": (1.000, 1.000, 0.878, 1.0), + "lime": (0.000, 1.000, 0.000, 1.0), + "limegreen": (0.196, 0.804, 0.196, 1.0), + "linen": (0.980, 0.941, 0.902, 1.0), + "magenta": (1.000, 0.000, 1.000, 1.0), + "maroon": (0.502, 0.000, 0.000, 1.0), + "mediumaquamarine": (0.400, 0.804, 0.667, 1.0), + "mediumblue": (0.000, 0.000, 0.804, 1.0), + "mediumorchid": (0.729, 0.333, 0.827, 1.0), + "mediumpurple": (0.576, 0.439, 0.859, 1.0), + "mediumseagreen": (0.235, 0.702, 0.443, 1.0), + "mediumslateblue": (0.482, 0.408, 0.933, 1.0), + "mediumspringgreen": (0.000, 0.980, 0.604, 1.0), + "mediumturquoise": (0.282, 0.820, 0.800, 1.0), + "mediumvioletred": (0.780, 0.082, 0.522, 1.0), + "midnightblue": (0.098, 0.098, 0.439, 1.0), + "mintcream": (0.961, 1.000, 0.980, 1.0), + "mistyrose": (1.000, 0.894, 0.882, 1.0), + "moccasin": (1.000, 0.894, 0.710, 1.0), + "navajowhite": (1.000, 0.871, 0.678, 1.0), + "navy": (0.000, 0.000, 0.502, 1.0), + "oldlace": (0.992, 0.961, 0.902, 1.0), + "olive": (0.502, 0.502, 0.000, 1.0), + "olivedrab": (0.420, 0.557, 0.137, 1.0), + "orange": (1.000, 0.647, 0.000, 1.0), + "orangered": (1.000, 0.271, 0.000, 1.0), + "orchid": (0.855, 0.439, 0.839, 1.0), + "palegoldenrod": (0.933, 0.910, 0.667, 1.0), + "palegreen": (0.596, 0.984, 0.596, 1.0), + "paleturquoise": (0.686, 0.933, 0.933, 1.0), + "palevioletred": (0.859, 0.439, 0.576, 1.0), + "papayawhip": (1.000, 0.937, 0.835, 1.0), + "peachpuff": (1.000, 0.855, 0.725, 1.0), + "peru": (0.804, 0.522, 0.247, 1.0), + "pink": (1.000, 0.753, 0.796, 1.0), + "plum": (0.867, 0.627, 0.867, 1.0), + "powderblue": (0.690, 0.878, 0.902, 1.0), + "purple": (0.502, 0.000, 0.502, 1.0), + "red": (1.000, 0.000, 0.000, 1.0), + "rosybrown": (0.737, 0.561, 0.561, 1.0), + "royalblue": (0.255, 0.412, 0.882, 1.0), + "saddlebrown": (0.545, 0.271, 0.075, 1.0), + "salmon": (0.980, 0.502, 0.447, 1.0), + "sandybrown": (0.957, 0.643, 0.376, 1.0), + "seagreen": (0.180, 0.545, 0.341, 1.0), + "seashell": (1.000, 0.961, 0.933, 1.0), + "sienna": (0.627, 0.322, 0.176, 1.0), + "silver": (0.753, 0.753, 0.753, 1.0), + "skyblue": (0.529, 0.808, 0.922, 1.0), + "slateblue": (0.416, 0.353, 0.804, 1.0), + "slategray": (0.439, 0.502, 0.565, 1.0), + "slategrey": (0.439, 0.502, 0.565, 1.0), + "snow": (1.000, 0.980, 0.980, 1.0), + "springgreen": (0.000, 1.000, 0.498, 1.0), + "steelblue": (0.275, 0.510, 0.706, 1.0), + "tan": (0.824, 0.706, 0.549, 1.0), + "teal": (0.000, 0.502, 0.502, 1.0), + "thistle": (0.847, 0.749, 0.847, 1.0), + "tomato": (1.000, 0.388, 0.278, 1.0), + "turquoise": (0.251, 0.878, 0.816, 1.0), + "violet": (0.933, 0.510, 0.933, 1.0), + "wheat": (0.961, 0.871, 0.702, 1.0), + "white": (1.000, 1.000, 1.000, 1.0), + "whitesmoke": (0.961, 0.961, 0.961, 1.0), + "yellow": (1.000, 1.000, 0.000, 1.0), + "yellowgreen": (0.604, 0.804, 0.196, 1.0), + "rebeccapurple": (0.4, 0.2, 0.6, 1.0), + + # Several aliases for transparent + "clear": (0.0, 0.0, 0.0, 0.0), + "transparent": (0.0, 0.0, 0.0, 0.0), + "none": (0.0, 0.0, 0.0, 0.0), +} + +# Regular expression matching a 3-channel functional representation. +three_channel_functional = re.compile( + r""" + \s* # optional space + (?Prgb|hsv|hls|) # function type + s*\(s* # open parens with optional space + (?P\d+(\.\d*)?) # first channel value + (\s*,\s*|\s+) # comma with optional space or space + (?P\d+(\.\d*)?) # second channel value + (\s*,\s*|\s+) # comma with optional space or space + (?P\d+(\.\d*)?) # third channel value + \s*,?\s* # optional space and comma + \) # close parens + \s* # optional space + """, + flags=re.VERBOSE | re.IGNORECASE, +) + +# Regular expression matching a 4-channel functional representation. +four_channel_functional = re.compile( + r""" + \s* # optional space + (?Prgba|hsva|hlsa|) # function type + s*\(s* # open parens with optional space + (?P\d+(\.\d*)?) # first channel value + (\s*,\s*|\s+) # comma with optional space or space + (?P\d+(\.\d*)?) # second channel value + (\s*,\s*|\s+) # comma with optional space or space + (?P\d+(\.\d*)?) # third channel value + (\s*,\s*|\s+) # comma with optional space or space + (?P\d+(\.\d*)?) # fourth channel value + \s*,?\s* # optional space and comma + \) # close parens + \s* # optional space + """, + flags=re.VERBOSE | re.IGNORECASE, +) + + +# Translation table for stripping extraneous characters out of names. +ignored = str.maketrans({' ': None, '-': None, '_': None}) + + +def channels_to_ints(channels, maximum=255): + """ Convert an iterable of floating point channel values to integers. + + Values are rounded to the nearest integer, rather than truncated. + + Parameters + ---------- + channels : iterable of float + An iterable of channel values, each value between 0.0 and 1.0, + inclusive. + maximum : int + The maximum value of the integer range. Common values are 15, + 65535 or 255, which is the default. + + Returns + ------- + values : tuple of int + A tuple of values as integers between 0 and max, inclusive. + """ + return tuple(int(round(channel * maximum)) for channel in channels) + + +def ints_to_channels(values, maximum=255): + """ Convert an iterable of integers to floating point channel values. + + Parameters + ---------- + values : tuple of int + An iterable of values as integers between 0 and max, inclusive. + maximum : int + The maximum value of the integer range. Common values are 15, + 65535 or 255, which is the default. + + Returns + ------- + channels : iterable of float + A tuple of channel values, each value between 0.0 and 1.0, + inclusive. + """ + return tuple(value / maximum for value in values) + + +def parse_name(text): + """ Parse a color name. + + Parameters + ---------- + text : str + A string holding a color name, including all CSS color names, plus + any additional names found in pyface.color.color_table. The names + are normalized to lower case and stripped of whitespace, hyphens and + underscores. + + Returns + ------- + result : (space, channels), or None + Either a tuple of the form ('rgba', channels), where channels is a + tuple of 4 floating point values between 0.0 and 1.0, includive; + or None if there is no matching color name. + """ + text = text.lower() + text = text.translate(ignored) + if text in color_table: + return 'rgba', color_table[text] + return None + + +def parse_functional(text): + """ Parse a functional form of a color. + + Parameters + ---------- + text : str + A string holding a CSS functional representation, including "rgb()", + "rgba()", "hsv()", "hsva()", "hls()", "hlsa()". Channel values are + expected to be in the range 0.0 to 1.0, inclusive, but if values over + 1.0 are observed then they will be assumed to be from 0 to 255. + Commas separating the channel values are optional, as in the CSS + specification. + + Returns + ------- + result : (space, channels), or None + Either a tuple of the form (space, channels), where space is one of + 'rgb' or 'rgba', and channels is a tuple of 3 or 4 floating point + values between 0.0 and 1.0, inclusive; or None if no hex + representation could be found. + """ + match = three_channel_functional.match(text) + if match is not None: + space = match['space'] + if not space: + space = 'rgb' + channels = (match['channel_0'], match['channel_1'], match['channel_2']) + else: + match = four_channel_functional.match(text) + if match is not None: + space = match['space'] + if not space: + space = 'rgba' + channels = ( + match['channel_0'], + match['channel_1'], + match['channel_2'], + match['channel_3'], + ) + else: + return None + channels = tuple(float(x) for x in channels) + if any(x > 1.0 for x in channels): + channels = ints_to_channels(channels) + return space, channels + + +def parse_hex(text): + """ Parse a hex form of a color. + + Parameters + ---------- + text : str + A string holding a hex representation of the color in the form + '#RGB', '#RGBA', '#RRGGBB', '#RRGGBBAA', '#RRRRGGGGBBBB', or + '#RRRRGGGGBBBBAAAA'. + + Returns + ------- + result : (space, channels), or None + Either a tuple of the form (space, channels), where space is one of + 'rgb', 'rgba', 'hsv', 'hsva', 'hls', or 'hlsa' and channels is a + tuple of 3 or 4 floating point values between 0.0 and 1.0, includive; + or None if no functional representation could be matched. + """ + text = text.strip() + if re.match("#[0-9a-fA-F]+", text) is None: + return None + text = text[1:] + if len(text) in {3, 4}: + step = 1 + elif len(text) in {6, 8}: + step = 2 + elif len(text) in {12, 16}: + step = 4 + else: + return None + maximum = (1 << 4 * step) - 1 + channels = ints_to_channels( + (int(text[i:i+step], 16) for i in range(0, len(text), step)), + maximum=maximum, + ) + space = 'rgb' if len(channels) == 3 else 'rgba' + return space, channels + + +class ColorParseError(ValueError): + """ An Exception raised when parsing fails. """ + pass + + +def parse_text(text): + """ Parse a text representation of a color. + + Parameters + ---------- + text : str + A string holding the representation of the color. This can be: + + - a color name, including all CSS color names, plus any additional + names found in pyface.color.color_table. The names are normalized + to lower case and stripped of whitespace, hyphens and underscores. + + - a hex representation of the color in the form '#RGB', '#RGBA', + '#RRGGBB', '#RRGGBBAA', '#RRRRGGGGBBBB', or '#RRRRGGGGBBBBAAAA'. + + - a CSS functional representation, including "rgb()", "rgba()", + "hsv()", "hsva()", "hls()", "hlsa()". Channel values are expected + to be in the range 0.0 to 1.0, inclusive. + + - a tuple of rgb or rgba values, eg. '(0.6, 0.2, 0.4, 1.0)'. Channel + values are expected to be in the range 0.0 to 1.0, inclusive. + + Returns + ------- + space : str + A string describing the color space for the channels. Will be one of + 'rgb', 'rgba', 'hsv', 'hsva', 'hls', 'hlsa'. + channels : tuple of floats + The channel values as a tuple of 3 or 4 floating point values between + 0.0 and 1.0, includive. + + Raises + ------ + ColorParseError + If the string cannot be converted to a valid color. + """ + result = None + for parser in parse_name, parse_functional, parse_hex: + result = parser(text) + if result is not None: + return result + else: + raise ColorParseError( + 'Unable to parse color value in string {!r}'.format(text) + ) + + +#: A trait holding a single channel value. +Channel = Range(0.0, 1.0, value=1.0, channel=True) + +#: A trait holding three channel values. +ChannelTuple = Tuple(Channel, Channel, Channel) + +#: A trait holding four channel values. +AlphaChannelTuple = Tuple(Channel, Channel, Channel, Channel) + + +class Color(HasStrictTraits): + """ A mutable specification of a color with alpha. + + This is a class designed to be used by user interface elements which + need to color some or all of the interface element. Each color has a + number of different representations as channel tuples, each channel + holding a value between 0.0 and 1.0, inclusive. The standard red, + green, blue and alpha channels are also provided as convenience + properties. + + Methods are provided to convert from common string representations + (such as CSS color strings) and toolkit-specific color objects. + + Colors implement equality testing, but are not hashable as they are + mutable, and so are not suitable for use as dictionary keys. If you + need a dictionary key, use an appropriate channel tuple from the + object. + """ + + #: A tuple holding the red, green, blue, and alpha channels. + rgba = AlphaChannelTuple() + + #: A tuple holding the red, green, and blue channels. + rgb = Property(ChannelTuple(), depends_on='rgba') + + #: The red channel. + red = Property(Channel, depends_on='rgba') + + #: The green channel. + green = Property(Channel, depends_on='rgba') + + #: The blue channel. + blue = Property(Channel, depends_on='rgba') + + #: The alpha channel. + alpha = Property(Channel, depends_on='rgba') + + #: A tuple holding the hue, saturation, value, and alpha channels. + hsva = Property(AlphaChannelTuple, depends_on=['rgba']) + + #: A tuple holding the hue, saturation, and value channels. + hsv = Property(ChannelTuple, depends_on=['rgb']) + + #: A tuple holding the hue, lightness, saturation, and alpha channels. + hlsa = Property(AlphaChannelTuple, depends_on=['rgba']) + + #: A tuple holding the hue, lightness, and saturation channels. + hls = Property(ChannelTuple, depends_on=['rgb']) + + @classmethod + def from_str(cls, text, **traits): + """ Create a new Color object from a string. + + Parameters + ---------- + text : str + A string holding the representation of the color. This can be: + + - a color name, including all CSS color names, plus any additional + names found in pyface.color.color_table. The names are + normalized to lower case and stripped of whitespace, hyphens and + underscores. + + - a hex representation of the color in the form '#RGB', '#RGBA', + '#RRGGBB', '#RRGGBBAA', '#RRRRGGGGBBBB', or '#RRRRGGGGBBBBAAAA'. + + - a CSS functional representation, including "rgb()", "rgba()", + "hsv()", "hsva()", "hls()", "hlsa()". Channel values are + expected to be in the range 0.0 to 1.0, inclusive. + + - a tuple of rgb or rgba values, eg. '(0.6, 0.2, 0.4, 1.0)'. + Channel values are expected to be in the range 0.0 to 1.0, + inclusive. + + **traits + Any additional trait values to be passed as keyword arguments. + + Raises + ------ + ColorParseError + If the string cannot be converted to a valid color. + """ + space, channels = parse_text(text) + traits[space] = channels + return cls(**traits) + + @classmethod + def from_toolkit(cls, toolkit_color, **traits): + """ Create a new Color object from a toolkit color object. + + Parameters + ---------- + toolkit_color : toolkit object + A toolkit color object, such as a Qt QColor or a Wx wx.Colour. + **traits + Any additional trait values to be passed as keyword arguments. + """ + from pyface.toolkit import toolkit_object + toolkit_color_to_rgba = toolkit_object('color:toolkit_color_to_rgba') + rgba = toolkit_color_to_rgba(toolkit_color) + return cls(rgba=rgba, **traits) + + def to_toolkit(self): + """ Create a new toolkit color object from a Color object. + + Returns + ------- + toolkit_color : toolkit object + A toolkit color object, such as a Qt QColor or a Wx wx.Colour. + """ + from pyface.toolkit import toolkit_object + rgba_to_toolkit_color = toolkit_object('color:rgba_to_toolkit_color') + return rgba_to_toolkit_color(self.rgba) + + def hex(self): + """ Provide a hex representation of the Color object. + + Note that because the hex value is restricted to 0-255 integer values + for each channel, the representation is not exact. + + Returns + ------- + hex : str + A hex string in standard ``#RRGGBBAA`` format that represents + the color. + """ + values = channels_to_ints(self.rgba) + return "#{:02X}{:02X}{:02X}{:02X}".format(*values) + + def __eq__(self, other): + if isinstance(other, Color): + return self.rgba == other.rgba + return NotImplemented + + def __str__(self): + return "({:0.5}, {:0.5}, {:0.5}, {:0.5})".format(*self.rgba) + + def __repr__(self): + return "{}(rgba={!r})".format(self.__class__.__name__, self.rgba) + + def _get_red(self): + return self.rgba[0] + + def _set_red(self, value): + r, g, b, a = self.rgba + self.rgba = (value, g, b, a) + + def _get_green(self): + return self.rgba[1] + + def _set_green(self, value): + r, g, b, a = self.rgba + self.rgba = (r, value, b, a) + + def _get_blue(self): + return self.rgba[2] + + def _set_blue(self, value): + r, g, b, a = self.rgba + self.rgba = (r, g, value, a) + + def _get_alpha(self): + return self.rgba[3] + + def _set_alpha(self, value): + r, g, b, a = self.rgba + self.rgba = (r, g, b, value) + + @cached_property + def _get_rgb(self): + return self.rgba[:-1] + + def _set_rgb(self, value): + r, g, b = value + self.rgba = (r, g, b, self.rgba[3]) + + @cached_property + def _get_hsva(self): + r, g, b, a = self.rgba + h, s, v = colorsys.rgb_to_hsv(r, g, b) + return (h, s, v, a) + + def _set_hsva(self, value): + h, s, v, a = value + r, g, b = colorsys.hsv_to_rgb(h, s, v) + self.rgba = (r, g, b, a) + + @cached_property + def _get_hsv(self): + r, g, b = self.rgb + return colorsys.rgb_to_hsv(r, g, b) + + def _set_hsv(self, value): + h, s, v = value + r, g, b = colorsys.hsv_to_rgb(h, s, v) + self.rgb = (r, g, b) + + @cached_property + def _get_hlsa(self): + r, g, b, a = self.rgba + h, l, s = colorsys.rgb_to_hls(r, g, b) + return (h, l, s, a) + + def _set_hlsa(self, value): + h, l, s, a = value + r, g, b = colorsys.hls_to_rgb(h, l, s) + self.rgba = (r, g, b, a) + + @cached_property + def _get_hls(self): + r, g, b = self.rgb + return colorsys.rgb_to_hls(r, g, b) + + def _set_hls(self, value): + h, l, s = value + r, g, b = colorsys.hls_to_rgb(h, l, s) + self.rgb = (r, g, b) + + +class PyfaceColor(TraitType): + + #: The default value should be a tuple (factory, args, kwargs) + default_value_type = DefaultValue.callable_and_args + + def __init__(self, value=None, **metadata): + if value is not None: + color = self.validate(None, None, value) + default_value = (Color, (), {'rgba': color.rgba}) + else: + default_value = (Color, (), {}) + super().__init__(default_value, **metadata) + + def validate(self, object, name, value): + if isinstance(value, Color): + return value + if isinstance(value, str): + try: + return Color.from_str(value) + except ColorParseError: + self.error(object, name, value) + is_array = (np is not None and isinstance(value, (np.ndarray, np.void))) # noqa: E501 + if is_array or isinstance(value, Sequence): + channels = tuple(value) + if len(channels) == 4: + return Color(rgba=channels) + elif len(channels) == 3: + return Color(rgb=channels) + + self.error(object, name, value) + + def info(self): + return ( + "a Pyface Color, an RGB string (as either a #-hexadecimal, " + "the rgb() or rgba() functional notation, or a tuple of 3 or 4 " + "numbers), an HSV string (in the hsv() or hsva() functional " + "notation), an HLS string (in the hls() or hlsa() functional " + "notation), a standard color name, or a tuple RGBA or RGB values" + ) + + +__all__ = [ + 'AlphaChannelTuple', + 'Channel', + 'ChannelTuple', + 'Color', + 'ColorParseError', + 'PyfaceColor', + 'channels_to_ints', + 'color_table', + 'ints_to_channels', + 'parse_functional', + 'parse_hex', + 'parse_name', + 'parse_text', +] diff --git a/pyface/color_dialog.py b/pyface/color_dialog.py new file mode 100644 index 000000000..7a731700d --- /dev/null +++ b/pyface/color_dialog.py @@ -0,0 +1,43 @@ +# (C) Copyright 2005-2020 Enthought, Inc., Austin, TX +# All rights reserved. +# +# This software is provided without warranty under the terms of the BSD +# license included in LICENSE.txt and may be redistributed only under +# the conditions described in the aforementioned license. The license +# is also available online at http://www.enthought.com/licenses/BSD.txt +# +# Thanks for using Enthought open source! + +""" The implementation of a dialog that allows the user to open/save files etc. +""" + +from .constant import OK +from .toolkit import toolkit_object + + +ColorDialog = toolkit_object("color_dialog:ColorDialog") + + +def get_color(parent, color, show_alpha=False): + """ Convenience function that displays a color dialog. + + Paramters + --------- + parent : toolkit control + The parent toolkit control for the modal dialog. + color : Color or color description + The initial Color object or a string holding a valid color description. + show_alpha : bool + Whether or not to show alpha channel information. + + Returns + ------- + color : Color or None + The selected color, or None if the user made no selection. + """ + dialog = ColorDialog(parent=parent, color=color, show_alpha=show_alpha) + result = dialog.open() + if result == OK: + return dialog.color + else: + return None diff --git a/pyface/font.py b/pyface/font.py new file mode 100644 index 000000000..2c2779b69 --- /dev/null +++ b/pyface/font.py @@ -0,0 +1,478 @@ +# (C) Copyright 2005-2020 Enthought, Inc., Austin, TX +# All rights reserved. +# +# This software is provided without warranty under the terms of the BSD +# license included in LICENSE.txt and may be redistributed only under +# the conditions described in the aforementioned license. The license +# is also available online at http://www.enthought.com/licenses/BSD.txt +# +# Thanks for using Enthought open source! + +""" Toolkit-independent font utilities. + +Pyface fonts are intended to be generic, but able to be mapped fairly well +to most backend toolkit font descriptions. In most cases we can describe +fonts along the common dimensions that are used by CSS, Wx, and Qt. However +when it comes to actually working with a font, the toolkit needs to take the +description and produce something that is as close as possible to the +specification, but within the constraints of the toolkit, operating system +and available fonts on the machine where this is being executed. + +Font Properties +--------------- + +The properties that fonts have are: + +Font Family + A list of font family names in order of preference, such as "Helvetica" + or "Comic Sans". There are several generic font family names that can + be used as fall-backs in case all preferred fonts are unavailable. In + the case of a font that has been selected by the toolkit this list will + have one value which is the actual font family name. + +Weight + How thick or dark the font glyphs are. These can be given as a number + from 1 (lightest) to 999 (darkest), but are typically specified by a + multiple of 100 from 100 to 900, with a number of synonyms such as 'light' + and 'bold' available for those values. + +Stretch + The amount of horizontal compression or expansion to apply to the glyphs. + These can be given as a number from 1 (most compressed) to 999 (most + expanded), with a number of synonyms such as 'condensed' and 'expanded' + available for those values. + +Style + This selects either 'oblique' or 'italic' variants typefaces of the given + font family. If neither is wanted, the value is 'normal'. + +Size + The overall size of the glyphs. This can be expressed either as the + numeric size in points, or as a string such as "small" or "large". + +Variants + A set of additional font style specifiers, such as "small-caps", + "strikethrough", "underline" or "overline", where supported by the + underlying toolkit. + +Text Description +---------------- + +Frequently it is useful to specify a font by a descriptive string (or supply +such a description to the user). For these we follow the conventions in CSS +where fonts are specfied by a string which specifies the weight, stretch, +style and variants by text synonyms (in any order), followed by size in points +and font family preferences (quoted if not a single word) and separated by +commas. Where the value is "normal" it can be omitted from the description. + +For example:: + + 'italic bold 14pt Helvetica, Arial, sans-serif' + '36pt "Comic Sans"' + +When converting numeric values to string synonyms for display, the nearest +value will be chosen. This may mean that text descriptions may not be not +idempotent when run through font selection multiple times. + +Font Specificiation Class +------------------------- + +The Pyface Font class is a HasStrictTraits class which specifies a requested +font. It has traits for all of the font properties, plus additional utility +methods that produce modifed versions of the font. + +It also has methods that convert the Font class to and from a toolkit Font +class. + +""" +import re + +from traits.api import ( + BaseCFloat, DefaultValue, Enum, HasStrictTraits, List, Map, Set, Str, + TraitError, TraitType +) +from traits.trait_type import NoDefaultSpecified + +weights = {str(i): i for i in range(100, 1001, 100)} +weights.update({ + 'thin': 100, + 'extra-light': 200, + 'ultra-ight': 200, + 'light': 300, + 'normal': 400, + 'regular': 400, + 'book': 400, + 'medium': 500, + 'semibold': 600, + 'demibold': 600, + 'demi': 600, + 'bold': 700, + 'heavy': 800, + 'extra-bold': 800, + 'ultra-bold': 800, + 'black': 900, + 'heavy': 900, + 'extra-heavy': 1000, +}) + +stretches = { + 'ultra-condensed': 100, + 'ultracondensed': 100, + 'extra-condensed': 200, + 'extracondensed': 200, + 'condensed': 300, + 'semi-condensed': 400, + 'semicondensed': 400, + 'normal': 500, + 'semi-expanded': 600, + 'semiexpanded': 600, + 'expanded': 700, + 'extra-expanded': 800, + 'extraexpanded': 800, + 'ultra-expanded': 900, + 'ultraexpanded': 900, +} + +sizes = { + 'xx-small': 7.0, + 'x-small': 9.0, + 'small': 10.0, + #'medium': 12.0, + 'large': 14.0, + 'x-large': 18.0, + 'xx-large': 20.0, +} + +styles = ['normal', 'italic', 'oblique'] + +variants = ['small-caps', 'underline', 'strikethrough', 'overline'] + +#: A trait for font families. +FontFamily = List(Str, ['default']) + +#: A trait for font weights. +FontWeight = Map(weights, default_value='normal') + +#: A trait for font stretch values. +FontStretch = Map(stretches, default_value='normal') + +#: A trait for font styles. +FontStyle = Enum(styles) + +#: A trait for font variant properties. +FontVariants = Set(Enum(variants)) + + +class FontSize(BaseCFloat): + """ Trait type for font sizes. + + The is a CFloat trait which also allows values which are keys of the + size dictionary, and also ignores trailing 'pt' annotation in string + values. The value stored is a float. + """ + + #: The default value for the trait. + default_value = 12.0 + + def __init__(self, default_value=NoDefaultSpecified, **metadata): + if default_value != NoDefaultSpecified: + default_value = self.validate(None, None, default_value) + super().__init__(default_value, **metadata) + + def validate(self, object, name, value): + if isinstance(value, str) and value.endswith('pt'): + value = value[:-2] + value = sizes.get(value, value) + value = super().validate(object, name, value) + if value <= 0: + self.error(object, name, value) + return value + + +font_tokens = [ + r'(?P\d+\.?\d*pt)', + r'(?P\d+\.?\d*)', + r'(?P[a-zA-Z\-]+)', + r'(?P"[^"]+"|\'[^\']+\')', + r'(?P,)', + r'(?P\s+)', + r'(?P.)', +] +token_re = re.compile('|'.join(font_tokens)) + + +class FontParseError(ValueError): + pass + + +parser_synonyms = { + 'slant': 'oblique' +} + + +def parse_font_description(description): + """ An extremely relaxed parser for font descriptions. + + This is designed to accept most reasonable human-written text font + descriptions and produce an acceptable set of parameters suitable as + trait values for a PyfaceFont instance. + + Parameters + ---------- + description : str + A text description of the font in a CSS-style format. + + Returns + ------- + properties : dict + A dictionary of font properties suitable for passing to a Font as + keyword arguments. + """ + family = [] + weight = 'normal' + stretch = 'normal' + size = -1 + style = 'normal' + variant_set = set() + for token in token_re.finditer(description): + kind = token.lastgroup + value = token.group() + index = token.start() + if kind == 'SIZE': + if size != -1: + raise FontParseError( + f"Size declared twice in {description!r}" + ) + value = value[:-2] + try: + size = float(value) + except ValueError: + raise FontParseError( + f"Invalid font size {value!r} at position {index} in {description!r}" + ) + elif kind == 'NUMBER': + if value in weights and weight == 'normal': + weight = value + elif size != -1: + raise FontParseError( + f"Size declared twice in {description!r}" + ) + else: + try: + size = float(value) + except ValueError: + raise FontParseError( + f"Invalid font size {value!r} at position {index} in {description!r}" + ) + elif kind == 'NAME': + # substitute synonyms + value = parser_synonyms.get(value, value) + if value.lower() in weights: + if weight != 'normal': + raise FontParseError( + f"Weight declared twice in {description!r}" + ) + weight = value.lower() + elif value.lower() in stretches: + if stretch != 'normal': + raise FontParseError( + f"Stretch declared twice in {description!r}" + ) + stretch = value.lower() + elif value.lower() in sizes: + if size != -1: + raise FontParseError( + f"Size declared twice in {description!r}" + ) + size = sizes[value.lower()] + elif value.lower() in styles: + if style != 'normal': + raise FontParseError( + f"Style declared twice in {description!r}" + ) + style = value.lower() + elif value in variants: + if value.lower() in variant_set: + raise FontParseError( + f"Variant {value!r} declared twice in {description!r}" + ) + variant_set.add(value.lower()) + else: + # assume it is a font family name + family.append(value) + elif kind == 'QUOTED_NAME': + family.append(value[1:-1]) + elif kind == 'MISMATCH': + raise FontParseError( + f"Parse error {value!r} at {index} in {description!r}" + ) + if len(family) == 0: + family = ['default'] + if size == -1: + size = 12.0 + return { + 'family': family, + 'weight': weight, + 'stretch': stretch, + 'style': style, + 'variants': variant_set, + 'size': size, + } + + +class Font(HasStrictTraits): + """ A toolkit-independent font specification. """ + + #: The preferred font families. + family = FontFamily() + + #: The weight of the font. + weight = FontWeight() + + #: How much the font is expanded or compressed. + stretch = FontStretch() + + #: The style of the font. + style = FontStyle() + + #: The size of the font. + size = FontSize() + + #: The font variants. + variants = FontVariants() + + @classmethod + def from_description(cls, description): + """ An extremely lax 'parser' for CSS-style font descriptions. + + Parameters + ---------- + description : str + A font description in string form such as + 'italic bold 14pt Helvetica, Arial, sans-serif' or + '36pt "Comic Sans"' + """ + return cls(**parse_font_description(description)) + + @classmethod + def from_toolkit(cls, toolkit_font): + """ Create a Font from a toolkit font object. + + Parameters + ---------- + toolkit_font : any + A toolkit font to be converted to a corresponding class instance, + within the limitations of the options supported by the class. + """ + from pyface.toolkit import toolkit_object + toolkit_font_to_properties = toolkit_object( + 'font:toolkit_font_to_properties') + + return cls(**toolkit_font_to_properties(toolkit_font)) + + def to_toolkit(self): + """ Create a toolkit font object from the Font instance. + + Returns + ------- + toolkit_font : any + A toolkit font which matches the property of the font as + closely as possible given the constraints of the toolkit. + """ + from pyface.toolkit import toolkit_object + font_to_toolkit_font = toolkit_object('font:font_to_toolkit_font') + + return font_to_toolkit_font(self) + + def __str__(self): + terms = [] + if self.style != 'normal': + terms.append(self.style) + terms.extend( + variant for variant in variants + if variant in self.variants + ) + if self.weight != 'normal': + terms.append(self.weight) + if self.stretch != 'normal': + terms.append(self.stretch) + size = self.size + # if size is an integer + if int(size) == size: + size = int(size) + terms.append(f"{size}pt") + terms.append( + ', '.join( + repr(family) if ' ' in family else family + for family in self.family + ) + ) + return ' '.join(terms) + + def __repr__(self): + traits = self.trait_get(self.editable_traits()) + trait_args = ', '.join( + f"{name}={value!r}" + for name, value in traits.items() + ) + return f"{self.__class__.__name__}({trait_args})" + + def __eq__(self, other): + if isinstance(other, Font): + return ( + self.family == other.family + and self.weight == other.weight + and self.stretch == other.stretch + and self.style == other.style + and self.size == other.size + and self.variants == other.variants + ) + else: + return NotImplemented + + +class PyfaceFont(TraitType): + """ A trait that holds a Pyface Font. + + The value can be assigned as a string, in which case it is parsed + as a font description and an appropriate font created for it. + """ + + #: The default value should be a tuple (factory, args, kwargs) + default_value_type = DefaultValue.callable_and_args + + def __init__(self, value=None, **metadata): + if isinstance(value, Font): + value = value.trait_get(value.editable_traits()) + if isinstance(value, str): + value = parse_font_description(value) + if isinstance(value, dict): + # freeze the family list and variant set + if 'family' in value: + value['family'] = tuple(value['family']) + if 'variants' in value: + value['variants'] = frozenset(value['variants']) + default_value = (Font, (), value.copy()) + elif value is None: + default_value = (Font, (), {}) + else: + raise TraitError(f"Invalid trait value {value!r}") + super().__init__(default_value, **metadata) + + def validate(self, object, name, value): + if isinstance(value, Font): + return value + elif isinstance(value, str): + try: + return Font.from_description(value) + except FontParseError: + self.error(object, name, value) + else: + self.error(object, name, value) + + def info(self): + return ( + "a Pyface Font or a string describing a Pyface Font " + "(eg. 'italic 12pt Arial, sans-serif' or " + "'demibold 36pt \"Comic Sans\"')" + ) diff --git a/pyface/font_dialog.py b/pyface/font_dialog.py new file mode 100644 index 000000000..cc29f58f8 --- /dev/null +++ b/pyface/font_dialog.py @@ -0,0 +1,40 @@ +# (C) Copyright 2005-2020 Enthought, Inc., Austin, TX +# All rights reserved. +# +# This software is provided without warranty under the terms of the BSD +# license included in LICENSE.txt and may be redistributed only under +# the conditions described in the aforementioned license. The license +# is also available online at http://www.enthought.com/licenses/BSD.txt +# +# Thanks for using Enthought open source! + +""" The implementation of a dialog that allows the user to choose a font. +""" + +from .constant import OK +from .toolkit import toolkit_object + +FontDialog = toolkit_object("font_dialog:FontDialog") + + +def get_font(parent, font): + """ Convenience function that displays a font dialog. + + Paramters + --------- + parent : toolkit control + The parent toolkit control for the modal dialog. + font : Font or font description + The initial Font object or a string holding a valid font description. + + Returns + ------- + font : Font or None + The selected font, or None if the user made no selection. + """ + dialog = FontDialog(font=font) + result = dialog.open() + if result == OK: + return dialog.font + else: + return None diff --git a/pyface/i_color_dialog.py b/pyface/i_color_dialog.py new file mode 100644 index 000000000..cefee0f03 --- /dev/null +++ b/pyface/i_color_dialog.py @@ -0,0 +1,29 @@ +# (C) Copyright 2005-2020 Enthought, Inc., Austin, TX +# All rights reserved. +# +# This software is provided without warranty under the terms of the BSD +# license included in LICENSE.txt and may be redistributed only under +# the conditions described in the aforementioned license. The license +# is also available online at http://www.enthought.com/licenses/BSD.txt +# +# Thanks for using Enthought open source! + +""" The interface for a dialog that allows the user to select a color. """ + +from traits.api import Bool + +from pyface.color import PyfaceColor +from pyface.i_dialog import IDialog + + +class IColorDialog(IDialog): + """ The interface for a dialog that allows the user to choose a color. + """ + + # 'IColorDialog' interface ---------------------------------------------# + + #: The color in the dialog. + color = PyfaceColor() + + #: Whether or not to allow the user to chose an alpha value. + show_alpha = Bool(False) diff --git a/pyface/i_font_dialog.py b/pyface/i_font_dialog.py new file mode 100644 index 000000000..4a4feea9b --- /dev/null +++ b/pyface/i_font_dialog.py @@ -0,0 +1,24 @@ +# (C) Copyright 2005-2020 Enthought, Inc., Austin, TX +# All rights reserved. +# +# This software is provided without warranty under the terms of the BSD +# license included in LICENSE.txt and may be redistributed only under +# the conditions described in the aforementioned license. The license +# is also available online at http://www.enthought.com/licenses/BSD.txt +# +# Thanks for using Enthought open source! + +""" The interface for a dialog that allows the user to select a font. """ + +from pyface.font import PyfaceFont +from pyface.i_dialog import IDialog + + +class IFontDialog(IDialog): + """ The interface for a dialog that allows the user to select a font. + """ + + # 'IFontDialog' interface ---------------------------------------------# + + #: The font in the dialog. + font = PyfaceFont() diff --git a/pyface/qt/QtGui.py b/pyface/qt/QtGui.py index 14c55b2f7..475f2b6bc 100644 --- a/pyface/qt/QtGui.py +++ b/pyface/qt/QtGui.py @@ -13,6 +13,11 @@ from PyQt4.Qt import QKeySequence, QTextCursor from PyQt4.QtGui import * + # forward-compatible font weights + QFont.ExtraLight = 12 + QFont.Medium = 57 + QFont.ExtraBold = 81 + elif qt_api == "pyqt5": from PyQt5.QtGui import * from PyQt5.QtWidgets import * @@ -48,3 +53,8 @@ else: from PySide.QtGui import * + + # forward-compatible font weights + QFont.ExtraLight = 12 + QFont.Medium = 57 + QFont.ExtraBold = 81 diff --git a/pyface/tests/test_color.py b/pyface/tests/test_color.py new file mode 100644 index 000000000..489c7fc80 --- /dev/null +++ b/pyface/tests/test_color.py @@ -0,0 +1,549 @@ +from unittest import TestCase + +from traits.api import DefaultValue, TraitError +from traits.testing.optional_dependencies import numpy as np, requires_numpy +from traits.testing.unittest_tools import UnittestTools + +from ..color import ( + Color, ColorParseError, PyfaceColor, channels_to_ints, color_table, + ints_to_channels, parse_functional, parse_hex, parse_name, parse_text, +) + + +class TestChannelConversion(TestCase): + + def test_ints_to_channels(self): + values = (102, 102, 0, 255) + channels = ints_to_channels(values) + self.assertEqual(channels, (0.4, 0.4, 0.0, 1.0)) + + def test_ints_to_channels_maximum(self): + values = (6, 6, 0, 15) + channels = ints_to_channels(values, maximum=15) + self.assertEqual(channels, (0.4, 0.4, 0.0, 1.0)) + + def test_channels_to_ints(self): + channels = (0.4, 0.4, 0.0, 1.0) + values = channels_to_ints(channels) + self.assertEqual(values, (102, 102, 0, 255)) + + def test_channels_to_ints_maximum(self): + channels = (0.4, 0.4, 0.0, 1.0) + values = channels_to_ints(channels, maximum=15) + self.assertEqual(values, (6, 6, 0, 15)) + + def test_round_trip(self): + """ Test to assert stability of values through round-trips """ + for value in range(256): + with self.subTest(int=value): + result = channels_to_ints(ints_to_channels([value])) + self.assertEqual(result, (value,)) + + def test_round_trip_maximum(self): + """ Test to assert stability of values through round-trips """ + for value in range(65536): + with self.subTest(int=value): + result = channels_to_ints( + ints_to_channels( + [value], + maximum=65535, + ), + maximum=65535, + ) + self.assertEqual(result, (value,)) + + +class TestParseFunctional(TestCase): + + def test_4_tuple_normalized(self): + space, channels = parse_functional('(0.4, 0.4, 0.0, 1.0)') + self.assertEqual(space, 'rgba') + self.assertEqual(channels, (0.4, 0.4, 0.0, 1.0)) + + def test_4_tuple_bytes(self): + space, channels = parse_functional('(102, 102, 0, 255)') + self.assertEqual(space, 'rgba') + self.assertEqual(channels, (0.4, 0.4, 0.0, 1.0)) + + def test_4_tuple_no_comma(self): + space, channels = parse_functional('(0.4 0.4 0.0 1.0)') + self.assertEqual(space, 'rgba') + self.assertEqual(channels, (0.4, 0.4, 0.0, 1.0)) + + def test_rgba_normalized(self): + space, channels = parse_functional('rgba(0.4, 0.4, 0.0, 1.0)') + self.assertEqual(space, 'rgba') + self.assertEqual(channels, (0.4, 0.4, 0.0, 1.0)) + + def test_rgba_bytes(self): + space, channels = parse_functional('rgba(102, 102, 0, 255)') + self.assertEqual(space, 'rgba') + self.assertEqual(channels, (0.4, 0.4, 0.0, 1.0)) + + def test_rgba_no_comma(self): + space, channels = parse_functional('rgba(0.4 0.4 0.0 1.0)') + self.assertEqual(space, 'rgba') + self.assertEqual(channels, (0.4, 0.4, 0.0, 1.0)) + + def test_hsva_normalized(self): + space, channels = parse_functional('hsva(0.4, 0.4, 0.0, 1.0)') + self.assertEqual(space, 'hsva') + self.assertEqual(channels, (0.4, 0.4, 0.0, 1.0)) + + def test_hsva_bytes(self): + space, channels = parse_functional('hsva(102, 102, 0, 255)') + self.assertEqual(space, 'hsva') + self.assertEqual(channels, (0.4, 0.4, 0.0, 1.0)) + + def test_hlsa_normalized(self): + space, channels = parse_functional('hlsa(0.4, 0.4, 0.0, 1.0)') + self.assertEqual(space, 'hlsa') + self.assertEqual(channels, (0.4, 0.4, 0.0, 1.0)) + + def test_hlsa_bytes(self): + space, channels = parse_functional('hlsa(102, 102, 0, 255)') + self.assertEqual(space, 'hlsa') + self.assertEqual(channels, (0.4, 0.4, 0.0, 1.0)) + + def test_3_tuple_normalized(self): + space, channels = parse_functional('(0.4, 0.4, 0.0)') + self.assertEqual(space, 'rgb') + self.assertEqual(channels, (0.4, 0.4, 0.0)) + + def test_3_tuple_bytes(self): + space, channels = parse_functional('(102, 102, 0)') + self.assertEqual(space, 'rgb') + self.assertEqual(channels, (0.4, 0.4, 0.0)) + + def test_3_tuple_no_comma(self): + space, channels = parse_functional('(0.4 0.4 0.0)') + self.assertEqual(space, 'rgb') + self.assertEqual(channels, (0.4, 0.4, 0.0)) + + def test_rgb_normalized(self): + space, channels = parse_functional('rgb(0.4, 0.4, 0.0)') + self.assertEqual(space, 'rgb') + self.assertEqual(channels, (0.4, 0.4, 0.0)) + + def test_rgb_bytes(self): + space, channels = parse_functional('rgb(102, 102, 0)') + self.assertEqual(space, 'rgb') + self.assertEqual(channels, (0.4, 0.4, 0.0)) + + def test_rgb_no_comma(self): + space, channels = parse_functional('rgb(0.4 0.4 0.0)') + self.assertEqual(space, 'rgb') + self.assertEqual(channels, (0.4, 0.4, 0.0)) + + def test_hsv_normalized(self): + space, channels = parse_functional('hsv(0.4, 0.4, 0.0)') + self.assertEqual(space, 'hsv') + self.assertEqual(channels, (0.4, 0.4, 0.0)) + + def test_hsv_bytes(self): + space, channels = parse_functional('hsv(102, 102, 0)') + self.assertEqual(space, 'hsv') + self.assertEqual(channels, (0.4, 0.4, 0.0)) + + def test_hls_normalized(self): + space, channels = parse_functional('hls(0.4, 0.4, 0.0)') + self.assertEqual(space, 'hls') + self.assertEqual(channels, (0.4, 0.4, 0.0)) + + def test_hls_bytes(self): + space, channels = parse_functional('hls(102, 102, 0)') + self.assertEqual(space, 'hls') + self.assertEqual(channels, (0.4, 0.4, 0.0)) + + +class TestParseHex(TestCase): + + def test_hex_3(self): + space, channels = parse_hex('#06c') + self.assertEqual(space, 'rgb') + self.assertEqual(channels, (0.0, 0.4, 0.8)) + + def test_hex_4(self): + space, channels = parse_hex('#06cf') + self.assertEqual(space, 'rgba') + self.assertEqual(channels, (0.0, 0.4, 0.8, 1.0)) + + def test_hex_6(self): + space, channels = parse_hex('#0066cc') + self.assertEqual(space, 'rgb') + self.assertEqual(channels, (0.0, 0.4, 0.8)) + + def test_hex_8(self): + space, channels = parse_hex('#0066ccff') + self.assertEqual(space, 'rgba') + self.assertEqual(channels, (0.0, 0.4, 0.8, 1.0)) + + def test_hex_12(self): + space, channels = parse_hex('#00006666cccc') + self.assertEqual(space, 'rgb') + self.assertEqual(channels, (0.0, 0.4, 0.8)) + + def test_hex_16(self): + space, channels = parse_hex('#00006666ccccffff') + self.assertEqual(space, 'rgba') + self.assertEqual(channels, (0.0, 0.4, 0.8, 1.0)) + + def test_hex_bad(self): + result = parse_hex('#0c') + self.assertIsNone(result) + + +class TestParseName(TestCase): + + def test_names(self): + for name, value in color_table.items(): + with self.subTest(color=name): + space, channels = parse_name(name) + self.assertEqual(space, 'rgba') + self.assertEqual(channels, value) + + def test_name_space(self): + space, channels = parse_name('rebecca purple') + self.assertEqual(space, 'rgba') + self.assertEqual(channels, (0.4, 0.2, 0.6, 1.0)) + + def test_name_capitals(self): + space, channels = parse_name('RebeccaPurple') + self.assertEqual(space, 'rgba') + self.assertEqual(channels, (0.4, 0.2, 0.6, 1.0)) + + +class TestParseText(TestCase): + + def test_name(self): + space, channels = parse_text('rebeccapurple') + self.assertEqual(space, 'rgba') + self.assertEqual(channels, (0.4, 0.2, 0.6, 1.0)) + + def test_hex(self): + space, channels = parse_text('#663399ff') + self.assertEqual(space, 'rgba') + self.assertEqual(channels, (0.4, 0.2, 0.6, 1.0)) + + def test_functional(self): + space, channels = parse_text('rgba(0.4, 0.2, 0.6, 1.0)') + self.assertEqual(space, 'rgba') + self.assertEqual(channels, (0.4, 0.2, 0.6, 1.0)) + + def test_error(self): + with self.assertRaises(ColorParseError): + parse_text('invalidcolorname') + + +class TestColor(UnittestTools, TestCase): + + def test_init(self): + color = Color() + self.assertEqual(color.rgba, (1.0, 1.0, 1.0, 1.0)) + + def test_init_rgba(self): + color = Color(rgba=(0.4, 0.2, 0.6, 0.8)) + self.assertEqual(color.rgba, (0.4, 0.2, 0.6, 0.8)) + + def test_init_rgb(self): + color = Color(rgb=(0.4, 0.2, 0.6)) + self.assertEqual(color.rgba, (0.4, 0.2, 0.6, 1.0)) + + def test_init_r_g_b_a(self): + color = Color(red=0.4, green=0.2, blue=0.6, alpha=0.8) + self.assertEqual(color.rgba, (0.4, 0.2, 0.6, 0.8)) + + def test_init_r_g_b(self): + color = Color(red=0.4, green=0.2, blue=0.6) + self.assertEqual(color.rgba, (0.4, 0.2, 0.6, 1.0)) + + def test_init_hsva(self): + color = Color(hsva=(0.4, 0.2, 0.6, 0.8)) + self.assertEqual(color.rgba, (0.48, 0.6, 0.528, 0.8)) + + def test_init_hsv(self): + color = Color(hsv=(0.4, 0.2, 0.6)) + self.assertEqual(color.rgba, (0.48, 0.6, 0.528, 1.0)) + + def test_init_hlsa(self): + color = Color(hlsa=(0.4, 0.2, 0.6, 0.8)) + self.assertEqual( + color.rgba, + (0.07999999999999996, 0.32000000000000006, 0.17600000000000007, 0.8) # noqa: E501 + ) + + def test_init_hls(self): + color = Color(hls=(0.4, 0.2, 0.6)) + self.assertEqual( + color.rgba, + (0.07999999999999996, 0.32000000000000006, 0.17600000000000007, 1.0) # noqa: E501 + ) + + def test_from_str_name(self): + color = Color.from_str('rebeccapurple') + self.assertEqual(color.rgba, (0.4, 0.2, 0.6, 1.0)) + + def test_from_str_hex(self): + color = Color.from_str('#663399ff') + self.assertEqual(color.rgba, (0.4, 0.2, 0.6, 1.0)) + + def test_from_str_functional(self): + color = Color.from_str('rgba(0.4, 0.2, 0.6, 1.0)') + self.assertEqual(color.rgba, (0.4, 0.2, 0.6, 1.0)) + + def test_toolkit_round_trip(self): + color = Color(rgba=(0.4, 0.2, 0.6, 0.8)) + toolkit_color = color.to_toolkit() + result = Color.from_toolkit(toolkit_color) + self.assertEqual(result.rgba, (0.4, 0.2, 0.6, 0.8)) + + def test_hex(self): + color = Color(rgba=(0.4, 0.2, 0.6, 0.8)) + hex_value = color.hex() + self.assertEqual(hex_value, "#663399CC") + + def test_hex_black(self): + color = Color(rgba=(0.0, 0.0, 0.0, 1.0)) + hex_value = color.hex() + self.assertEqual(hex_value, "#000000FF") + + def test_eq(self): + color_1 = Color(rgba=(0.4, 0.2, 0.6, 0.8)) + color_2 = Color(rgba=(0.4, 0.2, 0.6, 0.8)) + self.assertTrue(color_1 == color_2) + self.assertFalse(color_1 != color_2) + + def test_eq_not_eequal(self): + color_1 = Color(rgba=(0.4, 0.2, 0.6, 0.8)) + color_2 = Color(rgba=(0.4, 0.4, 0.6, 0.8)) + self.assertTrue(color_1 != color_2) + self.assertFalse(color_1 == color_2) + + def test_eq_other(self): + color = Color(rgba=(0.4, 0.2, 0.6, 0.8)) + self.assertFalse(color == 1) + self.assertTrue(color != 1) + + def test_not_eq(self): + color_1 = Color(rgba=(0.4, 0.2, 0.6, 0.8)) + color_2 = Color(rgba=(0.0, 0.0, 0.0, 1.0)) + self.assertTrue(color_1 != color_2) + self.assertFalse(color_1 == color_2) + + def test_str(self): + color = Color(rgba=(0.4, 0.2, 0.6, 0.8)) + result = str(color) + self.assertEqual(result, "(0.4, 0.2, 0.6, 0.8)") + + def test_repr(self): + color = Color(rgba=(0.4, 0.2, 0.6, 0.8)) + result = repr(color) + self.assertEqual(result, "Color(rgba=(0.4, 0.2, 0.6, 0.8))") + + def test_get_red(self): + color = Color(rgba=(0.4, 0.2, 0.6, 0.8)) + self.assertEqual(color.red, 0.4) + + def test_set_red(self): + color = Color(rgba=(0.4, 0.2, 0.6, 0.8)) + color.red = 1.0 + self.assertEqual(color.rgba, (1.0, 0.2, 0.6, 0.8)) + + def test_get_green(self): + color = Color(rgba=(0.4, 0.2, 0.6, 0.8)) + self.assertEqual(color.green, 0.2) + + def test_set_green(self): + color = Color(rgba=(0.4, 0.2, 0.6, 0.8)) + color.green = 1.0 + self.assertEqual(color.rgba, (0.4, 1.0, 0.6, 0.8)) + + def test_get_blue(self): + color = Color(rgba=(0.4, 0.2, 0.6, 0.8)) + self.assertEqual(color.blue, 0.6) + + def test_set_blue(self): + color = Color(rgba=(0.4, 0.2, 0.6, 0.8)) + color.blue = 1.0 + self.assertEqual(color.rgba, (0.4, 0.2, 1.0, 0.8)) + + def test_get_alpha(self): + color = Color(rgba=(0.4, 0.2, 0.6, 0.8)) + self.assertEqual(color.alpha, 0.8) + + def test_set_alpha(self): + color = Color(rgba=(0.4, 0.2, 0.6, 0.8)) + color.alpha = 1.0 + self.assertEqual(color.rgba, (0.4, 0.2, 0.6, 1.0)) + + def test_get_rgb(self): + color = Color(rgba=(0.4, 0.2, 0.6, 0.8)) + self.assertEqual(color.rgb, (0.4, 0.2, 0.6)) + + def test_set_rgb(self): + color = Color(rgba=(0.4, 0.2, 0.6, 0.8)) + color.rgb = (0.6, 0.8, 0.4) + self.assertEqual(color.rgba, (0.6, 0.8, 0.4, 0.8)) + + def test_get_hsv(self): + color = Color(rgba=(0.48, 0.6, 0.528, 0.8)) + self.assertEqual(color.hsv, (0.4000000000000001, 0.2, 0.6)) + + def test_set_hsv(self): + color = Color() + color.hsv = (0.4, 0.2, 0.6) + self.assertEqual(color.rgba, (0.48, 0.6, 0.528, 1.0)) + + def test_get_hsva(self): + color = Color(rgba=(0.48, 0.6, 0.528, 0.8)) + self.assertEqual(color.hsva, (0.4000000000000001, 0.2, 0.6, 0.8)) + + def test_set_hsva(self): + color = Color() + color.hsva = (0.4, 0.2, 0.6, 0.8) + self.assertEqual(color.rgba, (0.48, 0.6, 0.528, 0.8)) + + def test_get_hls(self): + color = Color(rgba=(0.08, 0.32, 0.176, 0.8)) + self.assertEqual( + color.hls, + (0.39999999999999997, 0.2, 0.6) + ) + + def test_set_hls(self): + color = Color() + color.hls = (0.4, 0.2, 0.6) + self.assertEqual( + color.rgba, + (0.07999999999999996, 0.32000000000000006, 0.17600000000000007, 1) + ) + + def test_get_hlsa(self): + color = Color(rgba=(0.08, 0.32, 0.176, 0.8)) + self.assertEqual( + color.hlsa, + (0.39999999999999997, 0.2, 0.6, 0.8), + ) + + def test_set_hlsa(self): + color = Color() + color.hlsa = (0.4, 0.2, 0.6, 0.8) + self.assertEqual( + color.rgba, + (0.07999999999999996, 0.32000000000000006, 0.17600000000000007, 0.8) # noqa: E501 + ) + + +class TestPyfaceColor(TestCase): + + def test_init(self): + trait = PyfaceColor() + self.assertEqual(trait.default_value, (Color, (), {})) + self.assertEqual( + trait.default_value_type, + DefaultValue.callable_and_args, + ) + + def test_init_str(self): + trait = PyfaceColor("rebeccapurple") + self.assertEqual( + trait.default_value, + (Color, (), {'rgba': (0.4, 0.2, 0.6, 1.0)}) + ) + + def test_init_color(self): + trait = PyfaceColor(Color(rgba=(0.4, 0.2, 0.6, 1.0))) + self.assertEqual( + trait.default_value, + (Color, (), {'rgba': (0.4, 0.2, 0.6, 1.0)}) + ) + + def test_init_tuple(self): + trait = PyfaceColor((0.4, 0.2, 0.6, 1.0)) + self.assertEqual( + trait.default_value, + (Color, (), {'rgba': (0.4, 0.2, 0.6, 1.0)}) + ) + + def test_init_list(self): + trait = PyfaceColor([0.4, 0.2, 0.6, 1.0]) + self.assertEqual( + trait.default_value, + (Color, (), {'rgba': (0.4, 0.2, 0.6, 1.0)}) + ) + + @requires_numpy + def test_init_array(self): + trait = PyfaceColor(np.array([0.4, 0.2, 0.6, 1.0])) + self.assertEqual( + trait.default_value, + (Color, (), {'rgba': (0.4, 0.2, 0.6, 1.0)}) + ) + + @requires_numpy + def test_init_array_structured_dtype(self): + """ Test if "typical" RGBA structured array value works. """ + arr = np.array( + [(0.4, 0.2, 0.6, 1.0)], + dtype=np.dtype([ + ('red', float), + ('green', float), + ('blue', float), + ('alpha', float), + ]), + ) + trait = PyfaceColor(arr[0]) + self.assertEqual( + trait.default_value, + (Color, (), {'rgba': (0.4, 0.2, 0.6, 1.0)}) + ) + + def test_init_invalid(self): + with self.assertRaises(TraitError): + PyfaceColor((0.4, 0.2)) + + def test_validate_color(self): + color = Color(rgba=(0.4, 0.2, 0.6, 1.0)) + trait = PyfaceColor() + validated = trait.validate(None, None, color) + self.assertIs( + validated, color + ) + + def test_validate_tuple(self): + color = Color(rgba=(0.4, 0.2, 0.6, 0.8)) + trait = PyfaceColor() + validated = trait.validate(None, None, (0.4, 0.2, 0.6, 0.8)) + self.assertEqual( + validated, color + ) + + def test_validate_list(self): + color = Color(rgba=(0.4, 0.2, 0.6, 0.8)) + trait = PyfaceColor() + validated = trait.validate(None, None, [0.4, 0.2, 0.6, 0.8]) + self.assertEqual( + validated, color + ) + + def test_validate_rgb_list(self): + color = Color(rgba=(0.4, 0.2, 0.6, 1.0)) + trait = PyfaceColor() + validated = trait.validate(None, None, [0.4, 0.2, 0.6]) + self.assertEqual( + validated, color + ) + + def test_validate_bad_string(self): + trait = PyfaceColor() + with self.assertRaises(TraitError): + trait.validate(None, None, "not a color") + + def test_validate_bad_object(self): + trait = PyfaceColor() + with self.assertRaises(TraitError): + trait.validate(None, None, object()) + + def test_info(self): + trait = PyfaceColor() + self.assertIsInstance(trait.info(), str) diff --git a/pyface/tests/test_color_dialog.py b/pyface/tests/test_color_dialog.py new file mode 100644 index 000000000..2ce6c3cb7 --- /dev/null +++ b/pyface/tests/test_color_dialog.py @@ -0,0 +1,100 @@ +# (C) Copyright 2005-2020 Enthought, Inc., Austin, TX +# All rights reserved. +# +# This software is provided without warranty under the terms of the BSD +# license included in LICENSE.txt and may be redistributed only under +# the conditions described in the aforementioned license. The license +# is also available online at http://www.enthought.com/licenses/BSD.txt +# +# Thanks for using Enthought open source! + + +import unittest + +from ..color import Color +from ..color_dialog import ColorDialog, get_color +from ..toolkit import toolkit_object + +GuiTestAssistant = toolkit_object("util.gui_test_assistant:GuiTestAssistant") +no_gui_test_assistant = GuiTestAssistant.__name__ == "Unimplemented" + +ModalDialogTester = toolkit_object( + "util.modal_dialog_tester:ModalDialogTester" +) +no_modal_dialog_tester = ModalDialogTester.__name__ == "Unimplemented" + + +@unittest.skipIf(no_gui_test_assistant, "No GuiTestAssistant") +class TestColorDialog(unittest.TestCase, GuiTestAssistant): + + def setUp(self): + GuiTestAssistant.setUp(self) + self.dialog = ColorDialog(color="rebeccapurple") + + def tearDown(self): + if self.dialog.control is not None: + with self.delete_widget(self.dialog.control): + self.dialog.destroy() + del self.dialog + GuiTestAssistant.tearDown(self) + + def test_color(self): + # test that colors are translated as expected + self.dialog.color = "red" + + self.assertEqual(self.dialog.color, Color.from_str("red")) + + def test_create(self): + # test that creation and destruction works as expected + with self.event_loop(): + self.dialog._create() + with self.event_loop(): + self.dialog.destroy() + + def test_destroy(self): + # test that destroy works even when no control + with self.event_loop(): + self.dialog.destroy() + + def test_close(self): + # test that close works + with self.event_loop(): + self.dialog._create() + with self.event_loop(): + self.dialog.close() + + def test_show_alpha(self): + # test that creation and destruction works with show_alpha True + self.dialog.show_alpha = True + + with self.event_loop(): + self.dialog._create() + + +@unittest.skipIf(no_gui_test_assistant, "No GuiTestAssistant") +class TestGetColor(unittest.TestCase, GuiTestAssistant): + def setUp(self): + GuiTestAssistant.setUp(self) + + def tearDown(self): + GuiTestAssistant.tearDown(self) + + @unittest.skipIf(no_modal_dialog_tester, "ModalDialogTester unavailable") + def test_close(self): + # test that cancel works as expected + tester = ModalDialogTester( + lambda: get_color(None, "rebeccapurple") + ) + tester.open_and_run(when_opened=lambda x: x.close(accept=False)) + + self.assertEqual(tester.result, None) + + @unittest.skipIf(no_modal_dialog_tester, "ModalDialogTester unavailable") + def test_close_show_alpha(self): + # test that cancel works as expected + tester = ModalDialogTester( + lambda: get_color(None, "rebeccapurple", True) + ) + tester.open_and_run(when_opened=lambda x: x.close(accept=False)) + + self.assertEqual(tester.result, None) diff --git a/pyface/tests/test_font.py b/pyface/tests/test_font.py new file mode 100644 index 000000000..b8d7eac20 --- /dev/null +++ b/pyface/tests/test_font.py @@ -0,0 +1,489 @@ +# (C) Copyright 2005-2020 Enthought, Inc., Austin, TX +# All rights reserved. +# +# This software is provided without warranty under the terms of the BSD +# license included in LICENSE.txt and may be redistributed only under +# the conditions described in the aforementioned license. The license +# is also available online at http://www.enthought.com/licenses/BSD.txt +# +# Thanks for using Enthought open source! + +import unittest + +from traits.api import HasStrictTraits, TraitError + +from ..font import ( + Font, FontSize, FontParseError, PyfaceFont, parse_font_description, sizes, stretches, + styles, variants, weights +) + + +class FontSizeDummy(HasStrictTraits): + + size = FontSize() + size_default_1 = FontSize(14.0) + size_default_2 = FontSize("14.0") + size_default_3 = FontSize("14.0pt") + size_default_4 = FontSize("large") + + +class TestFontTraits(unittest.TestCase): + + def test_font_size_trait_defaults(self): + dummy = FontSizeDummy() + + self.assertEqual(dummy.size, 12.0) + self.assertEqual(dummy.size_default_1, 14.0) + self.assertEqual(dummy.size_default_2, 14.0) + self.assertEqual(dummy.size_default_3, 14.0) + self.assertEqual(dummy.size_default_4, 14.0) + + def test_font_size_trait_invalid_default(self): + with self.assertRaises(TraitError): + FontSize("badvalue") + + with self.assertRaises(TraitError): + FontSize(-1.0) + + with self.assertRaises(TraitError): + FontSize("-1.0") + + with self.assertRaises(TraitError): + FontSize("0pt") + + def test_font_size_trait_validate(self): + dummy = FontSizeDummy() + + dummy.size = 14.0 + self.assertEqual(dummy.size, 14.0) + + dummy.size = "15.0" + self.assertEqual(dummy.size, 15.0) + + dummy.size = "16.0pt" + self.assertEqual(dummy.size, 16.0) + + dummy.size = "x-large" + self.assertEqual(dummy.size, 18.0) + + def test_font_size_trait_invalid_validate(self): + dummy = FontSizeDummy() + + with self.assertRaises(TraitError): + dummy.size = "badvalue" + + with self.assertRaises(TraitError): + dummy.size = -1.0 + + with self.assertRaises(TraitError): + dummy.size = "-1.0" + + with self.assertRaises(TraitError): + dummy.size = "0pt" + + +class TestFont(unittest.TestCase): + + def test_default(self): + font = Font() + + self.assertEqual(font.family, ['default']) + self.assertEqual(font.size, 12.0) + self.assertEqual(font.weight, 'normal') + self.assertEqual(font.stretch, 'normal') + self.assertEqual(font.style, 'normal') + self.assertEqual(font.variants, set()) + + def test_typical(self): + font = Font( + family=['Helvetica', 'sans-serif'], + size='large', + weight='demibold', + stretch='condensed', + style='italic', + variants={'small-caps', 'underline'}, + ) + + self.assertEqual(font.family, ['Helvetica', 'sans-serif']) + self.assertEqual(font.size, 14.0) + self.assertEqual(font.weight, 'demibold') + self.assertEqual(font.weight_, 600) + self.assertEqual(font.stretch, 'condensed') + self.assertEqual(font.stretch_, 300) + self.assertEqual(font.style, 'italic') + self.assertEqual(font.variants, {'small-caps', 'underline'}) + + def test_str(self): + font = Font() + + description = str(font) + + self.assertEqual(description, "12pt default") + + def test_str_typical(self): + font = Font( + family=['Comic Sans', 'decorative'], + size='large', + weight='demibold', + stretch='condensed', + style='italic', + variants={'small-caps', 'underline'}, + ) + + description = str(font) + + self.assertEqual( + description, + "italic small-caps underline demibold condensed 14pt " + "'Comic Sans', decorative" + ) + + def test_from_description(self): + font = Font.from_description( + "italic small-caps underline demibold condensed 14pt Helvetica, " + "sans-serif" + ) + + self.assertEqual(font.family, ['Helvetica', 'sans-serif']) + self.assertEqual(font.size, 14.0) + self.assertEqual(font.weight, 'demibold') + self.assertEqual(font.weight_, 600) + self.assertEqual(font.stretch, 'condensed') + self.assertEqual(font.stretch_, 300) + self.assertEqual(font.style, 'italic') + self.assertEqual(font.variants, {'small-caps', 'underline'}) + + def test_repr(self): + font = Font() + + text = repr(font) + + # this is little more than a smoke check, but good enough + self.assertTrue(text.startswith('Font(')) + + def test_repr_typical(self): + font = Font( + family=['Helvetica', 'sans-serif'], + size='large', + weight='demibold', + stretch='condensed', + style='italic', + variants={'small-caps', 'underline'}, + ) + + text = repr(font) + + # this is little more than a smoke check, but good enough + self.assertTrue(text.startswith('Font(')) + + def test_to_toolkit(self): + font = Font() + + # smoke test + toolkit_font = font.to_toolkit() + + def test_to_toolkit_typical(self): + font = Font( + family=['Helvetica', 'sans-serif'], + size='large', + weight='demibold', + stretch='condensed', + style='italic', + variants={'small-caps', 'underline'}, + ) + + # smoke test + toolkit_font = font.to_toolkit() + + def test_from_toolkit(self): + font = Font() + + # smoke test + result = Font.from_toolkit(font.to_toolkit()) + + def test_from_toolkit_typical(self): + font = Font( + family=['Helvetica', 'sans-serif'], + size='large', + weight='demibold', + stretch='condensed', + style='italic', + variants={'small-caps', 'underline'}, + ) + + # smoke test + result = Font.from_toolkit(font.to_toolkit()) + + +class TestParseFontDescription(unittest.TestCase): + + def test_empty(self): + properties = parse_font_description("") + self.assertEqual( + properties, + { + 'family': ['default'], + 'size': 12.0, + 'weight': 'normal', + 'stretch': 'normal', + 'style': 'normal', + 'variants': set() + } + ) + + def test_typical(self): + properties = parse_font_description( + "small-caps underline overline strikethrough condensed demibold " + "italic 18pt Helvetica, sans-serif" + ) + self.assertEqual( + properties, + { + 'family': ['Helvetica', 'sans-serif'], + 'size': 18.0, + 'weight': 'demibold', + 'stretch': 'condensed', + 'style': 'italic', + 'variants': set(variants) + } + ) + + def test_families(self): + sub_cases = { + 'default': ['default'], + 'san-serif': ['san-serif'], + 'Helvetica': ['Helvetica'], + '"Comic Sans"': ['Comic Sans'], + "'Comic Sans'": ['Comic Sans'], + "Times, serif": ['Times', 'serif'], + "'Times New Roman', serif": ['Times New Roman', 'serif'], + '"Times New Roman", serif': ['Times New Roman', 'serif'], + 'serif, "Times New Roman"': ['serif', 'Times New Roman'], + "Times serif": ['Times', 'serif'], + "'Times New Roman' serif": ['Times New Roman', 'serif'], + '"Times New Roman" serif': ['Times New Roman', 'serif'], + 'serif "Times New Roman"': ['serif', 'Times New Roman'], + '"italic"': ['italic'], + '"0!@#$%^&*()_+-={}[]\\|;:/?.,<>`~"': + ['0!@#$%^&*()_+-={}[]\\|;:/?.,<>`~'], + } + for text, family in sub_cases.items(): + with self.subTest(text=text): + properties = parse_font_description(text) + self.assertEqual( + properties, + { + 'family': family, + 'size': 12.0, + 'weight': 'normal', + 'stretch': 'normal', + 'style': 'normal', + 'variants': set(), + } + ) + + def test_styles(self): + for style in styles: + with self.subTest(style=style): + properties = parse_font_description(style) + self.assertEqual( + properties, + { + 'family': ['default'], + 'size': 12.0, + 'weight': 'normal', + 'stretch': 'normal', + 'style': style, + 'variants': set(), + } + ) + + def test_variants(self): + for variant in variants: + with self.subTest(variant=variant): + properties = parse_font_description(variant) + self.assertEqual( + properties, + { + 'family': ['default'], + 'size': 12.0, + 'weight': 'normal', + 'stretch': 'normal', + 'style': 'normal', + 'variants': {variant}, + } + ) + + def test_stretches(self): + for stretch in stretches: + with self.subTest(stretch=stretch): + properties = parse_font_description(stretch) + self.assertEqual( + properties, + { + 'family': ['default'], + 'size': 12.0, + 'weight': 'normal', + 'stretch': stretch, + 'style': 'normal', + 'variants': set(), + } + ) + + def test_weights(self): + sub_cases = weights + for weight in sub_cases: + with self.subTest(weight=weight): + properties = parse_font_description(weight) + self.assertEqual( + properties, + { + 'family': ['default'], + 'size': 12.0, + 'weight': weight, + 'stretch': 'normal', + 'style': 'normal', + 'variants': set(), + } + ) + + def test_sizes(self): + sub_cases = { + '14': 14.0, + '14.': 14.0, + '14.5': 14.5, + '14pt': 14.0, + '14.pt': 14.0, + '14.5pt': 14.5 + } + sub_cases.update(sizes) + for size, point_size in sub_cases.items(): + with self.subTest(size=size): + properties = parse_font_description(size) + self.assertEqual( + properties, + { + 'family': ['default'], + 'size': point_size, + 'weight': 'normal', + 'stretch': 'normal', + 'style': 'normal', + 'variants': set(), + } + ) + + def test_weight_size_corner_cases(self): + sub_cases = { + '100': {'weight': '100', 'size': 12.0}, + '100pt': {'weight': 'normal', 'size': 100.0}, + '100.': {'weight': 'normal', 'size': 100.0}, + '100.0': {'weight': 'normal', 'size': 100.0}, + '100 100': {'weight': '100', 'size': 100.0}, + '100 200': {'weight': '100', 'size': 200.0}, + } + for text, output in sub_cases.items(): + with self.subTest(text=text): + properties = parse_font_description(text) + expected = { + 'family': ['default'], + 'stretch': 'normal', + 'style': 'normal', + 'variants': set(), + } + expected.update(output) + self.assertEqual(properties, expected) + + def test_failures(self): + sub_cases = [ + 'bold demibold', # two weights + 'extra-condensed ultra-condensed', # two stretches + 'small-caps small-caps', # two styles + '10pt 12pt', # two sizes + 'default #*@&#*', # bad token + ] + for text in sub_cases: + with self.subTest(text=text): + with self.assertRaises(FontParseError): + parse_font_description(text) + + +class TestPyfaceFontTrait(unittest.TestCase): + + def test_simple_init(self): + trait = PyfaceFont() + + self.assertEqual(trait.default_value, (Font, (), {})) + + def test_dict_init(self): + trait = PyfaceFont({'size': 12, 'family': ["Comic Sans"]}) + + self.assertEqual( + trait.default_value, + (Font, (), {'size': 12, 'family': ("Comic Sans",)}) + ) + + def test_str_init(self): + trait = PyfaceFont('12pt "Comic Sans"') + + self.assertEqual( + trait.default_value, + ( + Font, + (), + { + 'size': 12, + 'family': ("Comic Sans",), + 'weight': 'normal', + 'style': 'normal', + 'stretch': 'normal', + 'variants': frozenset(), + } + ) + ) + + def test_font_init(self): + trait = PyfaceFont(Font(size='12pt', family=["Comic Sans"])) + + self.assertEqual( + trait.default_value, + ( + Font, + (), + { + 'size': 12, + 'family': ("Comic Sans",), + 'weight': 'normal', + 'style': 'normal', + 'stretch': 'normal', + 'variants': frozenset(), + } + ) + ) + + def test_font_validate_str(self): + trait = PyfaceFont() + + result = trait.validate(None, None, '12pt "Comic Sans"') + + self.assertEqual(result, Font.from_description('12pt "Comic Sans"')) + + def test_font_validate_font(self): + trait = PyfaceFont() + font = Font.from_description('12pt "Comic Sans"') + + result = trait.validate(None, None, font) + + self.assertIs(result, font) + + def test_font_validate_invalid_type(self): + trait = PyfaceFont() + + with self.assertRaises(TraitError): + trait.validate(None, None, 12) + + def test_font_validate_invalid_string(self): + trait = PyfaceFont() + + with self.assertRaises(TraitError): + trait.validate(None, None, 'default #*@&#*') + diff --git a/pyface/tests/test_font_dialog.py b/pyface/tests/test_font_dialog.py new file mode 100644 index 000000000..a25b054b6 --- /dev/null +++ b/pyface/tests/test_font_dialog.py @@ -0,0 +1,64 @@ +# (C) Copyright 2005-2020 Enthought, Inc., Austin, TX +# All rights reserved. +# +# This software is provided without warranty under the terms of the BSD +# license included in LICENSE.txt and may be redistributed only under +# the conditions described in the aforementioned license. The license +# is also available online at http://www.enthought.com/licenses/BSD.txt +# +# Thanks for using Enthought open source! + + +import unittest + +from ..font import Font +from ..font_dialog import FontDialog +from ..toolkit import toolkit_object + +GuiTestAssistant = toolkit_object("util.gui_test_assistant:GuiTestAssistant") +no_gui_test_assistant = GuiTestAssistant.__name__ == "Unimplemented" + +ModalDialogTester = toolkit_object( + "util.modal_dialog_tester:ModalDialogTester" +) +no_modal_dialog_tester = ModalDialogTester.__name__ == "Unimplemented" + + +@unittest.skipIf(no_gui_test_assistant, "No GuiTestAssistant") +class TestFontDialog(unittest.TestCase, GuiTestAssistant): + + def setUp(self): + GuiTestAssistant.setUp(self) + self.dialog = FontDialog(font="10pt Arial, Helvetica, sans-serif") + + def tearDown(self): + if self.dialog.control is not None: + with self.delete_widget(self.dialog.control): + self.dialog.destroy() + del self.dialog + GuiTestAssistant.tearDown(self) + + def test_font(self): + # test that creation and destruction works as expected + self.dialog.font = "10 pt Arial" + + self.assertEqual(self.dialog.font, Font.from_description("10 pt Arial")) + + def test_create(self): + # test that creation and destruction works as expected + with self.event_loop(): + self.dialog._create() + with self.event_loop(): + self.dialog.destroy() + + def test_destroy(self): + # test that destroy works even when no control + with self.event_loop(): + self.dialog.destroy() + + def test_close(self): + # test that close works + with self.event_loop(): + self.dialog._create() + with self.event_loop(): + self.dialog.close() diff --git a/pyface/ui/null/color.py b/pyface/ui/null/color.py new file mode 100644 index 000000000..1dac80b9f --- /dev/null +++ b/pyface/ui/null/color.py @@ -0,0 +1,51 @@ +# (C) Copyright 2005-2020 Enthought, Inc., Austin, TX +# All rights reserved. +# +# This software is provided without warranty under the terms of the BSD +# license included in LICENSE.txt and may be redistributed only under +# the conditions described in the aforementioned license. The license +# is also available online at http://www.enthought.com/licenses/BSD.txt +# +# Thanks for using Enthought open source! +""" Color conversion routines for the null toolkit. + +This module provides a couple of utility methods to support the +pyface.color.Color class to_toolkit and from_toolkit methods. + +For definiteness, the null toolkit uses tuples of RGBA values from 0 to 255 +to represent colors. +""" + +from pyface.color import channels_to_ints, ints_to_channels + + +def toolkit_color_to_rgba(color): + """ Convert a hex tuple to an RGBA tuple. + + Parameters + ---------- + color : tuple + A tuple of integer values from 0 to 255 inclusive. + + Returns + ------- + rgba : tuple + A tuple of 4 floating point values between 0.0 and 1.0 inclusive. + """ + return ints_to_channels(color) + + +def rgba_to_toolkit_color(rgba): + """ Convertan RGBA tuple to a hex tuple. + + Parameters + ---------- + color : tuple + A tuple of integer values from 0 to 255 inclusive. + + Returns + ------- + rgba : tuple + A tuple of 4 floating point values between 0.0 and 1.0 inclusive. + """ + return channels_to_ints(rgba) diff --git a/pyface/ui/qt4/color.py b/pyface/ui/qt4/color.py new file mode 100644 index 000000000..18dd07c54 --- /dev/null +++ b/pyface/ui/qt4/color.py @@ -0,0 +1,57 @@ +# (C) Copyright 2005-2020 Enthought, Inc., Austin, TX +# All rights reserved. +# +# This software is provided without warranty under the terms of the BSD +# license included in LICENSE.txt and may be redistributed only under +# the conditions described in the aforementioned license. The license +# is also available online at http://www.enthought.com/licenses/BSD.txt +# +# Thanks for using Enthought open source! +""" Color conversion routines for the Qt toolkit. + +This module provides a couple of utility methods to support the +pyface.color.Color class to_toolkit and from_toolkit methods. +""" + +from pyface.qt.QtGui import QColor + +from pyface.color import channels_to_ints, ints_to_channels + + +def toolkit_color_to_rgba(qcolor): + """ Convert a QColor to an RGBA tuple. + + Parameters + ---------- + qcolor : QColor + A QColor object. + + Returns + ------- + rgba_tuple : tuple + A tuple of 4 floating point values between 0.0 and 1.0 inclusive. + """ + values = ( + qcolor.red(), + qcolor.green(), + qcolor.blue(), + qcolor.alpha(), + ) + return ints_to_channels(values) + + +def rgba_to_toolkit_color(rgba): + """ Convert an RGBA tuple to a QColor. + + Parameters + ---------- + rgba_tuple : tuple + A tuple of 4 floating point values between 0.0 and 1.0 inclusive. + + Returns + ------- + qcolor : QColor + A QColor object. + """ + values = channels_to_ints(rgba) + return QColor(*values) diff --git a/pyface/ui/qt4/color_dialog.py b/pyface/ui/qt4/color_dialog.py new file mode 100644 index 000000000..e83cfbbef --- /dev/null +++ b/pyface/ui/qt4/color_dialog.py @@ -0,0 +1,62 @@ +# (C) Copyright 2005-2020 Enthought, Inc., Austin, TX +# All rights reserved. +# +# This software is provided without warranty under the terms of the BSD +# license included in LICENSE.txt and may be redistributed only under +# the conditions described in the aforementioned license. The license +# is also available online at http://www.enthought.com/licenses/BSD.txt +# +# Thanks for using Enthought open source! + +""" A dialog that allows the user to select a color. """ + +from pyface.qt import QtGui + +from traits.api import Bool, provides + +from pyface.color import Color, PyfaceColor +from pyface.i_color_dialog import IColorDialog +from .dialog import Dialog + + +@provides(IColorDialog) +class ColorDialog(Dialog): + """ A dialog that allows the user to choose a color. + """ + + # 'IColorDialog' interface ---------------------------------------------- + + #: The color in the dialog. + color = PyfaceColor() + + #: Whether or not to allow the user to chose an alpha value. + show_alpha = Bool(False) + + # ------------------------------------------------------------------------ + # 'IDialog' interface. + # ------------------------------------------------------------------------ + + def _create_contents(self, parent): + # In PyQt this is a canned dialog. + pass + + # ------------------------------------------------------------------------ + # 'IWindow' interface. + # ------------------------------------------------------------------------ + + def close(self): + if self.control.result() == QtGui.QDialog.Accepted: + qcolor = self.control.selectedColor() + self.color = Color.from_toolkit(qcolor) + return super(ColorDialog, self).close() + + # ------------------------------------------------------------------------ + # 'IWindow' interface. + # ------------------------------------------------------------------------ + + def _create_control(self, parent): + qcolor = self.color.to_toolkit() + dialog = QtGui.QColorDialog(qcolor, parent) + if self.show_alpha: + dialog.setOptions(QtGui.QColorDialog.ShowAlphaChannel) + return dialog diff --git a/pyface/ui/qt4/font.py b/pyface/ui/qt4/font.py new file mode 100644 index 000000000..75076ed83 --- /dev/null +++ b/pyface/ui/qt4/font.py @@ -0,0 +1,225 @@ +# (C) Copyright 2005-2020 Enthought, Inc., Austin, TX +# All rights reserved. +# +# This software is provided without warranty under the terms of the BSD +# license included in LICENSE.txt and may be redistributed only under +# the conditions described in the aforementioned license. The license +# is also available online at http://www.enthought.com/licenses/BSD.txt +# +# Thanks for using Enthought open source! + +""" +Font conversion utilities + +This module provides facilities for converting between pyface Font objects +and Qt QFont objects, trying to keep as much similarity as possible between +them. +""" + +from pyface.qt import is_qt4 +from pyface.qt.QtGui import QFont + + +qt_family_to_generic_family = { + QFont.AnyStyle: 'default', + QFont.System: 'default', + QFont.Decorative: 'fantasy', + QFont.Serif: 'serif', + QFont.Cursive: 'cursive', + QFont.SansSerif: 'sans-serif', + QFont.Monospace: 'monospace', + QFont.TypeWriter: 'typewriter', +} +generic_family_to_qt_family = { + 'default': QFont.System, + 'fantasy': QFont.Decorative, + 'decorative': QFont.Decorative, + 'serif': QFont.Serif, + 'roman': QFont.Serif, + 'cursive': QFont.Cursive, + 'script': QFont.Cursive, + 'sans-serif': QFont.SansSerif, + 'swiss': QFont.SansSerif, + 'monospace': QFont.Monospace, + 'modern': QFont.Monospace, + 'typewriter': QFont.TypeWriter, +} + +weight_to_qt_weight = { + 100: 0, + 200: QFont.ExtraLight, + 300: QFont.Light, + 400: QFont.Normal, + 500: QFont.Medium, + 600: QFont.DemiBold, + 700: QFont.Bold, + 800: QFont.ExtraBold, + 900: QFont.Black, + 1000: 99, +} +qt_weight_to_weight = { + 0: 'thin', + QFont.ExtraLight: 'extra-light', + QFont.Light: 'light', + QFont.Normal: 'normal', + QFont.Medium: 'medium', + QFont.DemiBold: 'demibold', + QFont.Bold: 'bold', + QFont.ExtraBold: 'extra-bold', + QFont.Black: 'black', + 99: 'extra-heavy', +} + +stretch_to_qt_stretch = { + 100: QFont.UltraCondensed, + 200: QFont.ExtraCondensed, + 300: QFont.Condensed, + 400: QFont.SemiCondensed, + 500: QFont.Normal, + 600: QFont.SemiExpanded, + 700: QFont.Expanded, + 800: QFont.ExtraExpanded, + 900: QFont.UltraExpanded, +} +qt_stretch_to_stretch = { + QFont.UltraCondensed: 'ultra-condensed', + QFont.ExtraCondensed: 'extra-condensed', + QFont.Condensed: 'condensed', + QFont.SemiCondensed: 'semi-condensed', + QFont.Normal: 'normal', + QFont.SemiExpanded: 'semi-expanded', + QFont.Expanded: 'expanded', + QFont.ExtraExpanded: 'extra-expanded', + QFont.UltraExpanded: 'ultra-expanded', +} + +style_to_qt_style = { + 'normal': QFont.StyleNormal, + 'oblique': QFont.StyleOblique, + 'italic': QFont.StyleItalic, +} +qt_style_to_style = {value: key for key, value in style_to_qt_style.items()} + + +def font_to_toolkit_font(font): + """ Convert a Pyface font to a Qfont. + + Parameters + ---------- + font : pyface.font.Font + The Pyface font to convert. + + Returns + ------- + qt_font : QFont + The best matching Qt font. + """ + qt_font = QFont() + families = [] + default_family = None + + for family in font.family: + if family not in generic_family_to_qt_family: + families.append(family) + elif default_family is None: + default_family = family + + if families and hasattr(qt_font, 'setFamilies'): + # Qt 5.13 and later + qt_font.setFamilies(families) + elif families: + qt_font.setFamily(families[0]) + # Note: possibily could use substitutions here, + # but not sure if global (which would be bad, so we don't) + + if default_family is not None: + qt_font.setStyleHint(generic_family_to_qt_family[default_family]) + + qt_font.setPointSizeF(font.size) + qt_font.setWeight(weight_to_qt_weight[font.weight_]) + qt_font.setStretch(stretch_to_qt_stretch[font.stretch_]) + qt_font.setStyle(style_to_qt_style[font.style]) + qt_font.setUnderline('underline' in font.variants) + qt_font.setStrikeOut('strikethrough' in font.variants) + qt_font.setOverline('overline' in font.variants) + if 'small-caps' in font.variants: + qt_font.setCapitalization(QFont.SmallCaps) + return qt_font + + +def toolkit_font_to_properties(toolkit_font): + """ Convert a QFont to a dictionary of font properties. + + Parameters + ---------- + toolkit_font : QFont + The Qt QFont to convert. + + Returns + ------- + properties : dict + Font properties suitable for use in creating a Pyface Font. + """ + family = [] + + if hasattr(toolkit_font, 'families'): + # Qt 5.13 and later + family = list(toolkit_font.families()) + elif toolkit_font.family(): + family.append(toolkit_font.family()) + if toolkit_font.defaultFamily(): + family.append(toolkit_font.defaultFamily()) + family.append(qt_family_to_generic_family[toolkit_font.styleHint()]) + + size = toolkit_font.pointSizeF() + style = qt_style_to_style[toolkit_font.style()] + weight = map_to_nearest(toolkit_font.weight(), qt_weight_to_weight) + stretch = map_to_nearest(toolkit_font.stretch(), qt_stretch_to_stretch) + variants = set() + if toolkit_font.underline(): + variants.add('underline') + if toolkit_font.strikeOut(): + variants.add('strikethrough') + if toolkit_font.overline(): + variants.add('overline') + if toolkit_font.capitalization() == QFont.SmallCaps: + variants.add('small-caps') + + return { + 'family': family, + 'size': size, + 'weight': weight, + 'stretch': stretch, + 'style': style, + 'variants': variants, + } + + +def map_to_nearest(target, mapping): + """ Given mapping with keys from 0 and 99, return closest value. + + This is used to map back from + + Parameters + ---------- + target : int + The value to map. + mapping : dict + A dictionary with integer keys ranging from 0 to 99. + + Returns + ------- + value : any + The value corresponding to the nearest key. In the case of a tie, + the first value is returned. + """ + if target in mapping: + return mapping[target] + + distance = 100 + nearest = None + for key in mapping: + if abs(target - key) < distance: + distance = abs(target - key) + nearest = key + return mapping[nearest] diff --git a/pyface/ui/qt4/font_dialog.py b/pyface/ui/qt4/font_dialog.py new file mode 100644 index 000000000..74adee1be --- /dev/null +++ b/pyface/ui/qt4/font_dialog.py @@ -0,0 +1,55 @@ +# (C) Copyright 2005-2020 Enthought, Inc., Austin, TX +# All rights reserved. +# +# This software is provided without warranty under the terms of the BSD +# license included in LICENSE.txt and may be redistributed only under +# the conditions described in the aforementioned license. The license +# is also available online at http://www.enthought.com/licenses/BSD.txt +# +# Thanks for using Enthought open source! + +""" A dialog that allows the user to select a font. """ + +from pyface.qt import QtGui + +from traits.api import provides + +from pyface.font import Font, PyfaceFont +from pyface.i_font_dialog import IFontDialog +from .dialog import Dialog + + +@provides(IFontDialog) +class FontDialog(Dialog): + """ A dialog for selecting fonts. + """ + + #: A Font instance that holds the initial font at the start and tracks + #: the selected font during user interactions. + font = PyfaceFont() + + # ------------------------------------------------------------------------ + # 'IDialog' interface. + # ------------------------------------------------------------------------ + + def _create_contents(self, parent): + # In PyQt this is a canned dialog. + pass + + # ------------------------------------------------------------------------ + # 'IWindow' interface. + # ------------------------------------------------------------------------ + + def close(self): + qt_font = self.control.selectedFont() + self.font = Font.from_toolkit(qt_font) + super(FontDialog, self).close() + + # ------------------------------------------------------------------------ + # 'IWidget' interface. + # ------------------------------------------------------------------------ + + def _create_control(self, parent): + qt_font = self.font.to_toolkit() + dialog = QtGui.QFontDialog(qt_font, parent) + return dialog diff --git a/pyface/ui/wx/color.py b/pyface/ui/wx/color.py new file mode 100644 index 000000000..eadfdbfbc --- /dev/null +++ b/pyface/ui/wx/color.py @@ -0,0 +1,58 @@ +# (C) Copyright 2005-2020 Enthought, Inc., Austin, TX +# All rights reserved. +# +# This software is provided without warranty under the terms of the BSD +# license included in LICENSE.txt and may be redistributed only under +# the conditions described in the aforementioned license. The license +# is also available online at http://www.enthought.com/licenses/BSD.txt +# +# Thanks for using Enthought open source! +""" Color conversion routines for the wx toolkit. + +This module provides a couple of utility methods to support the +pyface.color.Color class to_toolkit and from_toolkit methods. +""" + + +import wx + +from pyface.color import channels_to_ints, ints_to_channels + + +def toolkit_color_to_rgba(wx_colour): + """ Convert a wx.Colour to an RGBA tuple. + + Parameters + ---------- + wx_color : wx.Colour + A wx.Colour object. + + Returns + ------- + rgba : tuple + A tuple of 4 floating point values between 0.0 and 1.0 inclusive. + """ + values = ( + wx_colour.Red(), + wx_colour.Green(), + wx_colour.Blue(), + wx_colour.Alpha(), + ) + return ints_to_channels(values) + + +def rgba_to_toolkit_color(rgba): + """ Convert an RGBA tuple to a wx.Colour. + + Parameters + ---------- + rgba : tuple + A tuple of 4 floating point values between 0.0 and 1.0 inclusive. + + Returns + ------- + wx_color : wx.Colour + A wx.Colour object. + """ + values = channels_to_ints(rgba) + return wx.Colour(*values) diff --git a/pyface/ui/wx/color_dialog.py b/pyface/ui/wx/color_dialog.py new file mode 100644 index 000000000..7df29cab2 --- /dev/null +++ b/pyface/ui/wx/color_dialog.py @@ -0,0 +1,69 @@ +# (C) Copyright 2005-2020 Enthought, Inc., Austin, TX +# All rights reserved. +# +# This software is provided without warranty under the terms of the BSD +# license included in LICENSE.txt and may be redistributed only under +# the conditions described in the aforementioned license. The license +# is also available online at http://www.enthought.com/licenses/BSD.txt +# +# Thanks for using Enthought open source! + +""" The interface for a dialog that allows the user to select a color. """ + +import wx + +from traits.api import Bool, provides + +from pyface.color import Color, PyfaceColor +from pyface.i_color_dialog import IColorDialog +from .dialog import Dialog + +# The WxPython version in a convenient to compare form. +wx_version = tuple(int(x) for x in wx.__version__.split('.')[:3]) + + +@provides(IColorDialog) +class ColorDialog(Dialog): + """ A dialog for selecting colors. + """ + + # 'IColorDialog' interface ---------------------------------------------- + + #: The color in the dialog. + color = PyfaceColor() + + #: Whether or not to allow the user to chose an alpha value. Only works + #: for wxPython 4.1 and higher. + show_alpha = Bool(False) + + # ------------------------------------------------------------------------ + # 'IDialog' interface. + # ------------------------------------------------------------------------ + + def _create_contents(self, parent): + # In wx this is a canned dialog. + pass + + # ------------------------------------------------------------------------ + # 'IWindow' interface. + # ------------------------------------------------------------------------ + + def close(self): + if self.control.GetReturnCode() == wx.ID_OK: + colour_data = self.control.GetColourData() + wx_colour = colour_data.GetColour() + self.color = Color.from_toolkit(wx_colour) + super(ColorDialog, self).close() + + # ------------------------------------------------------------------------ + # 'IWidget' interface. + # ------------------------------------------------------------------------ + + def _create_control(self, parent): + wx_colour = self.color.to_toolkit() + data = wx.ColourData() + data.SetColour(wx_colour) + if wx_version >= (4, 1): + data.SetChooseAlpha(self.show_alpha) + dialog = wx.ColourDialog(parent, data) + return dialog diff --git a/pyface/ui/wx/font.py b/pyface/ui/wx/font.py new file mode 100644 index 000000000..091bfce39 --- /dev/null +++ b/pyface/ui/wx/font.py @@ -0,0 +1,183 @@ +# (C) Copyright 2005-2020 Enthought, Inc., Austin, TX +# All rights reserved. +# +# This software is provided without warranty under the terms of the BSD +# license included in LICENSE.txt and may be redistributed only under +# the conditions described in the aforementioned license. The license +# is also available online at http://www.enthought.com/licenses/BSD.txt +# +# Thanks for using Enthought open source! + +""" +Font conversion utilities + +This module provides facilities for converting between pyface Font objects +and Wx Font objects, trying to keep as much similarity as possible between +them. +""" + +import wx + +from pyface.font import Font + + +# font weight and size features changed in wxPython 4.1/wxWidgets 3.1 +wx_version = tuple(int(x) for x in wx.__version__.split('.')[:2]) +wx_python_4_1 = (wx_version >= (4, 1)) + + +wx_family_to_generic_family = { + wx.FONTFAMILY_DEFAULT: 'default', + wx.FONTFAMILY_DECORATIVE: 'fantasy', + wx.FONTFAMILY_ROMAN: 'serif', + wx.FONTFAMILY_SCRIPT: 'cursive', + wx.FONTFAMILY_SWISS: 'sans-serif', + wx.FONTFAMILY_MODERN: 'monospace', + wx.FONTFAMILY_TELETYPE: 'typewriter', +} +generic_family_to_wx_family = { + 'default': wx.FONTFAMILY_DEFAULT, + 'fantasy': wx.FONTFAMILY_DECORATIVE, + 'decorative': wx.FONTFAMILY_DECORATIVE, + 'serif': wx.FONTFAMILY_ROMAN, + 'roman': wx.FONTFAMILY_ROMAN, + 'cursive': wx.FONTFAMILY_SCRIPT, + 'script': wx.FONTFAMILY_SCRIPT, + 'sans-serif': wx.FONTFAMILY_SWISS, + 'swiss': wx.FONTFAMILY_SWISS, + 'monospace': wx.FONTFAMILY_MODERN, + 'modern': wx.FONTFAMILY_MODERN, + 'typewriter': wx.FONTFAMILY_TELETYPE, +} + +if wx_python_4_1: + weight_to_wx_weight = { + 100: wx.FONTWEIGHT_THIN, + 200: wx.FONTWEIGHT_EXTRALIGHT, + 300: wx.FONTWEIGHT_LIGHT, + 400: wx.FONTWEIGHT_NORMAL, + 500: wx.FONTWEIGHT_MEDIUM, + 600: wx.FONTWEIGHT_SEMIBOLD, + 700: wx.FONTWEIGHT_BOLD, + 800: wx.FONTWEIGHT_EXTRABOLD, + 900: wx.FONTWEIGHT_HEAVY, + 1000: wx.FONTWEIGHT_EXTRAHEAVY, + } + wx_weight_to_weight = { + wx.FONTWEIGHT_THIN: 'thin', + wx.FONTWEIGHT_EXTRALIGHT: 'extra-light', + wx.FONTWEIGHT_LIGHT: 'light', + wx.FONTWEIGHT_NORMAL: 'normal', + wx.FONTWEIGHT_MEDIUM: 'medium', + wx.FONTWEIGHT_SEMIBOLD: 'semibold', + wx.FONTWEIGHT_BOLD: 'bold', + wx.FONTWEIGHT_EXTRABOLD: 'extra-bold', + wx.FONTWEIGHT_HEAVY: 'heavy', + wx.FONTWEIGHT_EXTRAHEAVY: 'extra-heavy', + wx.FONTWEIGHT_MAX: 'extra-heavy', + } +else: + weight_to_wx_weight = { + 100: wx.FONTWEIGHT_LIGHT, + 200: wx.FONTWEIGHT_LIGHT, + 300: wx.FONTWEIGHT_LIGHT, + 400: wx.FONTWEIGHT_NORMAL, + 500: wx.FONTWEIGHT_NORMAL, + 600: wx.FONTWEIGHT_BOLD, + 700: wx.FONTWEIGHT_BOLD, + 800: wx.FONTWEIGHT_BOLD, + 900: wx.FONTWEIGHT_MAX, + 1000: wx.FONTWEIGHT_MAX, + } + wx_weight_to_weight = { + wx.FONTWEIGHT_LIGHT: 'light', + wx.FONTWEIGHT_NORMAL: 'normal', + wx.FONTWEIGHT_BOLD: 'bold', + wx.FONTWEIGHT_MAX: 'extra-heavy', + } + +style_to_wx_style = { + 'normal': wx.FONTSTYLE_NORMAL, + 'oblique': wx.FONTSTYLE_SLANT, + 'italic': wx.FONTSTYLE_ITALIC, +} +wx_style_to_style = {value: key for key, value in style_to_wx_style.items()} + + +def font_to_toolkit_font(font): + """ Convert a Pyface font to a wx.font Font. + + Wx fonts have no notion of stretch values or small-caps or overline variants, + so these are ignored when converting. + + Parameters + ---------- + font : pyface.font.Font + The Pyface font to convert. + + Returns + ------- + wx_font : wx.font.Font + The best matching wx font. + """ + size = font.size + for family in font.family: + if family in generic_family_to_wx_family: + family = generic_family_to_wx_family[family] + break + else: + family = wx.FONTFAMILY_DEFAULT + weight = weight_to_wx_weight[font.weight_] + style = style_to_wx_style[font.style] + underline = ('underline' in font.variants) + + # get a default font candidate + wx_font = wx.Font(size, family, style, weight, underline) + for face in font.family: + # don't try to match generic family + if face in generic_family_to_wx_family: + break + wx_font = wx.Font(size, family, style, weight, underline, face) + # we have a match, so stop + if wx_font.GetFaceName().lower() == face.lower(): + break + + wx_font.SetStrikethrough('strikethrough' in font.variants) + return wx_font + + +def toolkit_font_to_properties(toolkit_font): + """ Convert a Wx Font to a dictionary of font properties. + + Parameters + ---------- + toolkit_font : wx.font.Font + The Wx font to convert. + + Returns + ------- + properties : dict + Font properties suitable for use in creating a Pyface Font. + """ + family = wx_family_to_generic_family[toolkit_font.GetFamily()] + face = toolkit_font.GetFaceName() + if wx_python_4_1: + size = toolkit_font.GetFractionalPointSize() + else: + size = toolkit_font.GetPointSize() + style = wx_style_to_style[toolkit_font.GetStyle()] + weight = wx_weight_to_weight[toolkit_font.GetWeight()] + variants = set() + if toolkit_font.GetUnderlined(): + variants.add('underline') + if toolkit_font.GetStrikethrough(): + variants.add('strikethrough') + + return { + 'family': [face, family], + 'size': size, + 'weight': weight, + 'stretch': 'normal', + 'style': style, + 'variants': variants, + } diff --git a/pyface/ui/wx/font_dialog.py b/pyface/ui/wx/font_dialog.py new file mode 100644 index 000000000..977e2de94 --- /dev/null +++ b/pyface/ui/wx/font_dialog.py @@ -0,0 +1,58 @@ +# (C) Copyright 2005-2020 Enthought, Inc., Austin, TX +# All rights reserved. +# +# This software is provided without warranty under the terms of the BSD +# license included in LICENSE.txt and may be redistributed only under +# the conditions described in the aforementioned license. The license +# is also available online at http://www.enthought.com/licenses/BSD.txt +# +# Thanks for using Enthought open source! + +""" A dialog that allows the user to select a font. """ + +import wx + +from traits.api import provides + +from pyface.font import Font, PyfaceFont +from pyface.i_font_dialog import IFontDialog +from .dialog import Dialog + + +@provides(IFontDialog) +class FontDialog(Dialog): + """ A dialog for selecting fonts. + """ + + #: A Font instance that holds the initial font at the start and tracks + #: the selected font during user interactions. + font = PyfaceFont() + + # ------------------------------------------------------------------------ + # 'IDialog' interface. + # ------------------------------------------------------------------------ + + def _create_contents(self, parent): + # In wx this is a canned dialog. + pass + + # ------------------------------------------------------------------------ + # 'IWindow' interface. + # ------------------------------------------------------------------------ + + def close(self): + font_data = self.control.GetFontData() + wx_font = font_data.GetChosenFont() + self.font = Font.from_toolkit(wx_font) + super(FontDialog, self).close() + + # ------------------------------------------------------------------------ + # 'IWidget' interface. + # ------------------------------------------------------------------------ + + def _create_control(self, parent): + wx_font = self.font.to_toolkit() + data = wx.FontData() + data.SetInitialFont(wx_font) + dialog = wx.FontDialog(parent, data) + return dialog