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

Pyface Font class and Trait Type #520

Closed
wants to merge 20 commits into from
Closed

Conversation

corranwebster
Copy link
Contributor

@corranwebster corranwebster commented May 16, 2020

This is a step in the direction of sorting out #476

This PR provides:

  • a toolkit-independent pure traits Font class that roughly corresponds to the CSS font specification
  • a simple parser for CSS font specifications
  • a PyfaceFont trait type that holds such a Font instance and auto-converts CSS font descriptions to those fonts
  • methods for translating Font objects to and from the toolkit font specification objects
  • a FontDialog and associated classes for bringing up a standard system font dialog to get a Font value.
  • tests
  • basic documentation

@corranwebster corranwebster marked this pull request as ready for review June 12, 2020 11:00
@corranwebster corranwebster reopened this Jun 12, 2020
@codecov-commenter
Copy link

codecov-commenter commented Jun 19, 2020

Codecov Report

Merging #520 into master will decrease coverage by 1.11%.
The diff coverage is 87.46%.

Impacted file tree graph

@@            Coverage Diff             @@
##           master     #520      +/-   ##
==========================================
- Coverage   38.97%   37.86%   -1.12%     
==========================================
  Files         487      477      -10     
  Lines       26798    26434     -364     
  Branches     4066     4040      -26     
==========================================
- Hits        10445    10009     -436     
- Misses      15897    15993      +96     
+ Partials      456      432      -24     
Impacted Files Coverage Δ
pyface/qt/QtGui.py 81.48% <50.00%> (-9.00%) ⬇️
pyface/ui/wx/font_dialog.py 54.54% <54.54%> (ø)
pyface/font_dialog.py 80.00% <80.00%> (ø)
pyface/ui/wx/font.py 87.23% <87.23%> (ø)
pyface/font.py 90.18% <90.18%> (ø)
pyface/ui/qt4/font.py 91.30% <91.30%> (ø)
pyface/api.py 100.00% <100.00%> (ø)
pyface/i_font_dialog.py 100.00% <100.00%> (ø)
pyface/ui/qt4/font_dialog.py 100.00% <100.00%> (ø)
pyface/ui/qt4/tasks/editor_area_pane.py 22.22% <0.00%> (-33.67%) ⬇️
... and 37 more

Continue to review full report at Codecov.

Legend - Click here to learn more
Δ = absolute <relative> (impact), ø = not affected, ? = missing data
Powered by Codecov. Last update b8d2ece...49421a6. Read the comment docs.

@kitchoi kitchoi self-requested a review June 19, 2020 10:52
Copy link
Contributor

@ievacerny ievacerny left a comment

Choose a reason for hiding this comment

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

Some minor comments re code cleanliness and documentation. And either a couple major bugs or my usage mistakes.

docs/source/trait_types.rst Outdated Show resolved Hide resolved
pyface/font.py Outdated Show resolved Hide resolved
pyface/font.py Outdated Show resolved Hide resolved
pyface/font.py Outdated Show resolved Hide resolved
pyface/font.py Outdated Show resolved Hide resolved
pyface/font.py Show resolved Hide resolved
pyface/tests/test_font_dialog.py Outdated Show resolved Hide resolved
pyface/tests/test_font_dialog.py Outdated Show resolved Hide resolved
pyface/tests/test_font_dialog.py Show resolved Hide resolved
FontDialog = toolkit_object("font_dialog:FontDialog")


def get_font(parent, font):
Copy link
Contributor

Choose a reason for hiding this comment

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

Shouldn't parent be passed to the actual dialog?

Another thing, I might be using this function incorrectly, but simply calling it with parent=None and any font, and then clicking on either of the dialog buttons freezes the application with qt. No dialog window is brought up at all if using wx.

And finally, this function isn't tested at all, so might be good to add some tests.

Copy link
Contributor

Choose a reason for hiding this comment

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

Qt dialog now works fine and wx dialog is brought up. But now it's the wx dialog that freezes for quite some time after a button is clicked (either OK, Cancel). I'm guessing that it's related to the closing of the dialog window.

@corranwebster
Copy link
Contributor Author

Assuming tests pass, this is ready for re-review.

