pytermgui.widgets.input_field

This module contains the InputField class.

  1"""This module contains the `InputField` class."""
  2
  3from __future__ import annotations
  4
  5import string
  6from collections.abc import Iterable
  7from dataclasses import dataclass
  8from typing import Any, Iterator
  9
 10from ..ansi_interface import MouseAction, MouseEvent
 11from ..enums import HorizontalAlignment
 12from ..helpers import break_line
 13from ..input import keys
 14from . import styles as w_styles
 15from .base import Widget
 16
 17
 18@dataclass
 19class Cursor(Iterable):
 20    """A simple dataclass representing the InputField's cursor."""
 21
 22    row: int
 23    col: int
 24
 25    def __iadd__(self, difference: tuple[int, int]) -> Cursor:
 26        """Move the cursor by the difference."""
 27
 28        row, col = difference
 29
 30        self.row += row
 31        self.col += col
 32
 33        return self
 34
 35    def __iter__(self) -> Iterator[int]:
 36        return iter((self.row, self.col))
 37
 38    def __len__(self) -> int:
 39        return 2
 40
 41
 42class InputField(Widget):  # pylint: disable=too-many-instance-attributes
 43    """An element to display user input"""
 44
 45    styles = w_styles.StyleManager(
 46        value="",
 47        cursor="dim inverse",
 48    )
 49
 50    keys = {
 51        "move_left": {keys.LEFT},
 52        "move_right": {keys.RIGHT},
 53        "move_up": {keys.UP},
 54        "move_down": {keys.DOWN},
 55        "select_left": {keys.SHIFT_LEFT},
 56        "select_right": {keys.SHIFT_RIGHT},
 57        "select_up": {keys.SHIFT_UP},
 58        "select_down": {keys.SHIFT_DOWN},
 59    }
 60
 61    parent_align = HorizontalAlignment.LEFT
 62
 63    is_bindable = True
 64
 65    def __init__(
 66        self,
 67        value: str = "",
 68        *,
 69        tablength: int = 4,
 70        multiline: bool = False,
 71        cursor: Cursor | None = None,
 72        **attrs: Any,
 73    ) -> None:
 74        """Initialize object"""
 75
 76        super().__init__(**attrs)
 77
 78        if "width" not in attrs:
 79            self.width = len(value)
 80
 81        self.height = 1
 82        self.tablength = tablength
 83        self.multiline = multiline
 84
 85        self.cursor = cursor or Cursor(0, 0)
 86
 87        self._lines = value.splitlines() or [""]
 88        self._selection_length = 1
 89
 90        self._styled_cache: list[str] | None = self._style_and_break_lines()
 91
 92        self._cached_state: int = self.width
 93        self._drag_start: tuple[int, int] | None = None
 94
 95    @property
 96    def selectables_length(self) -> int:
 97        """Get length of selectables in object"""
 98
 99        return 1
