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, ignore_any=True):
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            if keys.ANY_KEY in self._bindings:
291                method, _ = self._bindings[keys.ANY_KEY]
292                method(self, key)
293
294            return True
295
296        if key == keys.BACKSPACE:
297            if self._selection_length == 1:
298                self.delete_back(1)
299            else:
300                self.delete_back(-self._selection_length)
301
302            # self.handle_action("move_left")
303
304            # if self._selection_length == 1:
305
306            self._selection_length = 1
307            self._styled_cache = None
308
309            return True
310
311        return False
312
313    def handle_mouse(self, event: MouseEvent) -> bool:
314        """Allows point-and-click selection."""
315
316        x_offset = event.position[0] - self.pos[0]
317        y_offset = event.position[1] - self.pos[1]
318
319        # Set cursor to mouse location
320        if event.action is MouseAction.LEFT_CLICK:
321            if not y_offset < len(self._lines):
322                return False
323
324            line = self._lines[y_offset]
325            self.move_cursor((y_offset, min(len(line), x_offset)), absolute=True)
326
327            self._drag_start = (x_offset, y_offset)
328            self._selection_length = 1
329
330            return True
331
332        # Select text using dragging the mouse
333        if event.action is MouseAction.LEFT_DRAG and self._drag_start is not None:
334            change = x_offset - self._drag_start[0]
335            self.update_selection(
336                change - self._selection_length + 1, correct_zero_length=False
337            )
338
339            return True
340
341        return super().handle_mouse(event)
342
343    def move_cursor(self, new: tuple[int, int], *, absolute: bool = False) -> None:
344        """Moves the cursor, then possible re-positions it to a valid location.
345
346        Args:
347            new: The new set of (y, x) positions to use.
348            absolute: If set, `new` will be interpreted as absolute coordinates,
349                instead of being added on top of the current ones.
350        """
351
352        if len(self._lines) == 0:
353            return
354
355        if absolute:
356            new_y, new_x = new
357            self.cursor.row = new_y
358            self.cursor.col = new_x
359
360        else:
361            self.cursor += new
362
363        self.cursor.row = max(0, min(self.cursor.row, len(self._lines) - 1))
364        row, col = self.cursor
365
366        line = self._lines[row]
367
368        # Going left, possibly upwards
369        if col < 0:
370            if row <= 0:
371                self.cursor.col = 0
372
373            else:
374                self.cursor.row -= 1
375                line = self._lines[self.cursor.row]
376                self.cursor.col = len(line)
377
378        # Going right, possibly downwards
379        elif col > len(line) and line != "":
380            if len(self._lines) > row + 1:
381                self.cursor.row += 1
382                self.cursor.col = 0
383
384            line = self._lines[self.cursor.row]
385
386        self.cursor.col = max(0, min(self.cursor.col, len(line)))
387
388    def get_lines(self) -> list[str]:
389        """Builds the input field's lines."""
390
391        style = self.styles.value
392
393        if not self._cache_is_valid() or self._styled_cache is None:
394            self._styled_cache = self._style_and_break_lines()
395
396        lines = self._styled_cache
397
398        row, col = self.cursor
399
400        if len(self._lines) == 0:
401            line = " "
402        else:
403            line = self._lines[row]
404
405        start = col
406        cursor_char = " "
407        if len(line) > col:
408            start = col
409            end = col + self._selection_length
410            start, end = sorted([start, end])
411
412            try:
413                cursor_char = line[start:end]
414            except IndexError as error:
415                raise ValueError(f"Invalid index in {line!r}: {col}") from error
416
417        style_cursor = style if self.selected_index is None else self.styles.cursor
418
419        # TODO: This is horribly hackish, but is the only way to "get around" the
420        #       limits of the current scrolling techniques. Should be refactored
421        #       once a better solution is available
422        if self.parent is not None:
423            offset = 0
424            parent = self.parent
425            while hasattr(parent, "parent"):
426                offset += getattr(parent, "_scroll_offset")
427
428                parent = parent.parent  # type: ignore
429
430            offset_row = self.pos[1] - offset + row
431            position = (self.pos[0] + start, offset_row)
432
433            self.positioned_line_buffer.append(
434                (position, style_cursor(cursor_char))  # type: ignore
435            )
436
437        lines = lines or [""]
438        self.height = len(lines)
439
440        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, ignore_any=True):
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            if keys.ANY_KEY in self._bindings:
292                method, _ = self._bindings[keys.ANY_KEY]
293                method(self, key)
294
295            return True
296
297        if key == keys.BACKSPACE:
298            if self._selection_length == 1:
299                self.delete_back(1)
300            else:
301                self.delete_back(-self._selection_length)
302
303            # self.handle_action("move_left")
304
305            # if self._selection_length == 1:
306
307            self._selection_length = 1
308            self._styled_cache = None
309
310            return True
311
312        return False
313
314    def handle_mouse(self, event: MouseEvent) -> bool:
315        """Allows point-and-click selection."""
316
317        x_offset = event.position[0] - self.pos[0]
318        y_offset = event.position[1] - self.pos[1]
319
320        # Set cursor to mouse location
321        if event.action is MouseAction.LEFT_CLICK:
322            if not y_offset < len(self._lines):
323                return False
324
325            line = self._lines[y_offset]
326            self.move_cursor((y_offset, min(len(line), x_offset)), absolute=True)
327
328            self._drag_start = (x_offset, y_offset)
329            self._selection_length = 1
330
331            return True
332
333        # Select text using dragging the mouse
334        if event.action is MouseAction.LEFT_DRAG and self._drag_start is not None:
335            change = x_offset - self._drag_start[0]
336            self.update_selection(
337                change - self._selection_length + 1, correct_zero_length=False
338            )
339
340            return True
341
342        return super().handle_mouse(event)
343
344    def move_cursor(self, new: tuple[int, int], *, absolute: bool = False) -> None:
345        """Moves the cursor, then possible re-positions it to a valid location.
346
347        Args:
348            new: The new set of (y, x) positions to use.
349            absolute: If set, `new` will be interpreted as absolute coordinates,
350                instead of being added on top of the current ones.
351        """
352
353        if len(self._lines) == 0:
354            return
355
356        if absolute:
357            new_y, new_x = new
358            self.cursor.row = new_y
359            self.cursor.col = new_x
360
361        else:
362            self.cursor += new
363
364        self.cursor.row = max(0, min(self.cursor.row, len(self._lines) - 1))
365        row, col = self.cursor
366
367        line = self._lines[row]
368
369        # Going left, possibly upwards
370        if col < 0:
371            if row <= 0:
372                self.cursor.col = 0
373
374            else:
375                self.cursor.row -= 1
376                line = self._lines[self.cursor.row]
377                self.cursor.col = len(line)
378
379        # Going right, possibly downwards
380        elif col > len(line) and line != "":
381            if len(self._lines) > row + 1:
382                self.cursor.row += 1
383                self.cursor.col = 0
384
385            line = self._lines[self.cursor.row]
386
387        self.cursor.col = max(0, min(self.cursor.col, len(line)))
388
389    def get_lines(self) -> list[str]:
390        """Builds the input field's lines."""
391
392        style = self.styles.value
393
394        if not self._cache_is_valid() or self._styled_cache is None:
395            self._styled_cache = self._style_and_break_lines()
396
397        lines = self._styled_cache
398
399        row, col = self.cursor
400
401        if len(self._lines) == 0:
402            line = " "
403        else:
404            line = self._lines[row]
405
406        start = col
407        cursor_char = " "
408        if len(line) > col:
409            start = col
410            end = col + self._selection_length
411            start, end = sorted([start, end])
412
413            try:
414                cursor_char = line[start:end]
415            except IndexError as error:
416                raise ValueError(f"Invalid index in {line!r}: {col}") from error
417
418        style_cursor = style if self.selected_index is None else self.styles.cursor
419
420        # TODO: This is horribly hackish, but is the only way to "get around" the
421        #       limits of the current scrolling techniques. Should be refactored
422        #       once a better solution is available
423        if self.parent is not None:
424            offset = 0
425            parent = self.parent
426            while hasattr(parent, "parent"):
427                offset += getattr(parent, "_scroll_offset")
428
429                parent = parent.parent  # type: ignore
430
431            offset_row = self.pos[1] - offset + row
432            position = (self.pos[0] + start, offset_row)
433
434            self.positioned_line_buffer.append(
435                (position, style_cursor(cursor_char))  # type: ignore
436            )
437
438        lines = lines or [""]
439        self.height = len(lines)
440
441        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, ignore_any=True):
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            if keys.ANY_KEY in self._bindings:
292                method, _ = self._bindings[keys.ANY_KEY]
293                method(self, key)
294
295            return True
296
297        if key == keys.BACKSPACE:
298            if self._selection_length == 1:
299                self.delete_back(1)
300            else:
301                self.delete_back(-self._selection_length)
302
303            # self.handle_action("move_left")
304
305            # if self._selection_length == 1:
306
307            self._selection_length = 1
308            self._styled_cache = None
309
310            return True
311
312        return False

