Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

Add line_height_factor to canvas #3217

Open
wants to merge 27 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
9c3a3a8
feat: Add line height factor
jannikhoesch Feb 24, 2025
d9466fb
feat: Update macOS backend (#11)
jannikhoesch Feb 25, 2025
31dc150
feat: Windows: Update canvas.py to use line_height_factor (#13)
Heroldpls Feb 25, 2025
0acea1f
refactor: Improve formatting of write_text and measure_text (#16)
Trighap52 Feb 26, 2025
1c44b4f
feat: Change default value of line_height_factor (#18)
jannikhoesch Feb 26, 2025
f73e493
Add line_height_factor to write_text and measure_text #2
karindev Feb 26, 2025
1bb06c4
feat: line_height_factor iOS (#29)
Heroldpls Feb 26, 2025
44bf721
Update core api tests to account for line_height_factor
karindev Feb 26, 2025
e4cc9b8
feat: line_height_factor for linux (#31)
Heroldpls Feb 27, 2025
4ca6962
feat: line_height_factor for iOS (#30)
Heroldpls Feb 27, 2025
ce0160c
fix: remove default value and correct parameter order (#35)
karindev Feb 27, 2025
35e597f
fix: fix windows backend bug (#33)
karindev Feb 27, 2025
c916546
fix: Fix bug in iOS backend (#38)
jannikhoesch Feb 27, 2025
fdf4b9a
fix: fix bug in linux backend (#42)
jannikhoesch Feb 27, 2025
3048a93
feat: add line height slider to canvas (#32)
amaekh Feb 27, 2025
f248714
doc: delete changefiles
jannikhoesch Feb 28, 2025
ee512b6
doc: edit changefile
jannikhoesch Feb 28, 2025
501f3c1
fix: missing multiply by line height factor
karindev Mar 4, 2025
c61fb7e
refactor: change line_height_factor to line_height (#53)
Heroldpls Mar 4, 2025
766cbe6
feat: add None as valid parameter for line_height
karindev Mar 6, 2025
0b14e10
Merge branch 'beeware:main' into main
karindev Mar 6, 2025
e39a6c3
feat: add line height test to testbed (#44)
jannikhoesch Mar 7, 2025
5f1bc44
feat: change line_height to scale by font size
karindev Mar 7, 2025
2603c76
Merge pull request #63 from Heroldpls/main
jannikhoesch Mar 7, 2025
3b9190d
fix: update testbed reference images
karindev Mar 7, 2025
9098a9b
fix: update testbed reference images
jannikhoesch Mar 7, 2025
8823ce5
doc: Remove changefiles
jannikhoesch Mar 7, 2025
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
17 changes: 11 additions & 6 deletions android/src/toga_android/widgets/canvas.py
Original file line number Diff line number Diff line change
Expand Up @@ -188,20 +188,25 @@ def reset_transform(self, canvas, **kwargs):
self.scale(self.dpi_scale, self.dpi_scale, canvas)

# Text
def _line_height(self, paint, line_height):
if line_height is None:
return paint.getFontSpacing()
else:
return paint.getTextSize() * line_height

def measure_text(self, text, font):
def measure_text(self, text, font, line_height):
paint = self._text_paint(font)
sizes = [paint.measureText(line) for line in text.splitlines()]
return (
max(size for size in sizes),
paint.getFontSpacing() * len(sizes),
self._line_height(paint, line_height) * len(sizes),
)

def write_text(self, text, x, y, font, baseline, canvas, **kwargs):
def write_text(self, text, x, y, font, baseline, line_height, canvas, **kwargs):
lines = text.splitlines()
paint = self._text_paint(font)
line_height = paint.getFontSpacing()
total_height = line_height * len(lines)
scaled_line_height = self._line_height(paint, line_height)
total_height = scaled_line_height * len(lines)

# paint.ascent returns a negative number.
if baseline == Baseline.TOP:
Expand All @@ -217,7 +222,7 @@ def write_text(self, text, x, y, font, baseline, canvas, **kwargs):
for line_num, line in enumerate(text.splitlines()):
# FILL_AND_STROKE doesn't allow separate colors, so we have to draw twice.
def draw():
canvas.drawText(line, x, top + (line_height * line_num), paint)
canvas.drawText(line, x, top + (scaled_line_height * line_num), paint)

if (color := kwargs.get("fill_color")) is not None:
paint.setStyle(Paint.Style.FILL)
Expand Down
1 change: 1 addition & 0 deletions changes/2144.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
A line_height argument can now be used to change the line height of multiline text.
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
A line_height argument can now be used to change the line height of multiline text.
The line height of multiline text on a Canvas can be configured.

21 changes: 12 additions & 9 deletions cocoa/src/toga_cocoa/widgets/canvas.py
Original file line number Diff line number Diff line change
Expand Up @@ -269,25 +269,28 @@ def _render_string(self, text, font, **kwargs):

# Although the native API can measure and draw multi-line strings, this makes the
# line spacing depend on the scale factor, which messes up the tests.
def _line_height(self, font):
# descender is a negative number.
return ceil(font.native.ascender - font.native.descender)
def _line_height(self, font, line_height):
if line_height is None:
# descender is a negative number.
return ceil(font.native.ascender - font.native.descender)
else:
return font.native.ascender * line_height
Copy link
Member

Choose a reason for hiding this comment

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

Are you sure this should be ascender, and not fontSize? The two are likely very similar, but I'm not sure they're required to be identical.


def measure_text(self, text, font):
def measure_text(self, text, font, line_height):
# We need at least a fill color to render, but that won't change the size.
sizes = [
self._render_string(line, font, fill_color=color(BLACK)).size()
for line in text.splitlines()
]
return (
ceil(max(size.width for size in sizes)),
self._line_height(font) * len(sizes),
self._line_height(font, line_height) * len(sizes),
)

def write_text(self, text, x, y, font, baseline, **kwargs):
def write_text(self, text, x, y, font, baseline, line_height, **kwargs):
lines = text.splitlines()
line_height = self._line_height(font)
total_height = line_height * len(lines)
scaled_line_height = self._line_height(font, line_height)
total_height = scaled_line_height * len(lines)

if baseline == Baseline.TOP:
top = y + font.native.ascender
Expand All @@ -301,7 +304,7 @@ def write_text(self, text, x, y, font, baseline, **kwargs):

for line_num, line in enumerate(lines):
# Rounding minimizes differences between scale factors.
origin = NSPoint(round(x), round(top) + (line_height * line_num))
origin = NSPoint(round(x), round(top) + (scaled_line_height * line_num))
rs = self._render_string(line, font, **kwargs)

# "This method uses the baseline origin by default. If
Expand Down
5 changes: 4 additions & 1 deletion core/src/toga/widgets/canvas/canvas.py
Original file line number Diff line number Diff line change
Expand Up @@ -297,19 +297,22 @@ def measure_text(
self,
text: str,
font: Font | None = None,
line_height: float | None = None,
) -> tuple[float, float]:
"""Measure the size at which :meth:`~.Context.write_text` would
render some text.

:param text: The text to measure. Newlines will cause line breaks, but long
lines will not be wrapped.
:param font: The font in which to draw the text. The default is the system font.
:param line_height: Height of the line box as a multiple of the font size
when multiple lines are present.
:returns: A tuple of ``(width, height)``.
"""
if font is None:
font = Font(family=SYSTEM, size=SYSTEM_DEFAULT_FONT_SIZE)

return self._impl.measure_text(str(text), font._impl)
return self._impl.measure_text(str(text), font._impl, line_height)

def as_image(self, format: type[ImageT] = toga.Image) -> ImageT:
"""Render the canvas as an image.
Expand Down
5 changes: 4 additions & 1 deletion core/src/toga/widgets/canvas/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,7 @@ def write_text(
y: float = 0.0,
font: Font | None = None,
baseline: Baseline = Baseline.ALPHABETIC,
line_height: float | None = None,
) -> WriteText:
"""Write text at a given position in the canvas context.

Expand All @@ -359,9 +360,11 @@ def write_text(
:param y: The Y coordinate: its meaning depends on ``baseline``.
:param font: The font in which to draw the text. The default is the system font.
:param baseline: Alignment of text relative to the Y coordinate.
:param line_height: Height of the line box as a multiple of the font size
when multiple lines are present.
:returns: The ``WriteText`` :any:`DrawingObject` for the operation.
"""
write_text = WriteText(text, x, y, font, baseline)
write_text = WriteText(text, x, y, font, baseline, line_height)
self.append(write_text)
return write_text

Expand Down
13 changes: 11 additions & 2 deletions core/src/toga/widgets/canvas/drawingobject.py
Original file line number Diff line number Diff line change
Expand Up @@ -319,22 +319,31 @@ def __init__(
y: float = 0.0,
font: Font | None = None,
baseline: Baseline = Baseline.ALPHABETIC,
line_height: float | None = None,
):
self.text = text
self.x = x
self.y = y
self.font = font
self.baseline = baseline
self.line_height = line_height

def __repr__(self) -> str:
return (
f"{self.__class__.__name__}(text={self.text!r}, x={self.x}, y={self.y}, "
f"font={self.font!r}, baseline={self.baseline})"
f"font={self.font!r}, baseline={self.baseline}, "
f"line_height={self.line_height})"
)

def _draw(self, impl: Any, **kwargs: Any) -> None:
impl.write_text(
str(self.text), self.x, self.y, self.font._impl, self.baseline, **kwargs
str(self.text),
self.x,
self.y,
self.font._impl,
self.baseline,
self.line_height,
**kwargs,
)

@property
Expand Down
49 changes: 41 additions & 8 deletions core/tests/widgets/canvas/test_canvas.py
Original file line number Diff line number Diff line change
Expand Up @@ -139,18 +139,51 @@ def test_stroke(widget):


@pytest.mark.parametrize(
"font, expected",
"font, line_height, expected",
[
(None, (132, 12)),
(Font(family=SYSTEM, size=SYSTEM_DEFAULT_FONT_SIZE), (132, 12)),
(Font(family=SYSTEM, size=20), (220, 20)),
(Font(family="Cutive", size=SYSTEM_DEFAULT_FONT_SIZE), (198, 18)),
(Font(family="Cutive", size=20), (330, 30)),
(None, None, (132, 12)),
(Font(family=SYSTEM, size=SYSTEM_DEFAULT_FONT_SIZE), None, (132, 12)),
(Font(family=SYSTEM, size=SYSTEM_DEFAULT_FONT_SIZE), 1, (132, 12)),
(Font(family=SYSTEM, size=20), None, (220, 20)),
(Font(family=SYSTEM, size=20), 1, (220, 20)),
(Font(family=SYSTEM, size=20), 1.5, (220, 30)),
(Font(family="Cutive", size=SYSTEM_DEFAULT_FONT_SIZE), None, (198, 18)),
(Font(family="Cutive", size=SYSTEM_DEFAULT_FONT_SIZE), 1, (198, 18)),
(Font(family="Cutive", size=20), None, (330, 30)),
(Font(family="Cutive", size=20), 1, (330, 30)),
(Font(family="Cutive", size=20), 1.5, (330, 45)),
],
)
def test_measure_text(widget, font, expected):
def test_measure_text(widget, font, line_height, expected):
"""Canvas can measure rendered text size."""
assert widget.measure_text("Hello world", font=font) == expected
assert (
widget.measure_text("Hello world", font=font, line_height=line_height)
== expected
)


@pytest.mark.parametrize(
"font, line_height, expected",
[
(None, None, (132, 24)),
(Font(family=SYSTEM, size=SYSTEM_DEFAULT_FONT_SIZE), None, (132, 24)),
(Font(family=SYSTEM, size=SYSTEM_DEFAULT_FONT_SIZE), 1, (132, 24)),
(Font(family=SYSTEM, size=20), None, (220, 40)),
(Font(family=SYSTEM, size=20), 1, (220, 40)),
(Font(family=SYSTEM, size=20), 1.5, (220, 60)),
(Font(family="Cutive", size=SYSTEM_DEFAULT_FONT_SIZE), None, (198, 36)),
(Font(family="Cutive", size=SYSTEM_DEFAULT_FONT_SIZE), 1, (198, 36)),
(Font(family="Cutive", size=20), None, (330, 60)),
(Font(family="Cutive", size=20), 1, (330, 60)),
(Font(family="Cutive", size=20), 1.5, (330, 90)),
],
)
def test_measure_text_multiline(widget, font, line_height, expected):
"""Canvas can measure rendered text size of a multiline string."""
assert (
widget.measure_text("Hello\nworld", font=font, line_height=line_height)
== expected
)


def test_as_image(widget):
Expand Down
24 changes: 21 additions & 3 deletions core/tests/widgets/canvas/test_draw_operations.py
Original file line number Diff line number Diff line change
Expand Up @@ -579,39 +579,56 @@ def test_rect(widget):
(
{"text": "Hello world", "x": 10, "y": 20},
"text='Hello world', x=10, y=20, font=<Font: system default size system>, "
"baseline=Baseline.ALPHABETIC",
"baseline=Baseline.ALPHABETIC, line_height=None",
{
"text": "Hello world",
"x": 10,
"y": 20,
"font": Font(SYSTEM, SYSTEM_DEFAULT_FONT_SIZE)._impl,
"baseline": Baseline.ALPHABETIC,
"line_height": None,
},
),
# Baseline
(
{"text": "Hello world", "x": 10, "y": 20, "baseline": Baseline.TOP},
"text='Hello world', x=10, y=20, font=<Font: system default size system>, "
"baseline=Baseline.TOP",
"baseline=Baseline.TOP, line_height=None",
{
"text": "Hello world",
"x": 10,
"y": 20,
"font": Font(SYSTEM, SYSTEM_DEFAULT_FONT_SIZE)._impl,
"baseline": Baseline.TOP,
"line_height": None,
},
),
# Font
(
{"text": "Hello world", "x": 10, "y": 20, "font": Font("Cutive", 42)},
"text='Hello world', x=10, y=20, font=<Font: 42pt Cutive>, "
"baseline=Baseline.ALPHABETIC",
"baseline=Baseline.ALPHABETIC, line_height=None",
{
"text": "Hello world",
"x": 10,
"y": 20,
"font": Font("Cutive", 42)._impl,
"baseline": Baseline.ALPHABETIC,
"line_height": None,
},
),
# Line height factor
(
{"text": "Hello world", "x": 10, "y": 20, "line_height": 1.5},
"text='Hello world', x=10, y=20, font=<Font: system default size system>, "
"baseline=Baseline.ALPHABETIC, line_height=1.5",
{
"text": "Hello world",
"x": 10,
"y": 20,
"font": Font(SYSTEM, SYSTEM_DEFAULT_FONT_SIZE)._impl,
"baseline": Baseline.ALPHABETIC,
"line_height": 1.5,
},
),
],
Expand All @@ -634,6 +651,7 @@ def test_write_text(widget, kwargs, args_repr, draw_kwargs):
assert draw_op.y == draw_kwargs["y"]
assert draw_op.font == draw_kwargs["font"].interface
assert draw_op.baseline == draw_kwargs["baseline"]
assert draw_op.line_height == draw_kwargs["line_height"]


def test_rotate(widget):
Expand Down
30 changes: 24 additions & 6 deletions dummy/src/toga_dummy/widgets/canvas.py
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,17 @@ def reset_transform(self, draw_instructions, **kwargs):

# Text

def write_text(self, text, x, y, font, baseline, draw_instructions, **kwargs):
def write_text(
self,
text,
x,
y,
font,
baseline,
line_height,
draw_instructions,
**kwargs,
):
draw_instructions.append(
(
"write text",
Expand All @@ -216,30 +226,38 @@ def write_text(self, text, x, y, font, baseline, draw_instructions, **kwargs):
"y": y,
"font": font,
"baseline": baseline,
"line_height": line_height,
},
**kwargs,
),
)
)

def measure_text(self, text, font):
def measure_text(self, text, font, line_height):
# Assume system font produces characters that have the same width and height as
# the point size, with a default point size of 12. Any other font is 1.5 times
# bigger.

if line_height is None:
line_height_factor = 1
else:
line_height_factor = line_height

lines = text.count("\n") + 1
if font.interface.family == SYSTEM:
if font.interface.size == SYSTEM_DEFAULT_FONT_SIZE:
width = len(text) * 12
height = 12
height = lines * line_height_factor * 12
else:
width = len(text) * font.interface.size
height = font.interface.size
height = lines * line_height_factor * font.interface.size
else:
if font.interface.size == SYSTEM_DEFAULT_FONT_SIZE:
width = len(text) * 18
height = 18
height = lines * line_height_factor * 18
else:
width = int(len(text) * font.interface.size * 1.5)
height = int(font.interface.size * 1.5)
height = lines * line_height_factor * int(font.interface.size * 1.5)

return width, height

Expand Down
16 changes: 14 additions & 2 deletions examples/canvas/canvas/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,9 @@ def startup(self):
self.line_width_slider = toga.Slider(
min=1, max=10, value=1, on_change=self.refresh_canvas
)
self.line_height_slider = toga.Slider(
min=1.0, max=10.0, value=1.0, on_change=self.refresh_canvas
)
self.dash_pattern_selection = toga.Selection(
items=list(self.dash_patterns.keys()), on_change=self.refresh_canvas
)
Expand Down Expand Up @@ -123,6 +126,8 @@ def startup(self):
children=[
toga.Label("Line Width:", style=label_style),
self.line_width_slider,
toga.Label("Line Height:", style=label_style),
self.line_height_slider,
self.dash_pattern_selection,
],
),
Expand Down Expand Up @@ -514,9 +519,16 @@ def draw_instructions(self, context, factor):
weight=self.get_weight(),
style=self.get_style(),
)
width, height = self.canvas.measure_text(text, font)
width, height = self.canvas.measure_text(
text, font, self.line_height_slider.value
)
context.write_text(
text, self.x_middle - width / 2, self.y_middle, font, Baseline.MIDDLE
text,
self.x_middle - width / 2,
self.y_middle,
font,
Baseline.MIDDLE,
self.line_height_slider.value,
)

def get_weight(self):
Expand Down
Loading