Skip to content

Commit 8c0e786

Browse files
committed
Add support for multiline text and text alignment
1 parent b3f2781 commit 8c0e786

File tree

9 files changed

+175
-11
lines changed

9 files changed

+175
-11
lines changed

buildconfig/stubs/pygame/__init__.pyi

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,9 @@ from .constants import (
181181
FINGERDOWN as FINGERDOWN,
182182
FINGERMOTION as FINGERMOTION,
183183
FINGERUP as FINGERUP,
184+
FONT_CENTER as FONT_CENTER,
185+
FONT_LEFT as FONT_LEFT,
186+
FONT_RIGHT as FONT_RIGHT,
184187
FULLSCREEN as FULLSCREEN,
185188
GL_ACCELERATED_VISUAL as GL_ACCELERATED_VISUAL,
186189
GL_ACCUM_ALPHA_SIZE as GL_ACCUM_ALPHA_SIZE,

buildconfig/stubs/pygame/constants.pyi

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,9 @@ DROPTEXT: int
103103
FINGERDOWN: int
104104
FINGERMOTION: int
105105
FINGERUP: int
106+
FONT_CENTER: int
107+
FONT_LEFT: int
108+
FONT_RIGHT: int
106109
FULLSCREEN: int
107110
GL_ACCELERATED_VISUAL: int
108111
GL_ACCUM_ALPHA_SIZE: int

buildconfig/stubs/pygame/font.pyi

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,13 +32,15 @@ class Font:
3232
italic: bool
3333
underline: bool
3434
strikethrough: bool
35+
align: int
3536
def __init__(self, name: Optional[FileArg], size: int) -> None: ...
3637
def render(
3738
self,
3839
text: Union[str, bytes, None],
3940
antialias: bool,
4041
color: ColorValue,
4142
background: Optional[ColorValue] = None,
43+
wraplength: int = 0
4244
) -> Surface: ...
4345
def size(self, text: Union[str, bytes]) -> Tuple[int, int]: ...
4446
def set_underline(self, value: bool) -> None: ...

buildconfig/stubs/pygame/locals.pyi

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,9 @@ DROPTEXT: int
104104
FINGERDOWN: int
105105
FINGERMOTION: int
106106
FINGERUP: int
107+
FONT_CENTER: int
108+
FONT_LEFT: int
109+
FONT_RIGHT: int
107110
FULLSCREEN: int
108111
GL_ACCELERATED_VISUAL: int
109112
GL_ACCUM_ALPHA_SIZE: int

docs/reST/ref/font.rst

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -244,17 +244,32 @@ solves no longer exists, it will likely be removed in the future.
244244

245245
.. ## Font.strikethrough ##
246246
247+
.. attribute:: align
248+
249+
| :sl:`Set how rendered text is aligned when given a wrap length`
250+
| :sg:`align -> int`
251+
252+
Can be set to `pygame.FONT_LEFT`, `pygame.FONT_RIGHT`, or
253+
`pygame.FONT_CENTER`. This controls the text alignment behavior for the
254+
font.
255+
256+
Requires pygame built with SDL_ttf 2.20.0, as all official pygame
257+
distributions are.
258+
259+
.. versionadded:: 2.1.4
260+
261+
.. ## Font.align ##
262+
247263
.. method:: render
248264

249265
| :sl:`draw text on a new Surface`
250-
| :sg:`render(text, antialias, color, background=None) -> Surface`
266+
| :sg:`render(text, antialias, color, background=None, wraplength=0) -> Surface`
251267
252268
This creates a new Surface with the specified text rendered on it.
253269
:mod:`pygame.font` provides no way to directly draw text on an existing
254270
Surface: instead you must use :func:`Font.render` to create an image
255271
(Surface) of the text, then blit this image onto another Surface.
256272

257-
The text can only be a single line: newline characters are not rendered.
258273
Null characters ('\x00') raise a TypeError. Both Unicode and char (byte)
259274
strings are accepted. For Unicode strings only UCS-2 characters
260275
('\\u0001' to '\\uFFFF') were previously supported and any greater
@@ -266,6 +281,10 @@ solves no longer exists, it will likely be removed in the future.
266281
to use for the text background. If no background is passed the area
267282
outside the text will be transparent.
268283

284+
The `wraplength` argument describes the width (in pixels) a line of text
285+
should be before wrapping to a new line. See
286+
:attr:`pygame.font.Font.align` for line-alignment settings.
287+
269288
The Surface returned will be of the dimensions required to hold the text.
270289
(the same as those returned by :func:`Font.size`). If an empty string is passed
271290
for the text, a blank surface will be returned that is zero pixel wide and
@@ -285,9 +304,6 @@ solves no longer exists, it will likely be removed in the future.
285304
cause the resulting image to maintain transparency information by
286305
colorkey rather than (much less efficient) alpha values.
287306

288-
If you render '\\n' an unknown char will be rendered. Usually a
289-
rectangle. Instead you need to handle newlines yourself.
290-
291307
Font rendering is not thread safe: only a single thread can render text
292308
at any time.
293309

@@ -296,6 +312,11 @@ solves no longer exists, it will likely be removed in the future.
296312
pygame supports rendering UCS4 unicode including more languages and
297313
emoji.
298314

315+
.. versionchanged:: 2.1.4 newline characters now will break text into
316+
multiple lines.
317+
318+
.. versionadded:: 2.1.4 wraplength parameter
319+
299320
.. ## Font.render ##
300321
301322
.. method:: size

src_c/constants.c

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -623,6 +623,11 @@ MODINIT_DEFINE(constants)
623623
// https://github.com/pygame-community/pygame-ce/issues/1845
624624
DEC_CONSTS(IS_CE, 1)
625625

626+
/* Font alignment constants */
627+
DEC_CONSTS(FONT_LEFT, 0);
628+
DEC_CONSTS(FONT_CENTER, 1);
629+
DEC_CONSTS(FONT_RIGHT, 2);
630+
626631
if (PyModule_AddObject(module, "__all__", all_list)) {
627632
Py_DECREF(all_list);
628633
Py_DECREF(module);

src_c/doc/font_doc.h

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@
1313
#define DOC_FONTITALIC "italic -> bool\nGets or sets whether the font should be rendered in (faked) italics."
1414
#define DOC_FONTUNDERLINE "underline -> bool\nGets or sets whether the font should be rendered with an underline."
1515
#define DOC_FONTSTRIKETHROUGH "strikethrough -> bool\nGets or sets whether the font should be rendered with a strikethrough."
16-
#define DOC_FONTRENDER "render(text, antialias, color, background=None) -> Surface\ndraw text on a new Surface"
16+
#define DOC_FONTALIGN "align -> int\nSet how rendered text is aligned when given a wrap length"
17+
#define DOC_FONTRENDER "render(text, antialias, color, background=None, wraplength=0) -> Surface\ndraw text on a new Surface"
1718
#define DOC_FONTSIZE "size(text) -> (width, height)\ndetermine the amount of space needed to render text"
1819
#define DOC_FONTSETUNDERLINE "set_underline(bool) -> None\ncontrol if text is rendered with an underline"
1920
#define DOC_FONTGETUNDERLINE "get_underline() -> bool\ncheck if text will be rendered with an underline"
@@ -92,8 +93,12 @@ pygame.font.Font.strikethrough
9293
strikethrough -> bool
9394
Gets or sets whether the font should be rendered with a strikethrough.
9495
96+
pygame.font.Font.align
97+
align -> int
98+
Set how rendered text is aligned when given a wrap length
99+
95100
pygame.font.Font.render
96-
render(text, antialias, color, background=None) -> Surface
101+
render(text, antialias, color, background=None, wraplength=0) -> Surface
97102
draw text on a new Surface
98103
99104
pygame.font.Font.size

src_c/font.c

Lines changed: 84 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,18 @@ static const char resourcefunc_name[] = "getResource";
7272
#endif
7373
static const char font_defaultname[] = "freesansbold.ttf";
7474

75+
#ifndef SDL_TTF_VERSION_ATLEAST
76+
/**
77+
* This macro will evaluate to true if compiled with SDL_ttf at least X.Y.Z.
78+
* New in SDL_ttf 2.0.15 so here it is in pygame for compat
79+
*/
80+
#define SDL_TTF_VERSION_ATLEAST(X, Y, Z) \
81+
((SDL_TTF_MAJOR_VERSION >= X) && \
82+
(SDL_TTF_MAJOR_VERSION > X || SDL_TTF_MINOR_VERSION >= Y) && \
83+
(SDL_TTF_MAJOR_VERSION > X || SDL_TTF_MINOR_VERSION > Y || \
84+
SDL_TTF_PATCHLEVEL >= Z))
85+
#endif
86+
7587
/*
7688
*/
7789
#if !SDL_TTF_VERSION_ATLEAST(2, 0, 15)
@@ -393,6 +405,52 @@ font_setter_strikethrough(PyObject *self, PyObject *value, void *closure)
393405
return 0;
394406
}
395407

408+
/* Implements getter for the align attribute */
409+
static PyObject *
410+
font_getter_align(PyObject *self, void *closure)
411+
{
412+
#if SDL_TTF_VERSION_ATLEAST(2, 20, 0)
413+
TTF_Font *font = PyFont_AsFont(self);
414+
return PyLong_FromLong(TTF_GetFontWrappedAlign(font));
415+
#else
416+
return RAISE(pgExc_SDLError,
417+
"pygame.font not compiled with a new enough SDL_ttf version. "
418+
"Needs SDL_ttf 2.20.0 or above.");
419+
#endif
420+
}
421+
422+
/* Implements setter for the align attribute */
423+
static int
424+
font_setter_align(PyObject *self, PyObject *value, void *closure)
425+
{
426+
#if SDL_TTF_VERSION_ATLEAST(2, 20, 0)
427+
TTF_Font *font = PyFont_AsFont(self);
428+
429+
DEL_ATTR_NOT_SUPPORTED_CHECK("align", value);
430+
431+
long val = PyLong_AsLong(value);
432+
if (val == -1 && PyErr_Occurred()) {
433+
PyErr_SetString(PyExc_TypeError, "font.align should be an integer");
434+
return -1;
435+
}
436+
437+
if (val < 0 || val > 2) {
438+
PyErr_SetString(
439+
pgExc_SDLError,
440+
"font.align should be FONT_LEFT, FONT_CENTER, or FONT_RIGHT");
441+
return -1;
442+
}
443+
444+
TTF_SetFontWrappedAlign(font, val);
445+
return 0;
446+
#else
447+
PyErr_SetString(pgExc_SDLError,
448+
"pygame.font not compiled with a new enough SDL_ttf "
449+
"version. Needs SDL_ttf 2.20.0 or above.");
450+
return -1;
451+
#endif
452+
}
453+
396454
/* Implements get_strikethrough() */
397455
static PyObject *
398456
font_get_strikethrough(PyObject *self, PyObject *args)
@@ -425,9 +483,10 @@ font_render(PyObject *self, PyObject *args)
425483
Uint8 rgba[] = {0, 0, 0, 0};
426484
SDL_Surface *surf;
427485
const char *astring = "";
486+
int wraplength = 0;
428487

429-
if (!PyArg_ParseTuple(args, "OpO|O", &text, &antialias, &fg_rgba_obj,
430-
&bg_rgba_obj)) {
488+
if (!PyArg_ParseTuple(args, "OpO|Oi", &text, &antialias, &fg_rgba_obj,
489+
&bg_rgba_obj, &wraplength)) {
431490
return NULL;
432491
}
433492

@@ -452,6 +511,11 @@ font_render(PyObject *self, PyObject *args)
452511
return RAISE_TEXT_TYPE_ERROR();
453512
}
454513

514+
if (wraplength < 0) {
515+
return RAISE(PyExc_ValueError,
516+
"wraplength parameter must be positive");
517+
}
518+
455519
if (PyUnicode_Check(text)) {
456520
Py_ssize_t _size = -1;
457521
astring = PyUnicode_AsUTF8AndSize(text, &_size);
@@ -484,19 +548,34 @@ font_render(PyObject *self, PyObject *args)
484548
#if !SDL_TTF_VERSION_ATLEAST(2, 0, 15)
485549
if (utf_8_needs_UCS_4(astring)) {
486550
return RAISE(PyExc_UnicodeError,
487-
"A Unicode character above '\\uFFFF' was found;"
551+
"a Unicode character above '\\uFFFF' was found;"
488552
" not supported with SDL_ttf version below 2.0.15");
489553
}
490554
#endif
491555

492556
if (antialias && bg_rgba_obj == Py_None) {
557+
#if SDL_TTF_VERSION_ATLEAST(2, 0, 18)
558+
surf = TTF_RenderUTF8_Blended_Wrapped(font, astring, foreg,
559+
wraplength);
560+
#else
493561
surf = TTF_RenderUTF8_Blended(font, astring, foreg);
562+
#endif
494563
}
495564
else if (antialias) {
565+
#if SDL_TTF_VERSION_ATLEAST(2, 0, 18)
566+
surf = TTF_RenderUTF8_Shaded_Wrapped(font, astring, foreg, backg,
567+
wraplength);
568+
#else
496569
surf = TTF_RenderUTF8_Shaded(font, astring, foreg, backg);
570+
#endif
497571
}
498572
else {
573+
#if SDL_TTF_VERSION_ATLEAST(2, 0, 18)
574+
surf =
575+
TTF_RenderUTF8_Solid_Wrapped(font, astring, foreg, wraplength);
576+
#else
499577
surf = TTF_RenderUTF8_Solid(font, astring, foreg);
578+
#endif
500579
/* If an explicit background was provided and the rendering options
501580
resolve to Render_Solid, that needs to be explicitly handled. */
502581
if (surf != NULL && bg_rgba_obj != Py_None) {
@@ -679,6 +758,8 @@ static PyGetSetDef font_getsets[] = {
679758
DOC_FONTUNDERLINE, NULL},
680759
{"strikethrough", (getter)font_getter_strikethrough,
681760
(setter)font_setter_strikethrough, DOC_FONTSTRIKETHROUGH, NULL},
761+
{"align", (getter)font_getter_align, (setter)font_setter_align,
762+
DOC_FONTALIGN, NULL},
682763
{NULL, NULL, NULL, NULL, NULL}};
683764

684765
static PyMethodDef font_methods[] = {

test/font_test.py

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,28 @@ def test_render_args(self):
268268
self.assertEqual(tuple(screen.get_at((0, 0)))[:3], (10, 10, 10))
269269
self.assertEqual(tuple(screen.get_at(font_rect.topleft))[:3], (10, 10, 10))
270270

271+
def test_render_multiline(self):
272+
if pygame_font.__name__ == "pygame.ftfont":
273+
return
274+
275+
if pygame.font.get_sdl_ttf_version() < (2, 0, 18):
276+
# When the SDL_TTF version is too low, it just ignores the
277+
# line wrap parameter and newlines
278+
return
279+
280+
f = pygame_font.Font(None, 20)
281+
one_line = f.render("hello", True, "black", "white", 200)
282+
two_lines = f.render("hello\nworld", True, "black", "white", 200)
283+
self.assertGreater(two_lines.get_height(), one_line.get_height())
284+
285+
one_line = f.render("hello", True, "black", None, 200)
286+
two_lines = f.render("hello\nworld", True, "black", None, 200)
287+
self.assertGreater(two_lines.get_height(), one_line.get_height())
288+
289+
one_line = f.render("hello", False, "black", None, 200)
290+
two_lines = f.render("hello\nworld", False, "black", None, 200)
291+
self.assertGreater(two_lines.get_height(), one_line.get_height())
292+
271293

272294
@unittest.skipIf(IS_PYPY, "pypy skip known failure") # TODO
273295
class FontTypeTest(unittest.TestCase):
@@ -304,7 +326,7 @@ def test_get_height(self):
304326
self.assertTrue(isinstance(height, int))
305327
self.assertTrue(height > 0)
306328
s = f.render("X", False, (255, 255, 255))
307-
self.assertTrue(s.get_size()[1] == height)
329+
self.assertAlmostEqual(s.get_height(), height, delta=3)
308330

309331
def test_get_linesize(self):
310332
# Checking linesize would need a custom test font to do properly.
@@ -484,6 +506,25 @@ def test_set_strikethrough_property(self):
484506
f.strikethrough = False
485507
self.assertFalse(f.strikethrough)
486508

509+
def test_set_align_property(self):
510+
if pygame_font.__name__ == "pygame.ftfont":
511+
return
512+
513+
f = pygame_font.Font(None, 20)
514+
515+
if pygame.font.get_sdl_ttf_version() < (2, 20, 0):
516+
with self.assertRaises(pygame.error):
517+
f.align = pygame.FONT_CENTER
518+
return
519+
520+
self.assertEqual(f.align, pygame.FONT_LEFT)
521+
f.align = pygame.FONT_CENTER
522+
self.assertEqual(f.align, pygame.FONT_CENTER)
523+
f.align = pygame.FONT_RIGHT
524+
self.assertEqual(f.align, pygame.FONT_RIGHT)
525+
f.align = pygame.FONT_LEFT
526+
self.assertEqual(f.align, pygame.FONT_LEFT)
527+
487528
def test_size(self):
488529
f = pygame_font.Font(None, 20)
489530
text = "Xg"

0 commit comments

Comments
 (0)