Skip to content

Commit 064ec51

Browse files
authored
Merge pull request #490 from neilsimp1/feature/button-wordwrap
Add wordwrap feature in Button Widget - Fixes #488
2 parents 7a200ac + 8e496c0 commit 064ec51

File tree

2 files changed

+179
-27
lines changed

2 files changed

+179
-27
lines changed

docs/_source/contributors.rst

+2-1
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ Other contributors:
3232
- `vnmabus <https://github.com/vnmabus>`_
3333
- `werdeil <https://github.com/werdeil>`_
3434
- `zPaw <https://github.com/zPaw>`_
35+
- `neilsimp1 <https://github.com/neilsimp1>`_
3536

3637
Ideas and contributions are always welcome. Any found bugs or enhancement
37-
suggestions should be posted on the `GitHub project page <https://github.com/ppizarror/pygame-menu>`_.
38+
suggestions should be posted on the `GitHub project page <https://github.com/ppizarror/pygame-menu>`_.

pygame_menu/widgets/widget/button.py

+177-26
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919

2020
from abc import ABC
2121
from pygame_menu.locals import FINGERUP, CURSOR_HAND
22-
from pygame_menu.utils import assert_color, get_finger_pos, warn
22+
from pygame_menu.utils import assert_color, get_finger_pos, make_surface, warn
2323
from pygame_menu.widgets.core.widget import AbstractWidgetManager, Widget
2424

2525
from pygame_menu._types import Any, CallbackType, Callable, Union, List, Tuple, \
@@ -45,10 +45,16 @@ class Button(Widget):
4545
:param title: Button title
4646
:param button_id: Button ID
4747
:param onreturn: Callback when pressing the button
48+
:param wordwrap: Wraps label if newline is found on widget
49+
:param leading: Font leading for ``wordwrap``. If ``None`` retrieves from widget font
50+
:param max_nlines: Number of maximum lines for ``wordwrap``. If ``None`` the number is dynamically computed. If exceded, ``label.get_overflow_lines()`` will return the lines not displayed
4851
:param args: Optional arguments for callbacks
4952
:param kwargs: Optional keyword arguments
5053
"""
5154
_last_underline: List[Union[str, Optional[Tuple[ColorType, int, int]]]] # deco id, (color, offset, width)
55+
_leading: Optional[int]
56+
_max_nlines: Optional[int]
57+
_wordwrap: bool
5258
to_menu: bool
5359

5460
def __init__(
@@ -59,6 +65,9 @@ def __init__(
5965
*args,
6066
**kwargs
6167
) -> None:
68+
leading = kwargs.pop('leading', None)
69+
max_nlines = kwargs.pop('max_nlines', None)
70+
wordwrap = kwargs.pop('wordwrap', False)
6271
super(Button, self).__init__(
6372
args=args,
6473
kwargs=kwargs,
@@ -68,6 +77,10 @@ def __init__(
6877
)
6978
self._accept_events = True
7079
self._last_underline = ['', None]
80+
self._leading = leading
81+
self._lines = [] # Lines of text displayed
82+
self._max_nlines = max_nlines
83+
self._wordwrap = wordwrap
7184
self.to_menu = False # True if the button opens a new Menu
7285

7386
def _apply_font(self) -> None:
@@ -131,14 +144,16 @@ def add_underline(
131144
force_render: bool = False
132145
) -> 'Button':
133146
"""
134-
Adds an underline to text. This is added if widget is rendered.
147+
Adds an underline to text. This is added if widget is rendered. Underline
148+
is only enabled for non wordwrap label.
135149
136150
:param color: Underline color
137151
:param offset: Underline offset
138152
:param width: Underline width
139153
:param force_render: If ``True`` force widget render after addition
140154
:return: Self reference
141155
"""
156+
assert not self._wordwrap, 'underline is not enabled for wordwrap is active'
142157
color = assert_color(color)
143158
assert isinstance(offset, int)
144159
assert isinstance(width, int) and width > 0
@@ -153,6 +168,7 @@ def remove_underline(self) -> 'Button':
153168
154169
:return: Self reference
155170
"""
171+
assert not self._wordwrap, 'underline is not enabled for wordwrap is active'
156172
if self._last_underline[0] != '':
157173
self._decorator.remove(self._last_underline[0])
158174
self._last_underline[0] = ''
@@ -165,28 +181,158 @@ def _render(self) -> Optional[bool]:
165181
if not self._render_hash_changed(self._selected, self._title, self._visible, self.readonly,
166182
self._last_underline[1]):
167183
return True
184+
self._lines = []
168185