Copy link
Contributor

@ievacerny ievacerny left a comment

Choose a reason for hiding this comment

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

Qt dialog is now fine but wx dialog is still not working properly

def close(self):
font_data = self.control.GetFontData()
wx_font = font_data.GetChosenFont()
self.font = Font.from_toolkit(wx_font)
Copy link
Contributor

Choose a reason for hiding this comment

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

Clicking Cancel on the dialog raises this error:

>>> get_font(None, "Helvetica")
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/Users/icernyte/Documents/repos/pyface/pyface/font_dialog.py", line 36, in get_font
    result = dialog.open()
  File "/Users/icernyte/Documents/repos/pyface/pyface/i_dialog.py", line 168, in open
    self.close()
  File "/Users/icernyte/Documents/repos/pyface/pyface/ui/wx/font_dialog.py", line 46, in close
    self.font = Font.from_toolkit(wx_font)
  File "/Users/icernyte/Documents/repos/pyface/pyface/font.py", line 387, in from_toolkit
    return cls(**toolkit_font_to_properties(toolkit_font))
  File "/Users/icernyte/Documents/repos/pyface/pyface/ui/wx/font.py", line 162, in toolkit_font_to_properties
    family = wx_family_to_generic_family[toolkit_font.GetFamily()]
wx._core.wxAssertionError: C++ assertion "IsOk()" failed at /Users/robind/projects/bb2/dist-osx-py36/build/ext/wxW
idgets/src/common/fontcmn.cpp(407) in GetFamily(): invalid font

Copy link
Contributor

Choose a reason for hiding this comment

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

The fact that this logic is wrapped inside a GUI dialog and that wx testing utility is lacking means this conversion logic is not tested with wx.
We should be able to extract this and test without launching the dialog:

def to_font_data(font):
    data = wx.FontData()
    data.SetInitialFont(font.to_toolkit())
    return data

def from_font_data(font_data):
    wx_font = font_data.GetChosenFont()
    return Font.from_toolkit(wx_font)

The dialog can call these functions instead.

Then the test will look like this:

from_font_data(to_font_data(Font()))

It'd be worth adding this test with the logic extracted from the dialog. And I can confirm I got the same error above.

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'll have a play with this - I think it might require exposing (at least at the private level) methods for getting and setting the font, but I'm OK with that as they can then be used to push common code into the mixin.

Copy link
Contributor

@kitchoi kitchoi left a comment

Choose a reason for hiding this comment

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

Thank you for this - I did not realize it was this substantial and I am sorry for the late review. It has taken me quite some time to finalize the review.

The ability to configure the various font options in a toolkit agnostic way is going to be very useful. The fact that Font can be subclassed means it should be possible for downstream applications to extend the capability (e.g. Qt's font support is quite rich!)

I think I got carried away by the text parsing logic and forgot the core of the feature should be about having a toolkit-agnostic layer for creating toolkit-specific font objects.

There are two ways to create a toolkit-agnostic (1) Font object: instantiating the object directly, or (2) via a text description. It is the second way where I have the most concerns. I believe the core functionality should not depend on the text parsing capability. The parsing should be a thin optional layer that can be added later. At the moment the design seems very much driven by what is possible or convenient in the text description. There are places where this is limiting the design and the extent of which Font can be capable of. It is also somewhat distracting... With a lot of efforts going into text parsing, there are not many tests to do with creating the toolkit specific objects. Could we focus on the conversion from the toolkit-agnostic Font (HasTraits) class to/from the toolkit specific object, and defer the text-parsing instantiation layer to a separate PR? I guess the text parsing feature won't block other work such as the data table view, but if it does, then we may have a bigger problem.

'extraexpanded': 800,
'ultra-expanded': 900,
'ultraexpanded': 900,
}
Copy link
Contributor