100
101    @property
102    def value(self) -> str:
103        """Returns the internal value of this field."""
104
105        return "\n".join(self._lines)
106
107    @property
108    def selection(self) -> str:
109        """Returns the currently selected span of text."""
110
111        start, end = sorted([self.cursor.col, self.cursor.col + self._selection_length])
112        return self._lines[self.cursor.row][start:end]
113
114    def _cache_is_valid(self) -> bool:
115        """Determines if the styled line cache is still usable."""
116
117        return self.width == self._cached_state
118
119    def _style_and_break_lines(self) -> list[str]:
120        """Styles and breaks self._lines."""
121
122        value = self.value
123        style = self.styles.value
124
125        # TODO: This is done line-by-line due to parser performance problems.
126        #       These should be resolved in the upcoming parser refactor.
127        document = [style(line) for line in value.splitlines()]
128        # document = style(value).splitlines()
129
130        lines: list[str] = []
131        width = self.width
132        extend = lines.extend
133
134        for line in document:
135            extend(break_line(line.replace("\n", "\\n"), width, fill=" "))
136            extend("")
137
138        return lines
139
140    def update_selection(self, count: int, correct_zero_length: bool = True) -> None:
141        """Updates the selection state.
142
143        Args:
144            count: How many characters the cursor should change by. Negative for
145                selecting leftward, positive for right.
146            correct_zero_length: If set, when the selection length is 0 both the cursor
147                and the selection length are manipulated to keep the original selection
148                start while moving the selection in more of the way the user might
149                expect.
150        """
151
152        self._selection_length += count
153
154        if correct_zero_length and abs(self._selection_length) == 0:
155            self._selection_length += 2 if count > 0 else -2
156            self.move_cursor((0, (-1 if count > 0 else 1)))
157
158    def delete_back(self, count: int = 1) -> str:
159        """Deletes `count` characters from the cursor, backwards.
160
161        Args:
162            count: How many characters should be deleted.
163
164        Returns:
165            The deleted string.
166        """
167
168        row, col = self.cursor
169
170        if len(self._lines) <= row:
171            return ""
172
173        line = self._lines[row]
174
175        start, end = sorted([col, col - count])
176        start = max(0, start)
177        self._lines[row] = line[:start] + line[end:]
178
179        self._styled_cache = None
180
181        if self._lines[row] == "":
182            self.move_cursor((-1, len(self._lines[row - 1])))
183
184            return self._lines.pop(row)
185
186        if count > 0:
187            self.move_cursor((0, -count))
188
189        return line[col - count : col]
190
191    def insert_text(self, text: str) -> None:
192        """Inserts text at the cursor location."""
193
194        row, col = self.cursor
195
196        if len(self._lines) <= row:
197            self._lines.insert(row, "")
198
199        line = self._lines[row]
200
201        self._lines[row] = line[:col] + text + line[col:]
202        self.move_cursor((0, len(text)))
203
204        self._styled_cache = None
205
206    def handle_action(self, action: str) -> bool:
207        """Handles some action.
208
209        This will be expanded in the future to allow using all behaviours with
210        just their actions.
211        """
212
213        cursors = {
214            "move_left": (0, -1),
215            "move_right": (0, 1),
216            "move_up": (-1, 0),
217            "move_down": (1, 0),
218        }
219
220        if action.startswith("move_"):
221            row, col = cursors[action]
222
223            if self.cursor.row + row > len(self._lines):
224                self._lines.append("")
225
226            col += self._selection_length
227            if self._selection_length > 0:
228                col -= 1
229
230            self._selection_length = 1
231            self.move_cursor((row, col))
232            return True
233
234        if action.startswith("select_"):
235            if action == "select_right":
236                self.update_selection(1)
237
238            elif action == "select_left":
239                self.update_selection(-1)
240
241            return True
242
243        return False
244
245    # TODO: This could probably be simplified by a wider adoption of the action pattern.
246    def handle_key(  # pylint: disable=too-many-return-statements, too-many-branches
247        self, key: str
248    ) -> bool:
249        """Adds text to the field, or moves the cursor."""
250
251        if self.execute_binding(key):
252            return True
253
254        for name, options in self.keys.items():
255            if (
256                name.rsplit("_", maxsplit=1)[-1] in ("up", "down")
257                and not self.multiline
258            ):
259                continue
260
261            if key in options:
262                return self.handle_action(name)
263
264        if key == keys.TAB:
265            if not self.multiline:
266                return False
267
268            for _ in range(self.tablength):
269                self.handle_key(" ")
270
271            return True
272
273        if key in string.printable and key not in "\x0c\x0b":
274            if key == keys.ENTER:
275                if not self.multiline:
276                    return False
277
278                line = self._lines[self.cursor.row]
279                left, right = line[: self.cursor.col], line[self.cursor.col :]
280
281                self._lines[self.cursor.row] = left
282                self._lines.insert(self.cursor.row + 1, right)
283
284                self.move_cursor((1, -self.cursor.col))
285                self._styled_cache = None
286
287            else:
288                self.insert_text(key)
289
290            return True
291
292        if key == keys.BACKSPACE:
293            if self._selection_length == 1:
294                self.delete_back(1)
295            else:
296                self.delete_back(-self._selection_length)
297
298            # self.handle_action("move_left")
299
300            # if self._selection_length == 1:
301
302            self._selection_length = 1
303            self._styled_cache = None
304
305            return True
306
307        return False
308
309    def handle_mouse(self, event: MouseEvent) -> bool:
310        """Allows point-and-click selection."""
311
312        x_offset = event.position[0] - self.pos[0]
313        y_offset = event.position[1] - self.pos[1]
314
315        # Set cursor to mouse location
316        if event.action is MouseAction.LEFT_CLICK:
317            line = self._lines[y_offset]
318            self.move_cursor((y_offset, min(len(line), x_offset)), absolute=True)
319
320            self._drag_start = (x_offset, y_offset)
321            self._selection_length = 1
322
323            return True
324
325        # Select text using dragging the mouse
326        if event.action is MouseAction.LEFT_DRAG:
327            assert self._drag_start is not None
328
329            change = x_offset - self._drag_start[0]
330            self.update_selection(
331                change - self._selection_length + 1, correct_zero_length=False
332            )
333
334            return True
335
336        return super().handle_mouse(event)
337
338    def move_cursor(self, new: tuple[int, int], *, absolute: bool = False) -> None:
339        """Moves the cursor, then possible re-positions it to a valid location.
340
341        Args:
342            new: The new set of (y, x) positions to use.
343            absolute: If set, `new` will be interpreted as absolute coordinates,
344                instead of being added on top of the current ones.
345        """
346
347        if len(self._lines) == 0:
348            return
349
350        if absolute:
351            new_y, new_x = new
352            self.cursor.row = new_y
353            self.cursor.col = new_x
354
355        else:
356            self.cursor += new
357
358        self.cursor.row = max(0, min(self.cursor.row, len(self._lines) - 1))
359        row, col = self.cursor
360
361        line = self._lines[row]
362
363        # Going left, possibly upwards
364        if col < 0:
365            if row <= 0:
366                self.cursor.col = 0
367
368            else:
369                self.cursor.row -= 1
370                line = self._lines[self.cursor.row]
371                self.cursor.col = len(line)
372
373        # Going right, possibly downwards
374        elif col > len(line) and line != "":
375            if len(self._lines) > row + 1:
376                self.cursor.row += 1
377                self.cursor.col = 0
378
379            line = self._lines[self.cursor.row]
380
381        self.cursor.col = max(0, min(self.cursor.col, len(line)))
382
383    def get_lines(self) -> list[str]:
384        """Builds the input field's lines."""
385
386        style = self.styles.value
387
388        if not self._cache_is_valid() or self._styled_cache is None:
389            self._styled_cache = self._style_and_break_lines()
390
391        lines = self._styled_cache
392
393        row, col = self.cursor
394
395        if len(self._lines) == 0:
396            line = " "
397        else:
398            line = self._lines[row]
399
400        start = col
401        cursor_char = " "
402        if len(line) > col:
403            start = col
404            end = col + self._selection_length
405            start, end = sorted([start, end])
406
407            try:
408                cursor_char = line[start:end]
409            except IndexError as error:
410                raise ValueError(f"Invalid index in {line!r}: {col}") from error
411
412        style_cursor = style if self.selected_index is None else self.styles.cursor
413
414        # TODO: This is horribly hackish, but is the only way to "get around" the
415        #       limits of the current scrolling techniques. Should be refactored
416        #       once a better solution is available
417        if self.parent is not None:
418            offset = 0
419            parent = self.parent
420            while hasattr(parent, "parent"):
421                offset += getattr(parent, "_scroll_offset")
422
423                parent = parent.parent  # type: ignore
424
425            offset_row = self.pos[1] - offset + row
426            position = (self.pos[0] + start, offset_row)
427
428            self.positioned_line_buffer.append(
429                (position, style_cursor(cursor_char))  # type: ignore
430            )
431
432        lines = lines or [""]
433        self.height = len(lines)
434
435        return lines
@dataclass
class Cursor(collections.abc.Iterable):
19@dataclass
20class Cursor(Iterable):
21    """A simple dataclass representing the InputField's cursor."""
22
23    row: int
24    col: int
25
26    def __iadd__(self, difference: tuple[int, int]) -> Cursor:
27        """Move the cursor by the difference."""
28
29        row, col = difference
30
31        self.row += row
32        self.col += col
33
34        return self
35
36    def __iter__(self) -> Iterator[int]:
37        return iter((self.row, self.col))
38
39    def __len__(self) -> int:
40        return 2

