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
Cursor19@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.
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
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.
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.