Skip to content
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 60 additions & 57 deletions android/src/toga_android/fonts.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
SERIF,
SYSTEM,
SYSTEM_DEFAULT_FONT_SIZE,
SYSTEM_DEFAULT_FONTS,
UnknownFontError,
)

_FONT_CACHE = {}
Expand All @@ -28,69 +28,72 @@ class Font:
def __init__(self, interface):
self.interface = interface

def typeface(self, *, default=Typeface.DEFAULT):
cache_key = (self.interface, default)
if typeface := _FONT_CACHE.get(cache_key):
return typeface

font_key = self.interface._registered_font_key(
self.interface.family,
weight=self.interface.weight,
style=self.interface.style,
variant=self.interface.variant,
)
# Check for a cached typeface.
try:
font_path = _REGISTERED_FONT_CACHE[font_key]
self.native_typeface = _FONT_CACHE[self.interface]
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"

# Check for a system font.
try:
self.native_typeface = {
# None is left as a placeholder.
# The default button font is not marked as bold, but it has a weight
# of "medium" (500), which is in between "normal" (400), and "bold"
# (600 or 700). To preserve this, we use the widget's original
# typeface as a starting point rather than Typeface.DEFAULT.
SYSTEM: None,
MESSAGE: Typeface.DEFAULT,
SERIF: Typeface.SERIF,
SANS_SERIF: Typeface.SANS_SERIF,
MONOSPACE: Typeface.MONOSPACE,
# Android appears to not have a fantasy font available by default,
# but if it ever does, we'll start using it. Android seems to
# choose a serif font when asked for a fantasy font.
FANTASY: Typeface.create("fantasy", Typeface.NORMAL),
CURSIVE: Typeface.create("cursive", Typeface.NORMAL),
}[interface.family]
except KeyError:

# Check for a user-registered font.
font_key = self.interface._registered_font_key(
self.interface.family,
weight=self.interface.weight,
style=self.interface.style,
variant=self.interface.variant,
)
else:
if Path(font_path).is_file():
typeface = Typeface.createFromFile(font_path)
if typeface is Typeface.DEFAULT:
raise ValueError(f"Unable to load font file {font_path}")
else:
raise ValueError(f"Font file {font_path} could not be found")

if typeface is None:
if self.interface.family is SYSTEM:
# The default button font is not marked as bold, but it has a weight
# of "medium" (500), which is in between "normal" (400), and "bold"
# (600 or 700). To preserve this, we use the widget's original
# typeface as a starting point rather than Typeface.DEFAULT.
typeface = default
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:
typeface = Typeface.SANS_SERIF
elif self.interface.family is MONOSPACE:
typeface = Typeface.MONOSPACE
elif self.interface.family is CURSIVE:
typeface = Typeface.create("cursive", Typeface.NORMAL)
elif self.interface.family is FANTASY:
# Android appears to not have a fantasy font available by default,
# but if it ever does, we'll start using it. Android seems to choose
# a serif font when asked for a fantasy font.
typeface = Typeface.create("fantasy", Typeface.NORMAL)
else:
typeface = Typeface.create(self.interface.family, Typeface.NORMAL)

native_style = typeface.getStyle()
try:
font_path = _REGISTERED_FONT_CACHE[font_key]
except KeyError:

# No, not a user-registered font
raise UnknownFontError(f"Unknown font '{self.interface}'")

# Yes, user has indeed registered this font.
else:
if Path(font_path).is_file():
self.native_typeface = Typeface.createFromFile(font_path)
if self.native_typeface is Typeface.DEFAULT:
raise ValueError(f"Unable to load font file {font_path}")
else:
raise ValueError(f"Font file {font_path} could not be found")

_FONT_CACHE[self.interface] = self.native_typeface

self.native_style = 0
if self.interface.weight == BOLD:
native_style |= Typeface.BOLD
self.native_style |= Typeface.BOLD
if self.interface.style in {ITALIC, OBLIQUE}:
native_style |= Typeface.ITALIC
self.native_style |= Typeface.ITALIC

def typeface(self, *, default=Typeface.DEFAULT):
"""Return the appropriate native Typeface object."""
typeface = default if self.native_typeface is None else self.native_typeface

if native_style != typeface.getStyle():
typeface = Typeface.create(typeface, native_style)
if self.native_style != typeface.getStyle():
# While we're not caching this result, Android does its own caching of
# different styles of the same Typeface.
typeface = Typeface.create(typeface, self.native_style)

_FONT_CACHE[cache_key] = typeface
return typeface