A simple dataclass representing the InputField's cursor.

Cursor(row: int, col: int)
class InputField(pytermgui.widgets.base.Widget):
 43class InputField(Widget):  # pylint: disable=too-many-instance-attributes
 44    """An element to display user input"""
 45
 46    styles = w_styles.StyleManager(
 47        value="",
 48        cursor="dim inverse",
 49    )
 50
 51    keys = {
 52        "move_left": {keys.LEFT},
 53        "move_right": {keys.RIGHT},
 54        "move_up": {keys.UP},
 55        "move_down": {keys.DOWN},
 56        "select_left": {keys.SHIFT_LEFT},
 57        "select_right": {keys.SHIFT_RIGHT},
 58        "select_up": {keys.SHIFT_UP},
 59        "select_down": {keys.SHIFT_DOWN},
 60    }
 61
 62    parent_align = HorizontalAlignment.LEFT
 63
 64    is_bindable = True
 65
 66    def __init__(
 67        self,
 68        value: str = "",
 69        *,
 70        tablength: int = 4,
 71        multiline: bool = False,
 72        cursor: Cursor | None = None,
 73        **attrs: Any,
 74    ) -> None:
 75        """Initialize object"""
 76
 77        super().__init__(**attrs)
 78
 79        if "width" not in attrs:
 80            self.width = len(value)
 81
 82        self.height = 1
 83        self.tablength = tablength
 84        self.multiline = multiline
 85
 86        self.cursor = cursor or Cursor(0, 0)
 87
 88        self._lines = value.splitlines() or [""]
 89        self._selection_length = 1
 90
 91        self._styled_cache: list[str] | None = self._style_and_break_lines()
 92
 93        self._cached_state: int = self.width
 94        self._drag_start: tuple[int, int] | None = None
 95
 96    @property
 97    def selectables_length(self) -> int:
 98        """Get length of selectables in object"""
 99