169-
# Render surface
170-
self._surface = self._render_string(self._title, self.get_font_color_status())
186+
# Generate surface
187+
if not self._wordwrap:
188+
self._surface = self._render_string(self._title, self.get_font_color_status())
189+
self._lines.append(self._title)
190+
191+
else:
192+
self._overflow_lines = []
193+
if self._font is None or self._menu is None:
194+
self._surface = make_surface(0, 0, alpha=True)
195+
else:
196+
lines = self._title.split('\n')
197+
lines = sum(
198+
(
199+
self._wordwrap_line(
200+
line=line,
201+
font=self._font,
202+
max_width=self._get_max_container_width(),
203+
tab_size=self._tab_size
204+
)
205+
for line in lines
206+
),
207+
[]
208+
)
209+
num_lines = len(lines)
210+
if isinstance(self._max_nlines, int):
211+
if num_lines > self._max_nlines:
212+
for j in range(num_lines - self._max_nlines):
213+
self._overflow_lines.append(lines[num_lines - j - 1])
214+
num_lines = min(num_lines, self._max_nlines)
215+
216+
self._surface = make_surface(
217+
max(self._font.size(line)[0] for line in lines),
218+
num_lines * self._get_leading(),
219+
alpha=True
220+
)
221+
222+
for n_line, line in enumerate(lines):
223+
line_surface = self._render_string(line, self._font_color)
224+
self._surface.blit(
225+
line_surface,
226+
pygame.Rect(
227+
0,
228+
n_line * self._get_leading(),
229+
self._rect.width,
230+
self._rect.height
231+
)
232+
)
233+
self._lines.append(line)
234+
if n_line + 1 == num_lines:
235+
break
236+
237+
# Update rect object
171238
self._apply_transforms()
172239
self._rect.width, self._rect.height = self._surface.get_size()
173240

174-
# Add underline if enabled
175-
self.remove_underline()
176-
if self._last_underline[1] is not None:
177-
w = self._surface.get_width()
178-
h = self._surface.get_height()
179-
color, offset, width = self._last_underline[1]
180-
if w > 0 and h > 0:
181-
self._last_underline[0] = self._decorator.add_line(
182-
pos1=(-w / 2, h / 2 + offset),
183-
pos2=(w / 2, h / 2 + offset),
184-
color=color,
185-
width=width
186-
)
241+
# Add underline
242+
if not self._wordwrap:
243+
self.remove_underline()
244+
if self._last_underline[1] is not None:
245+
w = self._surface.get_width()
246+
h = self._surface.get_height()
247+
color, offset, width = self._last_underline[1]
248+
if w > 0 and h > 0:
249+
self._last_underline[0] = self._decorator.add_line(
250+
pos1=(-w / 2, h / 2 + offset),
251+
pos2=(w / 2, h / 2 + offset),
252+
color=color,
253+
width=width
254+
)
187255

188256
self.force_menu_surface_update()
189257