Choose a reason for hiding this comment

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

  • This applies to both weights and stretches: Could we stick to the ones specified in the CSS specification (font-stretch here and font-weight here)? If we can follow the existing specification strictly, then we won't have to document it: it is already documented. Chances are, the users might already be familiar with CSS. If we roll our own font property mini-language, we are on our own the maintain the it and we won't be able to make use of open-source CSS parsers/validators available in the public domain.

  • On stretches: These keywords have a well defined mapping to percentages in the web browser world, e.g. https://developer.mozilla.org/en-US/docs/Web/CSS/font-stretch and the values are different here. Where do the values here come from?

  • On stretches: What units are these numbers? If we follow Qt documentation, then stretch number should be percentage. Then "normal" should be mapped to 100 (as in the reference in mozilla.org)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This looks like a miss on my part- I think somehow I had got into my head that stretches were using the 0 to 1000 values instead of percentages.

pyface/font.py Outdated
FontWeight = Map(weights, default_value='normal')

#: A trait for font stretch values.
FontStretch = Map(stretches, default_value='normal')
Copy link
Contributor

Choose a reason for hiding this comment

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

The synchronization of the key and mapped trait is one-directional. e.g. setting stretch will update stretch_, but setting stretch_ won't update stretch. This means one can only set stretch to one of the approved set of strings.

This limits usage, e.g. if "expanded" is too narrow but "extra-expanded" is too wide, one cannot set something in between. That would be a real shame because Qt allows any integer to be set. For weight, both Qt and wx accept any integer values.

Perhaps FontStretch should be implemented like FontSize, where it can support any number while also allow adaptation from text. It would be important to decide not to use Map early this determines whether downstream code will use stretch or stretch_ for setting the stretch numerical value.

Same applies to FontWeight

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 think I want this as a design choice. I certainly do for FontWeight.

pyface/font.py Show resolved Hide resolved
pyface/font.py Outdated

styles = ['normal', 'italic', 'oblique']

variants = ['small-caps', 'underline', 'strikethrough', 'overline']
Copy link
Contributor

Choose a reason for hiding this comment

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

Could we use capital naming for module level constants such that it is less likely to accidentally mutate them, like this scenario:

def to_variants(...):
    # variants = [] # forgot to create a new local list
    variants.append(...) # does not fail because Python found it in the outer scope, now we are mutating the global state :(
    return variants

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'm personally in two minds about all caps for container module "constants". If they are intended to be mutated (eg. a registry) then I don't like all-caps. In this case it's probably fine (in fact, I should possibly make them tuples rather than lists.


return font_to_toolkit_font(self)

def __str__(self):
Copy link
Contributor

Choose a reason for hiding this comment

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

Similar to the comment in from_description, this Font object may grow beyond the definition of CSS Font Property and this will be like inventing another mini-language. Likewise, if someone tries to extend this class, they will have to override this method as well. That seems unnecessary... why is this needed?

Comment on lines 176 to 177
weight = map_to_nearest(toolkit_font.weight(), qt_weight_to_weight)
stretch = map_to_nearest(toolkit_font.stretch(), qt_stretch_to_stretch)
Copy link
Contributor

Choose a reason for hiding this comment

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

If Font.weight and Font.stretch are integers then we don't need to approximate the values. I think it would preferable if the conversion is less lossy.

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 think I want font weights to stay multiples of 100: almost all typefaces outside of specialist typographical fonts are multiples of 100. Our use case is scientific apps, so I think we're good.

I'll assess the stretch situation separately as there is an underlying problem with the implementation, but even there I think we are constrained by what most fonts in the wild actually support.

These numbers look like the could be arbitrary values, but in practice they are not, and there will never be an actual difference in what appears on a screen or what font you get if you set weight to 501 instead of 500, for example.

)

# smoke test
toolkit_font = font.to_toolkit()
Copy link
Contributor

@kitchoi kitchoi Jun 24, 2020

Choose a reason for hiding this comment

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

I think some toolkit-specific tests are sorely needed for checking the content of the font object. We may not be able to check the font family where the final value used changes from system to system, but we should able to check other statically computed properties.

break
else:
family = wx.FONTFAMILY_DEFAULT
weight = weight_to_wx_weight[font.weight_]
Copy link
Contributor

Choose a reason for hiding this comment

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

If Font.weight is an integer, we can use wx.Font.SetNumericWeight and we will be able to use weights that are in between the presets.
Each of size, family, style, weight, underline can all be set after instantiating wx.Font.