100        return 1
101
102    @property
103    def value(self) -> str:
104        """Returns the internal value of this field."""
105
106        return "\n".join(self._lines)
107
108    @property
109    def selection(self) -> str:
110        """Returns the currently selected span of text."""
111
112        start, end = sorted([self.cursor.col, self.cursor.col + self._selection_length])
113        return self._lines[self.cursor.row][start:end]
114
115    def _cache_is_valid(self) -> bool:
116        """Determines if the styled line cache is still usable."""
117
118        return self.width == self._cached_state
119
120    def _style_and_break_lines(self) -> list[str]:
121        """Styles and breaks self._lines."""
122
123        value = self.value
124        style = self.styles.value
125
126        # TODO: This is done line-by-line due to parser performance problems.
127        #       These should be resolved in the upcoming parser refactor.
128        document = [style(line) for line in value.splitlines()]
129        # document = style(value).splitlines()
130
131        lines: list[str] = []
132        width = self.width
133        extend = lines.extend
134
135        for line in document:
136            extend(break_line(line.replace("\n", "\\n"), width, fill=" "))
137            extend("")
138
139        return lines
140
141    def update_selection(self, count: int, correct_zero_length: bool = True) -> None:
142        """Updates the selection state.
143
144        Args:
145            count: How many characters the cursor should change by. Negative for
146                selecting leftward, positive for right.
147            correct_zero_length: If set, when the selection length is 0 both the cursor
148                and the selection length are manipulated to keep the original selection
149                start while moving the selection in more of the way the user might
150                expect.
151        """
152
153        self._selection_length += count
154
155        if correct_zero_length and abs(self._selection_length) == 0:
156            self._selection_length += 2 if count > 0 else -2
157            self.move_cursor((0, (-1 if count > 0 else 1)))
158
159    def delete_back(self, count: int = 1) -> str:
160        """Deletes `count` characters from the cursor, backwards.
161
162        Args:
163            count: How many characters should be deleted.
164
165        Returns:
166            The deleted string.
167        """
168
169        row, col = self.cursor
170
171        if len(self._lines) <= row:
172            return ""
173
174        line = self._lines[row]
175
176        start, end = sorted([col, col - count])
177        start = max(0, start)
178        self._lines[row] = line[:start] + line[end:]
179
180        self._styled_cache = None
181
182        if self._lines[row] == "":
183            self.move_cursor((-1, len(self._lines[row - 1])))
184
185            return self._lines.pop(row)
186
187        if count > 0:
188            self.move_cursor((0, -count))
189
190        return line[col - count : col]
191
192    def insert_text(self, text: str) -> None:
193        """Inserts text at the cursor location."""
194
195        row, col = self.cursor
196
197        if len(self._lines) <= row:
198            self._lines.insert(row, "")
199
200        line = self._lines[row]
201
202        self._lines[row] = line[:col] + text + line[col:]
203        self.move_cursor((0, len(text)))
204
205        self._styled_cache = None
206
207    def handle_action(self, action: str) -> bool:
208        """Handles some action.
209
210        This will be expanded in the future to allow using all behaviours with
211        just their actions.
212        """
213
214        cursors = {
215            "move_left": (0, -1),
216            "move_right": (0, 1),
217            "move_up": (-1, 0),
218            "move_down": (1, 0),
219        }
220
221        if action.startswith("move_"):
222            row, col = cursors[action]
223
224            if self.cursor.row + row > len(self._lines):
225                self._lines.append("")
226
227            col += self._selection_length
228            if self._selection_length > 0:
229                col -= 1
230
231            self._selection_length = 1
232            self.move_cursor((row, col))
233            return True
234
235        if action.startswith("select_"):
236            if action == "select_right":
237                self.update_selection(1)
238
239            elif action == "select_left":
240                self.update_selection(-1)
241
242            return True
243
244        return False
245
246    # TODO: This could probably be simplified by a wider adoption of the action pattern.
247    def handle_key(  # pylint: disable=too-many-return-statements, too-many-branches
248        self, key: str
249    ) -> bool:
250        """Adds text to the field, or moves the cursor."""
251
252        if self.execute_binding(key):
253            return True
254
255        for name, options in self.keys.items():
256            if (
257                name.rsplit("_", maxsplit=1)[-1] in ("up", "down")
258                and not self.multiline
259            ):
260                continue
261
262            if key in options:
263                return self.handle_action(name)
264
265        if key == keys.TAB:
266            if not self.multiline:
267                return False
268
269            for _ in range(self.tablength):
270                self.handle_key(" ")
271
272            return True
273
274        if key in string.printable and key not in "\x0c\x0b":
275            if key == keys.ENTER:
276                if not self.multiline:
277                    return False
278
279                line = self._lines[self.cursor.row]
280                left, right = line[: self.cursor.col], line[self.cursor.col :]
281
282                self._lines[self.cursor.row] = left
283                self._lines.insert(self.cursor.row + 1, right)
284
285                self.move_cursor((1, -self.cursor.col))
286                self._styled_cache = None
287
288            else:
289                self.insert_text(key)
290
291            return True
292
293        if key == keys.BACKSPACE:
294            if self._selection_length == 1:
295                self.delete_back(1)
296            else:
297                self.delete_back(-self._selection_length)
298
299            # self.handle_action("move_left")
300
301            # if self._selection_length == 1:
302
303            self._selection_length = 1
304            self._styled_cache = None
305
306            return True
307
308        return False
309
310    def handle_mouse(self, event: MouseEvent) -> bool:
311        """Allows point-and-click selection."""
312
313        x_offset = event.position[0] - self.pos[0]
314        y_offset = event.position[1] - self.pos[1]
315
316        # Set cursor to mouse location
317        if event.action is MouseAction.LEFT_CLICK:
318            line = self._lines[y_offset]
319            self.move_cursor((y_offset, min(len(line), x_offset)), absolute=True)
320
321            self._drag_start = (x_offset, y_offset)
322            self._selection_length = 1
323
324            return True
325
326        # Select text using dragging the mouse
327        if event.action is MouseAction.LEFT_DRAG:
328            assert self._drag_start is not None
329
330            change = x_offset - self._drag_start[0]
331            self.update_selection(
332                change - self._selection_length + 1, correct_zero_length=False
333            )
334
335            return True
336
337        return super().handle_mouse(event)
338
339    def move_cursor(self, new: tuple[int, int], *, absolute: bool = False) -> None:
340        """Moves the cursor, then possible re-positions it to a valid location.
341
342        Args:
343            new: The new set of (y, x) positions to use.
344            absolute: If set, `new` will be interpreted as absolute coordinates,
345                instead of being added on top of the current ones.
346        """
347
348        if len(self._lines) == 0:
349            return
350
351        if absolute:
352            new_y, new_x = new
353            self.cursor.row = new_y
354            self.cursor.col = new_x
355
356        else:
357            self.cursor += new
358
359        self.cursor.row = max(0, min(self.cursor.row, len(self._lines) - 1))
360        row, col = self.cursor
361
362        line = self._lines[row]
363
364        # Going left, possibly upwards
365        if col < 0:
366            if row <= 0:
367                self.cursor.col = 0
368
369            else:
370                self.cursor.row -= 1
371                line = self._lines[self.cursor.row]
372                self.cursor.col = len(line)
373
374        # Going right, possibly downwards
375        elif col > len(line) and line != "":
376            if len(self._lines) > row + 1:
377                self.cursor.row += 1
378                self.cursor.col = 0
379
380            line = self._lines[self.cursor.row]
381
382        self.cursor.col = max(0, min(self.cursor.col, len(line)))
383
384    def get_lines(self) -> list[str]:
385        """Builds the input field's lines."""
386
387        style = self.styles.value
388
389        if not self._cache_is_valid() or self._styled_cache is None:
390            self._styled_cache = self._style_and_break_lines()
391
392        lines = self._styled_cache
393
394        row, col = self.cursor
395
396        if len(self._lines) == 0:
397            line = " "
398        else:
399            line = self._lines[row]
400
401        start = col
402        cursor_char = " "
403        if len(line) > col:
404            start = col
405            end = col + self._selection_length
406            start, end = sorted([start, end])
407
408            try:
409                cursor_char = line[start:end]
410            except IndexError as error:
411                raise ValueError(f"Invalid index in {line!r}: {col}") from error
412
413        style_cursor = style if self.selected_index is None else self.styles.cursor
414
415        # TODO: This is horribly hackish, but is the only way to "get around" the
416        #       limits of the current scrolling techniques. Should be refactored
417        #       once a better solution is available
418        if self.parent is not None:
419            offset = 0
420            parent = self.parent
421            while hasattr(parent, "parent"):
422                offset += getattr(parent, "_scroll_offset")
423
424                parent = parent.parent  # type: ignore
425
426            offset_row = self.pos[1] - offset + row
427            position = (self.pos[0] + start, offset_row)
428
429            self.positioned_line_buffer.append(
430                (position, style_cursor(cursor_char))  # type: ignore
431            )
432
433        lines = lines or [""]
434        self.height = len(lines)
435
436        return lines

