# (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()