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