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

An element to display user input

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

Initialize object

styles = {'value': StyleCall(obj=None, method=MarkupFormatter(markup='{item}', ensure_strip=False, _markup_cache={})), 'prompt': 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:
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)))

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:
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]

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:
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

Inserts text at the cursor location.

def handle_action(self, action: str) -> bool:
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

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:
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

Adds text to the field, or moves the cursor.

def handle_mouse(self, event: pytermgui.ansi_interface.MouseEvent) -> bool:
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
326            if y_offset == 0:
327                line = self.prompt + line
328
329            self.move_cursor((y_offset, min(len(line), x_offset)), absolute=True)
330
331            self._drag_start = (x_offset, y_offset)
332            self._selection_length = 1
333
334            return True
335
336        # Select text using dragging the mouse
337        if event.action is MouseAction.LEFT_DRAG and self._drag_start is not None:
338            change = x_offset - self._drag_start[0]
339            self.update_selection(
340                change - self._selection_length + 1, correct_zero_length=False
341            )
342
343            return True
344
345        return super().handle_mouse(event)

Allows point-and-click selection.

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

Builds the input field's lines.