An element to display user input

InputField( value: str = '', *, tablength: int = 4, multiline: bool = False, cursor: pytermgui.widgets.input_field.Cursor | None = None, **attrs: Any)
66    def __init__(
67        self,
68        value: str = "",
69        *,
70        tablength: int = 4,
71        multiline: bool = False,
72        cursor: Cursor | None = None,
73        **attrs: Any,
74    ) -> None:
75        """Initialize object"""
76
77        super().__init__(**attrs)
78
79        if "width" not in attrs:
80            self.width = len(value)
81
82        self.height = 1
83        self.tablength = tablength
84        self.multiline = multiline
85
86        self.cursor = cursor or Cursor(0, 0)
87
88        self._lines = value.splitlines() or [""]
89        self._selection_length = 1
90
91        self._styled_cache: list[str] | None = self._style_and_break_lines()
92
93        self._cached_state: int = self.width
94        self._drag_start: tuple[int, int] | None = None

Initialize object

styles = {'value': StyleCall(obj=None, method=MarkupFormatter(markup='{item}', ensure_strip=False, _markup_cache={})), 'cursor': StyleCall(obj=None, method=MarkupFormatter(markup='[dim inverse]{item}', ensure_strip=False, _markup_cache={}))}

Default styles for this class

keys: dict[str, set[str]] = {'move_left': {'\x1b[D'}, 'move_right': {'\x1b[C'}, 'move_up': {'\x1b[A'}, 'move_down': {'\x1b[B'}, 'select_left': {'\x1b[1;2D'}, 'select_right': {'\x1b[1;2C'}, 'select_up': {'\x1b[1;2A'}, 'select_down': {'\x1b[1;2B'}}

