codekingpro/portable-devtools
115k
1from __future__ import annotations2 3import string4import typing5 6from urwid import text_layout7from urwid.canvas import CompositeCanvas8from urwid.command_map import Command9from urwid.split_repr import remove_defaults10from urwid.str_util import is_wide_char, move_next_char, move_prev_char11from urwid.util import decompose_tagmarkup12 13from .constants import Align, Sizing, WrapMode14from .text import Text, TextError15 16if typing.TYPE_CHECKING:17 from collections.abc import Hashable18 19 from typing_extensions import Literal20 21 from urwid.canvas import TextCanvas22 23 24class EditError(TextError):25 pass26 27 28class Edit(Text):29 """30 Text editing widget implements cursor movement, text insertion and31 deletion. A caption may prefix the editing area. Uses text class32 for text layout.33 34 Users of this class may listen for ``"change"`` or ``"postchange"``35 events. See :func:``connect_signal``.36 37 * ``"change"`` is sent just before the value of edit_text changes.38 It receives the new text as an argument. Note that ``"change"`` cannot39 change the text in question as edit_text changes the text afterwards.40 * ``"postchange"`` is sent after the value of edit_text changes.41 It receives the old value of the text as an argument and thus is42 appropriate for changing the text. It is possible for a ``"postchange"``43 event handler to get into a loop of changing the text and then being44 called when the event is re-emitted. It is up to the event45 handler to guard against this case (for instance, by not changing the46 text if it is signaled for text that it has already changed once).47 """48 49 _sizing = frozenset([Sizing.FLOW])50 _selectable = True51 ignore_focus = False52 # (this variable is picked up by the MetaSignals metaclass)53 signals: typing.ClassVar[list[str]] = ["change", "postchange"]54 55 def valid_char(self, ch: str) -> bool:56 """57 Filter for text that may be entered into this widget by the user58 59 :param ch: character to be inserted60 :type ch: str61 62 This implementation returns True for all printable characters.63 """64 return is_wide_char(ch, 0) or (len(ch) == 1 and ord(ch) >= 32)65 66 def __init__(67 self,68 caption: str | tuple[Hashable, str] | list[str | tuple[Hashable, str]] = "",69 edit_text: str = "",70 multiline: bool = False,71 align: Literal["left", "center", "right"] | Align = Align.LEFT,72 wrap: Literal["space", "any", "clip", "ellipsis"] | WrapMode = WrapMode.SPACE,73 allow_tab: bool = False,74 edit_pos: int | None = None,75 layout: text_layout.TextLayout = None,76 mask: str | None = None,77 ) -> None:78 """79 :param caption: markup for caption preceding edit_text, see80 :class:`Text` for description of text markup.81 :type caption: text markup82 :param edit_text: initial text for editing, type (bytes or unicode)83 must match the text in the caption84 :type edit_text: bytes or unicode85 :param multiline: True: 'enter' inserts newline False: return it86 :type multiline: bool87 :param align: typically 'left', 'center' or 'right'88 :type align: text alignment mode89 :param wrap: typically 'space', 'any' or 'clip'90 :type wrap: text wrapping mode91 :param allow_tab: True: 'tab' inserts 1-8 spaces False: return it92 :type allow_tab: bool93 :param edit_pos: initial position for cursor, None:end of edit_text94 :type edit_pos: int95 :param layout: defaults to a shared :class:`StandardTextLayout` instance96 :type layout: text layout instance97 :param mask: hide text entered with this character, None:disable mask98 :type mask: bytes or unicode99 100 >>> Edit()101 <Edit selectable flow widget '' edit_pos=0>102 >>> Edit("Y/n? ", "yes")103 <Edit selectable flow widget 'yes' caption='Y/n? ' edit_pos=3>104 >>> Edit("Name ", "Smith", edit_pos=1)105 <Edit selectable flow widget 'Smith' caption='Name ' edit_pos=1>106 >>> Edit("", "3.14", align="right")107 <Edit selectable flow widget '3.14' align='right' edit_pos=4>108 """109 110 super().__init__("", align, wrap, layout)111 self.multiline = multiline112 self.allow_tab = allow_tab113 self._edit_pos = 0114 self._caption, self._attrib = decompose_tagmarkup(caption)115 self._edit_text = ""116 self.highlight: tuple[int, int] | None = None117 self.set_edit_text(edit_text)118 if edit_pos is None:119 edit_pos = len(edit_text)120 self.set_edit_pos(edit_pos)121 self.set_mask(mask)122 self._shift_view_to_cursor = False123 124 def _repr_words(self) -> list[str]:125 return (126 super()._repr_words()[:-1]127 + [repr(self._edit_text)]128 + [f"caption={self._caption!r}"] * bool(self._caption)129 + ["multiline"] * (self.multiline is True)130 )131 132 def _repr_attrs(self) -> dict[str, typing.Any]:133 attrs = {**super()._repr_attrs(), "edit_pos": self._edit_pos}134 return remove_defaults(attrs, Edit.__init__)135 136 def get_text(self) -> tuple[str | bytes, list[tuple[Hashable, int]]]:137 """138 Returns ``(text, display attributes)``. See :meth:`Text.get_text`139 for details.140 141 Text returned includes the caption and edit_text, possibly masked.142 143 >>> Edit("What? ", "oh, nothing.").get_text()144 ('What? oh, nothing.', [])145 >>> Edit(("bright", "user@host:~$ "), "ls").get_text()146 ('user@host:~$ ls', [('bright', 13)])147 >>> Edit("password:", "seekrit", mask="*").get_text()148 ('password:*******', [])149 """150 151 if self._mask is None:152 return self._caption + self._edit_text, self._attrib153 154 return self._caption + (self._mask * len(self._edit_text)), self._attrib155 156 def set_text(self, markup: str | tuple[Hashable, str] | list[str | tuple[Hashable, str]]) -> None:157 """158 Not supported by Edit widget.159 160 >>> Edit().set_text("test")161 Traceback (most recent call last):162 EditError: set_text() not supported. Use set_caption() or set_edit_text() instead.163 """164 # FIXME: this smells. reimplement Edit as a WidgetWrap subclass to165 # clean this up166 167 # hack to let Text.__init__() work168 if not hasattr(self, "_text") and markup == "": # noqa: PLC1901,RUF100169 self._text = None170 return171 172 raise EditError("set_text() not supported. Use set_caption() or set_edit_text() instead.")173 174 def get_pref_col(self, size: tuple[int]) -> int:175 """176 Return the preferred column for the cursor, or the177 current cursor x value. May also return ``'left'`` or ``'right'``178 to indicate the leftmost or rightmost column available.179 180 This method is used internally and by other widgets when181 moving the cursor up or down between widgets so that the182 column selected is one that the user would expect.183 184 >>> size = (10,)185 >>> Edit().get_pref_col(size)186 0187 >>> e = Edit("", "word")188 >>> e.get_pref_col(size)189 4190 >>> e.keypress(size, "left")191 >>> e.get_pref_col(size)192 3193 >>> e.keypress(size, "end")194 >>> e.get_pref_col(size)195 <Align.RIGHT: 'right'>196 >>> e = Edit("", "2\\nwords")197 >>> e.keypress(size, "left")198 >>> e.keypress(size, "up")199 >>> e.get_pref_col(size)200 4201 >>> e.keypress(size, "left")202 >>> e.get_pref_col(size)203 0204 """205 (maxcol,) = size206 pref_col, then_maxcol = self.pref_col_maxcol207 if then_maxcol != maxcol:208 return self.get_cursor_coords((maxcol,))[0]209 210 return pref_col211 212 def set_caption(self, caption: str | tuple[Hashable, str] | list[str | tuple[Hashable, str]]) -> None:213 """214 Set the caption markup for this widget.215 216 :param caption: markup for caption preceding edit_text, see217 :meth:`Text.__init__` for description of text markup.218 219 >>> e = Edit("")220 >>> e.set_caption("cap1")221 >>> print(e.caption)222 cap1223 >>> e.set_caption(("bold", "cap2"))224 >>> print(e.caption)225 cap2226 >>> e.attrib227 [('bold', 4)]228 >>> e.caption = "cap3" # not supported because caption stores text but set_caption() takes markup229 Traceback (most recent call last):230 AttributeError: can't set attribute231 """232 self._caption, self._attrib = decompose_tagmarkup(caption)233 self._invalidate()234 235 @property236 def caption(self) -> str:237 """238 Read-only property returning the caption for this widget.239 """240 return self._caption241 242 def set_edit_pos(self, pos: int) -> None:243 """244 Set the cursor position with a self.edit_text offset.245 Clips pos to [0, len(edit_text)].246 247 :param pos: cursor position248 :type pos: int249 250 >>> e = Edit("", "word")251 >>> e.edit_pos252 4253 >>> e.set_edit_pos(2)254 >>> e.edit_pos255 2256 >>> e.edit_pos = -1 # Urwid 0.9.9 or later257 >>> e.edit_pos258 0259 >>> e.edit_pos = 20260 >>> e.edit_pos261 4262 """263 pos = min(max(pos, 0), len(self._edit_text))264 self.highlight = None265 self.pref_col_maxcol = None, None266 self._edit_pos = pos267 self._invalidate()268 269 edit_pos = property(270 lambda self: self._edit_pos,271 set_edit_pos,272 doc="""273 Property controlling the edit position for this widget.274 """,275 )276 277 def set_mask(self, mask: str | None) -> None:278 """279 Set the character for masking text away.280 281 :param mask: hide text entered with this character, None:disable mask282 :type mask: bytes or unicode283 """284 285 self._mask = mask286 self._invalidate()287 288 def set_edit_text(self, text: str) -> None:289 """290 Set the edit text for this widget.291 292 :param text: text for editing, type (bytes or unicode)293 must match the text in the caption294 :type text: bytes or unicode295 296 >>> e = Edit()297 >>> e.set_edit_text("yes")298 >>> print(e.edit_text)299 yes300 >>> e301 <Edit selectable flow widget 'yes' edit_pos=0>302 >>> e.edit_text = "no" # Urwid 0.9.9 or later303 >>> print(e.edit_text)304 no305 """306 text = self._normalize_to_caption(text)307 self.highlight = None308 self._emit("change", text)309 old_text = self._edit_text310 self._edit_text = text311 self.edit_pos = min(self.edit_pos, len(text))312 313 self._emit("postchange", old_text)314 self._invalidate()315 316 def get_edit_text(self) -> str:317 """318 Return the edit text for this widget.319 320 >>> e = Edit("What? ", "oh, nothing.")321 >>> print(e.get_edit_text())322 oh, nothing.323 >>> print(e.edit_text)324 oh, nothing.325 """326 return self._edit_text327 328 edit_text = property(329 get_edit_text,330 set_edit_text,331 doc="""332 Property controlling the edit text for this widget.333 """,334 )335 336 def insert_text(self, text: str) -> None:337 """338 Insert text at the cursor position and update cursor.339 This method is used by the keypress() method when inserting340 one or more characters into edit_text.341 342 :param text: text for inserting, type (bytes or unicode)343 must match the text in the caption344 :type text: bytes or unicode345 346 >>> e = Edit("", "42")347 >>> e.insert_text(".5")348 >>> e349 <Edit selectable flow widget '42.5' edit_pos=4>350 >>> e.set_edit_pos(2)351 >>> e.insert_text("a")352 >>> print(e.edit_text)353 42a.5354 """355 text = self._normalize_to_caption(text)356 result_text, result_pos = self.insert_text_result(text)357 self.set_edit_text(result_text)358 self.set_edit_pos(result_pos)359 self.highlight = None360 361 def _normalize_to_caption(self, text: str | bytes) -> str | bytes:362 """Return text converted to the same type as self.caption (bytes or unicode)"""363 tu = isinstance(text, str)364 cu = isinstance(self._caption, str)365 if tu == cu:366 return text367 if tu:368 return text.encode("ascii") # follow python2's implicit conversion369 return text.decode("ascii")370 371 def insert_text_result(self, text: str) -> tuple[str | bytes, int]:372 """373 Return result of insert_text(text) without actually performing the374 insertion. Handy for pre-validation.375 376 :param text: text for inserting, type (bytes or unicode)377 must match the text in the caption378 :type text: bytes or unicode379 """380 381 # if there's highlighted text, it'll get replaced by the new text382 text = self._normalize_to_caption(text)383 if self.highlight:384 start, stop = self.highlight385 btext, etext = self.edit_text[:start], self.edit_text[stop:]386 result_text = btext + etext387 result_pos = start388 else:389 result_text = self.edit_text390 result_pos = self.edit_pos391 392 try:393 result_text = result_text[:result_pos] + text + result_text[result_pos:]394 except (IndexError, TypeError) as exc:395 raise ValueError(repr((self.edit_text, result_text, text))).with_traceback(exc.__traceback__) from exc396 397 result_pos += len(text)398 return (result_text, result_pos)399 400 def keypress(401 self,402 size: tuple[int], # type: ignore[override]403 key: str,404 ) -> str | None:405 """406 Handle editing keystrokes, return others.407 408 >>> e, size = Edit(), (20,)409 >>> e.keypress(size, "x")410 >>> e.keypress(size, "left")411 >>> e.keypress(size, "1")412 >>> print(e.edit_text)413 1x414 >>> e.keypress(size, "backspace")415 >>> e.keypress(size, "end")416 >>> e.keypress(size, "2")417 >>> print(e.edit_text)418 x2419 >>> e.keypress(size, "shift f1")420 'shift f1'421 """422 pos = self.edit_pos423 if self.valid_char(key):424 if isinstance(key, str) and not isinstance(self._caption, str):425 key = key.encode("utf-8")426 self.insert_text(key)427 return None428 429 if key == "tab" and self.allow_tab:430 key = " " * (8 - (self.edit_pos % 8))431 self.insert_text(key)432 return None433 434 if key == "enter" and self.multiline:435 key = "\n"436 self.insert_text(key)437 return None438 439 if self._command_map[key] == Command.LEFT:440 if pos == 0:441 return key442 pos = move_prev_char(self.edit_text, 0, pos)443 self.set_edit_pos(pos)444 return None445 446 if self._command_map[key] == Command.RIGHT:447 if pos >= len(self.edit_text):448 return key449 pos = move_next_char(self.edit_text, pos, len(self.edit_text))450 self.set_edit_pos(pos)451 return None452 453 if self._command_map[key] in {Command.UP, Command.DOWN}:454 self.highlight = None455 456 _x, y = self.get_cursor_coords(size)457 pref_col = self.get_pref_col(size)458 if pref_col is None:459 raise ValueError(pref_col)460 461 # if pref_col is None:462 # pref_col = x463 464 if self._command_map[key] == Command.UP:465 y -= 1466 else:467 y += 1468 469 if not self.move_cursor_to_coords(size, pref_col, y):470 return key471 return None472 473 if key == "backspace":474 self.pref_col_maxcol = None, None475 if not self._delete_highlighted():476 if pos == 0:477 return key478 pos = move_prev_char(self.edit_text, 0, pos)479 self.set_edit_text(self.edit_text[:pos] + self.edit_text[self.edit_pos :])480 self.set_edit_pos(pos)481 return None482 return None483 484 if key == "delete":485 self.pref_col_maxcol = None, None486 if not self._delete_highlighted():487 if pos >= len(self.edit_text):488 return key489 pos = move_next_char(self.edit_text, pos, len(self.edit_text))490 self.set_edit_text(self.edit_text[: self.edit_pos] + self.edit_text[pos:])491 return None492 return None493 494 if self._command_map[key] in {Command.MAX_LEFT, Command.MAX_RIGHT}:495 self.highlight = None496 self.pref_col_maxcol = None, None497 498 _x, y = self.get_cursor_coords(size)499 500 if self._command_map[key] == Command.MAX_LEFT:501 self.move_cursor_to_coords(size, Align.LEFT, y)502 else:503 self.move_cursor_to_coords(size, Align.RIGHT, y)504 return None505 506 # key wasn't handled507 return key508 509 def move_cursor_to_coords(510 self,511 size: tuple[int],512 x: int | Literal[Align.LEFT, Align.RIGHT],513 y: int,514 ) -> bool:515 """516 Set the cursor position with (x,y) coordinates.517 Returns True if move succeeded, False otherwise.518 519 >>> size = (10,)520 >>> e = Edit("", "edit\\ntext")521 >>> e.move_cursor_to_coords(size, 5, 0)522 True523 >>> e.edit_pos524 4525 >>> e.move_cursor_to_coords(size, 5, 3)526 False527 >>> e.move_cursor_to_coords(size, 0, 1)528 True529 >>> e.edit_pos530 5531 """532 (maxcol,) = size533 trans = self.get_line_translation(maxcol)534 _top_x, top_y = self.position_coords(maxcol, 0)535 if y < top_y or y >= len(trans):536 return False537 538 pos = text_layout.calc_pos(self.get_text()[0], trans, x, y)539 e_pos = min(max(pos - len(self.caption), 0), len(self.edit_text))540 self.edit_pos = e_pos541 self.pref_col_maxcol = x, maxcol542 self._invalidate()543 return True544 545 def mouse_event(546 self,547 size: tuple[int], # type: ignore[override]548 event: str,549 button: int,550 col: int,551 row: int,552 focus: bool,553 ) -> bool | None:554 """555 Move the cursor to the location clicked for button 1.556 557 >>> size = (20,)558 >>> e = Edit("", "words here")559 >>> e.mouse_event(size, "mouse press", 1, 2, 0, True)560 True561 >>> e.edit_pos562 2563 """564 if button == 1:565 return self.move_cursor_to_coords(size, col, row)566 return False567 568 def _delete_highlighted(self) -> bool:569 """570 Delete all highlighted text and update cursor position, if any571 text is highlighted.572 """573 if not self.highlight:574 return False575 start, stop = self.highlight576 btext, etext = self.edit_text[:start], self.edit_text[stop:]577 self.set_edit_text(btext + etext)578 self.edit_pos = start579 self.highlight = None580 return True581 582 def render(583 self,584 size: tuple[int], # type: ignore[override]585 focus: bool = False,586 ) -> TextCanvas | CompositeCanvas:587 """588 Render edit widget and return canvas. Include cursor when in589 focus.590 591 >>> edit = Edit("? ", "yes")592 >>> c = edit.render((10,), focus=True)593 >>> c.text594 [b'? yes ']595 >>> c.cursor596 (5, 0)597 """598 self._shift_view_to_cursor = bool(focus)599 600 canv: TextCanvas | CompositeCanvas = super().render(size, focus)601 if focus:602 canv = CompositeCanvas(canv)603 canv.cursor = self.get_cursor_coords(size)604 605 # .. will need to FIXME if I want highlight to work again606 # if self.highlight:607 # hstart, hstop = self.highlight_coords()608 # d.coords['highlight'] = [ hstart, hstop ]609 return canv610 611 def get_line_translation(612 self,613 maxcol: int,614 ta: tuple[str | bytes, list[tuple[Hashable, int]]] | None = None,615 ) -> list[list[tuple[int, int, int | bytes] | tuple[int, int | None]]]:616 trans = super().get_line_translation(maxcol, ta)617 if not self._shift_view_to_cursor:618 return trans619 620 text, _ignore = self.get_text()621 x, y = text_layout.calc_coords(text, trans, self.edit_pos + len(self.caption))622 if x < 0:623 return [624 *trans[:y],625 *[text_layout.shift_line(trans[y], -x)],626 *trans[y + 1 :],627 ]628 629 if x >= maxcol:630 return [631 *trans[:y],632 *[text_layout.shift_line(trans[y], -(x - maxcol + 1))],633 *trans[y + 1 :],634 ]635 636 return trans637 638 def get_cursor_coords(self, size: tuple[int]) -> tuple[int, int]:639 """640 Return the (*x*, *y*) coordinates of cursor within widget.641 642 >>> Edit("? ", "yes").get_cursor_coords((10,))643 (5, 0)644 """645 (maxcol,) = size646 647 self._shift_view_to_cursor = True648 return self.position_coords(maxcol, self.edit_pos)649 650 def position_coords(self, maxcol: int, pos: int) -> tuple[int, int]:651 """652 Return (*x*, *y*) coordinates for an offset into self.edit_text.653 """654 655 p = pos + len(self.caption)656 trans = self.get_line_translation(maxcol)657 x, y = text_layout.calc_coords(self.get_text()[0], trans, p)658 return x, y659 660 661class IntEdit(Edit):662 """Edit widget for integer values"""663 664 def valid_char(self, ch: str) -> bool:665 """666 Return true for decimal digits.667 """668 return len(ch) == 1 and ch in string.digits669 670 def __init__(self, caption="", default: int | str | None = None) -> None:671 """672 caption -- caption markup673 default -- default edit value674 675 >>> IntEdit("", 42)676 <IntEdit selectable flow widget '42' edit_pos=2>677 """678 if default is not None:679 val = str(default)680 else:681 val = ""682 super().__init__(caption, val)683 684 def keypress(685 self,686 size: tuple[int], # type: ignore[override]687 key: str,688 ) -> str | None:689 """690 Handle editing keystrokes. Remove leading zeros.691 692 >>> e, size = IntEdit("", 5002), (10,)693 >>> e.keypress(size, "home")694 >>> e.keypress(size, "delete")695 >>> print(e.edit_text)696 002697 >>> e.keypress(size, "end")698 >>> print(e.edit_text)699 2700 """701 if unhandled := super().keypress(size, key):702 return unhandled703 704 # trim leading zeros705 while self.edit_pos > 0 and self.edit_text[:1] == "0":706 self.set_edit_pos(self.edit_pos - 1)707 self.set_edit_text(self.edit_text[1:])708 709 return None710 711 def value(self) -> int:712 """713 Return the numeric value of self.edit_text.714 715 >>> e, size = IntEdit(), (10,)716 >>> e.keypress(size, "5")717 >>> e.keypress(size, "1")718 >>> e.value() == 51719 True720 """721 if self.edit_text:722 return int(self.edit_text)723 724 return 0725 