def size(self, *, default=None):
Expand Down
6 changes: 3 additions & 3 deletions android/src/toga_android/widgets/label.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,9 @@
from .base import Widget, android_text_align


def set_textview_font(tv, font, default_typeface, default_size):
tv.setTypeface(font.typeface(default=default_typeface))
tv.setTextSize(TypedValue.COMPLEX_UNIT_PX, font.size(default=default_size))
def set_textview_font(textview, font, default_typeface, default_size):
textview.setTypeface(font.typeface(default=default_typeface))
textview.setTextSize(TypedValue.COMPLEX_UNIT_PX, font.size(default=default_size))


class TextViewWidget(Widget):
Expand Down
1 change: 1 addition & 0 deletions changes/3526.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
``Pack.font_family`` now accepts a list of possible values; text will be rendered with the first font family that is available.
1 change: 1 addition & 0 deletions changes/3526.removal.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
``toga.Font`` objects now raise an `UnknownFontError` instead of silently falling back to system font if the font family can't be successfully loaded.
13 changes: 4 additions & 9 deletions cocoa/src/toga_cocoa/fonts.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
SMALL_CAPS,
SYSTEM,
SYSTEM_DEFAULT_FONT_SIZE,
UnknownFontError,
)
from toga_cocoa.libs import (
NSURL,
Expand Down Expand Up @@ -44,7 +45,7 @@ def __init__(self, interface):
)