Groups of keys that are used in handle_key

parent_align = <HorizontalAlignment.LEFT: 0>
is_bindable = True

Allow binding support

selectables_length: int

Get length of selectables in object

value: str

Returns the internal value of this field.

selection: str

Returns the currently selected span of text.

def update_selection(self, count: int, correct_zero_length: bool = True) -> None:
141    def update_selection(self, count: int, correct_zero_length: bool = True) -> None:
142        """Updates the selection state.
143
144        Args:
145            count: How many characters the cursor should change by. Negative for
146                selecting leftward, positive for right.
147            correct_zero_length: If set, when the selection length is 0 both the cursor
148                and the selection length are manipulated to keep the original selection
149                start while moving the selection in more of the way the user might
150                expect.
151        """
152
153        self._selection_length += count
154
155        if correct_zero_length and abs(self._selection_length) == 0:
156            self._selection_length += 2 if count > 0 else -2
157            self.move_cursor((0, (-1 if count > 0 else 1)))

Updates the selection state.

Args
  • count: How many characters the cursor should change by. Negative for selecting leftward, positive for right.
  • correct_zero_length: If set, when the selection length is 0 both the cursor and the selection length are manipulated to keep the original selection start while moving the selection in more of the way the user might expect.
def delete_back(self, count: int = 1) -> str:
159    def delete_back(self, count: int = 1) -> str:
160        """Deletes `count` characters from the cursor, backwards.
161
162        Args:
163            count: How many characters should be deleted.
164
165        Returns:
166            The deleted string.
167        """
168
169        row, col = self.cursor
170
171        if len(self._lines) <= row:
172            return ""
173
174        line = self._lines[row]
175
176        start, end = sorted([col, col - count])
177        start = max(0, start)
178        self._lines[row] = line[:start] + line[end:]
179
180        self._styled_cache = None
181
182        if self._lines[row] == "":
183            self.move_cursor((-1, len(self._lines[row - 1])))
184
185            return self._lines.pop(row)
186
187        if count > 0:
188            self.move_cursor((0, -count))
189
190        return line[col - count : col]