258+
def _get_leading(self) -> int:
259+
"""
260+
Computes the font leading.
261+
262+
:return: Leading
263+
"""
264+
assert self._font
265+
return (
266+
self._font.get_linesize()
267+
if self._leading is None
268+
else self._leading
269+
)
270+
271+
def get_lines(self) -> List[str]:
272+
"""
273+
Return the lines of text displayed. Each new line belongs to an item on list.
274+
275+
:return: List of displayed lines
276+
"""
277+
return self._lines
278+
279+
@staticmethod
280+
def _wordwrap_line(
281+
line: str,
282+
font: pygame.font.Font,
283+
max_width: int,
284+
tab_size: int,
285+
) -> List[str]:
286+
"""
287+
Wordwraps line.
288+
289+
:param line: Line
290+
:param font: Font
291+
:param max_width: Max width
292+
:param tab_size: Tab size
293+
:return: List of strings
294+
"""
295+
final_lines = []
296+
words = line.split(' ')
297+
i, current_line = 0, ''
298+
299+
while True:
300+
split_line = False
301+
for i, _ in enumerate(words):
302+
current_line = ' '.join(words[:i + 1])
303+
current_line = current_line.replace('\t', ' ' * tab_size)
304+
current_line_size = font.size(current_line)
305+
if current_line_size[0] > max_width:
306+
split_line = True
307+
break
308+
309+
if split_line:
310+
i = i if i > 0 else 1
311+
final_lines.append(' '.join(words[:i]))
312+
words = words[i:]
313+
else:
314+
final_lines.append(current_line)
315+
break
316+
317+
return final_lines
318+
319+
def _get_max_container_width(self) -> int:
320+
"""
321+
Return the maximum label container width. It can be the column width,
322+
menu width or frame width if horizontal.
323+
324+
:return: Container width
325+
"""
326+
menu = self._menu
327+
if menu is None:
328+
return 0
329+
try:
330+
# noinspection PyProtectedMember
331+
max_width = menu._column_widths[self.get_col_row_index()[0]]
332+
except IndexError:
333+
max_width = menu.get_width(inner=True)
334+
return max_width - self._padding[1] - self._padding[3] - self._selection_effect.get_width()
335+
190336
def update(self, events: EventVectorType) -> bool:
191337
self.apply_update_callbacks(events)
192338
rect = self.get_rect(to_real_position=True)
@@ -330,8 +476,8 @@ def button(
330476
"""
331477
Adds a button to the Menu.
332478
333-
The arguments and unknown keyword arguments are passed to the action, if
334-
it's a callable object:
479+
# The arguments and unknown keyword arguments are passed to the action, if
480+
# it's a callable object:
335481
336482
.. code-block:: python
337483
@@ -389,6 +535,7 @@ def button(
389535
- ``underline_offset`` (int) – Vertical offset in px. ``2`` by default
390536
- ``underline_width`` (int) – Underline width in px. ``2`` by default
391537
- ``underline`` (bool) – Enables text underline, using a properly placed decoration. ``False`` by default
538+
- ``wordwrap`` (bool) – Wraps label if newline is found on widget. If ``False`` the manager splits the string and creates a list of widgets, else, the widget itself splits and updates the height
392539
393540
.. note::
394541
@@ -416,6 +563,10 @@ def button(
416563
:return: Widget object
417564
:rtype: :py:class:`pygame_menu.widgets.Button`
418565
"""
566+
567+
# wordwrap = kwargs.pop('wordwrap', False)
568+
# assert isinstance(wordwrap, bool)
569+
419570
total_back = kwargs.pop('back_count', 1)
420571
assert isinstance(total_back, int) and 1 <= total_back
421572

@@ -457,29 +608,29 @@ def button(
457608
f'back_count number of menus to return from, default is 1'
458609
)
459610

460-
widget = Button(title, button_id, self._menu._open, action)
611+
widget = Button(title, button_id, self._menu._open, action, *args, **kwargs)
461612
widget.to_menu = True
462613

463614
# If element is a MenuAction
464615
elif action == _events.BACK: # Back to Menu
465-
widget = Button(title, button_id, self._menu.reset, total_back)
616+
widget = Button(title, button_id, self._menu.reset, total_back, *args, **kwargs)
466617

467618
elif action == _events.CLOSE: # Close Menu
468-
widget = Button(title, button_id, self._menu._close)
619+
widget = Button(title, button_id, self._menu._close, *args, **kwargs)
469620

470621
elif action == _events.EXIT: # Exit program
471-
widget = Button(title, button_id, self._menu._exit)
622+
widget = Button(title, button_id, self._menu._exit, *args, **kwargs)
472623

473624
elif action == _events.NONE: # None action
474625
widget = Button(title, button_id)
475626

476627
elif action == _events.RESET: # Back to Top Menu
477-
widget = Button(title, button_id, self._menu.full_reset)
628+
widget = Button(title, button_id, self._menu.full_reset, *args, **kwargs)
478629

479630
# If element is a function or callable
480631
elif callable(action):
481632
if not accept_kwargs:
482-
widget = Button(title, button_id, action, *args)
633+
widget = Button(title, button_id, action, *args, **kwargs)
483634
else:
484635
widget = Button(title, button_id, action, *args, **kwargs)
485636

@@ -600,4 +751,4 @@ def url(
600751
kwargs['underline'] = True
601752

602753
# Return new button
603-
return self.button(title if title != '' else href, lambda: webbrowser.open(href), **kwargs)
754+
return self.button(title if title != '' else href, lambda: webbrowser.open(href), **kwargs)

0 commit comments

Comments
 (0)