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