Deletes count characters from the cursor, backwards.

Args
  • count: How many characters should be deleted.
Returns

The deleted string.

def insert_text(self, text: str) -> None:
192    def insert_text(self, text: str) -> None:
193        """Inserts text at the cursor location."""
194
195        row, col = self.cursor
196
197        if len(self._lines) <= row:
198            self._lines.insert(row, "")
199
200        line = self._lines[row]
201
202        self._lines[row] = line[:col] + text + line[col:]
203        self.move_cursor((0, len(text)))
204
205        self._styled_cache = None

Inserts text at the cursor location.

def handle_action(self, action: str) -> bool:
207    def handle_action(self, action: str) -> bool:
208        """Handles some action.
209
210        This will be expanded in the future to allow using all behaviours with
211        just their actions.
212        """
213
214        cursors = {
215            "move_left": (0, -1),
216            "move_right": (0, 1),
217            "move_up": (-1, 0),
218            "move_down": (1, 0),
219        }
220
221        if action.startswith("move_"):
222            row, col = cursors[action]
223
224            if self.cursor.row + row > len(self._lines):
225                self._lines.append("")
226
227            col += self._selection_length
228            if self._selection_length > 0:
229                col -= 1
230
231            self._selection_length = 1
232            self.move_cursor((row, col))
233            return True
234
235        if action.startswith("select_"):
236            if action == "select_right":
237                self.update_selection(1)
238
239            elif action == "select_left":
240                self.update_selection(-1)
241
242            return True
243
244        return False

Handles some action.

This will be expanded in the future to allow using all behaviours with just their actions.

def handle_key(self, key: str) -> bool:
247    def handle_key(  # pylint: disable=too-many-return-statements, too-many-branches
248        self, key: str
249    ) -> bool:
250        """Adds text to the field, or moves the cursor."""
251
252        if self.execute_binding(key):
253            return True
254
255        for name, options in self.keys.items():
256            if (
257                name.rsplit("_", maxsplit=1)[-1] in ("up", "down")
258                and not self.multiline
259            ):
260                continue
261
262            if key in options:
263                return self.handle_action(name)
264
265        if key == keys.TAB:
266            if not self.multiline:
267                return False
268
269            for _ in range(self.tablength):
270                self.handle_key(" ")
271
272            return True
273
274        if key in string.printable and key not in "\x0c\x0b":
275            if key == keys.ENTER:
276                if not self.multiline:
277                    return False
278
279                line = self._lines[self.cursor.row]
280                left, right = line[: self.cursor.col], line[self.cursor.col :]
281
282                self._lines[self.cursor.row] = left
283                self._lines.insert(self.cursor.row + 1, right)
284
285                self.move_cursor((1, -self.cursor.col))
286                self._styled_cache = None
287
288            else:
289                self.insert_text(key)
290
291            return True
292
293        if key == keys.BACKSPACE:
294            if self._selection_length == 1:
295                self.delete_back(1)
296            else:
297                self.delete_back(-self._selection_length)
298
299            # self.handle_action("move_left")
300
301            # if self._selection_length == 1:
302
303            self._selection_length = 1
304            self._styled_cache = None
305
306            return True
307
308        return False

