Skip to content

Commit e230f69

Browse files
authored
Add line_height to multiline canvas text (#3217)
Modifies multiline text on Canvas to allow for a line height to be specified.
1 parent 0f00dc4 commit e230f69

File tree

20 files changed

+200
-71
lines changed

20 files changed

+200
-71
lines changed

android/src/toga_android/widgets/canvas.py

+11-6
Original file line numberDiff line numberDiff line change
@@ -188,20 +188,25 @@ def reset_transform(self, canvas, **kwargs):
188188
self.scale(self.dpi_scale, self.dpi_scale, canvas)
189189

190190
# Text
191+
def _line_height(self, paint, line_height):
192+
if line_height is None:
193+
return paint.getFontSpacing()
194+
else:
195+
return paint.getTextSize() * line_height
191196

192-
def measure_text(self, text, font):
197+
def measure_text(self, text, font, line_height):
193198
paint = self._text_paint(font)
194199
sizes = [paint.measureText(line) for line in text.splitlines()]
195200
return (
196201
max(size for size in sizes),
197-
paint.getFontSpacing() * len(sizes),
202+
self._line_height(paint, line_height) * len(sizes),
198203
)
199204

200-
def write_text(self, text, x, y, font, baseline, canvas, **kwargs):
205+
def write_text(self, text, x, y, font, baseline, line_height, canvas, **kwargs):
201206
lines = text.splitlines()
202207
paint = self._text_paint(font)
203-
line_height = paint.getFontSpacing()
204-
total_height = line_height * len(lines)
208+
scaled_line_height = self._line_height(paint, line_height)
209+
total_height = scaled_line_height * len(lines)
205210

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

222227
if (color := kwargs.get("fill_color")) is not None:
223228
paint.setStyle(Paint.Style.FILL)

changes/2144.feature.rst

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
The line height of multiline text on a Canvas can be configured.

cocoa/src/toga_cocoa/widgets/canvas.py

+12-9
Original file line numberDiff line numberDiff line change
@@ -269,25 +269,28 @@ def _render_string(self, text, font, **kwargs):
269269

270270
# Although the native API can measure and draw multi-line strings, this makes the
271271
# line spacing depend on the scale factor, which messes up the tests.
272-
def _line_height(self, font):
273-
# descender is a negative number.
274-
return ceil(font.native.ascender - font.native.descender)
272+
def _line_height(self, font, line_height):
273+
if line_height is None:
274+
# descender is a negative number.
275+
return ceil(font.native.ascender - font.native.descender)
276+
else:
277+
return font.native.pointSize * line_height
275278

276-
def measure_text(self, text, font):
279+
def measure_text(self, text, font, line_height):
277280
# We need at least a fill color to render, but that won't change the size.
278281
sizes = [
279282
self._render_string(line, font, fill_color=color(BLACK)).size()
280283
for line in text.splitlines()
281284
]
282285
return (
283286
ceil(max(size.width for size in sizes)),
284-
self._line_height(font) * len(sizes),
287+
self._line_height(font, line_height) * len(sizes),
285288
)
286289

287-
def write_text(self, text, x, y, font, baseline, **kwargs):
290+
def write_text(self, text, x, y, font, baseline, line_height, **kwargs):
288291
lines = text.splitlines()
289-
line_height = self._line_height(font)
290-
total_height = line_height * len(lines)
292+
scaled_line_height = self._line_height(font, line_height)
293+
total_height = scaled_line_height * len(lines)
291294

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

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

307310
# "This method uses the baseline origin by default. If

core/src/toga/widgets/canvas/canvas.py

+4-1
Original file line numberDiff line numberDiff line change
@@ -297,19 +297,22 @@ def measure_text(
297297
self,
298298
text: str,
299299
font: Font | None = None,
300+
line_height: float | None = None,
300301
) -> tuple[float, float]:
301302
"""Measure the size at which :meth:`~.Context.write_text` would
302303
render some text.
303304
304305
:param text: The text to measure. Newlines will cause line breaks, but long
305306
lines will not be wrapped.
306307
:param font: The font in which to draw the text. The default is the system font.
308+
:param line_height: Height of the line box as a multiple of the font size
309+
when multiple lines are present.
307310
:returns: A tuple of ``(width, height)``.
308311
"""
309312
if font is None:
310313
font = Font(family=SYSTEM, size=SYSTEM_DEFAULT_FONT_SIZE)
311314

312-
return self._impl.measure_text(str(text), font._impl)
315+
return self._impl.measure_text(str(text), font._impl, line_height)
313316

314317
def as_image(self, format: type[ImageT] = toga.Image) -> ImageT:
315318
"""Render the canvas as an image.

core/src/toga/widgets/canvas/context.py

+4-1
Original file line numberDiff line numberDiff line change
@@ -347,6 +347,7 @@ def write_text(
347347
y: float = 0.0,
348348
font: Font | None = None,
349349
baseline: Baseline = Baseline.ALPHABETIC,
350+
line_height: float | None = None,
350351
) -> WriteText:
351352
"""Write text at a given position in the canvas context.
352353
@@ -359,9 +360,11 @@ def write_text(
359360
:param y: The Y coordinate: its meaning depends on ``baseline``.
360361
:param font: The font in which to draw the text. The default is the system font.
361362
:param baseline: Alignment of text relative to the Y coordinate.
363+
:param line_height: Height of the line box as a multiple of the font size
364+
when multiple lines are present.
362365
:returns: The ``WriteText`` :any:`DrawingObject` for the operation.
363366
"""
364-
write_text = WriteText(text, x, y, font, baseline)
367+
write_text = WriteText(text, x, y, font, baseline, line_height)
365368
self.append(write_text)
366369
return write_text
367370

core/src/toga/widgets/canvas/drawingobject.py

+11-2
Original file line numberDiff line numberDiff line change
@@ -319,22 +319,31 @@ def __init__(
319319
y: float = 0.0,
320320
font: Font | None = None,
321321
baseline: Baseline = Baseline.ALPHABETIC,
322+
line_height: float | None = None,
322323
):
323324
self.text = text
324325
self.x = x
325326
self.y = y
326327
self.font = font
327328
self.baseline = baseline
329+
self.line_height = line_height
328330

329331
def __repr__(self) -> str:
330332
return (
331333
f"{self.__class__.__name__}(text={self.text!r}, x={self.x}, y={self.y}, "
332-
f"font={self.font!r}, baseline={self.baseline})"
334+
f"font={self.font!r}, baseline={self.baseline}, "
335+
f"line_height={self.line_height})"
333336
)
334337

335338
def _draw(self, impl: Any, **kwargs: Any) -> None:
336339
impl.write_text(
337-
str(self.text), self.x, self.y, self.font._impl, self.baseline, **kwargs
340+
str(self.text),
341+
self.x,
342+
self.y,
343+
self.font._impl,
344+
self.baseline,
345+
self.line_height,
346+
**kwargs,
338347
)
339348

340349
@property

core/tests/widgets/canvas/test_canvas.py

+41-8
Original file line numberDiff line numberDiff line change
@@ -139,18 +139,51 @@ def test_stroke(widget):
139139

140140

141141
@pytest.mark.parametrize(
142-
"font, expected",
142+
"font, line_height, expected",
143143
[
144-
(None, (132, 12)),
145-
(Font(family=SYSTEM, size=SYSTEM_DEFAULT_FONT_SIZE), (132, 12)),
146-
(Font(family=SYSTEM, size=20), (220, 20)),
147-
(Font(family="Cutive", size=SYSTEM_DEFAULT_FONT_SIZE), (198, 18)),
148-
(Font(family="Cutive", size=20), (330, 30)),
144+
(None, None, (132, 12)),
145+
(Font(family=SYSTEM, size=SYSTEM_DEFAULT_FONT_SIZE), None, (132, 12)),
146+
(Font(family=SYSTEM, size=SYSTEM_DEFAULT_FONT_SIZE), 1, (132, 12)),
147+
(Font(family=SYSTEM, size=20), None, (220, 20)),
148+
(Font(family=SYSTEM, size=20), 1, (220, 20)),
149+
(Font(family=SYSTEM, size=20), 1.5, (220, 30)),
150+
(Font(family="Cutive", size=SYSTEM_DEFAULT_FONT_SIZE), None, (198, 18)),
151+
(Font(family="Cutive", size=SYSTEM_DEFAULT_FONT_SIZE), 1, (198, 18)),
152+
(Font(family="Cutive", size=20), None, (330, 30)),
153+
(Font(family="Cutive", size=20), 1, (330, 30)),
154+
(Font(family="Cutive", size=20), 1.5, (330, 45)),
149155
],
150156
)
151-
def test_measure_text(widget, font, expected):
157+
def test_measure_text(widget, font, line_height, expected):
152158
"""Canvas can measure rendered text size."""
153-
assert widget.measure_text("Hello world", font=font) == expected
159+
assert (
160+
widget.measure_text("Hello world", font=font, line_height=line_height)
161+
== expected
162+
)
163+
164+
165+
@pytest.mark.parametrize(
166+
"font, line_height, expected",
167+
[
168+
(None, None, (132, 24)),
169+
(Font(family=SYSTEM, size=SYSTEM_DEFAULT_FONT_SIZE), None, (132, 24)),
170+
(Font(family=SYSTEM, size=SYSTEM_DEFAULT_FONT_SIZE), 1, (132, 24)),
171+
(Font(family=SYSTEM, size=20), None, (220, 40)),
172+
(Font(family=SYSTEM, size=20), 1, (220, 40)),
173+
(Font(family=SYSTEM, size=20), 1.5, (220, 60)),
174+
(Font(family="Cutive", size=SYSTEM_DEFAULT_FONT_SIZE), None, (198, 36)),
175+
(Font(family="Cutive", size=SYSTEM_DEFAULT_FONT_SIZE), 1, (198, 36)),
176+
(Font(family="Cutive", size=20), None, (330, 60)),
177+
(Font(family="Cutive", size=20), 1, (330, 60)),
178+
(Font(family="Cutive", size=20), 1.5, (330, 90)),
179+
],
180+
)
181+
def test_measure_text_multiline(widget, font, line_height, expected):
182+
"""Canvas can measure rendered text size of a multiline string."""
183+
assert (
184+
widget.measure_text("Hello\nworld", font=font, line_height=line_height)
185+
== expected
186+
)
154187

155188

156189
def test_as_image(widget):

core/tests/widgets/canvas/test_draw_operations.py

+21-3
Original file line numberDiff line numberDiff line change
@@ -579,39 +579,56 @@ def test_rect(widget):
579579
(
580580
{"text": "Hello world", "x": 10, "y": 20},
581581
"text='Hello world', x=10, y=20, font=<Font: system default size system>, "
582-
"baseline=Baseline.ALPHABETIC",
582+
"baseline=Baseline.ALPHABETIC, line_height=None",
583583
{
584584
"text": "Hello world",
585585
"x": 10,
586586
"y": 20,
587587
"font": Font(SYSTEM, SYSTEM_DEFAULT_FONT_SIZE)._impl,
588588
"baseline": Baseline.ALPHABETIC,
589+
"line_height": None,
589590
},
590591
),
591592
# Baseline
592593
(
593594
{"text": "Hello world", "x": 10, "y": 20, "baseline": Baseline.TOP},
594595
"text='Hello world', x=10, y=20, font=<Font: system default size system>, "
595-
"baseline=Baseline.TOP",
596+
"baseline=Baseline.TOP, line_height=None",
596597
{
597598
"text": "Hello world",
598599
"x": 10,
599600
"y": 20,
600601
"font": Font(SYSTEM, SYSTEM_DEFAULT_FONT_SIZE)._impl,
601602
"baseline": Baseline.TOP,
603+
"line_height": None,
602604
},
603605
),
604606
# Font
605607
(
606608
{"text": "Hello world", "x": 10, "y": 20, "font": Font("Cutive", 42)},
607609
"text='Hello world', x=10, y=20, font=<Font: 42pt Cutive>, "
608-
"baseline=Baseline.ALPHABETIC",
610+
"baseline=Baseline.ALPHABETIC, line_height=None",
609611
{
610612
"text": "Hello world",
611613
"x": 10,
612614
"y": 20,
613615
"font": Font("Cutive", 42)._impl,
614616
"baseline": Baseline.ALPHABETIC,
617+
"line_height": None,
618+
},
619+
),
620+
# Line height factor
621+
(
622+
{"text": "Hello world", "x": 10, "y": 20, "line_height": 1.5},
623+
"text='Hello world', x=10, y=20, font=<Font: system default size system>, "
624+
"baseline=Baseline.ALPHABETIC, line_height=1.5",
625+
{
626+
"text": "Hello world",
627+
"x": 10,
628+
"y": 20,
629+
"font": Font(SYSTEM, SYSTEM_DEFAULT_FONT_SIZE)._impl,
630+
"baseline": Baseline.ALPHABETIC,
631+
"line_height": 1.5,
615632
},
616633
),
617634
],
@@ -634,6 +651,7 @@ def test_write_text(widget, kwargs, args_repr, draw_kwargs):
634651
assert draw_op.y == draw_kwargs["y"]
635652
assert draw_op.font == draw_kwargs["font"].interface
636653
assert draw_op.baseline == draw_kwargs["baseline"]
654+
assert draw_op.line_height == draw_kwargs["line_height"]
637655

638656

639657
def test_rotate(widget):

dummy/src/toga_dummy/widgets/canvas.py

+24-6
Original file line numberDiff line numberDiff line change
@@ -205,7 +205,17 @@ def reset_transform(self, draw_instructions, **kwargs):
205205

206206
# Text
207207

208-
def write_text(self, text, x, y, font, baseline, draw_instructions, **kwargs):
208+
def write_text(
209+
self,
210+
text,
211+
x,
212+
y,
213+
font,
214+
baseline,
215+
line_height,
216+
draw_instructions,
217+
**kwargs,
218+
):
209219
draw_instructions.append(
210220
(
211221
"write text",
@@ -216,30 +226,38 @@ def write_text(self, text, x, y, font, baseline, draw_instructions, **kwargs):
216226
"y": y,
217227
"font": font,
218228
"baseline": baseline,
229+
"line_height": line_height,
219230
},
220231
**kwargs,
221232
),
222233
)
223234
)
224235

225-
def measure_text(self, text, font):
236+
def measure_text(self, text, font, line_height):
226237
# Assume system font produces characters that have the same width and height as
227238
# the point size, with a default point size of 12. Any other font is 1.5 times
228239
# bigger.
240+
241+
if line_height is None:
242+
line_height_factor = 1
243+
else:
244+
line_height_factor = line_height
245+
246+
lines = text.count("\n") + 1
229247
if font.interface.family == SYSTEM:
230248
if font.interface.size == SYSTEM_DEFAULT_FONT_SIZE:
231249
width = len(text) * 12
232-
height = 12
250+
height = lines * line_height_factor * 12
233251
else:
234252
width = len(text) * font.interface.size
235-
height = font.interface.size
253+
height = lines * line_height_factor * font.interface.size
236254
else:
237255
if font.interface.size == SYSTEM_DEFAULT_FONT_SIZE:
238256
width = len(text) * 18
239-
height = 18
257+
height = lines * line_height_factor * 18
240258
else:
241259
width = int(len(text) * font.interface.size * 1.5)
242-
height = int(font.interface.size * 1.5)
260+
height = lines * line_height_factor * int(font.interface.size * 1.5)
243261

244262
return width, height
245263

examples/canvas/canvas/app.py

+14-2
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,9 @@ def startup(self):
8282
self.line_width_slider = toga.Slider(
8383
min=1, max=10, value=1, on_change=self.refresh_canvas
8484
)
85+
self.line_height_slider = toga.Slider(
86+
min=1.0, max=10.0, value=1.2, on_change=self.refresh_canvas
87+
)
8588
self.dash_pattern_selection = toga.Selection(
8689
items=list(self.dash_patterns.keys()), on_change=self.refresh_canvas
8790
)
@@ -123,6 +126,8 @@ def startup(self):
123126
children=[
124127
toga.Label("Line Width:", style=label_style),
125128
self.line_width_slider,
129+
toga.Label("Line Height:", style=label_style),
130+
self.line_height_slider,
126131
self.dash_pattern_selection,
127132
],
128133
),
@@ -514,9 +519,16 @@ def draw_instructions(self, context, factor):
514519
weight=self.get_weight(),
515520
style=self.get_style(),
516521
)
517-
width, height = self.canvas.measure_text(text, font)
522+
width, height = self.canvas.measure_text(
523+
text, font, self.line_height_slider.value
524+
)
518525
context.write_text(
519-
text, self.x_middle - width / 2, self.y_middle, font, Baseline.MIDDLE
526+
text,
527+
self.x_middle - width / 2,
528+
self.y_middle,
529+
font,
530+
Baseline.MIDDLE,
531+
self.line_height_slider.value,
520532
)
521533

522534
def get_weight(self):

0 commit comments

Comments
 (0)