1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367
|
"""This module implements a multi line editing text box"""
from copy import copy
from logging import getLogger
from asciimatics.event import KeyboardEvent, MouseEvent
from asciimatics.screen import Screen
from asciimatics.strings import ColouredText
from asciimatics.widgets.widget import Widget
from asciimatics.widgets.utilities import _find_min_start, _enforce_width, _get_offset
# Logging
logger = getLogger(__name__)
class TextBox(Widget):
"""
A TextBox is a widget for multi-line text editing.
It consists of a framed box with option label.
"""
__slots__ = ["_line", "_column", "_start_line", "_start_column", "_required_height",
"_as_string", "_line_wrap", "_on_change", "_reflowed_text_cache", "_parser",
"_hide_cursor", "_auto_scroll"]
def __init__(self, height, label=None, name=None, as_string=False, line_wrap=False, parser=None,
on_change=None, readonly=False, **kwargs):
"""
:param height: The required number of input lines for this TextBox.
:param label: An optional label for the widget.
:param name: The name for the TextBox.
:param as_string: Use string with newline separator instead of a list
for the value of this widget.
:param line_wrap: Whether to wrap at the end of the line.
:param parser: Optional parser to colour text.
:param on_change: Optional function to call when text changes.
:param readonly: Whether the widget prevents user input to change values. Default is False.
Also see the common keyword arguments in :py:obj:`.Widget`.
"""
super().__init__(name, **kwargs)
self._label = label
self._line = 0
self._column = 0
self._start_line = 0
self._start_column = 0
self._required_height = height
self._as_string = as_string
self._line_wrap = line_wrap
self._parser = parser
self._on_change = on_change
self._reflowed_text_cache = None
self._readonly = readonly
self._hide_cursor = False
self._auto_scroll = True
def update(self, frame_no):
self._draw_label()
# Calculate new visible limits if needed.
height = self._h
if not self._line_wrap:
self._start_column = min(self._start_column, self._column)
self._start_column += _find_min_start(
str(self._value[self._line][self._start_column:self._column + 1]),
self.width,
self._frame.canvas.unicode_aware,
self._column >= self.string_len(str(self._value[self._line])))
# Clear out the existing box content
(colour, attr, background) = self._pick_colours("readonly" if self._readonly else "edit_text")
self._frame.canvas.clear_buffer(
colour, attr, background, self._x + self._offset, self._y, self.width, height)
# Convert value offset to display offsets
# NOTE: _start_column is always in display coordinates.
display_text = self._reflowed_text
display_start_column = self._start_column
display_line, display_column = 0, 0
for i, (_, line, col) in enumerate(display_text):
if line < self._line or (line == self._line and col <= self._column):
display_line = i
display_column = self._column - col
# Restrict to visible/valid content.
self._start_line = max(0, display_line - height + 1, min(self._start_line, display_line))
# Render visible portion of the text.
for line, (text, _, _) in enumerate(display_text):
if self._start_line <= line < self._start_line + height:
paint_text = _enforce_width(
text[display_start_column:], self.width, self._frame.canvas.unicode_aware)
self._frame.canvas.paint(
str(paint_text),
self._x + self._offset,
self._y + line - self._start_line,
colour, attr, background,
colour_map=paint_text.colour_map if hasattr(paint_text, "colour_map") else None)
# Since we switch off the standard cursor, we need to emulate our own
# if we have the input focus.
if self._has_focus and not self._hide_cursor:
line = str(display_text[display_line][0])
logger.debug("Cursor: %d,%d", display_start_column, display_column)
text_width = self.string_len(line[display_start_column:display_column])
self._draw_cursor(
" " if display_column >= len(line) else line[display_column],
frame_no,
self._x + self._offset + text_width,
self._y + display_line - self._start_line)
def reset(self):
# Reset to original data and move to end of the text.
self._start_line = 0
self._start_column = 0
if self._auto_scroll or self._line > len(self._value) - 1:
self._line = len(self._value) - 1
self._column = 0 if self._is_disabled else len(self._value[self._line])
self._reflowed_text_cache = None
def _change_line(self, delta):
"""
Move the cursor up/down the specified number of lines.
:param delta: The number of lines to move (-ve is up, +ve is down).
"""
# Ensure new line is within limits
self._line = min(max(0, self._line + delta), len(self._value) - 1)
# Fix up column if the new line is shorter than before.
if self._column >= len(self._value[self._line]):
self._column = len(self._value[self._line])
def process_event(self, event):
def _join(a, b):
if self._parser:
return ColouredText(a, self._parser, colour=b[0].first_colour).join(b)
return a.join(b)
if isinstance(event, KeyboardEvent):
old_value = copy(self._value)
if event.key_code in [10, 13] and not self._readonly:
# Split and insert line on CR or LF.
self._value.insert(self._line + 1,
self._value[self._line][self._column:])
self._value[self._line] = self._value[self._line][:self._column]
self._line += 1
self._column = 0
elif event.key_code == Screen.KEY_BACK and not self._readonly:
if self._column > 0:
# Delete character in front of cursor.
self._value[self._line] = _join(
"",
[self._value[self._line][:self._column - 1], self._value[self._line][self._column:]])
self._column -= 1
else:
if self._line > 0:
# Join this line with previous
self._line -= 1
self._column = len(self._value[self._line])
self._value[self._line] += \
self._value.pop(self._line + 1)
elif event.key_code == Screen.KEY_DELETE and not self._readonly:
if self._column < len(self._value[self._line]):
self._value[self._line] = _join(
"",
[self._value[self._line][:self._column], self._value[self._line][self._column + 1:]])
else:
if self._line < len(self._value) - 1:
# Join this line with next
self._value[self._line] += \
self._value.pop(self._line + 1)
elif event.key_code == Screen.KEY_PAGE_UP:
self._change_line(-self._h)
elif event.key_code == Screen.KEY_PAGE_DOWN:
self._change_line(self._h)
elif event.key_code == Screen.KEY_UP:
self._change_line(-1)
elif event.key_code == Screen.KEY_DOWN:
self._change_line(1)
elif event.key_code == Screen.KEY_LEFT:
# Move left one char, wrapping to previous line if needed.
self._column -= 1
if self._column < 0:
if self._line > 0:
self._line -= 1
self._column = len(self._value[self._line])
else:
self._column = 0
elif event.key_code == Screen.KEY_RIGHT:
# Move right one char, wrapping to next line if needed.
self._column += 1
if self._column > len(self._value[self._line]):
if self._line < len(self._value) - 1:
self._line += 1
self._column = 0
else:
self._column = len(self._value[self._line])
elif event.key_code == Screen.KEY_HOME:
# Go to the start of this line
self._column = 0
elif event.key_code == Screen.KEY_END:
# Go to the end of this line
self._column = len(self._value[self._line])
elif event.key_code >= 32 and not self._readonly:
# Insert any visible text at the current cursor position.
self._value[self._line] = _join(
chr(event.key_code),
[self._value[self._line][:self._column], self._value[self._line][self._column:]])
self._column += 1
else:
# Ignore any other key press.
return event
# If we got here we might have changed the value...
if old_value != self._value:
self._reflowed_text_cache = None
if self._on_change:
self._on_change()
elif isinstance(event, MouseEvent):
# Mouse event - rebase coordinates to Frame context.
if event.buttons != 0:
if self.is_mouse_over(event, include_label=False):
# Find the line first.
clicked_line = event.y - self._y + self._start_line
if self._line_wrap:
# Line-wrapped text needs to be mapped to visible lines
display_text = self._reflowed_text
clicked_line = min(clicked_line, len(display_text) - 1)
text_line = display_text[clicked_line][1]
text_col = display_text[clicked_line][2]
else:
# non-wrapped just needs a little end protection
text_line = max(0, clicked_line)
text_col = 0
self._line = min(len(self._value) - 1, text_line)
# Now figure out location in text based on width of each glyph.
self._column = (self._start_column + text_col +
_get_offset(
str(self._value[self._line][self._start_column + text_col:]),
event.x - self._x - self._offset,
self._frame.canvas.unicode_aware))
self._column = min(len(self._value[self._line]), self._column)
self._column = max(0, self._column)
return None
# Ignore other mouse events.
return event
else:
# Ignore other events
return event
# If we got here, we processed the event - swallow it.
return None
def required_height(self, offset, width):
return self._required_height
@property
def _reflowed_text(self):
"""
The text as should be formatted on the screen.
This is an array of tuples of the form (text, value line, value column offset) where
the line and column offsets are indices into the value (not displayed glyph coordinates).
"""
if self._reflowed_text_cache is None:
if self._line_wrap:
self._reflowed_text_cache = []
limit = self._w - self._offset
for i, line in enumerate(self._value):
column = 0
while self.string_len(str(line)) >= limit:
sub_string = _enforce_width(
line, limit, self._frame.canvas.unicode_aware)
self._reflowed_text_cache.append((sub_string, i, column))
line = line[len(sub_string):]
column += len(sub_string)
self._reflowed_text_cache.append((line, i, column))
else:
self._reflowed_text_cache = [(x, i, 0) for i, x in enumerate(self._value)]
return self._reflowed_text_cache
@property
def hide_cursor(self):
"""
Set to True to stop the cursor from showing. Defaults to False.
"""
return self._hide_cursor
@hide_cursor.setter
def hide_cursor(self, new_value):
self._hide_cursor = new_value
@property
def auto_scroll(self):
"""
When set to True the TextBox will scroll to the bottom when created or
next text is added. When set to False, the current scroll position
will remain even if the contents are changed.
Defaults to True.
"""
return self._auto_scroll
@auto_scroll.setter
def auto_scroll(self, new_value):
self._auto_scroll = new_value
@property
def value(self):
"""
The current value for this TextBox.
"""
if self._value is None:
self._value = [""]
return "\n".join([str(x) for x in self._value]) if self._as_string else self._value
@value.setter
def value(self, new_value):
# Convert to the internal format
old_value = self._value
if new_value is None:
new_value = [""]
elif self._as_string:
new_value = new_value.split("\n")
self._value = new_value
# TODO: Sort out speed of this code
if self._parser:
new_value = []
last_colour = None
for line in self._value:
if hasattr(line, "raw_text"):
value = line
else:
value = ColouredText(line, self._parser, colour=last_colour)
new_value.append(value)
last_colour = value.last_colour
self._value = new_value
self.reset()
# Only trigger the notification after we've changed the value.
if old_value != self._value and self._on_change:
self._on_change()
@property
def readonly(self):
"""
Whether this widget is readonly or not.
"""
return self._readonly
@readonly.setter
def readonly(self, new_value):
self._readonly = new_value
@property
def frame_update_count(self):
# Force refresh for cursor if needed.
if self._has_focus and not self._frame.reduce_cpu and not self._hide_cursor:
return 5
return 0
|