Shofel2_T124_python/venv/lib/python3.10/site-packages/urwid/widget/edit.py

710 lines
23 KiB
Python
Raw Normal View History

2024-05-25 16:45:07 +00:00
from __future__ import annotations
import typing
from urwid import text_layout
from urwid.canvas import CompositeCanvas
from urwid.command_map import Command
from urwid.split_repr import remove_defaults
from urwid.util import decompose_tagmarkup, is_wide_char, move_next_char, move_prev_char
if typing.TYPE_CHECKING:
from typing_extensions import Literal
from urwid.canvas import TextCanvas
from .constants import Align, WrapMode
from .text import Text, TextError
class EditError(TextError):
pass
class Edit(Text):
"""
Text editing widget implements cursor movement, text insertion and
deletion. A caption may prefix the editing area. Uses text class
for text layout.
Users of this class may listen for ``"change"`` or ``"postchange"``
events. See :func:``connect_signal``.
* ``"change"`` is sent just before the value of edit_text changes.
It receives the new text as an argument. Note that ``"change"`` cannot
change the text in question as edit_text changes the text afterwards.
* ``"postchange"`` is sent after the value of edit_text changes.
It receives the old value of the text as an argument and thus is
appropriate for changing the text. It is possible for a ``"postchange"``
event handler to get into a loop of changing the text and then being
called when the event is re-emitted. It is up to the event
handler to guard against this case (for instance, by not changing the
text if it is signaled for for text that it has already changed once).
"""
_selectable = True
ignore_focus = False
# (this variable is picked up by the MetaSignals metaclass)
signals: typing.ClassVar[list[str]] = ["change", "postchange"]
def valid_char(self, ch: str) -> bool:
"""
Filter for text that may be entered into this widget by the user
:param ch: character to be inserted
:type ch: str
This implementation returns True for all printable characters.
"""
return is_wide_char(ch, 0) or (len(ch) == 1 and ord(ch) >= 32)
def __init__(
self,
caption="",
edit_text: str | bytes = "",
multiline: bool = False,
align: Literal["left", "center", "right"] | Align = Align.LEFT,
wrap: Literal["space", "any", "clip", "ellipsis"] | WrapMode = WrapMode.SPACE,
allow_tab: bool = False,
edit_pos: int | None = None,
layout=None,
mask: str | bytes | None = None,
) -> None:
"""
:param caption: markup for caption preceding edit_text, see
:class:`Text` for description of text markup.
:type caption: text markup
:param edit_text: initial text for editing, type (bytes or unicode)
must match the text in the caption
:type edit_text: bytes or unicode
:param multiline: True: 'enter' inserts newline False: return it
:type multiline: bool
:param align: typically 'left', 'center' or 'right'
:type align: text alignment mode
:param wrap: typically 'space', 'any' or 'clip'
:type wrap: text wrapping mode
:param allow_tab: True: 'tab' inserts 1-8 spaces False: return it
:type allow_tab: bool
:param edit_pos: initial position for cursor, None:end of edit_text
:type edit_pos: int
:param layout: defaults to a shared :class:`StandardTextLayout` instance
:type layout: text layout instance
:param mask: hide text entered with this character, None:disable mask
:type mask: bytes or unicode
>>> Edit()
<Edit selectable flow widget '' edit_pos=0>
>>> Edit(u"Y/n? ", u"yes")
<Edit selectable flow widget 'yes' caption='Y/n? ' edit_pos=3>
>>> Edit(u"Name ", u"Smith", edit_pos=1)
<Edit selectable flow widget 'Smith' caption='Name ' edit_pos=1>
>>> Edit(u"", u"3.14", align='right')
<Edit selectable flow widget '3.14' align='right' edit_pos=4>
"""
super().__init__("", align, wrap, layout)
self.multiline = multiline
self.allow_tab = allow_tab
self._edit_pos = 0
self.set_caption(caption)
self._edit_text = ""
self.set_edit_text(edit_text)
if edit_pos is None:
edit_pos = len(edit_text)
self.set_edit_pos(edit_pos)
self.set_mask(mask)
self._shift_view_to_cursor = False
def _repr_words(self) -> list[str]:
return (
super()._repr_words()[:-1]
+ [repr(self._edit_text)]
+ [f"caption={self._caption!r}"] * bool(self._caption)
+ ["multiline"] * (self.multiline is True)
)
def _repr_attrs(self):
attrs = dict(super()._repr_attrs(), edit_pos=self._edit_pos)
return remove_defaults(attrs, Edit.__init__)
def get_text(self):
"""
Returns ``(text, display attributes)``. See :meth:`Text.get_text`
for details.
Text returned includes the caption and edit_text, possibly masked.
>>> Edit(u"What? ","oh, nothing.").get_text() # ... = u in Python 2
(...'What? oh, nothing.', [])
>>> Edit(('bright',u"user@host:~$ "),"ls").get_text()
(...'user@host:~$ ls', [('bright', 13)])
>>> Edit(u"password:", u"seekrit", mask=u"*").get_text()
(...'password:*******', [])
"""
if self._mask is None:
return self._caption + self._edit_text, self._attrib
return self._caption + (self._mask * len(self._edit_text)), self._attrib
def set_text(self, markup) -> None:
"""
Not supported by Edit widget.
>>> Edit().set_text("test")
Traceback (most recent call last):
EditError: set_text() not supported. Use set_caption() or set_edit_text() instead.
"""
# FIXME: this smells. reimplement Edit as a WidgetWrap subclass to
# clean this up
# hack to let Text.__init__() work
if not hasattr(self, "_text") and markup == "":
self._text = None
return
raise EditError("set_text() not supported. Use set_caption() or set_edit_text() instead.")
def get_pref_col(self, size: tuple[int]) -> int:
"""
Return the preferred column for the cursor, or the
current cursor x value. May also return ``'left'`` or ``'right'``
to indicate the leftmost or rightmost column available.
This method is used internally and by other widgets when
moving the cursor up or down between widgets so that the
column selected is one that the user would expect.
>>> size = (10,)
>>> Edit().get_pref_col(size)
0
>>> e = Edit(u"", u"word")
>>> e.get_pref_col(size)
4
>>> e.keypress(size, 'left')
>>> e.get_pref_col(size)
3
>>> e.keypress(size, 'end')
>>> e.get_pref_col(size)
<Align.RIGHT: 'right'>
>>> e = Edit(u"", u"2\\nwords")
>>> e.keypress(size, 'left')
>>> e.keypress(size, 'up')
>>> e.get_pref_col(size)
4
>>> e.keypress(size, 'left')
>>> e.get_pref_col(size)
0
"""
(maxcol,) = size
pref_col, then_maxcol = self.pref_col_maxcol
if then_maxcol != maxcol:
return self.get_cursor_coords((maxcol,))[0]
return pref_col
def set_caption(self, caption) -> None:
"""
Set the caption markup for this widget.
:param caption: markup for caption preceding edit_text, see
:meth:`Text.__init__` for description of text markup.
>>> e = Edit("")
>>> e.set_caption("cap1")
>>> print(e.caption)
cap1
>>> e.set_caption(('bold', "cap2"))
>>> print(e.caption)
cap2
>>> e.attrib
[('bold', 4)]
>>> e.caption = "cap3" # not supported because caption stores text but set_caption() takes markup
Traceback (most recent call last):
AttributeError: can't set attribute
"""
self._caption, self._attrib = decompose_tagmarkup(caption)
self._invalidate()
@property
def caption(self) -> str:
"""
Read-only property returning the caption for this widget.
"""
return self._caption
def set_edit_pos(self, pos: int) -> None:
"""
Set the cursor position with a self.edit_text offset.
Clips pos to [0, len(edit_text)].
:param pos: cursor position
:type pos: int
>>> e = Edit(u"", u"word")
>>> e.edit_pos
4
>>> e.set_edit_pos(2)
>>> e.edit_pos
2
>>> e.edit_pos = -1 # Urwid 0.9.9 or later
>>> e.edit_pos
0
>>> e.edit_pos = 20
>>> e.edit_pos
4
"""
if pos < 0:
pos = 0
if pos > len(self._edit_text):
pos = len(self._edit_text)
self.highlight = None
self.pref_col_maxcol = None, None
self._edit_pos = pos
self._invalidate()
edit_pos = property(
lambda self: self._edit_pos,
set_edit_pos,
doc="""
Property controlling the edit position for this widget.
""",
)
def set_mask(self, mask: str | bytes | None) -> None:
"""
Set the character for masking text away.
:param mask: hide text entered with this character, None:disable mask
:type mask: bytes or unicode
"""
self._mask = mask
self._invalidate()
def set_edit_text(self, text: str | bytes) -> None:
"""
Set the edit text for this widget.
:param text: text for editing, type (bytes or unicode)
must match the text in the caption
:type text: bytes or unicode
>>> e = Edit()
>>> e.set_edit_text(u"yes")
>>> print(e.edit_text)
yes
>>> e
<Edit selectable flow widget 'yes' edit_pos=0>
>>> e.edit_text = u"no" # Urwid 0.9.9 or later
>>> print(e.edit_text)
no
"""
text = self._normalize_to_caption(text)
self.highlight = None
self._emit("change", text)
old_text = self._edit_text
self._edit_text = text
if self.edit_pos > len(text):
self.edit_pos = len(text)
self._emit("postchange", old_text)
self._invalidate()
def get_edit_text(self) -> str:
"""
Return the edit text for this widget.
>>> e = Edit(u"What? ", u"oh, nothing.")
>>> print(e.get_edit_text())
oh, nothing.
>>> print(e.edit_text)
oh, nothing.
"""
return self._edit_text
edit_text = property(
get_edit_text,
set_edit_text,
doc="""
Property controlling the edit text for this widget.
""",
)
def insert_text(self, text: str | bytes) -> None:
"""
Insert text at the cursor position and update cursor.
This method is used by the keypress() method when inserting
one or more characters into edit_text.
:param text: text for inserting, type (bytes or unicode)
must match the text in the caption
:type text: bytes or unicode
>>> e = Edit(u"", u"42")
>>> e.insert_text(u".5")
>>> e
<Edit selectable flow widget '42.5' edit_pos=4>
>>> e.set_edit_pos(2)
>>> e.insert_text(u"a")
>>> print(e.edit_text)
42a.5
"""
text = self._normalize_to_caption(text)
result_text, result_pos = self.insert_text_result(text)
self.set_edit_text(result_text)
self.set_edit_pos(result_pos)
self.highlight = None
def _normalize_to_caption(self, text: str | bytes) -> str | bytes:
"""
Return text converted to the same type as self.caption
(bytes or unicode)
"""
tu = isinstance(text, str)
cu = isinstance(self._caption, str)
if tu == cu:
return text
if tu:
return text.encode("ascii") # follow python2's implicit conversion
return text.decode("ascii")
def insert_text_result(self, text: str | bytes) -> tuple[str | bytes, int]:
"""
Return result of insert_text(text) without actually performing the
insertion. Handy for pre-validation.
:param text: text for inserting, type (bytes or unicode)
must match the text in the caption
:type text: bytes or unicode
"""
# if there's highlighted text, it'll get replaced by the new text
text = self._normalize_to_caption(text)
if self.highlight:
start, stop = self.highlight
btext, etext = self.edit_text[:start], self.edit_text[stop:]
result_text = btext + etext
result_pos = start
else:
result_text = self.edit_text
result_pos = self.edit_pos
try:
result_text = result_text[:result_pos] + text + result_text[result_pos:]
except (IndexError, TypeError) as exc:
raise ValueError(repr((self.edit_text, result_text, text))).with_traceback(exc.__traceback__) from exc
result_pos += len(text)
return (result_text, result_pos)
def keypress(self, size: tuple[int], key: str) -> str | None:
"""
Handle editing keystrokes, return others.
>>> e, size = Edit(), (20,)
>>> e.keypress(size, 'x')
>>> e.keypress(size, 'left')
>>> e.keypress(size, '1')
>>> print(e.edit_text)
1x
>>> e.keypress(size, 'backspace')
>>> e.keypress(size, 'end')
>>> e.keypress(size, '2')
>>> print(e.edit_text)
x2
>>> e.keypress(size, 'shift f1')
'shift f1'
"""
(maxcol,) = size
pos = self.edit_pos
if self.valid_char(key):
if isinstance(key, str) and not isinstance(self._caption, str):
# screen is sending us unicode input, must be using utf-8
# encoding because that's all we support, so convert it
# to bytes to match our caption's type
key = key.encode("utf-8")
self.insert_text(key)
return None
if key == "tab" and self.allow_tab:
key = " " * (8 - (self.edit_pos % 8))
self.insert_text(key)
return None
if key == "enter" and self.multiline:
key = "\n"
self.insert_text(key)
return None
if self._command_map[key] == Command.LEFT:
if pos == 0:
return key
pos = move_prev_char(self.edit_text, 0, pos)
self.set_edit_pos(pos)
return None
if self._command_map[key] == Command.RIGHT:
if pos >= len(self.edit_text):
return key
pos = move_next_char(self.edit_text, pos, len(self.edit_text))
self.set_edit_pos(pos)
return None
if self._command_map[key] in (Command.UP, Command.DOWN):
self.highlight = None
x, y = self.get_cursor_coords((maxcol,))
pref_col = self.get_pref_col((maxcol,))
if pref_col is None:
raise ValueError(pref_col)
# if pref_col is None:
# pref_col = x
if self._command_map[key] == Command.UP:
y -= 1
else:
y += 1
if not self.move_cursor_to_coords((maxcol,), pref_col, y):
return key
return None
if key == "backspace":
self.pref_col_maxcol = None, None
if not self._delete_highlighted():
if pos == 0:
return key
pos = move_prev_char(self.edit_text, 0, pos)
self.set_edit_text(self.edit_text[:pos] + self.edit_text[self.edit_pos :])
self.set_edit_pos(pos)
return None
return None
if key == "delete":
self.pref_col_maxcol = None, None
if not self._delete_highlighted():
if pos >= len(self.edit_text):
return key
pos = move_next_char(self.edit_text, pos, len(self.edit_text))
self.set_edit_text(self.edit_text[: self.edit_pos] + self.edit_text[pos:])
return None
return None
if self._command_map[key] in (Command.MAX_LEFT, Command.MAX_RIGHT):
self.highlight = None
self.pref_col_maxcol = None, None
x, y = self.get_cursor_coords((maxcol,))
if self._command_map[key] == Command.MAX_LEFT:
self.move_cursor_to_coords((maxcol,), Align.LEFT, y)
else:
self.move_cursor_to_coords((maxcol,), Align.RIGHT, y)
return None
# key wasn't handled
return key
def move_cursor_to_coords(
self,
size: tuple[int],
x: int | Literal[Align.LEFT, Align.RIGHT],
y: int,
) -> bool:
"""
Set the cursor position with (x,y) coordinates.
Returns True if move succeeded, False otherwise.
>>> size = (10,)
>>> e = Edit("","edit\\ntext")
>>> e.move_cursor_to_coords(size, 5, 0)
True
>>> e.edit_pos
4
>>> e.move_cursor_to_coords(size, 5, 3)
False
>>> e.move_cursor_to_coords(size, 0, 1)
True
>>> e.edit_pos
5
"""
(maxcol,) = size
trans = self.get_line_translation(maxcol)
top_x, top_y = self.position_coords(maxcol, 0)
if y < top_y or y >= len(trans):
return False
pos = text_layout.calc_pos(self.get_text()[0], trans, x, y)
e_pos = pos - len(self.caption)
if e_pos < 0:
e_pos = 0
if e_pos > len(self.edit_text):
e_pos = len(self.edit_text)
self.edit_pos = e_pos
self.pref_col_maxcol = x, maxcol
self._invalidate()
return True
def mouse_event(self, size: tuple[int], event, button: int, x: int, y: int, focus: bool) -> bool:
"""
Move the cursor to the location clicked for button 1.
>>> size = (20,)
>>> e = Edit("","words here")
>>> e.mouse_event(size, 'mouse press', 1, 2, 0, True)
True
>>> e.edit_pos
2
"""
(maxcol,) = size
if button == 1:
return self.move_cursor_to_coords((maxcol,), x, y)
return False
def _delete_highlighted(self) -> bool:
"""
Delete all highlighted text and update cursor position, if any
text is highlighted.
"""
if not self.highlight:
return False
start, stop = self.highlight
btext, etext = self.edit_text[:start], self.edit_text[stop:]
self.set_edit_text(btext + etext)
self.edit_pos = start
self.highlight = None
return True
def render(self, size: tuple[int], focus: bool = False) -> TextCanvas | CompositeCanvas:
"""
Render edit widget and return canvas. Include cursor when in
focus.
>>> c = Edit("? ","yes").render((10,), focus=True)
>>> c.text # ... = b in Python 3
[...'? yes ']
>>> c.cursor
(5, 0)
"""
self._shift_view_to_cursor = bool(focus)
canv: TextCanvas | CompositeCanvas = super().render(size)
if focus:
canv = CompositeCanvas(canv)
canv.cursor = self.get_cursor_coords(size)
# .. will need to FIXME if I want highlight to work again
# if self.highlight:
# hstart, hstop = self.highlight_coords()
# d.coords['highlight'] = [ hstart, hstop ]
return canv
def get_line_translation(self, maxcol: int, ta=None):
trans = Text.get_line_translation(self, maxcol, ta)
if not self._shift_view_to_cursor:
return trans
text, ignore = self.get_text()
x, y = text_layout.calc_coords(text, trans, self.edit_pos + len(self.caption))
if x < 0:
return [
*trans[:y],
*[text_layout.shift_line(trans[y], -x)],
*trans[y + 1 :],
]
if x >= maxcol:
return [
*trans[:y],
*[text_layout.shift_line(trans[y], -(x - maxcol + 1))],
*trans[y + 1 :],
]
return trans
def get_cursor_coords(self, size: tuple[int]) -> tuple[int, int]:
"""
Return the (*x*, *y*) coordinates of cursor within widget.
>>> Edit("? ","yes").get_cursor_coords((10,))
(5, 0)
"""
(maxcol,) = size
self._shift_view_to_cursor = True
return self.position_coords(maxcol, self.edit_pos)
def position_coords(self, maxcol: int, pos) -> tuple[int, int]:
"""
Return (*x*, *y*) coordinates for an offset into self.edit_text.
"""
p = pos + len(self.caption)
trans = self.get_line_translation(maxcol)
x, y = text_layout.calc_coords(self.get_text()[0], trans, p)
return x, y
class IntEdit(Edit):
"""Edit widget for integer values"""
def valid_char(self, ch):
"""
Return true for decimal digits.
"""
return len(ch) == 1 and ch in "0123456789"
def __init__(self, caption="", default: int | str | None = None) -> None:
"""
caption -- caption markup
default -- default edit value
>>> IntEdit(u"", 42)
<IntEdit selectable flow widget '42' edit_pos=2>
"""
if default is not None:
val = str(default)
else:
val = ""
super().__init__(caption, val)
def keypress(self, size: tuple[int], key: str) -> str | None:
"""
Handle editing keystrokes. Remove leading zeros.
>>> e, size = IntEdit(u"", 5002), (10,)
>>> e.keypress(size, 'home')
>>> e.keypress(size, 'delete')
>>> print(e.edit_text)
002
>>> e.keypress(size, 'end')
>>> print(e.edit_text)
2
"""
unhandled = super().keypress(size, key)
if not unhandled:
# trim leading zeros
while self.edit_pos > 0 and self.edit_text[:1] == "0":
self.set_edit_pos(self.edit_pos - 1)
self.set_edit_text(self.edit_text[1:])
return unhandled
def value(self) -> int:
"""
Return the numeric value of self.edit_text.
>>> e, size = IntEdit(), (10,)
>>> e.keypress(size, '5')
>>> e.keypress(size, '1')
>>> e.value() == 51
True
"""
if self.edit_text:
return int(self.edit_text)
return 0