1# ---------------------------------------------------------------------------- 2# pyglet 3# Copyright (c) 2006-2008 Alex Holkner 4# Copyright (c) 2008-2021 pyglet contributors 5# All rights reserved. 6# 7# Redistribution and use in source and binary forms, with or without 8# modification, are permitted provided that the following conditions 9# are met: 10# 11# * Redistributions of source code must retain the above copyright 12# notice, this list of conditions and the following disclaimer. 13# * Redistributions in binary form must reproduce the above copyright 14# notice, this list of conditions and the following disclaimer in 15# the documentation and/or other materials provided with the 16# distribution. 17# * Neither the name of pyglet nor the names of its 18# contributors may be used to endorse or promote products 19# derived from this software without specific prior written 20# permission. 21# 22# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 23# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 24# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS 25# FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE 26# COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, 27# INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, 28# BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 29# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 30# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT 31# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN 32# ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 33# POSSIBILITY OF SUCH DAMAGE. 34# ---------------------------------------------------------------------------- 35 36"""Provides keyboard and mouse editing procedures for text layout. 37 38Example usage:: 39 40 from pyglet import window 41 from pyglet.text import layout, caret 42 43 my_window = window.Window(...) 44 my_layout = layout.IncrementalTextLayout(...) 45 my_caret = caret.Caret(my_layout) 46 my_window.push_handlers(my_caret) 47 48.. versionadded:: 1.1 49""" 50 51import re 52import time 53 54from pyglet import clock 55from pyglet import event 56from pyglet.window import key 57 58 59class Caret: 60 """Visible text insertion marker for 61 `pyglet.text.layout.IncrementalTextLayout`. 62 63 The caret is drawn as a single vertical bar at the document `position` 64 on a text layout object. If `mark` is not None, it gives the unmoving 65 end of the current text selection. The visible text selection on the 66 layout is updated along with `mark` and `position`. 67 68 By default the layout's graphics batch is used, so the caret does not need 69 to be drawn explicitly. Even if a different graphics batch is supplied, 70 the caret will be correctly positioned and clipped within the layout. 71 72 Updates to the document (and so the layout) are automatically propagated 73 to the caret. 74 75 The caret object can be pushed onto a window event handler stack with 76 `Window.push_handlers`. The caret will respond correctly to keyboard, 77 text, mouse and activation events, including double- and triple-clicks. 78 If the text layout is being used alongside other graphical widgets, a 79 GUI toolkit will be needed to delegate keyboard and mouse events to the 80 appropriate widget. pyglet does not provide such a toolkit at this stage. 81 """ 82 83 _next_word_re = re.compile(r'(?<=\W)\w') 84 _previous_word_re = re.compile(r'(?<=\W)\w+\W*$') 85 _next_para_re = re.compile(r'\n', flags=re.DOTALL) 86 _previous_para_re = re.compile(r'\n', flags=re.DOTALL) 87 88 _position = 0 89 90 _active = True 91 _visible = True 92 _blink_visible = True 93 _click_count = 0 94 _click_time = 0 95 96 #: Blink period, in seconds. 97 PERIOD = 0.5 98 99 #: Pixels to scroll viewport per mouse scroll wheel movement. Defaults 100 #: to 12pt at 96dpi. 101 SCROLL_INCREMENT = 12 * 96 // 72 102 103 def __init__(self, layout, batch=None, color=(0, 0, 0)): 104 """Create a caret for a layout. 105 106 By default the layout's batch is used, so the caret does not need to 107 be drawn explicitly. 108 109 :Parameters: 110 `layout` : `~pyglet.text.layout.TextLayout` 111 Layout to control. 112 `batch` : `~pyglet.graphics.Batch` 113 Graphics batch to add vertices to. 114 `color` : (int, int, int) 115 RGB tuple with components in range [0, 255]. 116 117 """ 118 from pyglet import gl 119 self._layout = layout 120 if batch is None: 121 batch = layout.batch 122 r, g, b = color 123 colors = (r, g, b, 255, r, g, b, 255) 124 self._list = batch.add(2, gl.GL_LINES, layout.background_group, 'v2f', ('c4B', colors)) 125 126 self._ideal_x = None 127 self._ideal_line = None 128 self._next_attributes = {} 129 130 self.visible = True 131 132 layout.push_handlers(self) 133 134 def delete(self): 135 """Remove the caret from its batch. 136 137 Also disconnects the caret from further layout events. 138 """ 139 self._list.delete() 140 self._layout.remove_handlers(self) 141 142 def _blink(self, dt): 143 if self.PERIOD: 144 self._blink_visible = not self._blink_visible 145 if self._visible and self._active and self._blink_visible: 146 alpha = 255 147 else: 148 alpha = 0 149 self._list.colors[3] = alpha 150 self._list.colors[7] = alpha 151 152 def _nudge(self): 153 self.visible = True 154 155 def _set_visible(self, visible): 156 self._visible = visible 157 clock.unschedule(self._blink) 158 if visible and self._active and self.PERIOD: 159 clock.schedule_interval(self._blink, self.PERIOD) 160 self._blink_visible = False # flipped immediately by next blink 161 self._blink(0) 162 163 def _get_visible(self): 164 return self._visible 165 166 visible = property(_get_visible, _set_visible, doc="""Caret visibility. 167 168 The caret may be hidden despite this property due to the periodic blinking 169 or by `on_deactivate` if the event handler is attached to a window. 170 171 :type: bool 172 """) 173 174 def _set_color(self, color): 175 self._list.colors[:3] = color 176 self._list.colors[4:7] = color 177 178 def _get_color(self): 179 return self._list.colors[:3] 180 181 color = property(_get_color, _set_color, doc="""Caret color. 182 183 The default caret color is ``[0, 0, 0]`` (black). Each RGB color 184 component is in the range 0 to 255. 185 186 :type: (int, int, int) 187 """) 188 189 def _set_position(self, index): 190 self._position = index 191 self._next_attributes.clear() 192 self._update() 193 194 def _get_position(self): 195 return self._position 196 197 position = property(_get_position, _set_position, doc="""Position of caret within document. 198 199 :type: int 200 """) 201 202 _mark = None 203 204 def _set_mark(self, mark): 205 self._mark = mark 206 self._update(line=self._ideal_line) 207 if mark is None: 208 self._layout.set_selection(0, 0) 209 210 def _get_mark(self): 211 return self._mark 212 213 mark = property(_get_mark, _set_mark, 214 doc="""Position of immovable end of text selection within document. 215 216 An interactive text selection is determined by its immovable end (the 217 caret's position when a mouse drag begins) and the caret's position, which 218 moves interactively by mouse and keyboard input. 219 220 This property is ``None`` when there is no selection. 221 222 :type: int 223 """) 224 225 def _set_line(self, line): 226 if self._ideal_x is None: 227 self._ideal_x, _ = self._layout.get_point_from_position(self._position) 228 self._position = self._layout.get_position_on_line(line, self._ideal_x) 229 self._update(line=line, update_ideal_x=False) 230 231 def _get_line(self): 232 if self._ideal_line is not None: 233 return self._ideal_line 234 else: 235 return self._layout.get_line_from_position(self._position) 236 237 line = property(_get_line, _set_line, 238 doc="""Index of line containing the caret's position. 239 240 When set, `position` is modified to place the caret on requested line 241 while maintaining the closest possible X offset. 242 243 :type: int 244 """) 245 246 def get_style(self, attribute): 247 """Get the document's named style at the caret's current position. 248 249 If there is a text selection and the style varies over the selection, 250 `pyglet.text.document.STYLE_INDETERMINATE` is returned. 251 252 :Parameters: 253 `attribute` : str 254 Name of style attribute to retrieve. See 255 `pyglet.text.document` for a list of recognised attribute 256 names. 257 258 :rtype: object 259 """ 260 if self._mark is None or self._mark == self._position: 261 try: 262 return self._next_attributes[attribute] 263 except KeyError: 264 return self._layout.document.get_style(attribute, self._position) 265 266 start = min(self._position, self._mark) 267 end = max(self._position, self._mark) 268 return self._layout.document.get_style_range(attribute, start, end) 269 270 def set_style(self, attributes): 271 """Set the document style at the caret's current position. 272 273 If there is a text selection the style is modified immediately. 274 Otherwise, the next text that is entered before the position is 275 modified will take on the given style. 276 277 :Parameters: 278 `attributes` : dict 279 Dict mapping attribute names to style values. See 280 `pyglet.text.document` for a list of recognised attribute 281 names. 282 283 """ 284 285 if self._mark is None or self._mark == self._position: 286 self._next_attributes.update(attributes) 287 return 288 289 start = min(self._position, self._mark) 290 end = max(self._position, self._mark) 291 self._layout.document.set_style(start, end, attributes) 292 293 def _delete_selection(self): 294 start = min(self._mark, self._position) 295 end = max(self._mark, self._position) 296 self._position = start 297 self._mark = None 298 self._layout.document.delete_text(start, end) 299 self._layout.set_selection(0, 0) 300 301 def move_to_point(self, x, y): 302 """Move the caret close to the given window coordinate. 303 304 The `mark` will be reset to ``None``. 305 306 :Parameters: 307 `x` : int 308 X coordinate. 309 `y` : int 310 Y coordinate. 311 312 """ 313 line = self._layout.get_line_from_point(x, y) 314 self._mark = None 315 self._layout.set_selection(0, 0) 316 self._position = self._layout.get_position_on_line(line, x) 317 self._update(line=line) 318 self._next_attributes.clear() 319 320 def select_to_point(self, x, y): 321 """Move the caret close to the given window coordinate while 322 maintaining the `mark`. 323 324 :Parameters: 325 `x` : int 326 X coordinate. 327 `y` : int 328 Y coordinate. 329 330 """ 331 line = self._layout.get_line_from_point(x, y) 332 self._position = self._layout.get_position_on_line(line, x) 333 self._update(line=line) 334 self._next_attributes.clear() 335 336 def select_word(self, x, y): 337 """Select the word at the given window coordinate. 338 339 :Parameters: 340 `x` : int 341 X coordinate. 342 `y` : int 343 Y coordinate. 344 345 """ 346 line = self._layout.get_line_from_point(x, y) 347 p = self._layout.get_position_on_line(line, x) 348 m1 = self._previous_word_re.search(self._layout.document.text, 0, p+1) 349 if not m1: 350 m1 = 0 351 else: 352 m1 = m1.start() 353 self.mark = m1 354 355 m2 = self._next_word_re.search(self._layout.document.text, p) 356 if not m2: 357 m2 = len(self._layout.document.text) 358 else: 359 m2 = m2.start() 360 self._position = m2 361 self._update(line=line) 362 self._next_attributes.clear() 363 364 def select_paragraph(self, x, y): 365 """Select the paragraph at the given window coordinate. 366 367 :Parameters: 368 `x` : int 369 X coordinate. 370 `y` : int 371 Y coordinate. 372 373 """ 374 line = self._layout.get_line_from_point(x, y) 375 p = self._layout.get_position_on_line(line, x) 376 self.mark = self._layout.document.get_paragraph_start(p) 377 self._position = self._layout.document.get_paragraph_end(p) 378 self._update(line=line) 379 self._next_attributes.clear() 380 381 def _update(self, line=None, update_ideal_x=True): 382 if line is None: 383 line = self._layout.get_line_from_position(self._position) 384 self._ideal_line = None 385 else: 386 self._ideal_line = line 387 x, y = self._layout.get_point_from_position(self._position, line) 388 if update_ideal_x: 389 self._ideal_x = x 390 391 x -= self._layout.top_group.view_x 392 y -= self._layout.top_group.view_y 393 font = self._layout.document.get_font(max(0, self._position - 1)) 394 self._list.vertices[:] = [x, y + font.descent, x, y + font.ascent] 395 396 if self._mark is not None: 397 self._layout.set_selection(min(self._position, self._mark), 398 max(self._position, self._mark)) 399 400 self._layout.ensure_line_visible(line) 401 self._layout.ensure_x_visible(x) 402 403 def on_layout_update(self): 404 if self.position > len(self._layout.document.text): 405 self.position = len(self._layout.document.text) 406 self._update() 407 408 def on_text(self, text): 409 """Handler for the `pyglet.window.Window.on_text` event. 410 411 Caret keyboard handlers assume the layout always has keyboard focus. 412 GUI toolkits should filter keyboard and text events by widget focus 413 before invoking this handler. 414 """ 415 if self._mark is not None: 416 self._delete_selection() 417 418 text = text.replace('\r', '\n') 419 pos = self._position 420 self._position += len(text) 421 self._layout.document.insert_text(pos, text, self._next_attributes) 422 self._nudge() 423 return event.EVENT_HANDLED 424 425 def on_text_motion(self, motion, select=False): 426 """Handler for the `pyglet.window.Window.on_text_motion` event. 427 428 Caret keyboard handlers assume the layout always has keyboard focus. 429 GUI toolkits should filter keyboard and text events by widget focus 430 before invoking this handler. 431 """ 432 if motion == key.MOTION_BACKSPACE: 433 if self.mark is not None: 434 self._delete_selection() 435 elif self._position > 0: 436 self._position -= 1 437 self._layout.document.delete_text( 438 self._position, self._position + 1) 439 elif motion == key.MOTION_DELETE: 440 if self.mark is not None: 441 self._delete_selection() 442 elif self._position < len(self._layout.document.text): 443 self._layout.document.delete_text( 444 self._position, self._position + 1) 445 elif self._mark is not None and not select: 446 self._mark = None 447 self._layout.set_selection(0, 0) 448 449 if motion == key.MOTION_LEFT: 450 self.position = max(0, self.position - 1) 451 elif motion == key.MOTION_RIGHT: 452 self.position = min(len(self._layout.document.text), 453 self.position + 1) 454 elif motion == key.MOTION_UP: 455 self.line = max(0, self.line - 1) 456 elif motion == key.MOTION_DOWN: 457 line = self.line 458 if line < self._layout.get_line_count() - 1: 459 self.line = line + 1 460 elif motion == key.MOTION_BEGINNING_OF_LINE: 461 self.position = self._layout.get_position_from_line(self.line) 462 elif motion == key.MOTION_END_OF_LINE: 463 line = self.line 464 if line < self._layout.get_line_count() - 1: 465 self._position = self._layout.get_position_from_line(line + 1) - 1 466 self._update(line) 467 else: 468 self.position = len(self._layout.document.text) 469 elif motion == key.MOTION_BEGINNING_OF_FILE: 470 self.position = 0 471 elif motion == key.MOTION_END_OF_FILE: 472 self.position = len(self._layout.document.text) 473 elif motion == key.MOTION_NEXT_WORD: 474 pos = self._position + 1 475 m = self._next_word_re.search(self._layout.document.text, pos) 476 if not m: 477 self.position = len(self._layout.document.text) 478 else: 479 self.position = m.start() 480 elif motion == key.MOTION_PREVIOUS_WORD: 481 pos = self._position 482 m = self._previous_word_re.search(self._layout.document.text, 0, pos) 483 if not m: 484 self.position = 0 485 else: 486 self.position = m.start() 487 488 self._next_attributes.clear() 489 self._nudge() 490 return event.EVENT_HANDLED 491 492 def on_text_motion_select(self, motion): 493 """Handler for the `pyglet.window.Window.on_text_motion_select` event. 494 495 Caret keyboard handlers assume the layout always has keyboard focus. 496 GUI toolkits should filter keyboard and text events by widget focus 497 before invoking this handler. 498 """ 499 if self.mark is None: 500 self.mark = self.position 501 self.on_text_motion(motion, True) 502 return event.EVENT_HANDLED 503 504 def on_mouse_scroll(self, x, y, scroll_x, scroll_y): 505 """Handler for the `pyglet.window.Window.on_mouse_scroll` event. 506 507 Mouse handlers do not check the bounds of the coordinates: GUI 508 toolkits should filter events that do not intersect the layout 509 before invoking this handler. 510 511 The layout viewport is scrolled by `SCROLL_INCREMENT` pixels per 512 "click". 513 """ 514 self._layout.view_x -= scroll_x * self.SCROLL_INCREMENT 515 self._layout.view_y += scroll_y * self.SCROLL_INCREMENT 516 return event.EVENT_HANDLED 517 518 def on_mouse_press(self, x, y, button, modifiers): 519 """Handler for the `pyglet.window.Window.on_mouse_press` event. 520 521 Mouse handlers do not check the bounds of the coordinates: GUI 522 toolkits should filter events that do not intersect the layout 523 before invoking this handler. 524 525 This handler keeps track of the number of mouse presses within 526 a short span of time and uses this to reconstruct double- and 527 triple-click events for selecting words and paragraphs. This 528 technique is not suitable when a GUI toolkit is in use, as the active 529 widget must also be tracked. Do not use this mouse handler if 530 a GUI toolkit is being used. 531 """ 532 t = time.time() 533 if t - self._click_time < 0.25: 534 self._click_count += 1 535 else: 536 self._click_count = 1 537 self._click_time = time.time() 538 539 if self._click_count == 1: 540 self.move_to_point(x, y) 541 elif self._click_count == 2: 542 self.select_word(x, y) 543 elif self._click_count == 3: 544 self.select_paragraph(x, y) 545 self._click_count = 0 546 547 self._nudge() 548 return event.EVENT_HANDLED 549 550 def on_mouse_drag(self, x, y, dx, dy, buttons, modifiers): 551 """Handler for the `pyglet.window.Window.on_mouse_drag` event. 552 553 Mouse handlers do not check the bounds of the coordinates: GUI 554 toolkits should filter events that do not intersect the layout 555 before invoking this handler. 556 """ 557 if self.mark is None: 558 self.mark = self.position 559 self.select_to_point(x, y) 560 self._nudge() 561 return event.EVENT_HANDLED 562 563 def on_activate(self): 564 """Handler for the `pyglet.window.Window.on_activate` event. 565 566 The caret is hidden when the window is not active. 567 """ 568 self._active = True 569 self.visible = self._active 570 return event.EVENT_HANDLED 571 572 def on_deactivate(self): 573 """Handler for the `pyglet.window.Window.on_deactivate` event. 574 575 The caret is hidden when the window is not active. 576 """ 577 self._active = False 578 self.visible = self._active 579 return event.EVENT_HANDLED 580