Source code for enable.text_field

# (C) Copyright 2005-2022 Enthought, Inc., Austin, TX
# All rights reserved.
#
# This software is provided without warranty under the terms of the BSD
# license included in LICENSE.txt and may be redistributed only under
# the conditions described in the aforementioned license. The license
# is also available online at http://www.enthought.com/licenses/BSD.txt
#
# Thanks for using Enthought open source!

# Standard library imports
from math import floor, sqrt

# Enthought library imports
from traits.api import (
    Any, Bool, DelegatesTo, Event, Instance, Int, List, Property
)

# Local, relative imports
from .component import Component
from .font_metrics_provider import font_metrics_provider
from .text_field_style import TextFieldStyle

StyleDelegate = DelegatesTo("_style")


[docs]class TextField(Component): """ A basic text entry field for Enable. fixme: Requires monospaced fonts. """ # ------------------------------------------------------------------------ # Public traits # ------------------------------------------------------------------------ # The text to be edited text = Property(observe=["_text_changed"]) # Events that get fired on certain keypresses accept = Event cancel = Event # Are multiple lines of text allowed? multiline = Bool(False) # The object to use to measure text extents metrics = Any char_w = Any char_h = Any # Is this text field editable? can_edit = Bool(True) # ------------------------------------------------------------------------ # Delegates for style # ------------------------------------------------------------------------ text_color = StyleDelegate font = StyleDelegate line_spacing = StyleDelegate text_offset = StyleDelegate cursor_color = StyleDelegate cursor_width = StyleDelegate border_visible = StyleDelegate border_color = StyleDelegate bgcolor = StyleDelegate # ------------------------------------------------------------------------ # Protected traits # ------------------------------------------------------------------------ # The style information used in drawing _style = Instance(TextFieldStyle, ()) # The max width/height of the displayed text in characters _text_width = Property( observe=["_style", "height"], cached="_height_cache" ) _text_height = Property( observe=["_style", "width"], cached="_width_cache" ) # The x-y position of the cursor in the text _cursor_pos = List(Int) _old_cursor_pos = List(Int) _desired_cursor_x = Int # The text as an x-y grid, the shadow property for 'text' _text = List(List) _text_changed = Event # The text that is actually displayed in the editor, and its shadow values _draw_text = Property __draw_text = List(List) _draw_text_xstart = Int _draw_text_ystart = Int # Whether or not to draw the cursor (is mouse over box?) _draw_cursor = Bool(False) # fixme: Shouldn't traits initialize these on its own? # fixme again: I moved these out of __init__ because they weren't # accessible from the _get__text_height and _get__text_width methods. # Not sure if this is the right fix (dmartin) _width_cache = None _height_cache = None # ------------------------------------------------------------------------ # Public methods # ------------------------------------------------------------------------ def __init__(self, **traits): # This will be overriden if 'text' is provided as a trait, but it # must be initialized if not self._text = [[]] # Initialize internal tracking variables self.reset() super().__init__(**traits) if self.metrics is None: self.metrics = font_metrics_provider() # Initialize border/bg colors self.__style_changed() # If this can't be editted and no width has been set, make sure # that is wide enough to display the text. if not self.can_edit and self.width == 0: x, y, width, height = self.metrics.get_text_extent(self.text) offset = 2 * self._style.text_offset self.width = width + offset # ------------------------------------------------------------------------ # Interactor interface # ------------------------------------------------------------------------
[docs] def normal_mouse_enter(self, event): if not self.can_edit: return event.window.set_pointer("ibeam") self.request_redraw() event.handled = True
[docs] def normal_mouse_leave(self, event): if not self.can_edit: return event.window.set_pointer("arrow") self.request_redraw() event.handled = True
[docs] def normal_left_down(self, event): if not self.can_edit: return self.event_state = "cursor" self._acquire_focus(event.window) event.handled = True # Transform pixel coordinates to text coordinates char_width, char_height = self.metrics.get_text_extent("T")[2:] char_height += self._style.line_spacing event_x = event.x - self.x - self._style.text_offset event_y = self.y2 - event.y - self._style.text_offset if self.multiline: y = int(round(event_y / char_height)) - 1 else: y = 0 x = int(round(event_x / char_width)) # Clip x and y so that they are with text bounds, then place the cursor y = min(max(y, 0), len(self.__draw_text) - 1) x = min(max(x, 0), len(self.__draw_text[y])) self._old_cursor_pos = self._cursor_pos self._cursor_pos = [ self._draw_text_ystart + y, self._draw_text_xstart + x, ]
[docs] def cursor_left_up(self, event): if not self.can_edit: return # Reset event state self.event_state = "normal" event.handled = True self.request_redraw()
[docs] def normal_character(self, event): "Actual text that we want to add to the buffer as-is." # XXX need to filter unprintables that are not handled in key_pressed if not self.can_edit: return # Save for bookkeeping purposes self._old_cursor_pos = self._cursor_pos y, x = self._cursor_pos self._text[y].insert(x, event.character) self._cursor_pos[1] += 1 self._desired_cursor_x = self._cursor_pos[1] self._text_changed = True event.handled = True self.invalidate_draw() self.request_redraw()
[docs] def normal_key_pressed(self, event): "Special character handling" if not self.can_edit: return # Save for bookkeeping purposes self._old_cursor_pos = self._cursor_pos if event.character == "Backspace": # Normal delete if self._cursor_pos[1] > 0: del self._text[self._cursor_pos[0]][self._cursor_pos[1] - 1] self._cursor_pos[1] -= 1 self._desired_cursor_x = self._cursor_pos[1] self._text_changed = True # Delete at the beginning of a line elif self._cursor_pos[0] - 1 >= 0: index = self._cursor_pos[0] - 1 old_line_len = len(self._text[index]) self._text[index] += self._text[index + 1] del self._text[index + 1] del self.__draw_text[index + 1 - self._draw_text_xstart] self._cursor_pos[0] -= 1 self._cursor_pos[1] = old_line_len self._desired_cursor_x = self._cursor_pos[1] self._text_changed = True elif event.character == "Delete": # Normal delete if self._cursor_pos[1] < len(self._text[self._cursor_pos[0]]): del self._text[self._cursor_pos[0]][self._cursor_pos[1]] self._desired_cursor_x = self._cursor_pos[1] self._text_changed = True # Delete at the end of a line elif self._cursor_pos[0] + 1 < len(self._text): index = self._cursor_pos[0] old_line_len = len(self._text[index]) self._text[index] += self._text[index + 1] del self._text[index + 1] del self.__draw_text[index + 1 - self._draw_text_xstart] self._desired_cursor_x = self._cursor_pos[1] self._text_changed = True # Cursor movement elif event.character == "Left": self._cursor_pos[1] -= 1 if self._cursor_pos[1] < 0: self._cursor_pos[0] -= 1 if self._cursor_pos[0] < 0: self._cursor_pos = [0, 0] else: self._cursor_pos[1] = len(self._text[self._cursor_pos[0]]) self._desired_cursor_x = self._cursor_pos[1] elif event.character == "Right": self._cursor_pos[1] += 1 if self._cursor_pos[1] > len(self._text[self._cursor_pos[0]]): self._cursor_pos[0] += 1 if self._cursor_pos[0] > len(self._text) - 1: self._cursor_pos[0] -= 1 self._cursor_pos[1] -= 1 else: self._cursor_pos[1] = 0 self._desired_cursor_x = self._cursor_pos[1] elif event.character == "Up": self._cursor_pos[0] -= 1 if self._cursor_pos[0] < 0: self._cursor_pos[0] = 0 else: self._cursor_pos[1] = min( len(self._text[self._cursor_pos[0]]), self._desired_cursor_x, ) elif event.character == "Down": self._cursor_pos[0] += 1 if self._cursor_pos[0] >= len(self._text): self._cursor_pos[0] = len(self._text) - 1 else: self._cursor_pos[1] = min( len(self._text[self._cursor_pos[0]]), self._desired_cursor_x, ) elif event.character == "Home": self._cursor_pos[1] = 0 self._desired_cursor_x = self._cursor_pos[1] elif event.character == "End": self._cursor_pos[1] = len(self._text[self._cursor_pos[0]]) self._desired_cursor_x = self._cursor_pos[1] # Special characters elif event.character == "Tab": y, x = self._cursor_pos self._text[y] = self._text[y][:x] + [" "] * 4 + self._text[y][x:] self._cursor_pos[1] += 4 self._desired_cursor_x = self._cursor_pos[1] self._text_changed = True elif event.character == "Enter": if self.multiline: line = self._cursor_pos[0] self._text.insert( line + 1, self._text[line][self._cursor_pos[1]:] ) self._text[line] = self._text[line][: self._cursor_pos[1]] self._cursor_pos[0] += 1 self._cursor_pos[1] = 0 self._desired_cursor_x = self._cursor_pos[1] self._text_changed = True else: self.accept = event elif event.character == "Escape": self.cancel = event elif len(event.character) == 1: # XXX normal keypress, so let it go through return event.handled = True self.invalidate_draw() self.request_redraw()
# ------------------------------------------------------------------------ # Component interface # ------------------------------------------------------------------------ def _draw_mainlayer(self, gc, view_bounds=None, mode="default"): with gc: # Draw the text gc.set_font(self._style.font) gc.set_fill_color(self._style.text_color) char_w, char_h = self.metrics.get_text_extent("T")[2:4] char_h += self._style.line_spacing lines = ["".join(ln) for ln in self._draw_text] for i, line in enumerate(lines): x = self.x + self._style.text_offset if i > 0: y_offset = (i + 1) * char_h - self._style.line_spacing else: y_offset = char_h - self._style.line_spacing y = self.y2 - y_offset - self._style.text_offset # Show text at the same scale as the graphics context ctm = gc.get_ctm() if hasattr(ctm, "__len__") and len(ctm) == 6: scale = sqrt( (ctm[0] + ctm[1]) * (ctm[0] + ctm[1]) / 2.0 + (ctm[2] + ctm[3]) * (ctm[2] + ctm[3]) / 2.0 ) elif hasattr(gc, "get_ctm_scale"): scale = gc.get_ctm_scale() else: raise RuntimeError("Unable to get scale from GC.") x *= scale y *= scale gc.show_text_at_point(line, x, y) if self._draw_cursor: j, i = self._cursor_pos j -= self._draw_text_ystart i -= self._draw_text_xstart x_offset = self.metrics.get_text_extent(lines[j][:i])[2] y_offset = char_h * j y = self.y2 - y_offset - self._style.text_offset if not self.multiline: char_h -= float(self._style.line_spacing) * 0.5 gc.set_line_width(self._style.cursor_width) gc.set_stroke_color(self._style.cursor_color) gc.begin_path() x_position = self.x + x_offset + self._style.text_offset gc.move_to(x_position, y) gc.line_to(x_position, y - char_h) gc.stroke_path() # ------------------------------------------------------------------------ # TextField interface # ------------------------------------------------------------------------
[docs] def reset(self): """ Resets the text field. This involes reseting cursor position, text position, etc. """ self._cursor_pos = [0, 0] self._old_cursor_pos = [0, 0] self.__draw_text = [[]]
def _scroll_horz(self, num): """ Horizontally scrolls all the text that is being drawn by 'num' characters. If num is negative, scrolls left. If num is positive, scrolls right. """ self._draw_text_xstart += num self._realign_horz() def _realign_horz(self): """ Realign all the text being drawn such that the first character being drawn in each line is the one at index '_draw_text_xstart.' """ for i in range(len(self.__draw_text)): line = self._text[self._draw_text_ystart + i] self.__draw_text[i] = self._clip_line(line, self._draw_text_xstart) def _scroll_vert(self, num): """ Vertically scrolls all the text that is being drawn by 'num' lines. If num is negative, scrolls up. If num is positive, scrolls down. """ x, y = self._draw_text_xstart, self._draw_text_ystart if num < 0: self.__draw_text = self.__draw_text[:num] lines = [ self._clip_line(line, x) for line in self._text[y + num:y] ] self.__draw_text = lines + self.__draw_text elif num > 0: self.__draw_text = self.__draw_text[num:] y += self._text_height lines = [ self._clip_line(line, x) for line in self._text[y:y + num] ] self.__draw_text.extend(lines) self._draw_text_ystart += num def _clip_line(self, text, index, start=True): """ Return 'text' clipped beginning at 'index' if 'start' is True or ending at 'index' if 'start' is False. """ box_width = self.width - 2 * self._style.text_offset total_width = 0.0 end_index = 1 for t in text: w, h = self.metrics.get_text_extent(t)[2:4] total_width = total_width + w if total_width <= box_width: end_index = end_index + 1 else: break if start: return text[index:min(index + end_index - 1, len(text))] else: return text[max(0, index - end_index):index] def _refresh_viewed_line(self, line): """ Updates the appropriate line in __draw_text with the text at 'line' """ new_text = self._clip_line(self._text[line], self._draw_text_xstart) index = line - self._draw_text_ystart if index == len(self.__draw_text): self.__draw_text.append(new_text) else: self.__draw_text[index] = new_text def _acquire_focus(self, window): self._draw_cursor = True window.focus_owner = self window.observe(self._window_focus_owner_updated, "focus_owner") self.request_redraw() def _window_focus_owner_updated(self, event): obj = event.object old = event.old new = event.new if old == self and new != self: obj.observe( self._window_focus_owner_updated, "focus_owner", remove=True ) self._draw_cursor = False self.request_redraw() # ------------------------------------------------------------------------ # Property getters/setters and trait event handlers # ------------------------------------------------------------------------ def _get_text(self): return "\n".join(["".join(line) for line in self._text]) def _set_text(self, val): if val == "": self._text = [[]] else: self._text = [list(line) for line in val.splitlines()] self.reset() self.request_redraw() def _get__draw_text(self): # Rebuilding from scratch if self.__draw_text == [[]]: if self.multiline: self.__draw_text = [] self._draw_text_xstart, self._draw_text_ystart = 0, 0 end = min(len(self._text), self._text_height) for i in range(self._draw_text_ystart, end): line = self._clip_line(self._text[i], 0) self.__draw_text.append(line) else: self.__draw_text = [self._clip_line(self._text[0], 0)] # Updating only the things that need updating else: # Scroll if necessary depending on where cursor moved # Adjust up if self._cursor_pos[0] < self._draw_text_ystart: self._scroll_vert(-1) # Adjust down elif (self._cursor_pos[0] - self._draw_text_ystart >= self._text_height): self._scroll_vert(1) # Adjust left line = self._text[self._cursor_pos[0]] chars_before_start = len(line[: self._draw_text_xstart]) chars_after_start = len(line[self._draw_text_xstart:]) if self._cursor_pos[1] < self._draw_text_xstart: if chars_before_start <= self._text_width: self._draw_text_xstart = 0 self._realign_horz() else: self._scroll_horz(-self._text_width) if (self._draw_text_xstart > 0 and chars_after_start + 1 < self._text_width): self._scroll_horz(-1) # Adjust right num_chars = self._cursor_pos[1] - self._draw_text_xstart if num_chars >= self._text_width: self._scroll_horz(num_chars - self._text_width + 1) # Replace text at cursor location if self._old_cursor_pos[0] < self._cursor_pos[0]: # A line has been created by an enter event self._refresh_viewed_line(self._old_cursor_pos[0]) self._refresh_viewed_line(self._cursor_pos[0]) return self.__draw_text def _get__text_width(self): if self._width_cache is None: if self.metrics is not None: char_width = self.metrics.get_text_extent("T")[2] width = self.width - 2 * self._style.text_offset self._width_cache = int(floor(width / char_width)) return self._width_cache def _get__text_height(self): if self.multiline: if self._height_cache is None: if self.metrics is not None: char_height = self.metrics.get_text_extent("T")[3] height = self.height - 2 * self._style.text_offset line_height = char_height + self._style.line_spacing self._height_cache = int(floor(height / line_height)) return self._height_cache else: return 1 def __style_changed(self): """ Bg/border color is inherited from the style, so update it when the style changes. The height of a line also depends on style. """ self.bgcolor = self._style.bgcolor self.border_visible = self._style.border_visible self.border_color = self._style.border_color self.metrics.set_font(self._style.font) self.request_redraw()