Adds text to the field, or moves the cursor.

def handle_mouse(self, event: pytermgui.ansi_interface.MouseEvent) -> bool:
310    def handle_mouse(self, event: MouseEvent) -> bool:
311        """Allows point-and-click selection."""
312
313        x_offset = event.position[0] - self.pos[0]
314        y_offset = event.position[1] - self.pos[1]
315
316        # Set cursor to mouse location
317        if event.action is MouseAction.LEFT_CLICK:
318            line = self._lines[y_offset]
319            self.move_cursor((y_offset, min(len(line), x_offset)), absolute=True)
320
321            self._drag_start = (x_offset, y_offset)
322            self._selection_length = 1
323
324            return True
325
326        # Select text using dragging the mouse
327        if event.action is MouseAction.LEFT_DRAG:
328            assert self._drag_start is not None
329
330            change = x_offset - self._drag_start[0]
331            self.update_selection(
332                change - self._selection_length + 1, correct_zero_length=False
333            )
334
335            return True
336
337        return super().handle_mouse(event)

Allows point-and-click selection.

def move_cursor(self, new: tuple[int, int], *, absolute: bool = False) -> None:
339    def move_cursor(self, new: tuple[int, int], *, absolute: bool = False) -> None:
340        """Moves the cursor, then possible re-positions it to a valid location.
341
342        Args:
343            new: The new set of (y, x) positions to use.
344            absolute: If set, `new` will be interpreted as absolute coordinates,
345                instead of being added on top of the current ones.
346        """
347
348        if len(self._lines) == 0:
349            return
350
351        if absolute:
352            new_y, new_x = new
353            self.cursor.row = new_y
354            self.cursor.col = new_x
355
356        else:
357            self.cursor += new
358
359        self.cursor.row = max(0, min(self.cursor.row, len(self._lines) - 1))
360        row, col = self.cursor
361
362        line = self._lines[row]
363
364        # Going left, possibly upwards
365        if col < 0:
366            if row <= 0:
367                self.cursor.col = 0
368
369            else:
370                self.cursor.row -= 1
371                line = self._lines[self.cursor.row]
372                self.cursor.col = len(line)
373
374        # Going right, possibly downwards
375        elif col > len(line) and line != "":
376            if len(self._lines) > row + 1:
377                self.cursor.row += 1
378                self.cursor.col = 0
379
380            line = self._lines[self.cursor.row]
381
382        self.cursor.col = max(0, min(self.cursor.col, len(line)))

Moves the cursor, then possible re-positions it to a valid location.

Args
  • new: The new set of (y, x) positions to use.
  • absolute: If set, new will be interpreted as absolute coordinates, instead of being added on top of the current ones.
def get_lines(self) -> list[str]:
384    def get_lines(self) -> list[str]:
385        """Builds the input field's lines."""
386
387        style = self.styles.value
388
389        if not self._cache_is_valid() or self._styled_cache is None:
390            self._styled_cache = self._style_and_break_lines()
391
392        lines = self._styled_cache
393
394        row, col = self.cursor
395
396        if len(self._lines) == 0:
397            line = " "
398        else:
399            line = self._lines[row]
400
401        start = col
402        cursor_char = " "
403        if len(line) > col:
404            start = col
405            end = col + self._selection_length
406            start, end = sorted([start, end])
407
408            try:
409                cursor_char = line[start:end]
410            except IndexError as error:
411                raise ValueError(f"Invalid index in {line!r}: {col}") from error
412
413        style_cursor = style if self.selected_index is None else self.styles.cursor
414
415        # TODO: This is horribly hackish, but is the only way to "get around" the
416        #       limits of the current scrolling techniques. Should be refactored
417        #       once a better solution is available
418        if self.parent is not None:
419            offset = 0
420            parent = self.parent
421            while hasattr(parent, "parent"):
422                offset += getattr(parent, "_scroll_offset")
423
424                parent = parent.parent  # type: ignore
425
426            offset_row = self.pos[1] - offset + row
427            position = (self.pos[0] + start, offset_row)
428
429            self.positioned_line_buffer.append(
430                (position, style_cursor(cursor_char))  # type: ignore
431            )
432
433        lines = lines or [""]
434        self.height = len(lines)
435
436        return lines

Builds the input field's lines.