diff --git a/android/src/toga_android/fonts.py b/android/src/toga_android/fonts.py index 86e521648e..7f0d3455b6 100644 --- a/android/src/toga_android/fonts.py +++ b/android/src/toga_android/fonts.py @@ -1,17 +1,19 @@ -import os +from pathlib import Path -import toga from toga.fonts import ( _REGISTERED_FONT_CACHE, BOLD, CURSIVE, FANTASY, ITALIC, + MESSAGE, MONOSPACE, + OBLIQUE, SANS_SERIF, SERIF, SYSTEM, SYSTEM_DEFAULT_FONT_SIZE, + SYSTEM_DEFAULT_FONTS, ) from toga_android.libs.android.graphics import Typeface from toga_android.libs.android.util import TypedValue @@ -44,24 +46,28 @@ def apply(self, tv, default_size, default_typeface): typeface = _FONT_CACHE[cache_key] except KeyError: typeface = None - font_key = self.interface.registered_font_key( + font_key = self.interface._registered_font_key( self.interface.family, weight=self.interface.weight, style=self.interface.style, variant=self.interface.variant, ) - if font_key in _REGISTERED_FONT_CACHE: - font_path = str( - toga.App.app.paths.app / _REGISTERED_FONT_CACHE[font_key] - ) - if os.path.isfile(font_path): + try: + font_path = _REGISTERED_FONT_CACHE[font_key] + except KeyError: + # Not a pre-registered font + if self.interface.family not in SYSTEM_DEFAULT_FONTS: + print( + f"Unknown font '{self.interface}'; " + "using system font as a fallback" + ) + else: + if Path(font_path).is_file(): typeface = Typeface.createFromFile(font_path) - # If the typeface cannot be created, following Exception is thrown: - # E/Minikin: addFont failed to create font, invalid request - # It does not kill the app, but there is currently no way to - # catch this Exception on Android + if typeface is Typeface.DEFAULT: + raise ValueError(f"Unable to load font file {font_path}") else: - print(f"Registered font path {font_path!r} could not be found") + raise ValueError(f"Font file {font_path} could not be found") if typeface is None: if self.interface.family is SYSTEM: @@ -70,6 +76,8 @@ def apply(self, tv, default_size, default_typeface): # (600 or 700). To preserve this, we use the widget's original # typeface as a starting point rather than Typeface.DEFAULT. typeface = default_typeface + elif self.interface.family is MESSAGE: + typeface = Typeface.DEFAULT elif self.interface.family is SERIF: typeface = Typeface.SERIF elif self.interface.family is SANS_SERIF: @@ -87,25 +95,14 @@ def apply(self, tv, default_size, default_typeface): typeface = Typeface.create(self.interface.family, Typeface.NORMAL) native_style = typeface.getStyle() - if self.interface.weight is not None: - native_style = set_bits( - native_style, Typeface.BOLD, self.interface.weight == BOLD - ) - if self.interface.style is not None: - native_style = set_bits( - native_style, Typeface.ITALIC, self.interface.style == ITALIC - ) + if self.interface.weight == BOLD: + native_style |= Typeface.BOLD + if self.interface.style in {ITALIC, OBLIQUE}: + native_style |= Typeface.ITALIC + if native_style != typeface.getStyle(): typeface = Typeface.create(typeface, native_style) _FONT_CACHE[cache_key] = typeface tv.setTypeface(typeface) - - -def set_bits(input, mask, enable=True): - if enable: - output = input | mask - else: - output = input & ~mask - return output diff --git a/android/src/toga_android/widgets/selection.py b/android/src/toga_android/widgets/selection.py index b1d9b6be2a..3b140018e5 100644 --- a/android/src/toga_android/widgets/selection.py +++ b/android/src/toga_android/widgets/selection.py @@ -1,9 +1,9 @@ from travertino.size import at_least from ..libs.android import R__layout -from ..libs.android.view import Gravity, View__MeasureSpec +from ..libs.android.view import View__MeasureSpec from ..libs.android.widget import ArrayAdapter, OnItemSelectedListener, Spinner -from .base import Widget, align +from .base import Widget class TogaOnItemSelectedListener(OnItemSelectedListener): @@ -89,6 +89,3 @@ def rehint(self): ) self.interface.intrinsic.width = at_least(self.native.getMeasuredWidth()) self.interface.intrinsic.height = self.native.getMeasuredHeight() - - def set_alignment(self, value): - self.native.setGravity(Gravity.CENTER_VERTICAL | align(value)) diff --git a/android/tests_backend/fonts.py b/android/tests_backend/fonts.py new file mode 100644 index 0000000000..4f5966843b --- /dev/null +++ b/android/tests_backend/fonts.py @@ -0,0 +1,107 @@ +from concurrent.futures import ThreadPoolExecutor + +from fontTools.ttLib import TTFont +from java import jint +from java.lang import Integer, Long + +from android.graphics import Typeface +from android.graphics.fonts import FontFamily +from android.util import TypedValue +from toga.fonts import ( + BOLD, + ITALIC, + MESSAGE, + NORMAL, + OBLIQUE, + SMALL_CAPS, + SYSTEM, + SYSTEM_DEFAULT_FONT_SIZE, +) + +SYSTEM_FONTS = {} +nativeGetFamily = new_FontFamily = None + + +def load_fontmap(): + field = Typeface.getClass().getDeclaredField("sSystemFontMap") + field.setAccessible(True) + fontmap = field.get(None) + + for name in fontmap.keySet().toArray(): + typeface = fontmap.get(name) + SYSTEM_FONTS[typeface] = name + for native_style in [ + Typeface.BOLD, + Typeface.ITALIC, + Typeface.BOLD | Typeface.ITALIC, + ]: + SYSTEM_FONTS[Typeface.create(typeface, native_style)] = name + + +def reflect_font_methods(): + global nativeGetFamily, new_FontFamily + + # Bypass non-SDK interface restrictions by looking them up on a background thread + # with no Java stack frames (https://stackoverflow.com/a/61600526). + with ThreadPoolExecutor() as executor: + nativeGetFamily = executor.submit( + Typeface.getClass().getDeclaredMethod, + "nativeGetFamily", + Long.TYPE, + Integer.TYPE, + ).result() + nativeGetFamily.setAccessible(True) + + new_FontFamily = executor.submit( + FontFamily.getClass().getConstructor, Long.TYPE + ).result() + + +class FontMixin: + supports_custom_fonts = True + + def assert_font_options(self, weight=NORMAL, style=NORMAL, variant=NORMAL): + assert (BOLD if self.typeface.isBold() else NORMAL) == weight + + if style == OBLIQUE: + print("Interpreting OBLIQUE font as ITALIC") + assert self.typeface.isItalic() + else: + assert (ITALIC if self.typeface.isItalic() else NORMAL) == style + + if variant == SMALL_CAPS: + print("Ignoring SMALL CAPS font test") + else: + assert NORMAL == variant + + def assert_font_size(self, expected): + if expected == SYSTEM_DEFAULT_FONT_SIZE: + expected = self.default_font_size + assert round(self.text_size) == round( + TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_SP, + expected, + self.native.getResources().getDisplayMetrics(), + ) + ) + + def assert_font_family(self, expected): + if not SYSTEM_FONTS: + load_fontmap() + + if actual := SYSTEM_FONTS.get(self.typeface): + assert actual == { + SYSTEM: self.default_font_family, + MESSAGE: "sans-serif", + }.get(expected, expected) + else: + if not nativeGetFamily: + reflect_font_methods() + family_ptr = nativeGetFamily.invoke( + None, self.typeface.native_instance, jint(0) + ) + family = new_FontFamily.newInstance(family_ptr) + assert family.getSize() == 1 + + font = TTFont(family.getFont(0).getFile().getPath()) + assert font["name"].getDebugName(1) == expected diff --git a/android/tests_backend/probe.py b/android/tests_backend/probe.py index 64cc383b25..39c3f3608e 100644 --- a/android/tests_backend/probe.py +++ b/android/tests_backend/probe.py @@ -1,16 +1,7 @@ import asyncio -from toga.fonts import SYSTEM - class BaseProbe: - def assert_font_family(self, expected): - actual = self.font.family - if expected == SYSTEM: - assert actual == "sans-serif" - else: - assert actual == expected - async def redraw(self, message=None, delay=None): """Request a redraw of the app, waiting until that redraw has completed.""" # If we're running slow, wait for a second diff --git a/android/tests_backend/widgets/base.py b/android/tests_backend/widgets/base.py index eab0dd2a13..8a998f7bf3 100644 --- a/android/tests_backend/widgets/base.py +++ b/android/tests_backend/widgets/base.py @@ -20,6 +20,7 @@ from toga.colors import TRANSPARENT from toga.style.pack import JUSTIFY, LEFT +from ..fonts import FontMixin from ..probe import BaseProbe from .properties import toga_color, toga_vertical_alignment @@ -33,11 +34,15 @@ def onGlobalLayout(self): self.event.set() -class SimpleProbe(BaseProbe): +class SimpleProbe(BaseProbe, FontMixin): + default_font_family = "sans-serif" + default_font_size = 14 + def __init__(self, widget): super().__init__() self.app = widget.app self.widget = widget + self.impl = widget._impl self.native = widget._impl.native self.layout_listener = LayoutListener() self.native.getViewTreeObserver().addOnGlobalLayoutListener( diff --git a/android/tests_backend/widgets/button.py b/android/tests_backend/widgets/button.py index 6cdd2fecc5..27ab6c8f5e 100644 --- a/android/tests_backend/widgets/button.py +++ b/android/tests_backend/widgets/button.py @@ -1,7 +1,6 @@ from java import jclass from toga.colors import TRANSPARENT -from toga.fonts import SYSTEM from .label import LabelProbe @@ -10,12 +9,8 @@ class ButtonProbe(LabelProbe): native_class = jclass("android.widget.Button") - def assert_font_family(self, expected): - actual = self.font.family - if expected == SYSTEM: - assert actual == "sans-serif-medium" - else: - assert actual == expected + # Heavier than sans-serif, but lighter than sans-serif bold + default_font_family = "sans-serif-medium" @property def background_color(self): diff --git a/android/tests_backend/widgets/label.py b/android/tests_backend/widgets/label.py index df5fa9d0a0..93c5145f33 100644 --- a/android/tests_backend/widgets/label.py +++ b/android/tests_backend/widgets/label.py @@ -3,7 +3,7 @@ from android.os import Build from .base import SimpleProbe -from .properties import toga_alignment, toga_color, toga_font +from .properties import toga_alignment, toga_color class LabelProbe(SimpleProbe): @@ -19,12 +19,12 @@ def text(self): return str(self.native.getText()) @property - def font(self): - return toga_font( - self.native.getTypeface(), - self.native.getTextSize(), - self.native.getResources(), - ) + def typeface(self): + return self.native.getTypeface() + + @property + def text_size(self): + return self.native.getTextSize() @property def alignment(self): diff --git a/android/tests_backend/widgets/passwordinput.py b/android/tests_backend/widgets/passwordinput.py index 0ec4805f97..baba02b7ce 100644 --- a/android/tests_backend/widgets/passwordinput.py +++ b/android/tests_backend/widgets/passwordinput.py @@ -1,13 +1,5 @@ -from toga.fonts import SYSTEM - from .textinput import TextInputProbe class PasswordInputProbe(TextInputProbe): - # In password mode, the EditText defaults to monospace. - def assert_font_family(self, expected): - actual = self.font.family - if expected == SYSTEM: - assert actual == "monospace" - else: - assert actual == expected + default_font_family = "monospace" diff --git a/android/tests_backend/widgets/properties.py b/android/tests_backend/widgets/properties.py index cfabddf847..c202caecbe 100644 --- a/android/tests_backend/widgets/properties.py +++ b/android/tests_backend/widgets/properties.py @@ -1,18 +1,11 @@ from java import jint -from travertino.fonts import Font -from android.graphics import Color, Typeface +from android.graphics import Color from android.os import Build from android.text import Layout -from android.util import TypedValue from android.view import Gravity from toga.colors import TRANSPARENT, rgba from toga.constants import BOTTOM, CENTER, JUSTIFY, LEFT, RIGHT, TOP -from toga.fonts import ( - BOLD, - ITALIC, - NORMAL, -) def toga_color(color_int): @@ -29,45 +22,6 @@ def toga_color(color_int): ) -DECLARED_FONTS = {} - - -def load_fontmap(): - field = Typeface.getClass().getDeclaredField("sSystemFontMap") - field.setAccessible(True) - fontmap = field.get(None) - - for name in fontmap.keySet().toArray(): - typeface = fontmap.get(name) - DECLARED_FONTS[typeface] = name - for native_style in [ - Typeface.BOLD, - Typeface.ITALIC, - Typeface.BOLD | Typeface.ITALIC, - ]: - DECLARED_FONTS[Typeface.create(typeface, native_style)] = name - - -def toga_font(typeface, size, resources): - # Android provides font details in pixels; that size needs to be converted to SP (see - # notes in toga_android/fonts.py). - pixels_per_sp = TypedValue.applyDimension( - TypedValue.COMPLEX_UNIT_SP, 1, resources.getDisplayMetrics() - ) - - # Ensure we have a map of typeface to font names - if not DECLARED_FONTS: - load_fontmap() - - return Font( - family=DECLARED_FONTS[typeface], - size=round(size / pixels_per_sp), - style=ITALIC if typeface.isItalic() else NORMAL, - variant=NORMAL, - weight=BOLD if typeface.isBold() else NORMAL, - ) - - def toga_alignment(gravity, justification_mode=None): horizontal_gravity = gravity & Gravity.HORIZONTAL_GRAVITY_MASK if (Build.VERSION.SDK_INT < 26) or ( diff --git a/android/tests_backend/widgets/selection.py b/android/tests_backend/widgets/selection.py index f68b21f6d1..8925cf272f 100644 --- a/android/tests_backend/widgets/selection.py +++ b/android/tests_backend/widgets/selection.py @@ -3,7 +3,6 @@ from android.widget import Spinner from .base import SimpleProbe -from .properties import toga_alignment class SelectionProbe(SimpleProbe): @@ -15,14 +14,18 @@ def assert_resizes_on_content_change(self): @property def alignment(self): - return toga_alignment(self.native.getGravity()) + xfail("Can't change the alignment of Selection on this backend") @property def color(self): xfail("Can't change the color of Selection on this backend") @property - def font(self): + def typeface(self): + xfail("Can't change the font of Selection on this backend") + + @property + def text_size(self): xfail("Can't change the font of Selection on this backend") @property diff --git a/android/tests_backend/widgets/table.py b/android/tests_backend/widgets/table.py index a00de1269c..d27084314f 100644 --- a/android/tests_backend/widgets/table.py +++ b/android/tests_backend/widgets/table.py @@ -3,7 +3,6 @@ from android.widget import ScrollView, TableLayout, TextView from .base import SimpleProbe -from .properties import toga_font HEADER = "HEADER" @@ -90,6 +89,9 @@ async def activate_row(self, row): self._row_view(row).performLongClick() @property - def font(self): - tv = self._row_view(0).getChildAt(0) - return toga_font(tv.getTypeface(), tv.getTextSize(), tv.getResources()) + def typeface(self): + return self._row_view(0).getChildAt(0).getTypeface() + + @property + def text_size(self): + return self._row_view(0).getChildAt(0).getTextSize() diff --git a/android/tests_backend/widgets/textinput.py b/android/tests_backend/widgets/textinput.py index 2bf66e7fb7..339e82ed04 100644 --- a/android/tests_backend/widgets/textinput.py +++ b/android/tests_backend/widgets/textinput.py @@ -10,6 +10,7 @@ class TextInputProbe(LabelProbe): native_class = jclass("android.widget.EditText") + default_font_size = 18 def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) diff --git a/changes/1837.feature.rst b/changes/1837.feature.rst new file mode 100644 index 0000000000..896050da51 --- /dev/null +++ b/changes/1837.feature.rst @@ -0,0 +1 @@ +Support for custom font loading was added to the GTK backend. diff --git a/changes/1903.feature.rst b/changes/1903.feature.rst new file mode 100644 index 0000000000..a448645944 --- /dev/null +++ b/changes/1903.feature.rst @@ -0,0 +1 @@ +Font APIs now have 100% test coverage. diff --git a/changes/1903.removal.rst b/changes/1903.removal.rst new file mode 100644 index 0000000000..9deb0249da --- /dev/null +++ b/changes/1903.removal.rst @@ -0,0 +1 @@ +The ``weight``, ``style`` and ``variant`` arguments for ``Font`` and ``Font.register`` are now keyword-only. diff --git a/cocoa/src/toga_cocoa/fonts.py b/cocoa/src/toga_cocoa/fonts.py index 619451cd4a..bc40c9f69d 100644 --- a/cocoa/src/toga_cocoa/fonts.py +++ b/cocoa/src/toga_cocoa/fonts.py @@ -1,23 +1,25 @@ +from pathlib import Path + from toga.fonts import ( + _REGISTERED_FONT_CACHE, BOLD, CURSIVE, FANTASY, ITALIC, MESSAGE, MONOSPACE, + OBLIQUE, SANS_SERIF, SERIF, SMALL_CAPS, SYSTEM, SYSTEM_DEFAULT_FONT_SIZE, + SYSTEM_DEFAULT_FONTS, ) from toga_cocoa.libs import ( - NSAttributedString, NSFont, - NSFontAttributeName, NSFontManager, NSFontMask, - NSMutableDictionary, ) _FONT_CACHE = {} @@ -29,6 +31,30 @@ def __init__(self, interface): try: font = _FONT_CACHE[self.interface] except KeyError: + font_key = self.interface._registered_font_key( + self.interface.family, + weight=self.interface.weight, + style=self.interface.style, + variant=self.interface.variant, + ) + try: + font_path = _REGISTERED_FONT_CACHE[font_key] + except KeyError: + # Not a pre-registered font + if self.interface.family not in SYSTEM_DEFAULT_FONTS: + print( + f"Unknown font '{self.interface}'; " + "using system font as a fallback" + ) + else: + if Path(font_path).is_file(): + # TODO: Load font file + self.interface.factory.not_implemented("Custom font loading") + # if corrupted font file: + # raise ValueError(f"Unable to load font file {font_path}") + else: + raise ValueError(f"Font file {font_path} could not be found") + # Default system font size on Cocoa is 12pt if self.interface.size == SYSTEM_DEFAULT_FONT_SIZE: font_size = NSFont.systemFontSize @@ -40,20 +66,15 @@ def __init__(self, interface): elif self.interface.family == MESSAGE: font = NSFont.messageFontOfSize(font_size) else: - if self.interface.family is SERIF: - family = "Times-Roman" - elif self.interface.family is SANS_SERIF: - family = "Helvetica" - elif self.interface.family is CURSIVE: - family = "Apple Chancery" - elif self.interface.family is FANTASY: - family = "Papyrus" - elif self.interface.family is MONOSPACE: - family = "Courier New" - else: - family = self.interface.family + family = { + SERIF: "Times-Roman", + SANS_SERIF: "Helvetica", + CURSIVE: "Apple Chancery", + FANTASY: "Papyrus", + MONOSPACE: "Courier New", + }.get(self.interface.family, self.interface.family) - font = NSFont.fontWithName(family, size=self.interface.size) + font = NSFont.fontWithName(family, size=font_size) if font is None: print( @@ -67,10 +88,12 @@ def __init__(self, interface): attributes_mask = 0 if self.interface.weight == BOLD: attributes_mask |= NSFontMask.Bold.value - if self.interface.style == ITALIC: + if self.interface.style in {ITALIC, OBLIQUE}: + # Oblique is the fallback for Italic. attributes_mask |= NSFontMask.Italic.value if self.interface.variant == SMALL_CAPS: attributes_mask |= NSFontMask.SmallCaps.value + if attributes_mask: # If there is no font with the requested traits, this returns the original # font unchanged. @@ -81,16 +104,3 @@ def __init__(self, interface): _FONT_CACHE[self.interface] = font.retain() self.native = font - - def measure(self, text, tight=False): - textAttributes = NSMutableDictionary.alloc().init() - textAttributes[NSFontAttributeName] = self.native - text_string = NSAttributedString.alloc().initWithString( - text, attributes=textAttributes - ) - size = text_string.size() - - # TODO: This is a magic fudge factor... - # Replace the magic with SCIENCE. - size.width += 3 - return size.width, size.height diff --git a/cocoa/src/toga_cocoa/widgets/canvas.py b/cocoa/src/toga_cocoa/widgets/canvas.py index b7f28d111d..2aed5a95d8 100644 --- a/cocoa/src/toga_cocoa/widgets/canvas.py +++ b/cocoa/src/toga_cocoa/widgets/canvas.py @@ -249,7 +249,17 @@ def reset_transform(self, draw_context, *args, **kwargs): # Text def measure_text(self, text, font, tight=False): - return font._impl.measure(text, tight=tight) + textAttributes = NSMutableDictionary.alloc().init() + textAttributes[NSFontAttributeName] = self.native + text_string = NSAttributedString.alloc().initWithString( + text, attributes=textAttributes + ) + size = text_string.size() + + # TODO: This is a magic fudge factor... + # Replace the magic with SCIENCE. + size.width += 3 + return size.width, size.height def write_text(self, text, x, y, font, *args, **kwargs): width, height = self.measure_text(text, font) diff --git a/cocoa/tests_backend/fonts.py b/cocoa/tests_backend/fonts.py new file mode 100644 index 0000000000..532787614b --- /dev/null +++ b/cocoa/tests_backend/fonts.py @@ -0,0 +1,62 @@ +from toga.fonts import ( + BOLD, + CURSIVE, + FANTASY, + ITALIC, + MESSAGE, + MONOSPACE, + NORMAL, + OBLIQUE, + SANS_SERIF, + SERIF, + SMALL_CAPS, + SYSTEM, + SYSTEM_DEFAULT_FONT_SIZE, +) +from toga_cocoa.libs.appkit import NSFont, NSFontMask + + +class FontMixin: + supports_custom_fonts = False + + def assert_font_options(self, weight=NORMAL, style=NORMAL, variant=NORMAL): + # Cocoa's FANTASY (Papyrus) and CURSIVE (Apple Chancery) system + # fonts don't have any bold/italic variants. + if str(self.font.familyName) == "Papyrus": + print("Ignoring options on FANTASY system font") + return + elif str(self.font.familyName) == "Apple Chancery": + print("Ignoring options on CURSIVE system font") + return + + traits = self.font.fontDescriptor.symbolicTraits + + assert (BOLD if traits & NSFontMask.Bold else NORMAL) == weight + + if style == OBLIQUE: + print("Interpreting OBLIQUE font as ITALIC") + assert bool(traits & NSFontMask.Italic) + else: + assert ITALIC if traits & NSFontMask.Italic else NORMAL == style + + if variant == SMALL_CAPS: + print("Ignoring SMALL CAPS font test") + else: + assert NORMAL == variant + + def assert_font_size(self, expected): + if expected == SYSTEM_DEFAULT_FONT_SIZE: + assert self.font.pointSize == NSFont.systemFontSize + else: + assert self.font.pointSize == expected + + def assert_font_family(self, expected): + assert str(self.font.familyName) == { + CURSIVE: "Apple Chancery", + FANTASY: "Papyrus", + MONOSPACE: "Courier New", + SANS_SERIF: "Helvetica", + SERIF: "Times", + SYSTEM: ".AppleSystemUIFont", + MESSAGE: ".AppleSystemUIFont", + }.get(expected, expected) diff --git a/cocoa/tests_backend/probe.py b/cocoa/tests_backend/probe.py index 70fd0aa1a5..d28bfc049c 100644 --- a/cocoa/tests_backend/probe.py +++ b/cocoa/tests_backend/probe.py @@ -4,7 +4,6 @@ from rubicon.objc import SEL, NSArray, NSObject, ObjCClass, objc_method from rubicon.objc.api import NSString -from toga.fonts import CURSIVE, FANTASY, MONOSPACE, SANS_SERIF, SERIF, SYSTEM from toga_cocoa.libs.appkit import appkit NSRunLoop = ObjCClass("NSRunLoop") @@ -47,16 +46,6 @@ async def post_event(self, event, delay=None): ) await self.event_listener.event.wait() - def assert_font_family(self, expected): - assert self.font.family == { - CURSIVE: "Apple Chancery", - FANTASY: "Papyrus", - MONOSPACE: "Courier New", - SANS_SERIF: "Helvetica", - SERIF: "Times", - SYSTEM: ".AppleSystemUIFont", - }.get(expected, expected) - async def redraw(self, message=None, delay=None): """Request a redraw of the app, waiting until that redraw has completed.""" if self.app.run_slow: diff --git a/cocoa/tests_backend/widgets/base.py b/cocoa/tests_backend/widgets/base.py index a34df27ffb..5138c22e72 100644 --- a/cocoa/tests_backend/widgets/base.py +++ b/cocoa/tests_backend/widgets/base.py @@ -3,11 +3,12 @@ from toga.colors import TRANSPARENT from toga_cocoa.libs import NSEvent, NSEventType +from ..fonts import FontMixin from ..probe import BaseProbe from .properties import toga_color -class SimpleProbe(BaseProbe): +class SimpleProbe(BaseProbe, FontMixin): def __init__(self, widget): super().__init__() self.app = widget.app @@ -91,6 +92,10 @@ def background_color(self): else: return TRANSPARENT + @property + def font(self): + return self.native.font + async def press(self): self.native.performClick(None) diff --git a/cocoa/tests_backend/widgets/button.py b/cocoa/tests_backend/widgets/button.py index 2f6219df39..2746e88f1e 100644 --- a/cocoa/tests_backend/widgets/button.py +++ b/cocoa/tests_backend/widgets/button.py @@ -4,7 +4,7 @@ from toga_cocoa.libs import NSBezelStyle, NSButton, NSFont from .base import SimpleProbe -from .properties import toga_color, toga_font +from .properties import toga_color class ButtonProbe(SimpleProbe): @@ -18,10 +18,6 @@ def text(self): def color(self): xfail("Can't get/set the text color of a button on macOS") - @property - def font(self): - return toga_font(self.native.font) - @property def background_color(self): if self.native.bezelColor: diff --git a/cocoa/tests_backend/widgets/label.py b/cocoa/tests_backend/widgets/label.py index e89cb423ba..ee4d681c61 100644 --- a/cocoa/tests_backend/widgets/label.py +++ b/cocoa/tests_backend/widgets/label.py @@ -1,7 +1,7 @@ from toga_cocoa.libs import NSTextField from .base import SimpleProbe -from .properties import toga_alignment, toga_color, toga_font +from .properties import toga_alignment, toga_color class LabelProbe(SimpleProbe): @@ -15,10 +15,6 @@ def text(self): def color(self): return toga_color(self.native.textColor) - @property - def font(self): - return toga_font(self.native.font) - @property def alignment(self): return toga_alignment(self.native.alignment) diff --git a/cocoa/tests_backend/widgets/multilinetextinput.py b/cocoa/tests_backend/widgets/multilinetextinput.py index ae347b3433..0b9246d09b 100644 --- a/cocoa/tests_backend/widgets/multilinetextinput.py +++ b/cocoa/tests_backend/widgets/multilinetextinput.py @@ -2,7 +2,7 @@ from toga_cocoa.libs import NSScrollView, NSTextView from .base import SimpleProbe -from .properties import toga_alignment, toga_color, toga_font +from .properties import toga_alignment, toga_color class MultilineTextInputProbe(SimpleProbe): @@ -55,7 +55,7 @@ def background_color(self): @property def font(self): - return toga_font(self.native_text.font) + return self.native_text.font @property def alignment(self): diff --git a/cocoa/tests_backend/widgets/numberinput.py b/cocoa/tests_backend/widgets/numberinput.py index 3bc80a6d48..30394f95c3 100644 --- a/cocoa/tests_backend/widgets/numberinput.py +++ b/cocoa/tests_backend/widgets/numberinput.py @@ -10,7 +10,7 @@ ) from .base import SimpleProbe -from .properties import toga_alignment, toga_color, toga_font +from .properties import toga_alignment, toga_color class NumberInputProbe(SimpleProbe): @@ -82,7 +82,7 @@ def background_color(self): @property def font(self): - return toga_font(self.native_input.font) + return self.native_input.font @property def alignment(self): diff --git a/cocoa/tests_backend/widgets/properties.py b/cocoa/tests_backend/widgets/properties.py index 66a33ff3a5..85375bcb0d 100644 --- a/cocoa/tests_backend/widgets/properties.py +++ b/cocoa/tests_backend/widgets/properties.py @@ -1,11 +1,7 @@ -from travertino.fonts import Font - from toga.colors import rgba -from toga.fonts import BOLD, ITALIC, NORMAL from toga.style.pack import CENTER, JUSTIFY, LEFT, RIGHT from toga_cocoa.libs.appkit import ( NSCenterTextAlignment, - NSFontMask, NSJustifiedTextAlignment, NSLeftTextAlignment, NSRightTextAlignment, @@ -24,17 +20,6 @@ def toga_color(color): return None -def toga_font(font): - traits = font.fontDescriptor.symbolicTraits - return Font( - family=str(font.familyName), - size=font.pointSize, - style=ITALIC if traits & NSFontMask.Italic else NORMAL, - variant=NORMAL, - weight=BOLD if traits & NSFontMask.Bold else NORMAL, - ) - - def toga_alignment(alignment): return { NSLeftTextAlignment: LEFT, diff --git a/cocoa/tests_backend/widgets/switch.py b/cocoa/tests_backend/widgets/switch.py index ce6f5d7391..9f314e3bbd 100644 --- a/cocoa/tests_backend/widgets/switch.py +++ b/cocoa/tests_backend/widgets/switch.py @@ -3,7 +3,6 @@ from toga_cocoa.libs import NSButton from .base import SimpleProbe -from .properties import toga_font class SwitchProbe(SimpleProbe): @@ -16,7 +15,3 @@ def text(self): @property def color(self): xfail("Can't get/set the text color of a button on macOS") - - @property - def font(self): - return toga_font(self.native.font) diff --git a/cocoa/tests_backend/widgets/textinput.py b/cocoa/tests_backend/widgets/textinput.py index ad430fba17..1001dab198 100644 --- a/cocoa/tests_backend/widgets/textinput.py +++ b/cocoa/tests_backend/widgets/textinput.py @@ -8,7 +8,7 @@ ) from .base import SimpleProbe -from .properties import toga_alignment, toga_color, toga_font +from .properties import toga_alignment, toga_color class TextInputProbe(SimpleProbe): @@ -56,7 +56,7 @@ def background_color(self): @property def font(self): - return toga_font(self.native.font) + return self.native.font @property def alignment(self): diff --git a/core/src/toga/fonts.py b/core/src/toga/fonts.py index c175410692..bbb1042dce 100644 --- a/core/src/toga/fonts.py +++ b/core/src/toga/fonts.py @@ -1,14 +1,12 @@ from __future__ import annotations -import warnings - # Use the Travertino font definitions as-is from travertino import constants -from travertino.constants import ITALIC # noqa: F401 -from travertino.constants import ( # noqa: F401 +from travertino.constants import ( BOLD, CURSIVE, FANTASY, + ITALIC, MESSAGE, MONOSPACE, NORMAL, @@ -18,12 +16,17 @@ SMALL_CAPS, SYSTEM, ) -from travertino.fonts import font # noqa: F401 from travertino.fonts import Font as BaseFont +import toga from toga.platform import get_platform_factory +SYSTEM_DEFAULT_FONTS = {SYSTEM, MESSAGE, SERIF, SANS_SERIF, CURSIVE, FANTASY, MONOSPACE} SYSTEM_DEFAULT_FONT_SIZE = -1 +FONT_WEIGHTS = {NORMAL, BOLD} +FONT_STYLES = {NORMAL, ITALIC, OBLIQUE} +FONT_VARIANTS = {NORMAL, SMALL_CAPS} + _REGISTERED_FONT_CACHE = {} @@ -32,69 +35,56 @@ def __init__( self, family: str, size: int | str, + *, + weight: str = NORMAL, style: str = NORMAL, variant: str = NORMAL, - weight: str = NORMAL, ): - super().__init__(family, size, style, variant, weight) + """Constructs a reference to a font. + + This class should be used when an API requires an explicit font reference (e.g. + :any:`Context.write_text`). In all other cases, fonts in Toga are controlled + using the style properties linked below. + + :param family: The :ref:`font family `. + :param size: The :ref:`font size `. + :param weight: The :ref:`font weight `. + :param style: The :ref:`font style `. + :param variant: The :ref:`font variant `. + """ + super().__init__(family, size, weight=weight, style=style, variant=variant) self.factory = get_platform_factory() self._impl = self.factory.Font(self) - def bind(self, factory: None = None): - warnings.warn( - "Fonts no longer need to be explicitly bound.", DeprecationWarning + def __str__(self) -> str: + size = ( + "default size" + if self.size == SYSTEM_DEFAULT_FONT_SIZE + else f"{self.size}pt" ) - return self._impl - - def measure(self, text, dpi, tight=False) -> tuple[int, int]: - return self._impl.measure(text, dpi=dpi, tight=tight) + weight = f" {self.weight}" if self.weight != NORMAL else "" + variant = f" {self.variant}" if self.variant != NORMAL else "" + style = f" {self.style}" if self.style != NORMAL else "" + return f"{self.family} {size}{weight}{variant}{style}" @staticmethod - def register(family, path, weight=NORMAL, style=NORMAL, variant=NORMAL): - """Registers a file-based font with its family name, style, variant - and weight. When invalid values for style, variant or weight are - passed, ``NORMAL`` will be used. + def register(family, path, *, weight=NORMAL, style=NORMAL, variant=NORMAL): + """Registers a file-based font. - When a font file includes multiple font weight/style/etc., each variant - must be registered separately:: + **Note:** This is not currently supported on macOS or iOS. - # Register a simple regular font - Font.register("Font Awesome 5 Free Solid", "resources/Font Awesome 5 Free-Solid-900.otf") - - # Register a regular and bold font, contained in separate font files - Font.register("Roboto", "resources/Roboto-Regular.ttf") - Font.register("Roboto", "resources/Roboto-Bold.ttf", weight=Font.BOLD) - - # Register a single font file that contains both a regular and bold weight - Font.register("Bahnschrift", "resources/Bahnschrift.ttf") - Font.register("Bahnschrift", "resources/Bahnschrift.ttf", weight=Font.BOLD) - - :param family: The font family name. This is the name that can be - referenced in style definitions. - :param path: The path to the font file. - :param weight: The font weight. Default value is ``NORMAL``. - :param style: The font style. Default value is ``NORMAL``. - :param variant: The font variant. Default value is ``NORMAL``. + :param family: The :ref:`font family `. + :param path: The path to the font file. This can be an absolute path, or a path + relative to the module that defines your :any:`App` class. + :param weight: The :ref:`font weight `. + :param style: The :ref:`font style `. + :param variant: The :ref:`font variant `. """ - font_key = Font.registered_font_key( - family, weight=weight, style=style, variant=variant - ) - _REGISTERED_FONT_CACHE[font_key] = path + font_key = Font._registered_font_key(family, weight, style, variant) + _REGISTERED_FONT_CACHE[font_key] = str(toga.App.app.paths.app / path) @staticmethod - def registered_font_key(family, weight, style, variant): - """Creates a key for storing a registered font in the font cache. - - If weight, style or variant contain an invalid value, ``NORMAL`` is - used instead. - - :param family: The font family name. This is the name that can be - referenced in style definitions. - :param weight: The font weight. Default value is ``NORMAL``. - :param style: The font style. Default value is ``NORMAL``. - :param variant: The font variant. Default value is ``NORMAL``. - :returns: The font key - """ + def _registered_font_key(family, weight, style, variant): if weight not in constants.FONT_WEIGHTS: weight = NORMAL if style not in constants.FONT_STYLES: diff --git a/core/src/toga/style/pack.py b/core/src/toga/style/pack.py index 47c792301b..adbe22e497 100644 --- a/core/src/toga/style/pack.py +++ b/core/src/toga/style/pack.py @@ -1,4 +1,4 @@ -from travertino.constants import ( +from travertino.constants import ( # noqa: F401 BOLD, BOTTOM, CENTER, @@ -29,7 +29,14 @@ from travertino.layout import BaseBox from travertino.size import BaseIntrinsicSize -from toga.fonts import SYSTEM_DEFAULT_FONT_SIZE, Font +from toga.fonts import ( + FONT_STYLES, + FONT_VARIANTS, + FONT_WEIGHTS, + SYSTEM_DEFAULT_FONT_SIZE, + SYSTEM_DEFAULT_FONTS, + Font, +) ###################################################################### # Display @@ -57,13 +64,10 @@ COLOR_CHOICES = Choices(color=True, default=True) BACKGROUND_COLOR_CHOICES = Choices(TRANSPARENT, color=True, default=True) -FONT_FAMILY_CHOICES = Choices( - SYSTEM, SERIF, SANS_SERIF, CURSIVE, FANTASY, MONOSPACE, string=True -) -# FONT_FAMILY_CHOICES = Choices(SERIF, SANS_SERIF, CURSIVE, FANTASY, MONOSPACE, string=True, default=True) -FONT_STYLE_CHOICES = Choices(NORMAL, ITALIC, OBLIQUE) -FONT_VARIANT_CHOICES = Choices(NORMAL, SMALL_CAPS) -FONT_WEIGHT_CHOICES = Choices(NORMAL, BOLD) +FONT_FAMILY_CHOICES = Choices(*SYSTEM_DEFAULT_FONTS, string=True) +FONT_STYLE_CHOICES = Choices(*FONT_STYLES) +FONT_VARIANT_CHOICES = Choices(*FONT_VARIANTS) +FONT_WEIGHT_CHOICES = Choices(*FONT_WEIGHTS) FONT_SIZE_CHOICES = Choices(integer=True) diff --git a/core/src/toga/widgets/canvas.py b/core/src/toga/widgets/canvas.py index 05ff87be0d..5ac30f305c 100644 --- a/core/src/toga/widgets/canvas.py +++ b/core/src/toga/widgets/canvas.py @@ -376,8 +376,9 @@ def __init__( self.preserve = preserve def __repr__(self): - return "{}(color={}, fill_rule={}, preserve={})".format( - self.__class__.__name__, self.color, self.fill_rule, self.preserve + return ( + f"{self.__class__.__name__}(color={self.color!r}, " + f"fill_rule={self.fill_rule}, preserve={self.preserve!r})" ) def _draw(self, impl, *args, **kwargs): @@ -440,8 +441,9 @@ def __init__( self.line_dash = line_dash def __repr__(self): - return "{}(color={}, line_width={}, line_dash={})".format( - self.__class__.__name__, self.color, self.line_width, self.line_dash + return ( + f"{self.__class__.__name__}(color={self.color!r}, " + f"line_width={self.line_width}, line_dash={self.line_dash!r})" ) def _draw(self, impl, *args, **kwargs): @@ -839,14 +841,10 @@ def __init__( self.y = y def __repr__(self): - return "{}(cp1x={}, cp1y={}, cp2x={}, cp2y={}, x={}, y={})".format( - self.__class__.__name__, - self.cp1x, - self.cp1y, - self.cp2x, - self.cp2y, - self.x, - self.y, + return ( + f"{self.__class__.__name__}(cp1x={self.cp1x}, cp1y={self.cp1y}, " + f"cp2x={self.cp2x}, cp2y={self.cp2y}, " + f"x={self.x}, y={self.y})" ) def _draw(self, impl, *args, **kwargs): @@ -878,9 +876,7 @@ def __init__(self, cpx: float, cpy: float, x: float, y: float): self.y = y def __repr__(self): - return "{}(cpx={}, cpy={}, x={}, y={})".format( - self.__class__.__name__, self.cpx, self.cpy, self.x, self.y - ) + return f"{self.__class__.__name__}(cpx={self.cpx}, cpy={self.cpy}, x={self.x}, y={self.y})" def _draw(self, impl, *args, **kwargs): """Draw the drawing object using the implementation.""" @@ -930,18 +926,10 @@ def __init__( def __repr__(self): return ( - "{}(x={}, y={}, radiusx={}, radiusy={}, " - "rotation={}, startangle={}, endangle={}, anticlockwise={})".format( - self.__class__.__name__, - self.x, - self.y, - self.radiusx, - self.radiusy, - self.rotation, - self.startangle, - self.endangle, - self.anticlockwise, - ) + f"{self.__class__.__name__}(x={self.x}, y={self.y}, " + f"radiusx={self.radiusx}, radiusy={self.radiusy}, " + f"rotation={self.rotation}, startangle={self.startangle}, endangle={self.endangle}, " + f"anticlockwise={self.anticlockwise})" ) def _draw(self, impl, *args, **kwargs): @@ -995,14 +983,10 @@ def __init__( self.anticlockwise = anticlockwise def __repr__(self): - return "{}(x={}, y={}, radius={}, startangle={}, endangle={}, anticlockwise={})".format( - self.__class__.__name__, - self.x, - self.y, - self.radius, - self.startangle, - self.endangle, - self.anticlockwise, + return ( + f"{self.__class__.__name__}(x={self.x}, y={self.y}, " + f"radius={self.radius}, startangle={self.startangle}, " + f"endangle={self.endangle}, anticlockwise={self.anticlockwise})" ) def _draw(self, impl, *args, **kwargs): @@ -1041,8 +1025,9 @@ def __init__(self, x: float, y: float, width: float, height: float): self.height = height def __repr__(self): - return "{}(x={}, y={}, width={}, height={})".format( - self.__class__.__name__, self.x, self.y, self.width, self.height + return ( + f"{self.__class__.__name__}(x={self.x}, y={self.y}, " + f"width={self.width}, height={self.height})" ) def _draw(self, impl, *args, **kwargs): @@ -1151,9 +1136,7 @@ def __init__(self, text: str, x: float = 0, y: float = 0, font: Font | None = No self.font = font def __repr__(self): - return "{}(text={}, x={}, y={}, font={})".format( - self.__class__.__name__, self.text, self.x, self.y, self.font - ) + return f"{self.__class__.__name__}(text={self.text!r}, x={self.x}, y={self.y}, font={self.font!r})" def _draw(self, impl, *args, **kwargs): """Draw the drawing object using the implementation.""" diff --git a/core/tests/style/pack/test_apply.py b/core/tests/style/pack/test_apply.py index 72a85d8595..d72545ba7b 100644 --- a/core/tests/style/pack/test_apply.py +++ b/core/tests/style/pack/test_apply.py @@ -63,7 +63,7 @@ def test_set_font(): ) root.style.reapply() root._impl.set_font.assert_called_with( - Font("Roboto", 12, "normal", "small-caps", "bold") + Font("Roboto", 12, style="normal", variant="small-caps", weight="bold") ) root.refresh.assert_called_with() diff --git a/core/tests/test_deprecated_factory.py b/core/tests/test_deprecated_factory.py index 47c1926b34..aeead236dc 100644 --- a/core/tests/test_deprecated_factory.py +++ b/core/tests/test_deprecated_factory.py @@ -1,5 +1,4 @@ import toga -from toga.fonts import SANS_SERIF from toga_dummy.utils import TestCase @@ -48,13 +47,6 @@ def test_command_set(self): with self.assertWarns(DeprecationWarning): toga.CommandSet(factory=self.factory) - def test_font(self): - widget = toga.Font(SANS_SERIF, 14) - with self.assertWarns(DeprecationWarning): - widget.bind(factory=self.factory) - self.assertEqual(widget._impl.interface, widget) - self.assertNotEqual(widget.factory, self.factory) - def test_window(self): with self.assertWarns(DeprecationWarning): widget = toga.Window(factory=self.factory) diff --git a/core/tests/test_font.py b/core/tests/test_font.py deleted file mode 100644 index 22ae251d31..0000000000 --- a/core/tests/test_font.py +++ /dev/null @@ -1,63 +0,0 @@ -import toga -from toga.fonts import ( - _REGISTERED_FONT_CACHE, - BOLD, - ITALIC, - NORMAL, - SANS_SERIF, - SMALL_CAPS, -) -from toga_dummy.utils import TestCase - - -class FontTests(TestCase): - def setUp(self): - super().setUp() - - self.family = SANS_SERIF - self.size = 14 - self.style = ITALIC - self.variant = SMALL_CAPS - self.weight = BOLD - self.custom_family = "customFamily" - self.custom_path = "resource/custom-font.otf" - - self.font = toga.Font( - family=self.family, - size=self.size, - style=self.style, - variant=self.variant, - weight=self.weight, - ) - - # Bind is a no-op - with self.assertWarns(DeprecationWarning): - impl = self.font.bind() - - self.assertIsNotNone(impl) - - # Register a file-based custom font - toga.Font.register(self.custom_family, self.custom_path) - - def test_family(self): - self.assertEqual(self.font.family, self.family) - - def test_size(self): - self.assertEqual(self.font.size, self.size) - - def test_style(self): - self.assertEqual(self.font.style, self.style) - - def test_variant(self): - self.assertEqual(self.font.variant, self.variant) - - def test_weight(self): - self.assertEqual(self.font.weight, self.weight) - - def test_register(self): - font_key = toga.Font.registered_font_key( - self.custom_family, NORMAL, NORMAL, NORMAL - ) - - self.assertIn(font_key, _REGISTERED_FONT_CACHE) - self.assertEqual(self.custom_path, _REGISTERED_FONT_CACHE[font_key]) diff --git a/core/tests/test_fonts.py b/core/tests/test_fonts.py new file mode 100644 index 0000000000..e5dd0b7b5b --- /dev/null +++ b/core/tests/test_fonts.py @@ -0,0 +1,200 @@ +from pathlib import Path + +import pytest + +import toga +from toga.fonts import ( + _REGISTERED_FONT_CACHE, + BOLD, + ITALIC, + NORMAL, + SANS_SERIF, + SMALL_CAPS, + SYSTEM, + SYSTEM_DEFAULT_FONT_SIZE, +) + + +@pytest.fixture +def app(): + return toga.App("Fonts Test", "org.beeware.toga.fonts") + + +@pytest.mark.parametrize( + "family, size, weight, style, variant, as_str", + [ + # No modifiers + ( + SANS_SERIF, + 12, + NORMAL, + NORMAL, + NORMAL, + "sans-serif 12pt", + ), + # Weight modifier + ( + SANS_SERIF, + 13, + BOLD, + NORMAL, + NORMAL, + "sans-serif 13pt bold", + ), + # Style modifier + ( + SANS_SERIF, + 14, + NORMAL, + ITALIC, + NORMAL, + "sans-serif 14pt italic", + ), + # Variant modifier + ( + SANS_SERIF, + 15, + NORMAL, + NORMAL, + SMALL_CAPS, + "sans-serif 15pt small-caps", + ), + # All modifiers + ( + SANS_SERIF, + 37, + BOLD, + ITALIC, + SMALL_CAPS, + "sans-serif 37pt bold small-caps italic", + ), + # System font, fixed size + ( + SYSTEM, + 42, + NORMAL, + NORMAL, + NORMAL, + "system 42pt", + ), + # Custom font, default size + ( + "Custom Font", + SYSTEM_DEFAULT_FONT_SIZE, + NORMAL, + NORMAL, + NORMAL, + "Custom Font default size", + ), + # System font, default size + ( + SYSTEM, + SYSTEM_DEFAULT_FONT_SIZE, + NORMAL, + NORMAL, + NORMAL, + "system default size", + ), + ], +) +def test_builtin_font(family, size, weight, style, variant, as_str): + "A builtin font can be constructed" + font = toga.Font( + family=family, + size=size, + style=style, + weight=weight, + variant=variant, + ) + + assert font.family == family + assert font.size == size + assert font.style == style + assert font.weight == weight + assert font.variant == variant + assert str(font) == as_str + + +@pytest.mark.parametrize( + "family, weight, style, variant, key", + [ + ("Helvetica", NORMAL, NORMAL, NORMAL, ("Helvetica", NORMAL, NORMAL, NORMAL)), + ( + "Times New Roman", + BOLD, + ITALIC, + SMALL_CAPS, + ("Times New Roman", BOLD, ITALIC, SMALL_CAPS), + ), + # Unknown style/weight/variants are normalized to "NORMAL" + ("Wonky", "unknown", ITALIC, SMALL_CAPS, ("Wonky", NORMAL, ITALIC, SMALL_CAPS)), + ("Wonky", BOLD, "unknown", SMALL_CAPS, ("Wonky", BOLD, NORMAL, SMALL_CAPS)), + ("Wonky", BOLD, ITALIC, "unknown", ("Wonky", BOLD, ITALIC, NORMAL)), + ], +) +def test_registered_font_key(app, family, style, weight, variant, key): + "Registered font keys can be generarted" + assert ( + toga.Font._registered_font_key( + family, style=style, weight=weight, variant=variant + ) + == key + ) + + +@pytest.mark.parametrize( + "path, registered", + [ + # Absolute path + (Path("/path/to/custom/font.otf"), Path("/path/to/custom/font.otf")), + (str(Path("/path/to/custom/font.otf")), Path("/path/to/custom/font.otf")), + # Relative path + ( + Path("path/to/custom/font.otf"), + Path(toga.__file__).parent / "path" / "to" / "custom" / "font.otf", + ), + ( + "path/to/custom/font.otf", + Path(toga.__file__).parent / "path" / "to" / "custom" / "font.otf", + ), + ], +) +def test_register_font(app, path, registered): + "A custom font can be registered" + toga.Font.register("Custom Font", path) + + # Test fixture has paths in Path format; fully resolve for test comparison. This + # gets around Windows path separator and absolute path discrepancies. + assert ( + Path(_REGISTERED_FONT_CACHE[("Custom Font", NORMAL, NORMAL, NORMAL)]).resolve() + == registered.resolve() + ) + + +@pytest.mark.parametrize( + "path, registered", + [ + # Absolute path + (Path("/path/to/custom/font.otf"), Path("/path/to/custom/font.otf")), + (str(Path("/path/to/custom/font.otf")), Path("/path/to/custom/font.otf")), + # Relative path + ( + Path("path/to/custom/font.otf"), + Path(toga.__file__).parent / "path" / "to" / "custom" / "font.otf", + ), + ( + str(Path("path/to/custom/font.otf")), + Path(toga.__file__).parent / "path" / "to" / "custom" / "font.otf", + ), + ], +) +def test_register_font_variant(app, path, registered): + "A custom font can be registered as a variant" + toga.Font.register("Custom Font", path, weight=BOLD) + + # Test fixture has paths in Path format; fully resolve for test comparison. This + # gets around Windows path separator and absolute path discrepancies. + assert ( + Path(_REGISTERED_FONT_CACHE[("Custom Font", BOLD, NORMAL, NORMAL)]).resolve() + == registered.resolve() + ) diff --git a/core/tests/widgets/test_canvas.py b/core/tests/widgets/test_canvas.py index 1a01b1a468..fe112a71d8 100644 --- a/core/tests/widgets/test_canvas.py +++ b/core/tests/widgets/test_canvas.py @@ -586,7 +586,7 @@ def test_write_text_default(self): ) self.assertEqual( repr(write_text), - "WriteText(text=test text, x=0, y=0, font=)", + "WriteText(text='test text', x=0, y=0, font=)", ) def test_write_text_modify(self): @@ -613,7 +613,7 @@ def test_write_text_repr(self): write_text = self.testing_canvas.write_text("hello", x=10, y=-4.2, font=font) self.assertEqual( repr(write_text), - "WriteText(text=hello, x=10, y=-4.2, font=)", + "WriteText(text='hello', x=10, y=-4.2, font=)", ) def test_on_resize(self): diff --git a/docs/conf.py b/docs/conf.py index ec3ac9c25a..64c5580c29 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -134,8 +134,15 @@ def setup(app): def autodoc_process_signature( app, what, name, obj, options, signature, return_annotation ): - if (what == "class") and (obj.__bases__ != (object,)): - options.show_inheritance = True + if what == "class": + # Travertino classes are not part of the public API. + bases = [ + base + for base in obj.__bases__ + if (base != object) and not base.__module__.startswith("travertino.") + ] + if bases: + options.show_inheritance = True # -- Options for link checking ------------------------------------------------- diff --git a/docs/reference/api/resources/fonts.rst b/docs/reference/api/resources/fonts.rst index 6c643a5a06..76ae364a37 100644 --- a/docs/reference/api/resources/fonts.rst +++ b/docs/reference/api/resources/fonts.rst @@ -1,6 +1,8 @@ Font ==== +A font for displaying text. + .. rst-class:: widget-support .. csv-filter:: Availability (:ref:`Key `) :header-rows: 1 @@ -8,7 +10,74 @@ Font :included_cols: 4,5,6,7,8,9,10 :exclude: {0: '(?!(Font|Component))'} -The font class is used for abstracting the platforms implementation of fonts. +Usage +----- + +For most widget styling, you do not need to create instances of the :class:`Font` class. +Fonts are applied to widgets using style properties:: + + import toga + from toga.style.pack import pack, SERIF, BOLD + + # Create a bold label in the system's serif font at default system size. + my_label = toga.Label("Hello World", style=Pack(font_family=SERIF, font_weight=BOLD)) + +Toga provides a number of :ref:`built-in system fonts `. Font sizes +are specified in points; if unspecified, the size will fall back to the default font size +for the widget being styled. + +If you want to use a custom font, the font file must be provided as part of your app's +resources, and registered before first use:: + + import toga + + # Register the user font with name "Roboto" + toga.Font.register("Roboto", "resources/Roboto-Regular.ttf") + + # Create a label with the new font. + my_label = toga.Label("Hello World", style=Pack(font_family="Roboto") + +When registering a font, if an invalid value is provided for the style, variant or +weight, ``NORMAL`` will be used. + +When a font includes multiple weights, styles or variants, each one must be registered +separately, even if they're stored in the same file:: + + import toga + from toga.style.pack import BOLD + + # Register a regular and bold font, contained in separate font files + Font.register("Roboto", "resources/Roboto-Regular.ttf") + Font.register("Roboto", "resources/Roboto-Bold.ttf", weight=BOLD) + + # Register a single font file that contains both a regular and bold weight + Font.register("Bahnschrift", "resources/Bahnschrift.ttf") + Font.register("Bahnschrift", "resources/Bahnschrift.ttf", weight=BOLD) + +A small number of Toga APIs (e.g., :any:`Context.write_text`) *do* require the use of +:class:`Font` instance. In these cases, you can instantiate a Font using similar +properties to the ones used for widget styling:: + + import toga + from toga.style.pack import BOLD + + # Obtain a 14 point Serif bold font instance + my_font = toga.Font(SERIF, 14, weight=BOLD) + + # Use the font to write on a canvas. + canvas = toga.Canvas() + canvas.context.write_text("Hello", font=my_font) + +Notes +----- + +* macOS and iOS do not currently support registering user fonts. + +* Android and Windows do not support the oblique font style. If an oblique font is + specified, Toga will attempt to use an italic style of the same font. + +* Android and Windows do not support the small caps font variant. If a Small Caps font + is specified, Toga will use the normal variant of the same font. Reference --------- diff --git a/docs/reference/api/widgets/divider.rst b/docs/reference/api/widgets/divider.rst index 94eb8c096a..c5d34a3db1 100644 --- a/docs/reference/api/widgets/divider.rst +++ b/docs/reference/api/widgets/divider.rst @@ -21,7 +21,7 @@ To separate two labels stacked vertically with a horizontal line: .. code-block:: python import toga - from toga.style import Pack, COLUMN + from toga.style.pack import Pack, COLUMN box = toga.Box( children=[ diff --git a/docs/reference/api/widgets/selection.rst b/docs/reference/api/widgets/selection.rst index 78497b0e2e..6063d59cb8 100644 --- a/docs/reference/api/widgets/selection.rst +++ b/docs/reference/api/widgets/selection.rst @@ -72,9 +72,9 @@ selected item. Notes ----- -* On macOS, you cannot change the font of a Selection. +* On macOS and Android, you cannot change the font of a Selection. -* On macOS and GTK, you cannot change the text color, background color, or +* On macOS, GTK and Android, you cannot change the text color, background color, or alignment of labels in a Selection. * On GTK, a Selection widget with flexible sizing will expand its width (to the diff --git a/docs/reference/data/widgets_by_platform.csv b/docs/reference/data/widgets_by_platform.csv index 3329aeda64..a6c51fc6a6 100644 --- a/docs/reference/data/widgets_by_platform.csv +++ b/docs/reference/data/widgets_by_platform.csv @@ -28,7 +28,7 @@ ScrollContainer,Layout Widget,:class:`~toga.ScrollContainer`,A container that ca SplitContainer,Layout Widget,:class:`~toga.SplitContainer`,A container that divides an area into two panels with a movable border,|y|,|y|,|y|,,,, OptionContainer,Layout Widget,:class:`~toga.OptionContainer`,A container that can display multiple labeled tabs of content,|y|,|y|,|y|,,,, App Paths,Resource,:class:`~toga.paths.Paths`,A mechanism for obtaining platform-appropriate filesystem locations for an application.,|y|,|y|,|y|,|y|,|y|,,|b| -Font,Resource,:class:`~toga.Font`,Fonts,|b|,|b|,|b|,|b|,|b|,, +Font,Resource,:class:`~toga.Font`,A text font,|b|,|y|,|y|,|b|,|y|,, Command,Resource,:class:`~toga.Command`,Command,|b|,|b|,|b|,,|b|,, Group,Resource,:class:`~toga.Group`,Command group,|b|,|b|,|b|,|b|,|b|,, Icon,Resource,:class:`~toga.Icon`,"A small, square image, used to provide easily identifiable visual context to a widget.",|y|,|y|,|y|,|y|,|y|,,|b| diff --git a/docs/reference/style/pack.rst b/docs/reference/style/pack.rst index 3dd07c0c56..11a6dfc0ee 100644 --- a/docs/reference/style/pack.rst +++ b/docs/reference/style/pack.rst @@ -194,10 +194,13 @@ Defines the alignment of text in the object being rendered. Defines the natural direction of horizontal content. +.. _pack-font-family: + ``font_family`` --------------- -**Values:** ``system`` | ``serif`` | ``sans-serif`` | ``cursive`` | ``fantasy`` | ``monospace`` | ```` +**Values:** ``system`` | ``serif`` | ``sans-serif`` | ``cursive`` | ``fantasy`` | +``monospace`` | ```` **Initial value:** ``system`` @@ -206,9 +209,14 @@ The font family to be used. A value of ``system`` indicates that whatever is a system-appropriate font should be used. -A value of ``serif``, ``sans-serif``, ``cursive``, ``fantasy``, or ``monospace`` will use a system defined font that matches the description (e.g.,"Times New Roman" for ``serif``, "Courier New" for ``monospace``). +A value of ``serif``, ``sans-serif``, ``cursive``, ``fantasy``, or ``monospace`` will +use a system-defined font that matches the description (e.g. "Times New Roman" for +``serif``, "Courier New" for ``monospace``). + +Any other value will be checked against the family names previously registered with +:any:`Font.register`. If the name cannot be resolved, the system font will be used. -Otherwise, any font name can be specified. If the font name cannot be resolved, the system font will be used. +.. _pack-font-style: ``font_style`` ---------------- @@ -219,6 +227,11 @@ Otherwise, any font name can be specified. If the font name cannot be resolved, The style of the font to be used. +**Note:** Windows and Android do not support the oblique font style. A request for an +``oblique`` font will be interpreted as ``italic``. + +.. _pack-font-variant: + ``font_variant`` ---------------- @@ -228,6 +241,11 @@ The style of the font to be used. The variant of the font to be used. +**Note:** Windows and Android do not support the small caps variant. A request for a +``small_caps`` font will be interpreted as ``normal``. + +.. _pack-font-weight: + ``font_weight`` --------------- @@ -237,6 +255,8 @@ The variant of the font to be used. The weight of the font to be used. +.. _pack-font-size: + ``font_size`` ------------- @@ -244,6 +264,7 @@ The weight of the font to be used. **Initial value:** System default +The size of the font to be used, in points. The relationship between Pack and CSS ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/dummy/src/toga_dummy/app.py b/dummy/src/toga_dummy/app.py index 9a5fd67fe4..7895c8acd5 100644 --- a/dummy/src/toga_dummy/app.py +++ b/dummy/src/toga_dummy/app.py @@ -32,6 +32,7 @@ def create_menus(self): self._action("create menus") def main_loop(self): + print("Starting app using Dummy backend.") self._action("main loop") self.create() diff --git a/examples/font/font/app.py b/examples/font/font/app.py index a2238f0171..be9b87c677 100644 --- a/examples/font/font/app.py +++ b/examples/font/font/app.py @@ -22,12 +22,19 @@ def do_style(self, widget, **kwargs): else: widget.style.font_style = NORMAL - def do_monospace_button(self, widget): + def do_monospace_button(self, widget, **kwargs): self.textpanel.value += widget.text + "\n" - def do_icon_button(self, widget): + def do_icon_button(self, widget, **kwargs): self.textpanel.value += widget.id + "\n" + def do_add_content(self, widget, **kwargs): + new_lbl = toga.Label( + "More Endor bold", + style=Pack(font_family="Endor", font_size=14, font_weight=BOLD), + ) + self.labels.add(new_lbl) + def startup(self): # Set up main window self.main_window = toga.MainWindow(title=self.name) @@ -54,6 +61,7 @@ def startup(self): toga.Button("Clear", on_press=self.do_clear), toga.Button("Weight", on_press=self.do_weight), toga.Button("Style", on_press=self.do_style), + toga.Button("Add", on_press=self.do_add_content), ], ) @@ -143,11 +151,8 @@ def startup(self): readonly=False, style=Pack(flex=1), placeholder="Ready." ) - # Outermost box - outer_box = toga.Box( + self.labels = toga.Box( children=[ - btn_box1, - btn_box2, lbl1, lbl2, lbl3, @@ -160,6 +165,15 @@ def startup(self): lbl_ub, lbl_ui, lbl_ubi, + ], + style=Pack(direction=COLUMN), + ) + # Outermost box + outer_box = toga.Box( + children=[ + btn_box1, + btn_box2, + self.labels, self.textpanel, ], style=Pack(flex=1, direction=COLUMN, padding=10), diff --git a/gtk/setup.py b/gtk/setup.py index 9e15f6fe78..c7ce5cbbdf 100644 --- a/gtk/setup.py +++ b/gtk/setup.py @@ -9,6 +9,6 @@ "toga-core==%s" % version, "gbulb>=0.5.3", "pycairo>=1.17.0", - "pygobject>=3.14.0", + "pygobject>=3.46.0", ], ) diff --git a/gtk/src/toga_gtk/fonts.py b/gtk/src/toga_gtk/fonts.py index cb75ee8e1e..8647a7d656 100644 --- a/gtk/src/toga_gtk/fonts.py +++ b/gtk/src/toga_gtk/fonts.py @@ -1,13 +1,17 @@ -from toga.constants import ( +from pathlib import Path + +from toga.fonts import ( + _REGISTERED_FONT_CACHE, BOLD, ITALIC, OBLIQUE, SMALL_CAPS, SYSTEM, SYSTEM_DEFAULT_FONT_SIZE, + SYSTEM_DEFAULT_FONTS, ) -from .libs import Pango +from .libs import FontConfig, Pango _FONT_CACHE = {} @@ -16,18 +20,39 @@ class Font: def __init__(self, interface): self.interface = interface - if Pango is None: + # Can't meaningfully get test coverage for pango not being installed + if Pango is None: # pragma: no cover raise RuntimeError( - "'from gi.repository import Pango' failed; you may need to install gir1.2-pango-1.0." + "Unable to import Pango. Have you installed the Pango and gobject-introspection system libraries?" ) try: font = _FONT_CACHE[self.interface] except KeyError: + font_key = self.interface._registered_font_key( + self.interface.family, + weight=self.interface.weight, + style=self.interface.style, + variant=self.interface.variant, + ) + try: + font_path = _REGISTERED_FONT_CACHE[font_key] + except KeyError: + # Not a pre-registered font + if self.interface.family not in SYSTEM_DEFAULT_FONTS: + print( + f"Unknown font '{self.interface}'; " + "using system font as a fallback" + ) + else: + if Path(font_path).is_file(): + FontConfig.add_font_file(font_path) + else: + raise ValueError(f"Font file {font_path} could not be found") + # Initialize font with properties 'None NORMAL NORMAL NORMAL 0' font = Pango.FontDescription() - # Set font family family = self.interface.family if family != SYSTEM: family = f"{family}, {SYSTEM}" # Default to system diff --git a/gtk/src/toga_gtk/libs/__init__.py b/gtk/src/toga_gtk/libs/__init__.py index f22a0fe33c..299e982b49 100644 --- a/gtk/src/toga_gtk/libs/__init__.py +++ b/gtk/src/toga_gtk/libs/__init__.py @@ -1,3 +1,4 @@ +from .fontconfig import FontConfig # noqa: F401, F403 from .gtk import * # noqa: F401, F403 from .styles import * # noqa: F401, F403 from .utils import * # noqa: F401, F403 diff --git a/gtk/src/toga_gtk/libs/fontconfig.py b/gtk/src/toga_gtk/libs/fontconfig.py new file mode 100644 index 0000000000..c3415e1845 --- /dev/null +++ b/gtk/src/toga_gtk/libs/fontconfig.py @@ -0,0 +1,46 @@ +from ctypes import CDLL, c_char_p, c_int, c_void_p, util + +libfontconfig = util.find_library("fontconfig") +if libfontconfig: + fontconfig = CDLL(libfontconfig) + + FcConfig = c_void_p + + fontconfig.FcInit.argtypes = [] + fontconfig.FcInit.restype = c_int + + fontconfig.FcConfigGetCurrent.argtypes = [] + fontconfig.FcConfigGetCurrent.restype = FcConfig + + fontconfig.FcConfigAppFontAddFile.argtypes = [FcConfig, c_char_p] + fontconfig.FcConfigAppFontAddFile.restypes = c_int +else: # pragma: no cover + fontconfig = None + + +class _FontConfig: + def __init__(self): + if fontconfig: + fontconfig.FcInit() + self.config = fontconfig.FcConfigGetCurrent() + else: # pragma: no cover + print( + "Unable to initialize FontConfig library. Is libfontconfig.so.1 on your LD_LIBRARY_PATH?" + ) + self.config = None + + def add_font_file(self, path): + if self.config is None: # pragma: no cover + raise RuntimeError( + "Can't load custom fonts without a working Fontconfig library" + ) + + result = fontconfig.FcConfigAppFontAddFile( + self.config, str(path).encode("utf-8") + ) + if result == 0: + raise ValueError(f"Unable to load font file {path}") + + +# Instantiate and configure a singleton FontConfig instance +FontConfig = _FontConfig() diff --git a/gtk/src/toga_gtk/libs/gtk.py b/gtk/src/toga_gtk/libs/gtk.py index efb202add2..e996db6b18 100644 --- a/gtk/src/toga_gtk/libs/gtk.py +++ b/gtk/src/toga_gtk/libs/gtk.py @@ -7,7 +7,7 @@ if Gdk.Screen.get_default() is None: # pragma: no cover raise RuntimeError( - "Cannot identify an active display. Is the ``DISPLAY`` environment variable set correctly?" + "Cannot identify an active display. Is the `DISPLAY` environment variable set correctly?" ) # The following imports will fail if the underlying libraries or their API diff --git a/gtk/src/toga_gtk/libs/styles.py b/gtk/src/toga_gtk/libs/styles.py index 6f52d6ae4e..686bb3d1b3 100644 --- a/gtk/src/toga_gtk/libs/styles.py +++ b/gtk/src/toga_gtk/libs/styles.py @@ -43,7 +43,7 @@ def get_font_css(value): "font-style": f"{value.style}", "font-variant": f"{value.variant}", "font-weight": f"{value.weight}", - "font-family": f"{value.family}", + "font-family": f"{value.family!r}", } if value.size != SYSTEM_DEFAULT_FONT_SIZE: diff --git a/gtk/src/toga_gtk/widgets/base.py b/gtk/src/toga_gtk/widgets/base.py index a9404932b9..8270714e86 100644 --- a/gtk/src/toga_gtk/widgets/base.py +++ b/gtk/src/toga_gtk/widgets/base.py @@ -44,7 +44,7 @@ def container(self): @container.setter def container(self, container): if self.container: - assert container is None, "Widget Already have a container" + assert container is None, "Widget already has a container" # container is set to None, removing self from the container.native # Note from pygtk documentation: Note that the container will own a diff --git a/gtk/tests/test_font.py b/gtk/tests/test_font.py deleted file mode 100644 index 7c53979d32..0000000000 --- a/gtk/tests/test_font.py +++ /dev/null @@ -1,94 +0,0 @@ -import unittest - -import toga -from toga.constants import BOLD, CURSIVE, ITALIC, OBLIQUE, SMALL_CAPS, SYSTEM -from toga_gtk import fonts as gtk_fonts - -try: - import gi - - gi.require_version("Gtk", "3.0") - from gi.repository import Gtk -except ImportError: - import sys - - # If we're on Linux, Gtk *should* be available. If it isn't, make - # Gtk an object... but in such a way that every test will fail, - # because the object isn't actually the Gtk interface. - if sys.platform == "linux": - Gtk = object() - else: - Gtk = None - -try: - gi.require_version("Pango", "1.0") - from gi.repository import Pango -except ImportError: - Pango = None - - -@unittest.skipIf(Pango is None, "Pango import error") -@unittest.skipIf( - Gtk is None, "Can't run GTK implementation tests on a non-Linux platform" -) -class TestFontImplementation(unittest.TestCase): - def setUp(self): - self.font_family = SYSTEM - self.font_size = 12 - - def test_font_bind(self): - font = toga.Font(self.font_family, self.font_size) - font_impl = font.bind() - - self.assertEqual(font._impl, font_impl) - - def test_font_default_has_all_attr_set(self): - font = toga.Font(self.font_family, self.font_size) - native = font._impl.native - self.assertEqual(native.get_family(), SYSTEM) - self.assertEqual(native.get_size() / Pango.SCALE, self.font_size) - self.assertEqual(native.get_style(), Pango.Style.NORMAL) - self.assertEqual(native.get_variant(), Pango.Variant.NORMAL) - self.assertEqual(native.get_weight(), Pango.Weight.NORMAL) - - def test_font_size(self): - self.font_size = 22 - font = toga.Font(self.font_family, self.font_size) - native = font._impl.native - self.assertEqual(native.get_size() / Pango.SCALE, self.font_size) - - def test_font_style_italic(self): - font = toga.Font(self.font_family, self.font_size, style=ITALIC) - native = font._impl.native - self.assertEqual(native.get_style(), Pango.Style.ITALIC) - - def test_font_style_oblique(self): - font = toga.Font(self.font_family, self.font_size, style=OBLIQUE) - native = font._impl.native - self.assertEqual(native.get_style(), Pango.Style.OBLIQUE) - - def test_font_variant_small_caps(self): - font = toga.Font(self.font_family, self.font_size, variant=SMALL_CAPS) - native = font._impl.native - self.assertEqual(native.get_variant(), Pango.Variant.SMALL_CAPS) - - def test_font_weight_bold(self): - font = toga.Font(self.font_family, self.font_size, weight=BOLD) - native = font._impl.native - self.assertEqual(native.get_weight(), Pango.Weight.BOLD) - - def test_font_cache(self): - font = toga.Font(self.font_family, self.font_size) - self.impl = gtk_fonts.Font(font) - self.cache = gtk_fonts._FONT_CACHE - self.assertEqual(self.cache[font], self.impl.native) - - def test_font_family_defaults_to_system(self): - font = toga.Font(CURSIVE, self.font_size) - native = font._impl.native - self.assertIn(CURSIVE, native.get_family()) - self.assertIn(SYSTEM, native.get_family()) - - -if __name__ == "__main__": - unittest.main() diff --git a/gtk/tests_backend/fonts.py b/gtk/tests_backend/fonts.py new file mode 100644 index 0000000000..f82641e7e9 --- /dev/null +++ b/gtk/tests_backend/fonts.py @@ -0,0 +1,44 @@ +from toga.fonts import ( + BOLD, + ITALIC, + NORMAL, + OBLIQUE, + SMALL_CAPS, + SYSTEM_DEFAULT_FONT_SIZE, +) +from toga_gtk.libs import Pango + + +class FontMixin: + supports_custom_fonts = True + + def assert_font_family(self, expected): + assert self.font.get_family().split(",")[0] == expected + + def assert_font_size(self, expected): + # GTK fonts aren't realized until they appear on a widget. + # The actual system default size is determined by the widget theme. + # So - if the font size reports as 0, it must be a default system + # font size that hasn't been realized yet. Once a font has been realized, + # we can't reliably determine what the system font size is, other than + # knowing that it must be non-zero. Pick some reasonable bounds instead. + if self.font.get_size() == 0: + assert expected == SYSTEM_DEFAULT_FONT_SIZE + elif expected == SYSTEM_DEFAULT_FONT_SIZE: + assert 8 < int(self.font.get_size() / Pango.SCALE) < 18 + else: + assert int(self.font.get_size() / Pango.SCALE) == expected + + def assert_font_options(self, weight=NORMAL, style=NORMAL, variant=NORMAL): + assert { + Pango.Weight.BOLD: BOLD, + }.get(self.font.get_weight(), NORMAL) == weight + + assert { + Pango.Style.ITALIC: ITALIC, + Pango.Style.OBLIQUE: OBLIQUE, + }.get(self.font.get_style(), NORMAL) == style + + assert { + Pango.Variant.SMALL_CAPS: SMALL_CAPS, + }.get(self.font.get_variant(), NORMAL) == variant diff --git a/gtk/tests_backend/probe.py b/gtk/tests_backend/probe.py index c7122ade61..597c0a5470 100644 --- a/gtk/tests_backend/probe.py +++ b/gtk/tests_backend/probe.py @@ -4,9 +4,6 @@ class BaseProbe: - def assert_font_family(self, expected): - assert self.font.family == expected - def repaint_needed(self): return Gtk.events_pending() diff --git a/gtk/tests_backend/widgets/base.py b/gtk/tests_backend/widgets/base.py index ba5a0f5019..a3cd35d5ae 100644 --- a/gtk/tests_backend/widgets/base.py +++ b/gtk/tests_backend/widgets/base.py @@ -3,11 +3,12 @@ from toga_gtk.libs import Gdk, Gtk +from ..fonts import FontMixin from ..probe import BaseProbe -from .properties import toga_color, toga_font +from .properties import toga_color -class SimpleProbe(BaseProbe): +class SimpleProbe(BaseProbe, FontMixin): def __init__(self, widget): super().__init__() self.app = widget.app @@ -98,7 +99,7 @@ def background_color(self): @property def font(self): sc = self.native.get_style_context() - return toga_font(sc.get_property("font", sc.get_state())) + return sc.get_property("font", sc.get_state()) @property def is_hidden(self): diff --git a/gtk/tests_backend/widgets/multilinetextinput.py b/gtk/tests_backend/widgets/multilinetextinput.py index 5f38643b95..ffc76b3b9d 100644 --- a/gtk/tests_backend/widgets/multilinetextinput.py +++ b/gtk/tests_backend/widgets/multilinetextinput.py @@ -1,7 +1,7 @@ from toga_gtk.libs import Gtk from .base import SimpleProbe -from .properties import toga_alignment_from_justification, toga_color, toga_font +from .properties import toga_alignment_from_justification, toga_color class MultilineTextInputProbe(SimpleProbe): @@ -83,7 +83,7 @@ def background_color(self): @property def font(self): sc = self.native_textview.get_style_context() - return toga_font(sc.get_property("font", sc.get_state())) + return sc.get_property("font", sc.get_state()) @property def alignment(self): diff --git a/gtk/tests_backend/widgets/properties.py b/gtk/tests_backend/widgets/properties.py index 3771774ff1..d341975361 100644 --- a/gtk/tests_backend/widgets/properties.py +++ b/gtk/tests_backend/widgets/properties.py @@ -1,10 +1,8 @@ import pytest -from travertino.fonts import Font from toga.colors import TRANSPARENT, rgba -from toga.fonts import BOLD, ITALIC, NORMAL from toga.style.pack import BOTTOM, CENTER, JUSTIFY, LEFT, RIGHT, TOP -from toga_gtk.libs import Gtk, Pango +from toga_gtk.libs import Gtk def toga_color(color): @@ -25,15 +23,6 @@ def toga_color(color): return None -def toga_font(font): - return Font( - family=font.get_family(), - size=int(font.get_size() / Pango.SCALE), - style=ITALIC if font.get_style() == Pango.Style.ITALIC else NORMAL, - weight=BOLD if font.get_weight() == Pango.Weight.BOLD else NORMAL, - ) - - def toga_xalignment(xalign, justify=None): try: return { diff --git a/gtk/tests_backend/widgets/switch.py b/gtk/tests_backend/widgets/switch.py index a96769f3d9..4a87668992 100644 --- a/gtk/tests_backend/widgets/switch.py +++ b/gtk/tests_backend/widgets/switch.py @@ -1,7 +1,7 @@ from toga_gtk.libs import Gtk from .base import SimpleProbe -from .properties import toga_color, toga_font +from .properties import toga_color class SwitchProbe(SimpleProbe): @@ -28,7 +28,7 @@ def color(self): @property def font(self): sc = self.native_label.get_style_context() - return toga_font(sc.get_property("font", sc.get_state())) + return sc.get_property("font", sc.get_state()) def assert_width(self, min_width, max_width): super().assert_width(min_width, max_width) diff --git a/iOS/src/toga_iOS/fonts.py b/iOS/src/toga_iOS/fonts.py index aac6bb60b8..d7a6ce6c96 100644 --- a/iOS/src/toga_iOS/fonts.py +++ b/iOS/src/toga_iOS/fonts.py @@ -1,20 +1,21 @@ -from rubicon.objc import NSMutableDictionary +from pathlib import Path from toga.fonts import ( + _REGISTERED_FONT_CACHE, BOLD, CURSIVE, FANTASY, ITALIC, MESSAGE, MONOSPACE, + OBLIQUE, SANS_SERIF, SERIF, SYSTEM, SYSTEM_DEFAULT_FONT_SIZE, + SYSTEM_DEFAULT_FONTS, ) from toga_iOS.libs import ( - NSAttributedString, - NSFontAttributeName, UIFont, UIFontDescriptorTraitBold, UIFontDescriptorTraitItalic, @@ -29,6 +30,30 @@ def __init__(self, interface): try: font = _FONT_CACHE[self.interface] except KeyError: + font_key = self.interface._registered_font_key( + self.interface.family, + weight=self.interface.weight, + style=self.interface.style, + variant=self.interface.variant, + ) + try: + font_path = _REGISTERED_FONT_CACHE[font_key] + except KeyError: + # Not a pre-registered font + if self.interface.family not in SYSTEM_DEFAULT_FONTS: + print( + f"Unknown font '{self.interface}'; " + "using system font as a fallback" + ) + else: + if Path(font_path).is_file(): + # TODO: Load font file + self.interface.factory.not_implemented("Custom font loading") + # if corrupted font file: + # raise ValueError(f"Unable to load font file {font_path}") + else: + raise ValueError(f"Font file {font_path} could not be found") + if self.interface.size == SYSTEM_DEFAULT_FONT_SIZE: # iOS default label size is 17pt # FIXME: make this dynamic. @@ -37,55 +62,43 @@ def __init__(self, interface): size = self.interface.size if self.interface.family == SYSTEM: - font = UIFont.systemFontOfSize(size) + base_font = UIFont.systemFontOfSize(size) elif self.interface.family == MESSAGE: - font = UIFont.messageFontOfSize(size) + base_font = UIFont.systemFontOfSize(size) else: - if self.interface.family is SERIF: - family = "Times-Roman" - elif self.interface.family is SANS_SERIF: - family = "Helvetica" - elif self.interface.family is CURSIVE: - family = "Apple Chancery" - elif self.interface.family is FANTASY: - family = "Papyrus" - elif self.interface.family is MONOSPACE: - family = "Courier New" - else: - family = self.interface.family + family = { + SERIF: "Times-Roman", + SANS_SERIF: "Helvetica", + CURSIVE: "Snell Roundhand", + FANTASY: "Papyrus", + MONOSPACE: "Courier New", + }.get(self.interface.family, self.interface.family) - font = UIFont.fontWithName(family, size=size) - if font is None: + base_font = UIFont.fontWithName(family, size=size) + if base_font is None: print(f"Unable to load font: {size}pt {family}") - font = UIFont.systemFontOfSize(size) + base_font = UIFont.systemFontOfSize(size) # Convert the base font definition into a font with all the desired traits. traits = 0 if self.interface.weight == BOLD: traits |= UIFontDescriptorTraitBold - if self.interface.style == ITALIC: + if self.interface.style in {ITALIC, OBLIQUE}: traits |= UIFontDescriptorTraitItalic + if traits: # If there is no font with the requested traits, this returns the original # font unchanged. font = UIFont.fontWithDescriptor( - font.fontDescriptor.fontDescriptorWithSymbolicTraits(traits), + base_font.fontDescriptor.fontDescriptorWithSymbolicTraits(traits), size=size, ) + # If the traits conversion failed, fall back to the default font. + if font is None: + font = base_font + else: + font = base_font _FONT_CACHE[self.interface] = font.retain() self.native = font - - def measure(self, text, tight=False): - textAttributes = NSMutableDictionary.alloc().init() - textAttributes[NSFontAttributeName] = self.native - text_string = NSAttributedString.alloc().initWithString_attributes_( - text, textAttributes - ) - size = text_string.size() - - # TODO: This is a magic fudge factor... - # Replace the magic with SCIENCE. - size.width += 3 - return size.width, size.height diff --git a/iOS/src/toga_iOS/widgets/canvas.py b/iOS/src/toga_iOS/widgets/canvas.py index 6909d62a8f..25014d79aa 100644 --- a/iOS/src/toga_iOS/widgets/canvas.py +++ b/iOS/src/toga_iOS/widgets/canvas.py @@ -203,7 +203,17 @@ def reset_transform(self, draw_context, *args, **kwargs): # Text def measure_text(self, text, font, tight=False): - return font._impl.measure(text, tight=tight) + textAttributes = NSMutableDictionary.alloc().init() + textAttributes[NSFontAttributeName] = font._impl.native + text_string = NSAttributedString.alloc().initWithString_attributes_( + text, textAttributes + ) + size = text_string.size() + + # TODO: This is a magic fudge factor... + # Replace the magic with SCIENCE. + size.width += 3 + return size.width, size.height def write_text(self, text, x, y, font, *args, **kwargs): width, height = self.measure_text(text, font) diff --git a/iOS/tests_backend/fonts.py b/iOS/tests_backend/fonts.py new file mode 100644 index 0000000000..af1350fe3f --- /dev/null +++ b/iOS/tests_backend/fonts.py @@ -0,0 +1,65 @@ +from toga.fonts import ( + BOLD, + CURSIVE, + FANTASY, + ITALIC, + MESSAGE, + MONOSPACE, + NORMAL, + OBLIQUE, + SANS_SERIF, + SERIF, + SMALL_CAPS, + SYSTEM, + SYSTEM_DEFAULT_FONT_SIZE, +) +from toga_iOS.libs import ( + UIFontDescriptorTraitBold, + UIFontDescriptorTraitItalic, +) + + +class FontMixin: + supports_custom_fonts = False + + def assert_font_options(self, weight=NORMAL, style=NORMAL, variant=NORMAL): + # Cocoa's FANTASY (Papyrus) and CURSIVE (Snell Roundhand) system + # fonts don't have any bold/italic variants. + if str(self.font.familyName) == "Papyrus": + print("Ignoring options on FANTASY system font") + return + elif str(self.font.familyName) == "Snell Roundhand": + print("Ignoring options on CURSIVE system font") + return + + traits = self.font.fontDescriptor.symbolicTraits + + assert (BOLD if traits & UIFontDescriptorTraitBold else NORMAL) == weight + + if style == OBLIQUE: + print("Interpreting OBLIQUE font as ITALIC") + assert bool(traits & UIFontDescriptorTraitItalic) + else: + assert ITALIC if traits & UIFontDescriptorTraitItalic else NORMAL == style + + if variant == SMALL_CAPS: + print("Ignoring SMALL CAPS font test") + else: + assert NORMAL == variant + + def assert_font_size(self, expected): + if expected == SYSTEM_DEFAULT_FONT_SIZE: + assert self.font.pointSize == 17 + else: + assert self.font.pointSize == expected + + def assert_font_family(self, expected): + assert str(self.font.familyName) == { + CURSIVE: "Snell Roundhand", + FANTASY: "Papyrus", + MONOSPACE: "Courier New", + SANS_SERIF: "Helvetica", + SERIF: "Times New Roman", + SYSTEM: ".AppleSystemUIFont", + MESSAGE: ".AppleSystemUIFont", + }.get(expected, expected) diff --git a/iOS/tests_backend/probe.py b/iOS/tests_backend/probe.py index b87699fa5a..e600fb2e43 100644 --- a/iOS/tests_backend/probe.py +++ b/iOS/tests_backend/probe.py @@ -1,20 +1,9 @@ import asyncio -from toga.fonts import CURSIVE, FANTASY, MONOSPACE, SANS_SERIF, SERIF, SYSTEM from toga_iOS.libs import NSRunLoop class BaseProbe: - def assert_font_family(self, expected): - assert self.font.family == { - CURSIVE: "Apple Chancery", - FANTASY: "Papyrus", - MONOSPACE: "Courier New", - SANS_SERIF: "Helvetica", - SERIF: "Times New Roman", - SYSTEM: ".AppleSystemUIFont", - }.get(expected, expected) - async def redraw(self, message=None, delay=None): """Request a redraw of the app, waiting until that redraw has completed.""" # If we're running slow, wait for a second diff --git a/iOS/tests_backend/widgets/base.py b/iOS/tests_backend/widgets/base.py index 1b7cfe2129..75afdb0ad2 100644 --- a/iOS/tests_backend/widgets/base.py +++ b/iOS/tests_backend/widgets/base.py @@ -2,6 +2,7 @@ from toga_iOS.libs import UIApplication +from ..fonts import FontMixin from ..probe import BaseProbe from .properties import toga_color @@ -36,7 +37,7 @@ CATransaction = ObjCClass("CATransaction") -class SimpleProbe(BaseProbe): +class SimpleProbe(BaseProbe, FontMixin): def __init__(self, widget): super().__init__() self.app = widget.app @@ -130,6 +131,10 @@ def assert_height(self, min_height, max_height): def background_color(self): return toga_color(self.native.backgroundColor) + @property + def font(self): + return self.native.font + async def press(self): self.native.sendActionsForControlEvents(UIControlEventTouchDown) diff --git a/iOS/tests_backend/widgets/button.py b/iOS/tests_backend/widgets/button.py index 30e4a225b6..962f3d523b 100644 --- a/iOS/tests_backend/widgets/button.py +++ b/iOS/tests_backend/widgets/button.py @@ -1,7 +1,7 @@ from toga_iOS.libs import UIButton, UIControlStateNormal from .base import SimpleProbe -from .properties import toga_color, toga_font +from .properties import toga_color class ButtonProbe(SimpleProbe): @@ -17,4 +17,4 @@ def color(self): @property def font(self): - return toga_font(self.native.titleLabel.font) + return self.native.titleLabel.font diff --git a/iOS/tests_backend/widgets/label.py b/iOS/tests_backend/widgets/label.py index f344201938..15feca8b24 100644 --- a/iOS/tests_backend/widgets/label.py +++ b/iOS/tests_backend/widgets/label.py @@ -1,7 +1,7 @@ from toga_iOS.libs import UILabel from .base import SimpleProbe -from .properties import toga_alignment, toga_color, toga_font +from .properties import toga_alignment, toga_color class LabelProbe(SimpleProbe): @@ -18,10 +18,6 @@ def text(self): def color(self): return toga_color(self.native.textColor) - @property - def font(self): - return toga_font(self.native.font) - @property def alignment(self): return toga_alignment(self.native.textAlignment) diff --git a/iOS/tests_backend/widgets/numberinput.py b/iOS/tests_backend/widgets/numberinput.py index 76f9069de5..b5a1a3bd52 100644 --- a/iOS/tests_backend/widgets/numberinput.py +++ b/iOS/tests_backend/widgets/numberinput.py @@ -4,7 +4,7 @@ from toga_iOS.libs import UITextField from .base import SimpleProbe -from .properties import toga_alignment, toga_color, toga_font +from .properties import toga_alignment, toga_color class NumberInputProbe(SimpleProbe): @@ -28,10 +28,6 @@ async def decrement(self): def color(self): return toga_color(self.native.textColor) - @property - def font(self): - return toga_font(self.native.font) - @property def alignment(self): return toga_alignment(self.native.textAlignment) diff --git a/iOS/tests_backend/widgets/properties.py b/iOS/tests_backend/widgets/properties.py index 4f5fe6c008..1259b9131b 100644 --- a/iOS/tests_backend/widgets/properties.py +++ b/iOS/tests_backend/widgets/properties.py @@ -1,10 +1,8 @@ from ctypes import byref from rubicon.objc import CGFloat -from travertino.fonts import Font from toga.colors import TRANSPARENT, rgba -from toga.fonts import BOLD, ITALIC, NORMAL from toga.style.pack import CENTER, JUSTIFY, LEFT, RIGHT from toga_iOS.libs import ( NSCenterTextAlignment, @@ -12,8 +10,6 @@ NSLeftTextAlignment, NSRightTextAlignment, UIColor, - UIFontDescriptorTraitBold, - UIFontDescriptorTraitItalic, ) @@ -37,17 +33,6 @@ def toga_color(color): return None -def toga_font(font): - traits = font.fontDescriptor.symbolicTraits - return Font( - family=str(font.familyName), - size=font.pointSize, - style=ITALIC if traits & UIFontDescriptorTraitItalic else NORMAL, - variant=NORMAL, - weight=BOLD if traits & UIFontDescriptorTraitBold else NORMAL, - ) - - def toga_alignment(alignment): return { NSLeftTextAlignment: LEFT, diff --git a/iOS/tests_backend/widgets/selection.py b/iOS/tests_backend/widgets/selection.py index 2cc829d024..41c9dbf4f4 100644 --- a/iOS/tests_backend/widgets/selection.py +++ b/iOS/tests_backend/widgets/selection.py @@ -5,7 +5,7 @@ from toga_iOS.libs import UIPickerView, UITextField from .base import SimpleProbe -from .properties import toga_alignment, toga_color, toga_font +from .properties import toga_alignment, toga_color class SelectionProbe(SimpleProbe): @@ -31,10 +31,6 @@ def assert_vertical_alignment(self, expected): def color(self): return toga_color(self.native.textColor) - @property - def font(self): - return toga_font(self.native.font) - @property def titles(self): count = self.native_picker.pickerView( diff --git a/iOS/tests_backend/widgets/switch.py b/iOS/tests_backend/widgets/switch.py index 2170d0121b..1f2523e8e0 100644 --- a/iOS/tests_backend/widgets/switch.py +++ b/iOS/tests_backend/widgets/switch.py @@ -1,7 +1,7 @@ from toga_iOS.libs import UIStackView from .base import SimpleProbe, UIControlEventValueChanged -from .properties import toga_color, toga_font +from .properties import toga_color class SwitchProbe(SimpleProbe): @@ -26,7 +26,7 @@ def color(self): @property def font(self): - return toga_font(self.native_label.font) + return self.native_label.font def assert_width(self, min_width, max_width): super().assert_width(min_width, max_width) diff --git a/iOS/tests_backend/widgets/textinput.py b/iOS/tests_backend/widgets/textinput.py index bff254d545..54bae26a73 100644 --- a/iOS/tests_backend/widgets/textinput.py +++ b/iOS/tests_backend/widgets/textinput.py @@ -3,7 +3,7 @@ from toga_iOS.libs import UITextField from .base import SimpleProbe -from .properties import toga_alignment, toga_color, toga_font +from .properties import toga_alignment, toga_color class TextInputProbe(SimpleProbe): @@ -41,10 +41,6 @@ def placeholder_hides_on_focus(self): def color(self): return toga_color(self.native.textColor) - @property - def font(self): - return toga_font(self.native.font) - @property def alignment(self): return toga_alignment(self.native.textAlignment) diff --git a/testbed/pyproject.toml b/testbed/pyproject.toml index c96a05c878..9107fb175b 100644 --- a/testbed/pyproject.toml +++ b/testbed/pyproject.toml @@ -43,9 +43,6 @@ test_sources = [ ] requires = [ "../gtk", - # PyGObject #119 breaks the test suite; the fix has been merged, but we need to use - # a mainline version of PyGObject until we can pin an official release. - "git+https://gitlab.gnome.org/GNOME/pygobject.git@5acfb6cc7c34c8ce9e20aef7eaaa79f27248b1a6", ] [tool.briefcase.app.testbed.linux.appimage] @@ -91,6 +88,9 @@ test_sources = [ requires = [ "../android", ] +test_requires = [ + "fonttools==4.42.1", +] build_gradle_extra_content = """\ android.defaultConfig.python { // Coverage requires access to individual .py files. diff --git a/testbed/src/testbed/resources/fonts/Corrupted.ttf b/testbed/src/testbed/resources/fonts/Corrupted.ttf new file mode 100644 index 0000000000..9623145adf --- /dev/null +++ b/testbed/src/testbed/resources/fonts/Corrupted.ttf @@ -0,0 +1 @@ +This is not a font file. diff --git a/testbed/src/testbed/resources/fonts/ENDOR___.ttf b/testbed/src/testbed/resources/fonts/ENDOR___.ttf new file mode 100644 index 0000000000..3a7274f845 Binary files /dev/null and b/testbed/src/testbed/resources/fonts/ENDOR___.ttf differ diff --git a/testbed/src/testbed/resources/fonts/Font Awesome 5 Free-Solid-900.otf b/testbed/src/testbed/resources/fonts/Font Awesome 5 Free-Solid-900.otf new file mode 100644 index 0000000000..fb8c079bb1 Binary files /dev/null and b/testbed/src/testbed/resources/fonts/Font Awesome 5 Free-Solid-900.otf differ diff --git a/testbed/src/testbed/resources/fonts/Roboto-Bold.ttf b/testbed/src/testbed/resources/fonts/Roboto-Bold.ttf new file mode 100644 index 0000000000..3742457900 Binary files /dev/null and b/testbed/src/testbed/resources/fonts/Roboto-Bold.ttf differ diff --git a/testbed/src/testbed/resources/fonts/Roboto-BoldItalic.ttf b/testbed/src/testbed/resources/fonts/Roboto-BoldItalic.ttf new file mode 100644 index 0000000000..e85e7fb9e3 Binary files /dev/null and b/testbed/src/testbed/resources/fonts/Roboto-BoldItalic.ttf differ diff --git a/testbed/src/testbed/resources/fonts/Roboto-Italic.ttf b/testbed/src/testbed/resources/fonts/Roboto-Italic.ttf new file mode 100644 index 0000000000..c9df607a4d Binary files /dev/null and b/testbed/src/testbed/resources/fonts/Roboto-Italic.ttf differ diff --git a/testbed/tests/test_fonts.py b/testbed/tests/test_fonts.py new file mode 100644 index 0000000000..6eb33763f1 --- /dev/null +++ b/testbed/tests/test_fonts.py @@ -0,0 +1,171 @@ +from importlib import import_module + +import pytest + +import toga +from toga.fonts import ( + BOLD, + FONT_STYLES, + FONT_VARIANTS, + FONT_WEIGHTS, + ITALIC, + SYSTEM, + SYSTEM_DEFAULT_FONT_SIZE, + SYSTEM_DEFAULT_FONTS, + Font, +) + + +# Fully testing fonts requires a manifested widget. +@pytest.fixture +async def widget(): + return toga.Label("This is a font test") + + +@pytest.fixture +async def font_probe(main_window, widget): + box = toga.Box(children=[widget]) + main_window.content = box + + module = import_module("tests_backend.widgets.label") + probe = getattr(module, "LabelProbe")(widget) + await probe.redraw("\nConstructing Font probe") + probe.assert_container(box) + + yield probe + + main_window.content = toga.Box() + + +async def test_use_system_font_fallback( + widget: toga.Label, + font_probe, + capsys: pytest.CaptureFixture[str], +): + """If an unknown font is requested, the system font is used as a fallback.""" + font_probe.assert_font_family(SYSTEM) + widget.style.font_family = "unknown" + await font_probe.redraw("Falling back to system font") + + assert "using system font as a fallback" in capsys.readouterr().out + + +async def test_font_options(widget: toga.Label, font_probe): + """Every combination of weight, style and variant can be used on a font.""" + for font_family in SYSTEM_DEFAULT_FONTS: + for font_size in [20, SYSTEM_DEFAULT_FONT_SIZE]: + for font_weight in FONT_WEIGHTS: + for font_style in FONT_STYLES: + for font_variant in FONT_VARIANTS: + widget.style.font_family = font_family + widget.style.font_size = font_size + widget.style.font_style = font_style + widget.style.font_variant = font_variant + widget.style.font_weight = font_weight + await font_probe.redraw( + f"Using a {font_family} {font_size} {font_weight} {font_style} {font_variant} font" + ) + + font_probe.assert_font_family(font_family) + font_probe.assert_font_size(font_size) + font_probe.assert_font_options( + font_weight, font_style, font_variant + ) + + +@pytest.mark.parametrize( + "font_family,font_path,font_kwargs", + [ + # OpenType font with weight property + ( + "Font Awesome 5 Free Solid", + "resources/fonts/Font Awesome 5 Free-Solid-900.otf", + {"weight": BOLD}, + ), + # TrueType font, no options + ("Endor", "resources/fonts/ENDOR___.ttf", {}), + # Font with weight property + ("Roboto", "resources/fonts/Roboto-Bold.ttf", {"weight": BOLD}), + # Font with style property + ("Roboto", "resources/fonts/Roboto-Italic.ttf", {"style": ITALIC}), + # Font with multiple properties + ( + "Roboto", + "resources/fonts/Roboto-BoldItalic.ttf", + {"weight": BOLD, "style": ITALIC}, + ), + ], +) +async def test_font_file_loaded( + app: toga.App, + widget: toga.Label, + font_probe, + font_family: str, + font_path: str, + font_kwargs, + capsys: pytest.CaptureFixture[str], +): + """Custom fonts can be loaded and used.""" + Font.register( + family=font_family, + path=app.paths.app / font_path, + **font_kwargs, + ) + + if not font_probe.supports_custom_fonts: + pytest.skip("Platform doesn't support loading custom fonts") + + # Update widget font family and other options if needed + widget.style.font_family = font_family + for prop, value in font_kwargs.items(): + widget.style.update( + **{f"font_{kwarg}": value for kwarg, value in font_kwargs.items()} + ) + await font_probe.redraw(f"Using {font_family} {' '.join(font_kwargs.values())}") + + # Check that font properties are updated + font_probe.assert_font_family(font_family) + font_probe.assert_font_options(**font_kwargs) + + # Setting the font to "Roboto something" involves setting the font to + # "Roboto" as an intermediate step. However, we haven't registered "Roboto + # regular", so this will raise an warning about the missing "regular" font. + # Ignore this message. + stdout = capsys.readouterr().out + if font_kwargs: + stdout = stdout.replace( + f"Unknown font '{font_family} default size'; " + f"using system font as a fallback\n", + "", + ) + + assert "; using system font as a fallback" not in stdout + + +async def test_non_existent_font_file(widget: toga.Label, app: toga.App): + "Invalid font files fail registration" + Font.register( + family="non-existent", + path=app.paths.app / "resources" / "fonts" / "nonexistent.ttf", + ) + with pytest.raises( + ValueError, match=r"Font file .*nonexistent.ttf could not be found" + ): + widget.style.font_family = "non-existent" + + +async def test_corrupted_font_file( + widget: toga.Label, + font_probe, + app: toga.App, +): + "Corrupted font files fail registration" + if not font_probe.supports_custom_fonts: + pytest.skip("Platform doesn't support registering and loading custom fonts") + + Font.register( + family="corrupted", + path=app.paths.app / "resources" / "fonts" / "Corrupted.ttf", + ) + with pytest.raises(ValueError, match=r"Unable to load font file .*Corrupted.ttf"): + widget.style.font_family = "corrupted" diff --git a/testbed/tests/widgets/properties.py b/testbed/tests/widgets/properties.py index c33c89145d..0c7b335cbb 100644 --- a/testbed/tests/widgets/properties.py +++ b/testbed/tests/widgets/properties.py @@ -3,7 +3,15 @@ from pytest import approx from toga.colors import CORNFLOWERBLUE, RED, TRANSPARENT, color as named_color -from toga.fonts import BOLD, FANTASY, ITALIC, NORMAL, SERIF, SYSTEM +from toga.fonts import ( + BOLD, + FANTASY, + ITALIC, + NORMAL, + SERIF, + SYSTEM, + SYSTEM_DEFAULT_FONT_SIZE, +) from toga.style.pack import CENTER, COLUMN, JUSTIFY, LEFT, LTR, RIGHT, RTL from ..assertions import assert_color @@ -281,20 +289,14 @@ async def test_font(widget, probe, verify_font_sizes): orig_width = probe.width if verify_font_sizes[1]: orig_height = probe.height - orig_font = probe.font probe.assert_font_family(SYSTEM) + probe.assert_font_size(SYSTEM_DEFAULT_FONT_SIZE) + probe.assert_font_options(weight=NORMAL, variant=NORMAL, style=NORMAL) - # Set the font to larger than its original size - widget.style.font_size = orig_font.size * 3 + # Set the font to be large + widget.style.font_size = 30 await probe.redraw("Widget font should be larger than its original size") - - # Widget has a new font size - new_size_font = probe.font - # Font size in points is an integer; however, some platforms - # perform rendering in pixels (or device independent pixels, - # so round-tripping points->pixels->points through the probe - # can result in rounding errors. - assert (orig_font.size * 2.5) < new_size_font.size < (orig_font.size * 3.5) + probe.assert_font_size(30) # Widget should be taller and wider if verify_font_sizes[0]: @@ -307,11 +309,11 @@ async def test_font(widget, probe, verify_font_sizes): await probe.redraw("Widget font should be changed to FANTASY") # Font family has been changed - new_family_font = probe.font probe.assert_font_family(FANTASY) # Font size hasn't changed - assert new_family_font.size == new_size_font.size + probe.assert_font_size(30) + # Widget should still be taller and wider than the original if verify_font_sizes[0]: assert probe.width > orig_width @@ -324,7 +326,9 @@ async def test_font(widget, probe, verify_font_sizes): await probe.redraw( message="Widget text should be reset to original family and size" ) - assert probe.font == orig_font + probe.assert_font_family(SYSTEM) + probe.assert_font_size(SYSTEM_DEFAULT_FONT_SIZE) + probe.assert_font_options(weight=NORMAL, variant=NORMAL, style=NORMAL) if verify_font_sizes[0] and probe.shrink_on_resize: assert probe.width == orig_width if verify_font_sizes[1]: @@ -333,8 +337,7 @@ async def test_font(widget, probe, verify_font_sizes): async def test_font_attrs(widget, probe): "The font weight and style of a widget can be changed." - assert probe.font.weight == NORMAL - assert probe.font.style == NORMAL + probe.assert_font_options(weight=NORMAL, style=NORMAL) for family in [SYSTEM, SERIF]: widget.style.font_family = family @@ -343,11 +346,10 @@ async def test_font_attrs(widget, probe): for style in [NORMAL, ITALIC]: widget.style.font_style = style await probe.redraw( - message="Widget text font style should be %s" % style + message=f"Widget text font should be {family} {weight} {style}" ) probe.assert_font_family(family) - assert probe.font.weight == weight - assert probe.font.style == style + probe.assert_font_options(weight=weight, style=style) async def test_color(widget, probe): diff --git a/winforms/src/toga_winforms/fonts.py b/winforms/src/toga_winforms/fonts.py index 00a1de07f1..71aad8127b 100644 --- a/winforms/src/toga_winforms/fonts.py +++ b/winforms/src/toga_winforms/fonts.py @@ -1,24 +1,29 @@ -import System.Windows.Forms as WinForms -from System.Drawing import Font as WinFont +from System import ArgumentException +from System.Drawing import ( + Font as WinFont, + FontFamily, + FontStyle, + SystemFonts, +) from System.Drawing.Text import PrivateFontCollection from System.IO import FileNotFoundException from System.Runtime.InteropServices import ExternalException -import toga -from toga.fonts import _REGISTERED_FONT_CACHE -from toga_winforms.libs.fonts import ( - win_font_family, - win_font_size, - win_font_style, +from toga.fonts import ( + _REGISTERED_FONT_CACHE, + CURSIVE, + FANTASY, + MESSAGE, + MONOSPACE, + SANS_SERIF, + SERIF, + SYSTEM, + SYSTEM_DEFAULT_FONT_SIZE, ) _FONT_CACHE = {} -def points_to_pixels(points, dpi): - return points * 72 / dpi - - class Font: def __init__(self, interface): self._pfc = None # this needs to be an instance variable, otherwise we might get Winforms exceptions later @@ -27,45 +32,65 @@ def __init__(self, interface): font = _FONT_CACHE[self.interface] except KeyError: font = None - font_key = self.interface.registered_font_key( + font_key = self.interface._registered_font_key( self.interface.family, weight=self.interface.weight, style=self.interface.style, variant=self.interface.variant, ) try: - font_path = str( - toga.App.app.paths.app / _REGISTERED_FONT_CACHE[font_key] - ) + font_path = _REGISTERED_FONT_CACHE[font_key] + except KeyError: + try: + font_family = { + SYSTEM: SystemFonts.DefaultFont.FontFamily, + MESSAGE: SystemFonts.MenuFont.FontFamily, + SERIF: FontFamily.GenericSerif, + SANS_SERIF: FontFamily.GenericSansSerif, + CURSIVE: FontFamily("Comic Sans MS"), + FANTASY: FontFamily("Impact"), + MONOSPACE: FontFamily.GenericMonospace, + }[self.interface.family] + except KeyError: + try: + font_family = FontFamily(self.interface.family) + except ArgumentException: + print( + f"Unknown font '{self.interface}'; " + "using system font as a fallback" + ) + font_family = SystemFonts.DefaultFont.FontFamily + + else: try: self._pfc = PrivateFontCollection() self._pfc.AddFontFile(font_path) font_family = self._pfc.Families[0] - except FileNotFoundException as e: - print(f"Registered font path {font_path!r} could not be found: {e}") - except ExternalException as e: - print( - f"Registered font path {font_path!r} could not be loaded: {e}" - ) - except IndexError as e: - print(f"Registered font {font_key} could not be loaded: {e}") - except KeyError: - font_family = win_font_family(self.interface.family) + except FileNotFoundException: + raise ValueError(f"Font file {font_path} could not be found") + except (IndexError, ExternalException): + raise ValueError(f"Unable to load font file {font_path}") + + # Convert font style to Winforms format + font_style = FontStyle.Regular + if self.interface.weight.lower() == "bold" and font_family.IsStyleAvailable( + FontStyle.Bold + ): + font_style |= FontStyle.Bold + if ( + # Winforms doesn't recognize Oblique; so we interpret as Italic + self.interface.style.lower() in {"italic", "oblique"} + and font_family.IsStyleAvailable(FontStyle.Italic) + ): + font_style |= FontStyle.Italic + + # Convert font size to Winforms format + if self.interface.size == SYSTEM_DEFAULT_FONT_SIZE: + font_size = SystemFonts.DefaultFont.Size + else: + font_size = self.interface.size - font_style = win_font_style( - self.interface.weight, - self.interface.style, - font_family, - ) - font_size = win_font_size(self.interface.size) font = WinFont(font_family, font_size, font_style) _FONT_CACHE[self.interface] = font self.native = font - - def measure(self, text, dpi, tight=False): - size = WinForms.TextRenderer.MeasureText(text, self.native) - return ( - points_to_pixels(size.Width, dpi), - points_to_pixels(size.Height, dpi), - ) diff --git a/winforms/src/toga_winforms/libs/fonts.py b/winforms/src/toga_winforms/libs/fonts.py index df88d87155..d3fbe35e4c 100644 --- a/winforms/src/toga_winforms/libs/fonts.py +++ b/winforms/src/toga_winforms/libs/fonts.py @@ -1,23 +1,7 @@ import System.Windows.Forms as WinForms -from System import ArgumentException -from System.Drawing import ( - ContentAlignment, - FontFamily, - FontStyle, - SystemFonts, -) +from System.Drawing import ContentAlignment from toga.constants import CENTER, JUSTIFY, LEFT, RIGHT -from toga.fonts import ( - CURSIVE, - FANTASY, - MESSAGE, - MONOSPACE, - SANS_SERIF, - SERIF, - SYSTEM, - SYSTEM_DEFAULT_FONT_SIZE, -) def TextAlignment(value): @@ -37,41 +21,3 @@ def HorizontalTextAlignment(value): CENTER: WinForms.HorizontalAlignment.Center, JUSTIFY: WinForms.HorizontalAlignment.Left, }[value] - - -def win_font_family(value): - try: - return { - SYSTEM: SystemFonts.DefaultFont.FontFamily, - MESSAGE: SystemFonts.MenuFont.FontFamily, - SERIF: FontFamily.GenericSerif, - SANS_SERIF: FontFamily.GenericSansSerif, - CURSIVE: FontFamily("Comic Sans MS"), - FANTASY: FontFamily("Impact"), - MONOSPACE: FontFamily.GenericMonospace, - }[value] - except KeyError: - try: - return FontFamily(value) - except ArgumentException: - print( - "Unable to load font-family '{}', loading '{}' instead".format( - value, SystemFonts.DefaultFont.FontFamily.Name - ) - ) - return SystemFonts.DefaultFont.FontFamily - - -def win_font_style(weight, style, font_family): - font_style = FontStyle.Regular - if weight.lower() == "bold" and font_family.IsStyleAvailable(FontStyle.Bold): - font_style |= FontStyle.Bold - if style.lower() == "italic" and font_family.IsStyleAvailable(FontStyle.Italic): - font_style |= FontStyle.Italic - return font_style - - -def win_font_size(size): - if size == SYSTEM_DEFAULT_FONT_SIZE: - return SystemFonts.DefaultFont.Size - return size diff --git a/winforms/src/toga_winforms/widgets/canvas.py b/winforms/src/toga_winforms/widgets/canvas.py index a12c217769..82e437ef48 100644 --- a/winforms/src/toga_winforms/widgets/canvas.py +++ b/winforms/src/toga_winforms/widgets/canvas.py @@ -22,7 +22,6 @@ from toga.widgets.canvas import Context, FillRule from toga_winforms.colors import native_color -from toga_winforms.libs.fonts import win_font_family, win_font_style from .box import Box @@ -303,8 +302,8 @@ def reset_transform(self, draw_context, *args, **kwargs): # Text def write_text(self, text, x, y, font, draw_context, *args, **kwargs): full_height = 0 - font_family = win_font_family(font.family) - font_style = win_font_style(font.weight, font.style, font_family) + font_family = font._impl.native.FontFamily + font_style = font._impl.native.Style for line in text.splitlines(): _, height = self.measure_text(line, font) origin = PointF(x, y + full_height - height) diff --git a/winforms/tests_backend/fonts.py b/winforms/tests_backend/fonts.py new file mode 100644 index 0000000000..83a9748a99 --- /dev/null +++ b/winforms/tests_backend/fonts.py @@ -0,0 +1,52 @@ +from System.Drawing import FontFamily, SystemFonts + +from toga.fonts import ( + BOLD, + CURSIVE, + FANTASY, + ITALIC, + MESSAGE, + MONOSPACE, + NORMAL, + OBLIQUE, + SANS_SERIF, + SERIF, + SMALL_CAPS, + SYSTEM, + SYSTEM_DEFAULT_FONT_SIZE, +) + + +class FontMixin: + supports_custom_fonts = True + + def assert_font_options(self, weight=NORMAL, style=NORMAL, variant=NORMAL): + assert BOLD if self.font.Bold else NORMAL == weight + + if style == OBLIQUE: + print("Interpreting OBLIQUE font as ITALIC") + assert self.font.Italic + else: + assert (ITALIC if self.font.Italic else NORMAL) == style + + if variant == SMALL_CAPS: + print("Ignoring SMALL CAPS font test") + else: + assert NORMAL == variant + + def assert_font_size(self, expected): + if expected == SYSTEM_DEFAULT_FONT_SIZE: + assert int(self.font.SizeInPoints) == int(SystemFonts.DefaultFont.Size) + else: + assert int(self.font.SizeInPoints) == expected + + def assert_font_family(self, expected): + assert str(self.font.Name) == { + CURSIVE: "Comic Sans MS", + FANTASY: "Impact", + MESSAGE: SystemFonts.MenuFont.FontFamily.Name, + MONOSPACE: FontFamily.GenericMonospace.Name, + SANS_SERIF: FontFamily.GenericSansSerif.Name, + SERIF: FontFamily.GenericSerif.Name, + SYSTEM: SystemFonts.DefaultFont.FontFamily.Name, + }.get(expected, expected) diff --git a/winforms/tests_backend/probe.py b/winforms/tests_backend/probe.py index 69cd69cf32..1396bc1f1b 100644 --- a/winforms/tests_backend/probe.py +++ b/winforms/tests_backend/probe.py @@ -1,21 +1,7 @@ import asyncio -from System.Drawing import FontFamily, SystemFonts - -from toga.fonts import CURSIVE, FANTASY, MONOSPACE, SANS_SERIF, SERIF, SYSTEM - class BaseProbe: - def assert_font_family(self, expected): - assert self.font.family == { - CURSIVE: "Comic Sans MS", - FANTASY: "Impact", - MONOSPACE: FontFamily.GenericMonospace.Name, - SANS_SERIF: FontFamily.GenericSansSerif.Name, - SERIF: FontFamily.GenericSerif.Name, - SYSTEM: SystemFonts.DefaultFont.FontFamily.Name, - }.get(expected, expected) - async def redraw(self, message=None, delay=None): """Request a redraw of the app, waiting until that redraw has completed.""" # Winforms style changes always take effect immediately. diff --git a/winforms/tests_backend/widgets/base.py b/winforms/tests_backend/widgets/base.py index 59303e86c4..61b072099b 100644 --- a/winforms/tests_backend/widgets/base.py +++ b/winforms/tests_backend/widgets/base.py @@ -6,8 +6,9 @@ from toga.colors import TRANSPARENT from toga.style.pack import JUSTIFY, LEFT +from ..fonts import FontMixin from ..probe import BaseProbe -from .properties import toga_color, toga_font +from .properties import toga_color KEY_CODES = { f"<{name}>": f"{{{name.upper()}}}" @@ -20,7 +21,7 @@ ) -class SimpleProbe(BaseProbe): +class SimpleProbe(BaseProbe, FontMixin): fixed_height = None def __init__(self, widget): @@ -71,7 +72,7 @@ def background_color(self): @property def font(self): - return toga_font(self.native.Font) + return self.native.Font @property def hidden(self): diff --git a/winforms/tests_backend/widgets/properties.py b/winforms/tests_backend/widgets/properties.py index 7eb444c163..332191bac9 100644 --- a/winforms/tests_backend/widgets/properties.py +++ b/winforms/tests_backend/widgets/properties.py @@ -1,9 +1,7 @@ from System.Drawing import Color, ContentAlignment, SystemColors from System.Windows.Forms import HorizontalAlignment -from travertino.fonts import Font from toga.colors import TRANSPARENT, rgba -from toga.fonts import BOLD, ITALIC, NORMAL from toga.style.pack import BOTTOM, CENTER, LEFT, RIGHT, TOP @@ -14,16 +12,6 @@ def toga_color(color): return rgba(color.R, color.G, color.B, color.A / 255) -def toga_font(font): - return Font( - family=str(font.Name), - size=int(font.SizeInPoints), - style=ITALIC if font.Italic else NORMAL, - variant=NORMAL, - weight=BOLD if font.Bold else NORMAL, - ) - - def toga_xalignment(alignment): return { ContentAlignment.TopLeft: LEFT,