File: textbox.py

package info (click to toggle)
python-asciimatics 1.15.0-1
  • links: PTS, VCS
  • area: main
  • in suites: forky, sid
  • size: 4,488 kB
  • sloc: python: 15,713; sh: 8; makefile: 2
file content (367 lines) | stat: -rw-r--r-- 15,393 bytes parent folder | download
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