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