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