Adds text to the field, or moves the cursor.

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

Allows point-and-click selection.

def move_cursor(self, new: tuple[int, int], *, absolute: bool = False) -> None:
344    def move_cursor(self, new: tuple[int, int], *, absolute: bool = False) -> None:
345        """Moves the cursor, then possible re-positions it to a valid location.
346
347        Args:
348            new: The new set of (y, x) positions to use.
349            absolute: If set, `new` will be interpreted as absolute coordinates,
350                instead of being added on top of the current ones.
351        """
352
353        if len(self._lines) == 0:
354            return
355
356        if absolute:
357            new_y, new_x = new
358            self.cursor.row = new_y
359            self.cursor.col = new_x
360
361        else:
362            self.cursor += new
363
364        self.cursor.row = max(0, min(self.cursor.row, len(self._lines) - 1))
365        row, col = self.cursor
366
367        line = self._lines[row]
368
369        # Going left, possibly upwards
370        if col < 0:
371            if row <= 0:
372                self.cursor.col = 0
373
374            else:
375                self.cursor.row -= 1
376                line = self._lines[self.cursor.row]
377                self.cursor.col = len(line)
378
379        # Going right, possibly downwards
380        elif col > len(line) and line != "":
381            if len(self._lines) > row + 1:
382                self.cursor.row += 1
383                self.cursor.col = 0
384
385            line = self._lines[self.cursor.row]
386
387        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]:
389    def get_lines(self) -> list[str]:
390        """Builds the input field's lines."""
391
392        style = self.styles.value
393
394        if not self._cache_is_valid() or self._styled_cache is None:
395            self._styled_cache = self._style_and_break_lines()
396
397        lines = self._styled_cache
398
399        row, col = self.cursor
400
401        if len(self._lines) == 0:
402            line = " "
403        else:
404            line = self._lines[row]
405
406        start = col
407        cursor_char = " "
408        if len(line) > col:
409            start = col
410            end = col + self._selection_length
411            start, end = sorted([start, end])
412
413            try:
414                cursor_char = line[start:end]
415            except IndexError as error:
416                raise ValueError(f"Invalid index in {line!r}: {col}") from error
417
418        style_cursor = style if self.selected_index is None else self.styles.cursor
419
420        # TODO: This is horribly hackish, but is the only way to "get around" the
421        #       limits of the current scrolling techniques. Should be refactored
422        #       once a better solution is available
423        if self.parent is not None:
424            offset = 0
425            parent = self.parent
426            while hasattr(parent, "parent"):
427                offset += getattr(parent, "_scroll_offset")
428
429                parent = parent.parent  # type: ignore
430
431            offset_row = self.pos[1] - offset + row
432            position = (self.pos[0] + start, offset_row)
433
434            self.positioned_line_buffer.append(
435                (position, style_cursor(cursor_char))  # type: ignore
436            )
437
438        lines = lines or [""]
439        self.height = len(lines)
440
441        return lines

Builds the input field's lines.