def close(self):
font_data = self.control.GetFontData()
wx_font = font_data.GetChosenFont()
self.font = Font.from_toolkit(wx_font)
Copy link
Contributor

Choose a reason for hiding this comment

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

The fact that this logic is wrapped inside a GUI dialog and that wx testing utility is lacking means this conversion logic is not tested with wx.
We should be able to extract this and test without launching the dialog:

def to_font_data(font):
    data = wx.FontData()
    data.SetInitialFont(font.to_toolkit())
    return data

def from_font_data(font_data):
    wx_font = font_data.GetChosenFont()
    return Font.from_toolkit(wx_font)

The dialog can call these functions instead.

Then the test will look like this:

from_font_data(to_font_data(Font()))

It'd be worth adding this test with the logic extracted from the dialog. And I can confirm I got the same error above.

'variants': variant_set,
'size': size,
}

Copy link
Contributor

Choose a reason for hiding this comment

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

The text-to-font conversion is unlikely going to support all use cases. e.g. How are we going to represent superscript or subscript in this text? CSS Font Property has a limited scope for good reasons: It is not possible to pack all font styling properties into a single line of text without making the text more and more cryptic. The shorthand supports a subset of most commonly used styling, and does not try to encapsulate everything.

That aside, assuming we do want some sort of text-to-font convenience layer, having a relaxed parser worries me.

This is a valid CSS font property: "small-caps bold italic 12px Georgia, serif". But this parser wrongly considers "px" to be one of the font families

>>> parse_font_description("small-caps bold italic 12px Georgia, serif")
{'family': ['px', 'Georgia', 'serif'],
 'weight': 'bold',
 'stretch': 'normal',
 'style': 'italic',
 'variants': {'small-caps'},
 'size': 12.0}

Say this was used in production but it was fine maybe because the family is only a list of priority: The "px" font is not found so the toolkit tries the next one. And the developers would have adjusted the numeric value 12 such that it actually looks nice when the unit is pt. But this is clearly a parsing error so it has to be fixed, and should the fix raises an error or not? And if one day the description should support other font size unit (px, em, rem), existing code is going to see a different behaviour where the font size becomes too big or too small, and it would be difficult to track it down to this.

I think I'd be in favour of a strict parser that is easy to maintain, or have no parsing at all.

@corranwebster
Copy link
Contributor Author

This Font class isn't intended to be subclassed - it's intended to represent a font well enough for (almost) all use cases that a scientific application is likely to need. It is a leaf class, and deliberately so. It is not an interface or ABC.

Typography is fantastically fractal and we could imagine any number of things that we might like to support (eg. kerning options, different styles of numerals, etc.) many of which aren't even exposed in Qt, let alone Wx. I made a deliberate choice to target what CSS can express, as that seems good enough for almost everything we need (it's good enough for the web!), is approximately the intersection of what wxPython (at least in 4.1) and PyQt support, and additionally provides a standard textual representation for the font that is familiar to developers. The CSS textual representation is important because:

  • it is a UX affordance for developers who use the API to be able to specify fonts with a descriptive string rather than a bunch of keywords;
  • the intent is to eventually replace the current uses of TraitsUI Font, which accepts a text description in a hacky mini-language, so we would like something similar but better to aid eventual migration
  • it can even be exposed to sophisticated users
  • for better or worse, it is a standard

So it's quite deliberately limited. But its likely far more than what we actually need for real scientific computing use-cases.

@corranwebster
Copy link
Contributor Author

corranwebster commented Jun 26, 2020

Changes I am planning to make based on all of this:

  • a look at font stretch, because I think I messed up the shadowed values there; and to check if it makes sense to allow arbitrary values
  • extend parsing a bit so actual all CSS font descriptors are actually parsed correctly, and then complain if we can't handle something (eg. relative font sizes).
  • add some utility methods for accessing the font on a font dialog and refactor to use them both in implementation and tests
  • add some testing for backends for from_toolkit and to_toolkit

@corranwebster corranwebster marked this pull request as draft September 28, 2021 10:00
@corranwebster corranwebster mentioned this pull request Dec 21, 2021
1 task
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.

4 participants