try:
# Built in fonts have known names; no need to interrogate a file.
# Built-in fonts have known names; no need to interrogate a file.
custom_font_name = {
SYSTEM: None, # No font name required
MESSAGE: None, # No font name required
Expand All @@ -58,17 +59,11 @@ def __init__(self, interface):
try:
font_path = _REGISTERED_FONT_CACHE[font_key]
except KeyError:
# The requested font has not been registered
print(
f"Unknown font '{self.interface}'; "
"using system font as a fallback"
)
font_family = SYSTEM
custom_font_name = None
raise UnknownFontError(f"Unknown font '{self.interface}'")
else:
# We have a path for a font file.
try:
# A font *file* an only be registered once under Cocoa.
# A font *file* can only be registered once under Cocoa.
custom_font_name = _CUSTOM_FONT_NAMES[font_path]
except KeyError:
if Path(font_path).is_file():
Expand Down
16 changes: 12 additions & 4 deletions core/src/toga/fonts.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@
_REGISTERED_FONT_CACHE: dict[tuple[str, str, str, str], str] = {}


class UnknownFontError(Exception):
"""Raised when an unknown font family is requested."""


class Font(BaseFont):
def __init__(
self,
Expand All @@ -42,7 +46,7 @@ def __init__(
style: str = NORMAL,
variant: str = NORMAL,
):
"""Constructs a reference to a font.
"""Construct 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
Expand All @@ -53,6 +57,12 @@ def __init__(
:param weight: The :ref:`font weight <pack-font-weight>`.
:param style: The :ref:`font style <pack-font-style>`.
:param variant: The :ref:`font variant <pack-font-variant>`.

:raises UnknownFontError: If the font family requested corresponds to neither
one of the :ref:`built-in system fonts <pack-font-family>` nor a
user-registered font.
:raises ValueError: If a user-registered font is used, but the file specified
either doesn't exist or a font can't be successfully loaded from it.
"""
super().__init__(family, size, weight=weight, style=style, variant=variant)
self.factory = get_platform_factory()
Expand All @@ -78,9 +88,7 @@ def register(
style: str = NORMAL,
variant: str = NORMAL,
) -> None:
"""Registers a file-based font.

**Note:** This is not currently supported on macOS or iOS.
"""Register a file-based font.

:param family: The :ref:`font family <pack-font-family>`.
:param path: The path to the font file. This can be an absolute path, or a path
Expand Down
49 changes: 33 additions & 16 deletions core/src/toga/style/pack.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@
from travertino.layout import BaseBox
from travertino.properties.aliased import Condition, aliased_property
from travertino.properties.shorthand import directional_property
from travertino.properties.validated import validated_property
from travertino.properties.validated import list_property, validated_property
from travertino.size import BaseIntrinsicSize
from travertino.style import BaseStyle

Expand All @@ -51,6 +51,7 @@
SYSTEM_DEFAULT_FONT_SIZE,
SYSTEM_DEFAULT_FONTS,
Font,
UnknownFontError,
)

# Make sure deprecation warnings are shown by default
Expand Down Expand Up @@ -226,8 +227,8 @@ class IntrinsicSize(BaseIntrinsicSize):
text_align: str | None = validated_property(LEFT, RIGHT, CENTER, JUSTIFY)
text_direction: str | None = validated_property(RTL, LTR, initial=LTR)

font_family: str = validated_property(
*SYSTEM_DEFAULT_FONTS, string=True, initial=SYSTEM
font_family: str | list[str] = list_property(
*SYSTEM_DEFAULT_FONTS, string=True, initial=[SYSTEM]
)
font_style: str = validated_property(*FONT_STYLES, initial=NORMAL)
font_variant: str = validated_property(*FONT_VARIANTS, initial=NORMAL)
Expand Down Expand Up @@ -319,15 +320,30 @@ def _apply(self, names: set) -> None:
"font_variant",
"font_weight",
}:
self._applicator.set_font(
Font(
self.font_family,
self.font_size,
style=self.font_style,
variant=self.font_variant,
weight=self.font_weight,
font = None
font_kwargs = {
"size": self.font_size,
"style": self.font_style,
"variant": self.font_variant,
"weight": self.font_weight,
}

for family in self.font_family:
try:
font = Font(family, **font_kwargs)
break
except UnknownFontError:
pass

if font is None:
# Fall back to system font if no font families were valid
font = Font(SYSTEM, **font_kwargs)
print(
f"No valid font family in {self.font_family}; using system font as "
"a fallback"
)
)

self._applicator.set_font(font)

# Refresh if any properties that could affect layout are being set.
if names - {
Expand Down Expand Up @@ -955,11 +971,12 @@ def __css__(self) -> str:
css.append(f"text-direction: {self.text_direction};")

# font-*
if self.font_family != SYSTEM:
if " " in self.font_family:
css.append(f'font-family: "{self.font_family}";')
else:
css.append(f"font-family: {self.font_family};")
if self.font_family != [SYSTEM]:
families = [
f'"{family}"' if " " in family else family
for family in self.font_family
]
css.append(f"font-family: {', '.join(families)};")
if self.font_size != SYSTEM_DEFAULT_FONT_SIZE:
css.append(f"font-size: {self.font_size}pt;")
if self.font_weight != NORMAL:
Expand Down
23 changes: 22 additions & 1 deletion core/tests/style/pack/test_apply.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
from unittest.mock import call

from pytest import mark

from toga.colors import rgb
from toga.fonts import Font
from toga.fonts import SYSTEM_DEFAULT_FONT_SIZE, Font
from toga.style.pack import (
BOLD,
CENTER,
Expand All @@ -13,6 +15,7 @@
RIGHT,
RTL,
SMALL_CAPS,
SYSTEM,
VISIBLE,
Pack,
)
Expand Down Expand Up @@ -75,6 +78,24 @@ def test_set_font():
root.refresh.assert_called_once_with()


@mark.parametrize(
"family, result",
[
("Courier", "Courier"),
(["Courier", "Helvetica"], "Courier"),
(["Bogus Font", "Courier", "Helvetica"], "Courier"),
(["Courier", "Bogus Font", "Helvetica"], "Courier"),
(["Bogus Font"], SYSTEM),
("Bogus Font", SYSTEM),
],
)
def test_set_font_family(family, result):
"""The first viable family is used. Dummy backend rejects 'Bogus Font'."""
node = ExampleNode("app", style=Pack(font_family=family))
node.style.apply()
node._impl.set_font.assert_called_once_with(Font(result, SYSTEM_DEFAULT_FONT_SIZE))


def test_set_multiple_layout_properties():
"""Setting multiple layout properties at once should only trigger one refresh."""
root = ExampleNode(
Expand Down
18 changes: 18 additions & 0 deletions core/tests/style/pack/test_css.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
RIGHT,
ROW,
RTL,
SERIF,
SMALL_CAPS,
START,
SYSTEM,
Expand Down Expand Up @@ -409,6 +410,23 @@
'flex-direction: row; flex: 0.0 0 auto; font-family: "Times New Roman";',
id="font-family",
),
pytest.param(
Pack(font_family=["Times New Roman"]),
'flex-direction: row; flex: 0.0 0 auto; font-family: "Times New Roman";',
id="font-family",
),
pytest.param(
Pack(font_family=["Times New Roman", SERIF]),
'flex-direction: row; flex: 0.0 0 auto; font-family: "Times New Roman", '
"serif;",
id="font-family",
),
pytest.param(
Pack(font_family=["Times New Roman", "Courier", SERIF]),
'flex-direction: row; flex: 0.0 0 auto; font-family: "Times New Roman", '
"Courier, serif;",
id="font-family",
),
pytest.param(
Pack(font_family=SYSTEM),
"flex-direction: row; flex: 0.0 0 auto;",
Expand Down
Loading
Loading