diff --git a/android/src/toga_android/fonts.py b/android/src/toga_android/fonts.py index 1ce1ee8487..e09f0f2e0d 100644 --- a/android/src/toga_android/fonts.py +++ b/android/src/toga_android/fonts.py @@ -4,6 +4,9 @@ from android.graphics import Typeface from android.util import TypedValue from org.beeware.android import MainActivity +from travertino.constants import ( + FONT_SIZE_SCALE, +) from toga.fonts import ( _REGISTERED_FONT_CACHE, @@ -98,6 +101,7 @@ def typeface(self, *, default=Typeface.DEFAULT): def size(self, *, default=None): """Return the font size in physical pixels.""" context = MainActivity.singletonThis + base_size = 14 if self.interface.size == SYSTEM_DEFAULT_FONT_SIZE: if default is None: typed_array = context.obtainStyledAttributes( @@ -106,12 +110,15 @@ def size(self, *, default=None): default = typed_array.getDimension(0, 0) typed_array.recycle() return default - + elif isinstance(self.interface.size, str): + default = base_size * FONT_SIZE_SCALE[self.interface.size] + return default else: # Using SP means we follow the standard proportion between CSS pixels and # points by default, but respect the system text scaling setting. - return TypedValue.applyDimension( + default = TypedValue.applyDimension( TypedValue.COMPLEX_UNIT_SP, self.interface.size * (96 / 72), context.getResources().getDisplayMetrics(), ) + return default diff --git a/android/tests_backend/fonts.py b/android/tests_backend/fonts.py index 29d0e5a344..1ea34e1236 100644 --- a/android/tests_backend/fonts.py +++ b/android/tests_backend/fonts.py @@ -6,6 +6,9 @@ from fontTools.ttLib import TTFont from java import jint from java.lang import Integer, Long +from travertino.constants import ( + FONT_SIZE_SCALE, +) from toga.fonts import ( BOLD, @@ -77,14 +80,20 @@ def assert_font_options(self, weight=NORMAL, style=NORMAL, variant=NORMAL): def assert_font_size(self, expected): if expected == SYSTEM_DEFAULT_FONT_SIZE: - expected = self.default_font_size * (72 / 96) - assert round(self.text_size) == round( - TypedValue.applyDimension( + expected = TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_SP, + self.default_font_size, + self.native.getResources().getDisplayMetrics(), + ) + elif isinstance(expected, str): + expected = self.default_font_size * FONT_SIZE_SCALE[expected] + else: + expected = TypedValue.applyDimension( TypedValue.COMPLEX_UNIT_SP, expected * (96 / 72), self.native.getResources().getDisplayMetrics(), ) - ) + assert round(self.text_size) == round(expected) def assert_font_family(self, expected): if not SYSTEM_FONTS: diff --git a/changes/1814.misc.rst b/changes/1814.misc.rst new file mode 100644 index 0000000000..62c8fcfe12 --- /dev/null +++ b/changes/1814.misc.rst @@ -0,0 +1 @@ +Added support for absolute CSS font size keywords in Android, Cocoa, GTK, iOS, and Windows. diff --git a/cocoa/src/toga_cocoa/fonts.py b/cocoa/src/toga_cocoa/fonts.py index 75b05d0779..ee182ed13d 100644 --- a/cocoa/src/toga_cocoa/fonts.py +++ b/cocoa/src/toga_cocoa/fonts.py @@ -1,6 +1,9 @@ from pathlib import Path from fontTools.ttLib import TTFont +from travertino.constants import ( + FONT_SIZE_SCALE, +) from toga.fonts import ( _REGISTERED_FONT_CACHE, @@ -88,6 +91,9 @@ def __init__(self, interface): if self.interface.size == SYSTEM_DEFAULT_FONT_SIZE: font_size = NSFont.systemFontSize + elif isinstance(self.interface.size, str): + base_size = NSFont.systemFontSize + font_size = base_size * FONT_SIZE_SCALE[self.interface.size] else: # A "point" in Apple APIs is equivalent to a CSS pixel, but the Toga # public API works in CSS points, which are slightly larger diff --git a/cocoa/tests_backend/fonts.py b/cocoa/tests_backend/fonts.py index fcf3e22aa0..38ca49fe5e 100644 --- a/cocoa/tests_backend/fonts.py +++ b/cocoa/tests_backend/fonts.py @@ -1,3 +1,7 @@ +from travertino.constants import ( + FONT_SIZE_SCALE, +) + from toga.fonts import ( BOLD, CURSIVE, @@ -48,6 +52,9 @@ def assert_font_options(self, weight=NORMAL, style=NORMAL, variant=NORMAL): def assert_font_size(self, expected): if expected == SYSTEM_DEFAULT_FONT_SIZE: assert self.font.pointSize == 13 + elif isinstance(expected, str): + expected_size = 13 * FONT_SIZE_SCALE[expected] + assert abs(self.font.pointSize - expected_size) < 0.01 else: assert self.font.pointSize == expected * 96 / 72 diff --git a/core/src/toga/fonts.py b/core/src/toga/fonts.py index ec03fb782e..b9df4b70b4 100644 --- a/core/src/toga/fonts.py +++ b/core/src/toga/fonts.py @@ -72,6 +72,8 @@ def __str__(self) -> str: size = ( "default size" if self.size == SYSTEM_DEFAULT_FONT_SIZE + else f"{self.size}" + if isinstance(self.size, str) else f"{self.size}pt" ) weight = f" {self.weight}" if self.weight != NORMAL else "" diff --git a/core/src/toga/style/pack.py b/core/src/toga/style/pack.py index f3ab4575fa..4bb31549b0 100644 --- a/core/src/toga/style/pack.py +++ b/core/src/toga/style/pack.py @@ -9,6 +9,7 @@ from travertino.colors import hsl, rgb from travertino.constants import ( # noqa: F401 + ABSOLUTE_FONT_SIZES, BOLD, BOTTOM, CENTER, @@ -233,7 +234,11 @@ class IntrinsicSize(BaseIntrinsicSize): font_style: str = validated_property(*FONT_STYLES, initial=NORMAL) font_variant: str = validated_property(*FONT_VARIANTS, initial=NORMAL) font_weight: str = validated_property(*FONT_WEIGHTS, initial=NORMAL) - font_size: int = validated_property(integer=True, initial=SYSTEM_DEFAULT_FONT_SIZE) + font_size: int | str = validated_property( + *ABSOLUTE_FONT_SIZES, + integer=True, + initial=SYSTEM_DEFAULT_FONT_SIZE, + ) ###################################################################### # Directional aliases @@ -978,7 +983,10 @@ def __css__(self) -> str: ] 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 isinstance(self.font_size, str): + css.append(f"font-size: {self.font_size};") + else: + css.append(f"font-size: {self.font_size}pt;") if self.font_weight != NORMAL: css.append(f"font-weight: {self.font_weight};") if self.font_style != NORMAL: diff --git a/core/tests/style/pack/test_css.py b/core/tests/style/pack/test_css.py index 45e1478c7b..e9ff5821db 100644 --- a/core/tests/style/pack/test_css.py +++ b/core/tests/style/pack/test_css.py @@ -467,6 +467,26 @@ "flex-direction: row; flex: 0.0 0 auto; font-size: 42pt;", id="font-size", ), + pytest.param( + Pack(font_size="small"), + "flex-direction: row; flex: 0.0 0 auto; font-size: small;", + id="font-size-small", + ), + pytest.param( + Pack(font_size="xx-small"), + "flex-direction: row; flex: 0.0 0 auto; font-size: xx-small;", + id="font-size-xx-small", + ), + pytest.param( + Pack(font_size="x-large"), + "flex-direction: row; flex: 0.0 0 auto; font-size: x-large;", + id="font-size-x-large", + ), + pytest.param( + Pack(font_size="large"), + "flex-direction: row; flex: 0.0 0 auto; font-size: large;", + id="font-size-large", + ), pytest.param( Pack(font_size=SYSTEM_DEFAULT_FONT_SIZE), "flex-direction: row; flex: 0.0 0 auto;", diff --git a/core/tests/test_fonts.py b/core/tests/test_fonts.py index 8f43c43ca4..aa2b7b74a3 100644 --- a/core/tests/test_fonts.py +++ b/core/tests/test_fonts.py @@ -95,6 +95,69 @@ async def app(): NORMAL, "system default size", ), + # Custom font, small size + ( + "Custom Font", + "small", + NORMAL, + NORMAL, + NORMAL, + "Custom Font small", + ), + # System font, medium size + ( + SYSTEM, + "medium", + NORMAL, + NORMAL, + NORMAL, + "system medium", + ), + # System font, large size + ( + SYSTEM, + "large", + NORMAL, + NORMAL, + NORMAL, + "system large", + ), + # System font, x-small size + ( + SYSTEM, + "x-small", + NORMAL, + NORMAL, + NORMAL, + "system x-small", + ), + # System font, xx-small size + ( + SYSTEM, + "xx-small", + NORMAL, + NORMAL, + NORMAL, + "system xx-small", + ), + # System font, xx-large size + ( + SYSTEM, + "xx-large", + NORMAL, + NORMAL, + NORMAL, + "system xx-large", + ), + # System font, x-large size + ( + SYSTEM, + "x-large", + NORMAL, + NORMAL, + NORMAL, + "system x-large", + ), ], ) def test_builtin_font(family, size, weight, style, variant, as_str): diff --git a/docs/reference/style/pack.rst b/docs/reference/style/pack.rst index 6960ce86fb..01a45fdcbf 100644 --- a/docs/reference/style/pack.rst +++ b/docs/reference/style/pack.rst @@ -305,12 +305,17 @@ The weight of the font to be used. ``font_size`` ------------- -**Value:** an integer +**Values:** + - ```` (in :ref:`CSS points `) + - Absolute keywords: ``xx-small`` | ``x-small`` | ``small`` | ``medium`` | ``large`` | ``x-large`` | ``xx-large`` **Initial value:** ``-1``; will use the system default size. This is also stored as a constant named ``SYSTEM_DEFAULT_FONT_SIZE``. -The size of the font to be used, in :ref:`CSS points `. +The size of the font to be used. Can be specified in the following ways: + +* An integer value in :ref:`CSS points ` +* An absolute size keyword, which sets the size relative to the system's base font size The relationship between Pack and CSS ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/gtk/src/toga_gtk/fonts.py b/gtk/src/toga_gtk/fonts.py index a9429e0b69..87547388e1 100644 --- a/gtk/src/toga_gtk/fonts.py +++ b/gtk/src/toga_gtk/fonts.py @@ -1,6 +1,10 @@ from pathlib import Path from warnings import warn +from travertino.constants import ( + ABSOLUTE_FONT_SIZES, +) + from toga.fonts import ( _REGISTERED_FONT_CACHE, BOLD, @@ -76,8 +80,12 @@ def __init__(self, interface): font.set_family(family) - # If this is a non-default font size, set the font size - if self.interface.size != SYSTEM_DEFAULT_FONT_SIZE: + # Default font as well as values in absolute and relative font + # size are handled by Pango. Otherwise set font size manually. + if ( + self.interface.size != SYSTEM_DEFAULT_FONT_SIZE + and self.interface.size not in ABSOLUTE_FONT_SIZES + ): font.set_size(self.interface.size * Pango.SCALE) # Set font style diff --git a/gtk/src/toga_gtk/libs/styles.py b/gtk/src/toga_gtk/libs/styles.py index 583bd523d6..8a0c7a8985 100644 --- a/gtk/src/toga_gtk/libs/styles.py +++ b/gtk/src/toga_gtk/libs/styles.py @@ -1,3 +1,7 @@ +from travertino.constants import ( + ABSOLUTE_FONT_SIZES, +) + from toga.colors import TRANSPARENT from toga.fonts import SYSTEM_DEFAULT_FONT_SIZE @@ -61,7 +65,10 @@ def get_font_css(value): "font-family": f"{value.family!r}", } - if value.size != SYSTEM_DEFAULT_FONT_SIZE: + # If value is an absolute or relative keyword, use those to set size instead + if value.size in ABSOLUTE_FONT_SIZES: + style["font-size"] = f"{value.size}" + elif value.size != SYSTEM_DEFAULT_FONT_SIZE: style["font-size"] = f"{value.size}pt" return style diff --git a/gtk/tests_backend/fonts.py b/gtk/tests_backend/fonts.py index d09da437f6..3489206a94 100644 --- a/gtk/tests_backend/fonts.py +++ b/gtk/tests_backend/fonts.py @@ -1,3 +1,8 @@ +from travertino.constants import ( + ABSOLUTE_FONT_SIZES, + FONT_SIZE_SCALE, +) + from toga.fonts import ( BOLD, ITALIC, @@ -29,6 +34,9 @@ def assert_font_size(self, expected): assert expected == SYSTEM_DEFAULT_FONT_SIZE elif expected == SYSTEM_DEFAULT_FONT_SIZE: assert 8 < int(self.font.get_size() / Pango.SCALE) < 18 + elif expected in ABSOLUTE_FONT_SIZES: + scale = FONT_SIZE_SCALE[expected] + assert 8 * scale < int(self.font.get_size() / Pango.SCALE) < 18 * scale else: assert int(self.font.get_size() / Pango.SCALE) == expected diff --git a/iOS/src/toga_iOS/fonts.py b/iOS/src/toga_iOS/fonts.py index eb0794b083..a46ed36ea1 100644 --- a/iOS/src/toga_iOS/fonts.py +++ b/iOS/src/toga_iOS/fonts.py @@ -1,6 +1,9 @@ from pathlib import Path from fontTools.ttLib import TTFont +from travertino.constants import ( + FONT_SIZE_SCALE, +) from toga.fonts import ( _REGISTERED_FONT_CACHE, @@ -88,6 +91,8 @@ def __init__(self, interface): if self.interface.size == SYSTEM_DEFAULT_FONT_SIZE: size = UIFont.labelFontSize + elif isinstance(self.interface.size, str): + size = UIFont.labelFontSize * FONT_SIZE_SCALE[self.interface.size] else: # A "point" in Apple APIs is equivalent to a CSS pixel, but the Toga # public API works in CSS points, which are slightly larger diff --git a/iOS/tests_backend/fonts.py b/iOS/tests_backend/fonts.py index f69e4c91d9..ebadf751f8 100644 --- a/iOS/tests_backend/fonts.py +++ b/iOS/tests_backend/fonts.py @@ -1,3 +1,7 @@ +from travertino.constants import ( + FONT_SIZE_SCALE, +) + from toga.fonts import ( BOLD, CURSIVE, @@ -51,6 +55,9 @@ def assert_font_options(self, weight=NORMAL, style=NORMAL, variant=NORMAL): def assert_font_size(self, expected): if expected == SYSTEM_DEFAULT_FONT_SIZE: assert self.font.pointSize == 17 + elif isinstance(expected, str): + expected_size = 17 * FONT_SIZE_SCALE[expected] + assert abs(self.font.pointSize - expected_size) < 0.01 else: assert self.font.pointSize == expected * 96 / 72 diff --git a/testbed/tests/test_fonts.py b/testbed/tests/test_fonts.py index a50b6ef554..344470e1ab 100644 --- a/testbed/tests/test_fonts.py +++ b/testbed/tests/test_fonts.py @@ -1,6 +1,9 @@ from importlib import import_module import pytest +from travertino.constants import ( + ABSOLUTE_FONT_SIZES, +) import toga from toga.fonts import ( @@ -88,7 +91,11 @@ async def test_use_first_valid_font( 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_size in [ + 20, + SYSTEM_DEFAULT_FONT_SIZE, + *ABSOLUTE_FONT_SIZES, + ]: for font_weight in FONT_WEIGHTS: for font_style in FONT_STYLES: for font_variant in FONT_VARIANTS: diff --git a/travertino/src/travertino/constants.py b/travertino/src/travertino/constants.py index 5460c39279..7ee116bc03 100644 --- a/travertino/src/travertino/constants.py +++ b/travertino/src/travertino/constants.py @@ -90,7 +90,6 @@ LARGE = "large" X_LARGE = "x-large" XX_LARGE = "xx-large" -XXX_LARGE = "xxx-large" ABSOLUTE_FONT_SIZES = { XX_SMALL, @@ -100,13 +99,17 @@ LARGE, X_LARGE, XX_LARGE, - XXX_LARGE, } -LARGER = "larger" -SMALLER = "smaller" - -RELATIVE_FONT_SIZES = {LARGER, SMALLER} +FONT_SIZE_SCALE = { + XX_SMALL: 0.6, + X_SMALL: 0.75, + SMALL: 0.89, + MEDIUM: 1.0, + LARGE: 1.2, + X_LARGE: 1.5, + XX_LARGE: 2.0, +} ###################################################################### # Colors diff --git a/travertino/src/travertino/fonts.py b/travertino/src/travertino/fonts.py index 540040a5ec..7f1063f1c4 100644 --- a/travertino/src/travertino/fonts.py +++ b/travertino/src/travertino/fonts.py @@ -1,4 +1,5 @@ from .constants import ( + ABSOLUTE_FONT_SIZES, BOLD, FONT_STYLES, FONT_VARIANTS, @@ -26,6 +27,8 @@ def __init__(self, family, size, style=NORMAL, variant=NORMAL, weight=NORMAL): try: if size.strip().endswith("pt"): self.size = int(size[:-2]) + elif size.strip() in ABSOLUTE_FONT_SIZES: + self.size = size.strip() else: raise ValueError(f"Invalid font size {size!r}") except Exception as exc: @@ -47,7 +50,9 @@ def __repr__(self): ( "system default size" if self.size == SYSTEM_DEFAULT_FONT_SIZE - else f"{self.size}pt" + else ( + f"{self.size}" if isinstance(self.size, str) else f"{self.size}pt" + ) ), self.family, ) diff --git a/travertino/tests/test_fonts.py b/travertino/tests/test_fonts.py index 8729b51524..45048b762a 100644 --- a/travertino/tests/test_fonts.py +++ b/travertino/tests/test_fonts.py @@ -1,12 +1,20 @@ import pytest from travertino.constants import ( + ABSOLUTE_FONT_SIZES, BOLD, ITALIC, + LARGE, + MEDIUM, NORMAL, OBLIQUE, + SMALL, SMALL_CAPS, SYSTEM_DEFAULT_FONT_SIZE, + X_LARGE, + X_SMALL, + XX_LARGE, + XX_SMALL, ) from travertino.fonts import Font @@ -79,10 +87,55 @@ def test_simple_construction(size): assert_font(Font("Comic Sans", size), "Comic Sans", 12, NORMAL, NORMAL, NORMAL) +@pytest.mark.parametrize( + "size", + [ + XX_SMALL, + X_SMALL, + SMALL, + MEDIUM, + LARGE, + X_LARGE, + XX_LARGE, + ], +) +def test_css_font_size_keywords(size): + font = Font("Comic Sans", size) + assert_font(font, "Comic Sans", size, NORMAL, NORMAL, NORMAL) + assert isinstance(font.size, str) + assert font.size in ABSOLUTE_FONT_SIZES + + +@pytest.mark.parametrize( + "size, expected_repr", + [ + (XX_SMALL, ""), + (X_SMALL, ""), + (SMALL, ""), + (MEDIUM, ""), + (LARGE, ""), + (X_LARGE, ""), + (XX_LARGE, ""), + ], +) +def test_css_font_size_repr(size, expected_repr): + font = Font("Comic Sans", size) + assert repr(font) == expected_repr + + def test_invalid_construction(): with pytest.raises(ValueError): Font("Comic Sans", "12 quatloos") + with pytest.raises(ValueError): + Font("Comic Sans", "invalid-size") + + with pytest.raises(ValueError): + Font("Comic Sans", "") + + with pytest.raises(TypeError): + Font("Comic Sans", None) + @pytest.mark.parametrize( "family", diff --git a/winforms/src/toga_winforms/fonts.py b/winforms/src/toga_winforms/fonts.py index dfdd1d057e..b32b8ae4f3 100644 --- a/winforms/src/toga_winforms/fonts.py +++ b/winforms/src/toga_winforms/fonts.py @@ -8,6 +8,9 @@ from System.Drawing.Text import PrivateFontCollection from System.IO import FileNotFoundException from System.Runtime.InteropServices import ExternalException +from travertino.constants import ( + FONT_SIZE_SCALE, +) from toga.fonts import ( _REGISTERED_FONT_CACHE, @@ -94,6 +97,9 @@ def __init__(self, interface): # Convert font size to Winforms format if self.interface.size == SYSTEM_DEFAULT_FONT_SIZE: font_size = DEFAULT_FONT.Size + elif isinstance(self.interface.size, str): + font_size = DEFAULT_FONT.Size + font_size *= FONT_SIZE_SCALE[self.interface.size] else: font_size = self.interface.size diff --git a/winforms/tests_backend/fonts.py b/winforms/tests_backend/fonts.py index 55bbe6920c..836ec357bc 100644 --- a/winforms/tests_backend/fonts.py +++ b/winforms/tests_backend/fonts.py @@ -1,4 +1,7 @@ from System.Drawing import FontFamily, SystemFonts +from travertino.constants import ( + FONT_SIZE_SCALE, +) from toga.fonts import ( BOLD, @@ -45,8 +48,10 @@ def font_size(self): def assert_font_size(self, expected): if expected == SYSTEM_DEFAULT_FONT_SIZE: - expected = 9 - assert self.font_size == expected + expected = 9.0 + elif isinstance(expected, str): + expected = 9.0 * FONT_SIZE_SCALE[expected] + assert abs(self.font.SizeInPoints - expected) < 0.1 def assert_font_family(self, expected): assert str(self.font.Name) == {