1#!/usr/bin/env python3
2
3"""
4Widgets that can display and input text.
5"""
6
7import pyglet
8import autoprop
9
10from vecrec import Vector, Rect
11from glooey import drawing
12from glooey.widget import Widget
13from glooey.images import Background
14from glooey.containers import Stack, Deck
15from glooey.helpers import *
16
17@autoprop
18@register_event_type('on_edit_text')
19class Label(Widget):
20    custom_text = ""
21    custom_font_name = None
22    custom_font_size = None
23    custom_bold = None
24    custom_italic = None
25    custom_underline = None
26    custom_kerning = None
27    custom_baseline = None
28    custom_color = 'green'
29    custom_background_color = None
30    custom_text_alignment = None
31    custom_line_spacing = None
32
33    def __init__(self, text=None, line_wrap=None, **style):
34        super().__init__()
35        self._layout = None
36        self._text = text or self.custom_text
37        self._line_wrap_width = 0
38        self._style = {}
39        self.set_style(
40                font_name=self.custom_font_name,
41                font_size=self.custom_font_size,
42                bold=self.custom_bold,
43                italic=self.custom_italic,
44                underline=self.custom_underline,
45                kerning=self.custom_kerning,
46                baseline=self.custom_baseline,
47                color=self.custom_color,
48                background_color=self.custom_background_color,
49                align=self.custom_text_alignment,
50                line_spacing=self.custom_line_spacing,
51        )
52        self.set_style(**style)
53        if line_wrap:
54            self.enable_line_wrap(line_wrap)
55
56    def __repr__(self):
57        import textwrap
58
59        repr = '{cls}(id={id}, "{text}")'
60        args = {
61                'cls': self.__class__.__name__,
62                'id': hex(id(self))[-4:],
63        }
64        try:
65            args['text'] = textwrap.shorten(self.text, width=10, placeholder='...')
66        except:
67            repr = '{cls}(id={id})'
68
69        return repr.format(**args)
70
71    def do_claim(self):
72        # Make sure the label's text and style are up-to-date before we request
73        # space.  Be careful!  This means that do_draw() can be called before
74        # the widget has self.rect or self.group, which usually cannot happen.
75        self.do_draw(ignore_rect=True)
76
77        # Return the amount of space needed to render the label.
78        return self._layout.content_width, self._layout.content_height
79
80    def do_draw(self, ignore_rect=False):
81        # Any time we need to draw this widget, just delete the underlying
82        # label object and make a new one.  This isn't any slower than keeping
83        # the old object, because all the vertex lists would be redrawn either
84        # way.  And this is more flexible, because it allows us to reset the
85        # batch, the group, and the wrap_lines attribute.
86        if self._layout is not None:
87            self._layout.delete()
88
89        kwargs = {
90                'multiline': True,
91                'wrap_lines': False,
92                'batch': self.batch,
93                'group': self.group
94        }
95        # Usually self.rect is guaranteed to be set by the time this method is
96        # called, but that is not the case for this widget.  The do_claim()
97        # method needs to call do_draw() to see how much space the text will
98        # need, and that happens before self.rect is set (since it's part of
99        # the process of setting self.rect).
100        if not ignore_rect:
101            kwargs['width'] = self.rect.width
102            kwargs['height'] = self.rect.height
103
104        # Enable line wrapping, if the user requested it.  The width of the
105        # label is set to the value given by the user when line-wrapping was
106        # enabled.  This is done after the size of the assigned rect is
107        # considered, so the text will wrap at the specified line width no
108        # matter how much space is available to it.  This ensures that the text
109        # takes up all of the height it requested.  It would be better if the
110        # text could update its height claim after knowing how much width it
111        # got, but that's a non-trivial change.
112        if self._line_wrap_width:
113            kwargs['width'] = self._line_wrap_width
114            kwargs['wrap_lines'] = True
115
116        # It's best to make a fresh document each time.  Previously I was
117        # storing the document as a member variable, but I ran into corner
118        # cases where the document would have an old style that wouldn't be
119        # compatible with the new TextLayout (specifically 'align' != 'left' if
120        # line wrapping is no loner enabled).
121        document = pyglet.text.decode_text(self._text)
122        document.push_handlers(self.on_insert_text, self.on_delete_text)
123
124        if self._layout:
125            self._layout.delete()
126        self._layout = self.do_make_new_layout(document, kwargs)
127
128        # Use begin_update() and end_update() to prevent the layout from
129        # generating new vertex lists until the styles and coordinates have
130        # been set.
131        self._layout.begin_update()
132
133        # The layout will crash if it doesn't have an explicit width and the
134        # style specifies an alignment.
135        if self._layout.width is None:
136            self._layout.width = self._layout.content_width
137
138        document.set_style(0, len(self._text), self._style)
139
140        if not ignore_rect:
141            self._layout.x = self.rect.bottom_left.x
142            self._layout.y = self.rect.bottom_left.y
143
144        self._layout.end_update()
145
146    def do_undraw(self):
147        if self._layout is not None:
148            self._layout.delete()
149
150    def do_make_new_layout(self, document, kwargs):
151        return pyglet.text.layout.TextLayout(document, **kwargs)
152
153    def on_insert_text(self, start, text):
154        self._text = self._layout.document.text
155        self.dispatch_event('on_edit_text', self)
156
157    def on_delete_text(self, start, end):
158        self._text = self._layout.document.text
159        self.dispatch_event('on_edit_text', self)
160
161    def get_text(self):
162        return self._layout.document.text
163
164    def set_text(self, text, width=None, **style):
165        self._text = text
166        if width is not None:
167            self._line_wrap_width = width
168        # This will repack.
169        self.set_style(**style)
170
171    def del_text(self):
172        self.set_text("")
173
174    def get_font_name(self):
175        return self.get_style('font_name')
176
177    def set_font_name(self, name):
178        self.set_style(font_name=name)
179
180    def del_font_name(self):
181        return self.del_style('font_name')
182
183    def get_font_size(self):
184        return self.get_style('font_size')
185
186    def set_font_size(self, size):
187        self.set_style(font_size=size)
188
189    def del_font_size(self):
190        return self.del_style('font_size')
191
192    def get_bold(self):
193        return self.get_style('bold')
194
195    def set_bold(self, bold):
196        self.set_style(bold=bold)
197
198    def del_bold(self):
199        return self.del_style('bold')
200
201    def get_italic(self):
202        return self.get_style('italic')
203
204    def set_italic(self, italic):
205        self.set_style(italic=italic)
206
207    def del_italic(self):
208        return self.del_style('italic')
209
210    def get_underline(self):
211        return self.get_style('underline') is not None
212
213    def set_underline(self, underline):
214        self.set_style(underline=underline)
215
216    def del_underline(self):
217        return self.del_style('underline') is not None
218
219    def get_kerning(self):
220        return self.get_style('kerning')
221
222    def set_kerning(self, kerning):
223        self.set_style(kerning=kerning)
224
225    def del_kerning(self):
226        return self.del_style('kerning')
227
228    def get_baseline(self):
229        return self.get_style('baseline')
230
231    def set_baseline(self, baseline):
232        self.set_style(baseline=baseline)
233
234    def del_baseline(self):
235        return self.del_style('baseline')
236
237    def get_color(self):
238        return self.get_style('color')
239
240    def set_color(self, color):
241        self.set_style(color=color)
242
243    def del_color(self):
244        return self.del_style('color')
245
246    def get_background_color(self):
247        return self.get_style('background_color')
248
249    def set_background_color(self, color):
250        self.set_style(background_color=color)
251
252    def del_background_color(self):
253        return self.del_style('background_color')
254
255    def get_text_alignment(self):
256        return self.get_style('align')
257
258    def set_text_alignment(self, alignment):
259        self.set_style(align=alignment)
260
261    def del_text_alignment(self):
262        return self.del_style('align')
263
264    def get_line_spacing(self):
265        return self.get_style('line_spacing')
266
267    def set_line_spacing(self, spacing):
268        self.set_style(line_spacing=spacing)
269
270    def del_line_spacing(self):
271        self.del_style('line_spacing')
272
273    def enable_line_wrap(self, width):
274        self._line_wrap_width = width
275        self._repack()
276
277    def disable_line_wrap(self):
278        self.enable_line_wrap(0)
279
280    def get_style(self, style):
281        return self._style.get(style)
282
283    def set_style(self, **style):
284        self._style.update({k:v for k,v in style.items() if v is not None})
285        self._update_style()
286
287    def del_style(self, style):
288        del self._style[style]
289        self._update_style()
290
291    def _update_style(self):
292        # I want users to be able to specify colors using strings or Color
293        # objects, but pyglet expects tuples, so make the conversion here.
294
295        if 'color' in self._style:
296            self._style['color'] = drawing.Color.from_anything(
297                    self._style['color']).tuple
298
299        if 'background_color' in self._style:
300            self._style['background_color'] = drawing.Color.from_anything(
301                    self._style['background_color']).tuple
302
303        # I want the underline attribute to behave as a boolean, but in the
304        # TextLayout API it's a color.  So when it's set to either True or
305        # False, I need to translate that to either being a color or not being
306        # in the style dictionary.
307
308        if 'underline' in self._style:
309            if not self._style['underline']:
310                del self._style['underline']
311            else:
312                self._style['underline'] = self.color
313
314        self._repack()
315
316
317@autoprop
318@register_event_type('on_focus')
319@register_event_type('on_unfocus')
320class EditableLabel(Label):
321    custom_selection_color = 'black'
322    custom_selection_background_color = None
323    custom_unfocus_on_enter = True
324
325    def __init__(self, text="", line_wrap=None, **style):
326        super().__init__(text, line_wrap, **style)
327        self._caret = None
328        self._focus = False
329        self._is_mouse_over = False
330        self._unfocus_on_enter = self.custom_unfocus_on_enter
331
332        # I'm surprised pyglet doesn't treat the selection colors like all the
333        # other styles.  Instead they're attributes of IncrementalTextLayout.
334        self._selection_color = self.custom_selection_color
335        self._selection_background_color = self.custom_selection_background_color
336
337    def do_claim(self):
338        font = pyglet.font.load(self.font_name)
339        min_size = font.ascent - font.descent
340        return min_size, min_size
341
342    def focus(self):
343        # Push handlers directly to the window, so even if the user has
344        # attached their own handlers (e.g. for hotkeys) above the GUI, the
345        # form will still take focus.
346
347        if not self._focus:
348            self._focus = True
349            self._caret.on_activate()
350            self.window.push_handlers(self._caret)
351            self.window.push_handlers(
352                    on_mouse_press=self.on_window_mouse_press,
353                    on_key_press=self.on_window_key_press,
354                    on_key_release=self.on_window_key_release,
355            )
356            self.dispatch_event('on_focus', self)
357
358    def unfocus(self):
359        if self._focus:
360            self._focus = False
361            self._caret.on_deactivate()
362            self._layout.set_selection(0,0)
363            self.window.remove_handlers(self._caret)
364            self.window.remove_handlers(
365                    on_mouse_press=self.on_window_mouse_press,
366                    on_key_press=self.on_window_key_press,
367                    on_key_release=self.on_window_key_release,
368            )
369            self.dispatch_event('on_unfocus', self)
370
371    def on_mouse_enter(self, x, y):
372        super().on_mouse_enter(x, y)
373        self._is_mouse_over = True
374
375    def on_mouse_leave(self, x, y):
376        super().on_mouse_leave(x, y)
377        self._is_mouse_over = False
378
379    def on_mouse_press(self, x, y, button, modifiers):
380        if not self._focus:
381            self.focus()
382            self._caret.on_mouse_press(x, y, button, modifiers)
383
384    def on_window_mouse_press(self, x, y, button, modifiers):
385        # Determine if the mouse is over the form by tracking mouse enter and
386        # leave events.  This is more robust than checking the mouse
387        # coordinates in this method, because it still works when the form has
388        # a parent that changes its coordinates, like a ScrollBox.
389
390        if not self._is_mouse_over:
391            self.unfocus()
392
393            # This event will get swallowed by the caret, so dispatch a new
394            # event after the caret handlers have been popped.
395            #
396            # Update (2020/08/23): The above doesn't seem to be true anymore.
397            # Specifically, the problem in #40 is that the below line causes
398            # the same mouse press event to be dispatched twice, which
399            # ultimately causes a scroll bar grip to get choked up trying to
400            # grab the mouse twice.  Furthermore, removing this line didn't
401            # create any noticeable regressions in the tests.  I'm going to
402            # remove the extra event, but leave these comments in case this
403            # ends up creating another subtle bug.
404
405            #self.window.dispatch_event('on_mouse_press', x, y, button, modifiers)
406
407    def on_window_key_press(self, symbol, modifiers):
408        if self._unfocus_on_enter and symbol == pyglet.window.key.ENTER:
409            self.unfocus()
410        return True
411
412    def on_window_key_release(self, symbol, modifiers):
413        return True
414
415    def do_make_new_layout(self, document, kwargs):
416        # Make a new layout (optimized for editing).
417        new_layout = pyglet.text.layout.IncrementalTextLayout(document, **kwargs)
418
419        new_layout.selection_color = drawing.Color.from_anything(
420                self._selection_color).tuple
421        new_layout.selection_background_color = drawing.Color.from_anything(
422                self._selection_background_color or self.color).tuple
423
424        # If the previous layout had a selection, keep it.  Note that the
425        # normal text layout doesn't have the concept of a selection, so
426        # this logic needs to be here rather than in the base class.
427        if self._layout:
428            new_layout.set_selection(
429                    self._layout._selection_start,
430                    self._layout._selection_end,
431            )
432
433        # Make a new caret.
434        new_caret = pyglet.text.caret.Caret(new_layout, color=self.color[:3])
435
436        # Keep the caret in the same place as it was before, and clean up the
437        # old caret object.
438        if self._caret:
439            new_caret.position = self._caret.position
440            new_caret.mark = self._caret.mark
441
442            self.window.remove_handlers(self._caret)
443            self._caret.delete()
444
445        # Match the caret's behavior to the widget's current focus state.
446        if self._focus:
447            new_caret.on_activate()
448            self.window.push_handlers(new_caret)
449        else:
450            new_caret.on_deactivate()
451
452        self._caret = new_caret
453        return new_layout
454
455    def get_selection_color(self):
456        return self._selection_color
457
458    def set_selection_color(self, new_color):
459        self._selection_color = new_color
460        self._draw()
461
462    def get_selection_background_color(self):
463        return self._selection_background_color
464
465    def set_selection_background_color(self, new_color):
466        self._selection_background_color = new_color
467        self._draw()
468
469    def get_unfocus_on_enter(self):
470        return self._unfocus_on_enter
471
472    def set_unfocus_on_enter(self, new_behavior):
473        self._unfocus_on_enter = new_behavior
474
475
476@autoprop
477class Form(Widget):
478    Label = EditableLabel
479    Base = Background
480    Focused = None
481    Deck = Deck
482
483    def __init__(self, text=""):
484        super().__init__()
485
486        self._stack = Stack()
487
488        self._label = self.Label(text)
489        self._label.push_handlers(
490                on_focus=lambda w: self.dispatch_event('on_focus', self),
491                on_unfocus=lambda w: self.dispatch_event('on_unfocus', self),
492        )
493
494        # If there are two backgrounds, create a deck to switch between them.
495        # Otherwise skip the extra layer of hierarchy.
496        if self.Focused is None:
497            self._bg = self.Base()
498
499        else:
500            self._bg = self.Deck('base')
501            self._bg.add_states(
502                    base=self.Base(),
503                    focused=self.Focused(),
504            )
505            self._label.push_handlers(
506                    on_focus=lambda w: self._bg.set_state('focused'),
507                    on_unfocus=lambda w: self._bg.set_state('base'),
508            )
509
510        self._stack.add_front(self._label)
511        self._stack.add_back(self._bg)
512        self._attach_child(self._stack)
513
514    def get_label(self):
515        return self._label
516
517    def get_text(self):
518        return self._label.text
519
520    def set_text(self, new_text):
521        self._label.text = new_text
522
523
524