Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Font trait and parser #1042

Merged
merged 8 commits into from
Jan 7, 2022
Merged

Font trait and parser #1042

merged 8 commits into from
Jan 7, 2022

Conversation

corranwebster
Copy link
Contributor

@corranwebster corranwebster commented Nov 30, 2021

This PR provides a PyfaceFont trait which is similar to the Font traits in TraitsUI and Enable, but based on the Pyface Font class.

This permits constructs like:

class Report(HasTraits):
    title_font = PyfaceFont("24 pt bold Helvetica sans-serif")
    body_font = PyfaceFont("Helvetica sans-serif") 

report = Report(
    title_font="24 pt bold expanded Helvetica sans-serif"
)
report.body_font = Font(
    family=['Helvetica', 'Arial', 'sans-serif'],
    weight='light',
)

The value held by the trait is always a Font object.

Compatibility with the TraitsUI and Enable font traits for string font descriptions is provided by a simple parser class which extends the parsers of those older traits to provide close, but not exact compatibility. The primary difference is that the new parser understands the additional attributes and values exposed by the Font class (eg. additional weight values, stretch, variants, etc.), but this means that it will interpret font names with those terms differently. The work around is to simply specify the font more precisely with a Font object if that level of precision is needed, or replace with a fully backwards compatible parser.

The parser used by the Trait can be easily switched with a more (or less) capable parser if desired.

To-do:

  • tests

Fixes #476 (Color traits are already done)

Other relevant issues: #609 (Font class PR), #520 (older attempt), #600 (there is more work to be done on the parsing issue before this can be closed)

@corranwebster corranwebster marked this pull request as draft November 30, 2021 12:21
@corranwebster corranwebster marked this pull request as ready for review December 21, 2021 09:05
@corranwebster corranwebster modified the milestone: Release 7.4.0 Dec 21, 2021
@corranwebster corranwebster mentioned this pull request Dec 21, 2021
VARIANTS = {'small-caps'}
DECORATIONS = {'underline', 'strikethrough', 'overline'}
NOISE = {'pt', 'point', 'px', 'family'}

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it be possible to add an EBNF (or other) description of the grammar being parsed, either here or in the docs somewhere (or both)?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's probably not worth it for this parser: it's more-or-less a "bag of words" designed to be backwards compatible with the other ad-hoc parsers in TraitsUI and Enable. It is probably a good idea to have the "words" documented somewhere.

Longer-term I do want a proper parser with a well-defined grammar, which is why the parser is designed to be able to be switched out.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay, then maybe just an informal description in the comments here? Otherwise there's no description of the intended behaviour outside the code itself.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've expanded the parser docstring accordingly.

)
)

def test_init_empty_string(self):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Need a different method name here.

Copy link
Member

@mdickinson mdickinson left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM; a couple of comments

if parser is not None:
self.parser = parser
if value is not None:
font = self.validate(None, None, value)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks as though this means that a validation error would be turned into a TraitError. Would it make sense to explicitly raise ValueError or TypeError (as appropriate) instead? The error message right now isn't as helpful as it could be:

>>> PyfaceFont(b"times new roman")
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/Users/mdickinson/Enthought/ETS/pyface/pyface/ui_traits.py", line 212, in __init__
    font = self.validate(None, None, value)
  File "/Users/mdickinson/Enthought/ETS/pyface/pyface/ui_traits.py", line 231, in validate
    self.error(object, name, value)
  File "/Users/mdickinson/.venvs/pyface/lib/python3.9/site-packages/traits/base_trait_handler.py", line 74, in error
    raise TraitError(
traits.trait_errors.TraitError: None

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There might be something that's worth looking into in Traits about why the error message isn't good, since calling validate with None object and None trait name is done in a few places. It feels like this code might need an extra branch for that situation: https://github.com/enthought/traits/blob/d22ce1f096e2a6f87c78d7f1bb5bf0abab1a18ff/traits/trait_errors.py#L66-L94

I'll convert this to a ValueError here for now.

elif word in DECORATIONS:
decorations.add(word)
else:
if size is None:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It may not matter much, but from reading the code it looks as though there's an inconsistency between the first-one-wins handling for size and the last-one-wins handling for weight, stretch, etc.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree it's a bit weird. This parser is deliberately following the approach of the code here:
https://github.com/enthought/traitsui/blob/b7c38c7a47bf6ae7971f9ddab70c8a358647dd25/traitsui/qt4/font_trait.py#L92-L111
and here:
https://github.com/enthought/traitsui/blob/b7c38c7a47bf6ae7971f9ddab70c8a358647dd25/traitsui/wx/font_trait.py#L74-L91

The idea is that it should be possible to switch to this parser with a minimum of behaviour change; and then later we can have a "proper" well-defined parser which people can migrate to in a more controlled way.

@mdickinson
Copy link
Member

Should PyfaceFont be added to pyface.api?

@mdickinson
Copy link
Member

Not for this PR, but it may be useful to add an __eq__ method for Font. Otherwise, assigning the same string twice always generates an event:

>>> from pyface.ui_traits import PyfaceFont
>>> from traits.api import HasStrictTraits
>>> class A(HasStrictTraits):
...     foo = PyfaceFont()
... 
>>> a = A()
>>> a.observe(print, "foo")
>>> a.foo = "10 pt helvetica"
TraitChangeEvent(object=<__main__.A object at 0x12f19a540>, name='foo', old=Font(decorations=TraitSetObject(), family=['default'], size=12.0, stretch=100.0, style='normal', variants=TraitSetObject(), weight='normal'), new=Font(decorations=TraitSetObject(), family=['helvetica'], size=10.0, stretch=100.0, style='normal', variants=TraitSetObject(), weight='normal'))
>>> a.foo = "10 pt helvetica"
TraitChangeEvent(object=<__main__.A object at 0x12f19a540>, name='foo', old=Font(decorations=TraitSetObject(), family=['helvetica'], size=10.0, stretch=100.0, style='normal', variants=TraitSetObject(), weight='normal'), new=Font(decorations=TraitSetObject(), family=['helvetica'], size=10.0, stretch=100.0, style='normal', variants=TraitSetObject(), weight='normal'))
>>> a.foo = "10 pt helvetica"
TraitChangeEvent(object=<__main__.A object at 0x12f19a540>, name='foo', old=Font(decorations=TraitSetObject(), family=['helvetica'], size=10.0, stretch=100.0, style='normal', variants=TraitSetObject(), weight='normal'), new=Font(decorations=TraitSetObject(), family=['helvetica'], size=10.0, stretch=100.0, style='normal', variants=TraitSetObject(), weight='normal'))
>>> 

@corranwebster
Copy link
Contributor Author

Should PyfaceFont be added to pyface.api?

Not yet - but we should probably have an issue for this.

@corranwebster
Copy link
Contributor Author

Not for this PR, but it may be useful to add an __eq__ method for Font.

I had written one at one point, but had decided against because I didn't want to break hashing - which I'm not sure how important that actually is, since I can't think of a specific situation where I'd want a font as the keys of a dictionary or have a set of them.

@mdickinson
Copy link
Member

decided against because I didn't want to break hashing

Couldn't we just do the usual thing and implement __hash__ as well, in a way that's compatible with __eq__?

@corranwebster
Copy link
Contributor Author

Couldn't we just do the usual thing and implement __hash__ as well, in a way that's compatible with __eq__?

But the Font class is mutable, so I don't see a way of doing that safely. And I want the Font class to be mutable so it can sit behind editors which allow users to tweak individual attributes one at a time.

@corranwebster
Copy link
Contributor Author

Actually, thinking about this, having a trait change on the same string being passed in is probably the correct behaviour, because you are getting a new Font object even if it is equivalent to the old one.

@corranwebster
Copy link
Contributor Author

Not yet - but we should probably have an issue for this.

Done: #1071

@corranwebster corranwebster merged commit c12dbd5 into main Jan 7, 2022
@corranwebster corranwebster deleted the enh/font-trait-and-parser branch January 7, 2022 11:37
@mdickinson
Copy link
Member

Actually, thinking about this, having a trait change on the same string being passed in is probably the correct behaviour, because you are getting a new Font object even if it is equivalent to the old one.

Yep, agreed - if a Font instance is intentionally mutable, then two currently-identical Font objects are not interchangeable. (I don't much like the mutability, though.)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Basic Color and Font traits in Pyface
2 participants