diff --git a/docs/source/trait_types.rst b/docs/source/trait_types.rst new file mode 100644 index 000000000..de0541125 --- /dev/null +++ b/docs/source/trait_types.rst @@ -0,0 +1,97 @@ +=========== +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. + +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 widgets and +other toolkit-specific code expects 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" + + +.. |Font| replace:: :py:class:`~pyface.font.Font` +.. |PyfaceFont| replace:: :py:class:`~pyface.font.PyfaceFont` diff --git a/pyface/api.py b/pyface/api.py index f4768d057..88e605766 100644 --- a/pyface/api.py +++ b/pyface/api.py @@ -19,6 +19,8 @@ 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/font.py b/pyface/font.py new file mode 100644 index 000000000..ca85e45f5 --- /dev/null +++ b/pyface/font.py @@ -0,0 +1,538 @@ +# (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 percentage between 50% and 200%, or by strings + such as as 'condensed' and 'expanded' that correspond to 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 +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, CList, CSet, DefaultValue, Enum, HasStrictTraits, Map, Range, + Str, TraitError, TraitType +) +from traits.trait_type import NoDefaultSpecified + +WEIGHTS = {str(i): i for i in range(100, 1001, 100)} + +# Note: we don't support 'medium' as an alias for weight 500 because it +# conflicts with the usage of 'medium' as an alias for a 12pt font in the CSS +# specification for font attributes. +WEIGHTS.update({ + 'thin': 100, + 'extra-light': 200, + 'ultra-light': 200, + 'light': 300, + 'normal': 400, + 'regular': 400, + 'book': 400, + 'semibold': 600, + 'demibold': 600, + 'demi': 600, + 'bold': 700, + 'extra-bold': 800, + 'ultra-bold': 800, + 'black': 900, + 'heavy': 900, + 'extra-heavy': 1000, +}) + +STRETCHES = { + 'ultra-condensed': 50, + 'ultracondensed': 62.5, + 'extra-condensed': 62.5, + 'extracondensed': 62.5, + 'condensed': 75, + 'semi-condensed': 87.5, + 'semicondensed': 87.5, + 'normal': 100, + 'semi-expanded': 112.5, + 'semiexpanded': 112.5, + 'expanded': 125, + 'extra-expanded': 150, + 'extraexpanded': 150, + 'ultra-expanded': 200, + 'ultraexpanded': 200, +} + +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 = CList(Str, ['default']) + +#: A trait for font weights. +FontWeight = Map(WEIGHTS, default_value='normal') + +#: A trait for font stretch values. +#FontStretch = Range(50.0, 200.0, 100.0) + +#: A trait for font styles. +FontStyle = Enum(STYLES) + +#: A trait for font variant properties. +FontVariants = CSet(Enum(VARIANTS)) + + +class FontStretch(BaseCFloat): + """ Trait type for font stretches. + + The is a CFloat trait which also allows values which are keys of the + stretch dictionary Values must be floats between 50 and 200, inclusive. + """ + + #: The default value for the trait. + default_value = 100.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('%'): + value = value[:-1] + value = STRETCHES.get(value, value) + value = super().validate(object, name, value) + if not 50 <= value <= 200: + self.error(object, name, value) + return value + + def info(self): + info = ( + "a float from 50 to 200, " + "a value that can convert to a float from 50 to 200, " + ) + info += ', '.join(repr(key) for key in SIZES) + info += ( + " or a string with a float value from 50 to 200 followed by '%'" + ) + return info + + +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' ot 'px' 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') or value.endswith('px')): + value = value[:-2] + value = SIZES.get(value, value) + value = super().validate(object, name, value) + if value <= 0: + self.error(object, name, value) + return value + + def info(self): + info = ( + "a positive float, a value that can convert to a positive float, " + ) + info += ', '.join(repr(key) for key in SIZES) + info += ( + " or a string with a positive float value followed by 'pt' or 'px'" + ) + return info + + +font_tokens = [ + r'(?P\d+\.?\d*(pt|px))', + r'(?P\d+\.?\d*%)', + 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 = 100 + 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( + "Size declared twice in {!r}".format(description) + ) + value = value[:-2] + try: + size = float(value) + except ValueError: + raise FontParseError( + "Invalid font size {!r} at position {} in {!r}".format( + value, index, description) + ) + elif kind == 'PERCENT': + if stretch == 100: + stretch = float(value[:-1]) + else: + raise FontParseError( + "Stretch declared twice in {!r}".format(description) + ) + elif kind == 'NUMBER': + if value in WEIGHTS and weight == 'normal': + weight = value + elif size != -1: + raise FontParseError( + "Size declared twice in {!r}".format(description) + ) + else: + try: + size = float(value) + except ValueError: + raise FontParseError( + "Invalid font size {!r} at position {} in {!r}".format( + value, index, description) + ) + elif kind == 'NAME': + # substitute synonyms + value = parser_synonyms.get(value, value) + if value.lower() in WEIGHTS: + if weight != 'normal': + raise FontParseError( + "Weight declared twice in {!r}".format(description) + ) + weight = value.lower() + elif value.lower() in STRETCHES: + if stretch != 100: + raise FontParseError( + "Stretch declared twice in {!r}".format(description) + ) + stretch = STRETCHES[value.lower()] + elif value.lower() in SIZES: + if size != -1: + raise FontParseError( + "Size declared twice in {!r}".format(description) + ) + size = SIZES[value.lower()] + elif value.lower() in STYLES: + if style != 'normal': + raise FontParseError( + "Style declared twice in {!r}".format(description) + ) + style = value.lower() + elif value in VARIANTS: + if value.lower() in variant_set: + raise FontParseError( + "Variant {!r} declared twice in {!r}".format( + value, description) + ) + 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( + "Parse error {!r} at {} in {!r}".format( + value, index, description) + ) + 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 != 100: + terms.append("{:g}%".format(self.stretch)) + size = self.size + # if size is an integer + if int(size) == size: + size = int(size) + terms.append("{}pt".format(size)) + 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( + "{}={!r}".format(name, value) + for name, value in traits.items() + ) + return "{}({})".format(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("Invalid trait value {!r}".format(value)) + 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..e78e095cb --- /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(parent=parent, font=font) + result = dialog.open() + if result == OK: + return dialog.font + else: + return None 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_font.py b/pyface/tests/test_font.py new file mode 100644 index 000000000..8588c2aa2 --- /dev/null +++ b/pyface/tests/test_font.py @@ -0,0 +1,561 @@ +# (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, FontStretch, 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 TestFontSizeTrait(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 FontStretchDummy(HasStrictTraits): + + stretch = FontStretch() + stretch_default_1 = FontStretch(150) + stretch_default_2 = FontStretch("150.0") + stretch_default_3 = FontStretch("150.0%") + stretch_default_4 = FontStretch("expanded") + + +class TestFontStretchTrait(unittest.TestCase): + + def test_font_stretch_trait_defaults(self): + dummy = FontStretchDummy() + + self.assertEqual(dummy.stretch, 100.0) + self.assertEqual(dummy.stretch_default_1, 150.0) + self.assertEqual(dummy.stretch_default_2, 150.0) + self.assertEqual(dummy.stretch_default_3, 150.0) + self.assertEqual(dummy.stretch_default_4, 125.0) + + def test_font_stretch_trait_invalid_default(self): + with self.assertRaises(TraitError): + FontStretch("badvalue") + + with self.assertRaises(TraitError): + FontStretch(49.5) + + with self.assertRaises(TraitError): + FontStretch("49.5") + + with self.assertRaises(TraitError): + FontStretch("49.5%") + + with self.assertRaises(TraitError): + FontStretch(200.1) + + def test_font_stretch_trait_validate(self): + dummy = FontStretchDummy() + + dummy.stretch = 150.0 + self.assertEqual(dummy.stretch, 150.0) + + dummy.stretch = "125" + self.assertEqual(dummy.stretch, 125.0) + + dummy.stretch = "50%" + self.assertEqual(dummy.stretch, 50.0) + + dummy.stretch = "ultra-expanded" + self.assertEqual(dummy.stretch, 200.0) + + def test_font_stretch_trait_invalid_validate(self): + dummy = FontStretchDummy() + + with self.assertRaises(TraitError): + dummy.stretch = "badvalue" + + with self.assertRaises(TraitError): + dummy.stretch = 49.5 + + with self.assertRaises(TraitError): + dummy.stretch = "200.1" + + with self.assertRaises(TraitError): + dummy.stretch = "49.9%" + +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, 100) + 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, 75) + self.assertEqual(font.style, 'italic') + self.assertEqual(font.variants, {'small-caps', 'underline'}) + + def test_family_sequence(self): + font = Font(family=('Helvetica', 'sans-serif')) + self.assertEqual(font.family, ['Helvetica', 'sans-serif']) + + def test_variants_frozenset(self): + font = Font(variants=frozenset({'small-caps', 'underline'})) + 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 75% 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, 75) + 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': 100, + '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': 75, + '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': 100, + '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': 100, + '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': 100, + 'style': 'normal', + 'variants': {variant}, + } + ) + + def test_stretches(self): + for stretch, value in STRETCHES.items(): + with self.subTest(stretch=stretch): + properties = parse_font_description(stretch) + self.assertEqual( + properties, + { + 'family': ['default'], + 'size': 12.0, + 'weight': 'normal', + 'stretch': value, + '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': 100, + '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': 100, + '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': 100, + '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': 100, + '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': 100, + '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..539ae310b --- /dev/null +++ b/pyface/tests/test_font_dialog.py @@ -0,0 +1,83 @@ +# (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, get_font +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 the font trait works for strings + self.dialog.font = "10pt Arial" + + self.assertEqual(self.dialog.font, Font.from_description("10pt 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() + + +@unittest.skipIf(no_gui_test_assistant, "No GuiTestAssistant") +class TestGetFont(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_font(None, "10pt Arial") + ) + tester.open_and_run(when_opened=lambda x: x.close(accept=False)) + + self.assertEqual(tester.result, None) diff --git a/pyface/ui/qt4/font.py b/pyface/ui/qt4/font.py new file mode 100644 index 000000000..b5ee84629 --- /dev/null +++ b/pyface/ui/qt4/font.py @@ -0,0 +1,202 @@ +# (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', +} + +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(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 = toolkit_font.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/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