Files
2025-02-21 22:00:16 -05:00

381 lines
13 KiB
Python

#
# Urwid basic widget classes
# Copyright (C) 2004-2012 Ian Ward
#
# This library is free software; you can redistribute it and/or
# modify it under the terms of the GNU Lesser General Public
# License as published by the Free Software Foundation; either
# version 2.1 of the License, or (at your option) any later version.
#
# This library is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
# Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public
# License along with this library; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
#
# Urwid web site: https://urwid.org/
from __future__ import annotations
import re
import warnings
from decimal import Decimal
from typing import TYPE_CHECKING
from urwid import Edit
if TYPE_CHECKING:
from collections.abc import Container
class NumEdit(Edit):
"""NumEdit - edit numerical types
based on the characters in 'allowed' different numerical types
can be edited:
+ regular int: 0123456789
+ regular float: 0123456789.
+ regular oct: 01234567
+ regular hex: 0123456789abcdef
"""
ALLOWED = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"
def __init__(
self,
allowed: Container[str],
caption,
default: str | bytes,
trimLeadingZeros: bool | None = None,
*,
trim_leading_zeros: bool = True,
allow_negative: bool = False,
):
super().__init__(caption, default)
self._allowed = allowed
self._trim_leading_zeros = trim_leading_zeros
self._allow_negative = allow_negative
if trimLeadingZeros is not None:
warnings.warn(
"'trimLeadingZeros' argument is deprecated. Use 'trim_leading_zeros' keyword argument",
DeprecationWarning,
stacklevel=3,
)
self._trim_leading_zeros = trimLeadingZeros
def valid_char(self, ch: str) -> bool:
"""
Return true for allowed characters.
"""
if len(ch) == 1:
if ch.upper() in self._allowed:
return True
return self._allow_negative and ch == "-" and self.edit_pos == 0 and "-" not in self.edit_text
return False
def keypress(
self,
size: tuple[int], # type: ignore[override]
key: str,
) -> str | None:
"""
Handle editing keystrokes. Remove leading zeros.
>>> e, size = NumEdit("0123456789", "", "5002"), (10,)
>>> e.keypress(size, 'home')
>>> e.keypress(size, 'delete')
>>> assert e.edit_text == "002"
>>> e.keypress(size, 'end')
>>> assert e.edit_text == "2"
>>> # binary only
>>> e, size = NumEdit("01", "", ""), (10,)
>>> assert e.edit_text == ""
>>> e.keypress(size, '1')
>>> e.keypress(size, '0')
>>> e.keypress(size, '1')
>>> assert e.edit_text == "101"
>>> e, size = NumEdit("0123456789", "", "", allow_negative=True), (10,)
>>> e.keypress(size, "-")
>>> e.keypress(size, '1')
>>> e.edit_text
'-1'
>>> e.keypress(size, 'home')
>>> e.keypress(size, 'delete')
>>> e.edit_text
'1'
>>> e.keypress(size, 'end')
>>> e.keypress(size, "-")
'-'
>>> e.edit_text
'1'
"""
unhandled = super().keypress(size, key)
if not unhandled and self._trim_leading_zeros:
# 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
class IntegerEdit(NumEdit):
"""Edit widget for integer values"""
def __init__(
self,
caption="",
default: int | str | Decimal | None = None,
base: int = 10,
*,
allow_negative: bool = False,
) -> None:
"""
caption -- caption markup
default -- default edit value
>>> IntegerEdit(u"", 42)
<IntegerEdit selectable flow widget '42' edit_pos=2>
>>> e, size = IntegerEdit(u"", "5002"), (10,)
>>> e.keypress(size, 'home')
>>> e.keypress(size, 'delete')
>>> assert e.edit_text == "002"
>>> e.keypress(size, 'end')
>>> assert e.edit_text == "2"
>>> e.keypress(size, '9')
>>> e.keypress(size, '0')
>>> assert e.edit_text == "290"
>>> e, size = IntegerEdit("", ""), (10,)
>>> assert e.value() is None
>>> # binary
>>> e, size = IntegerEdit(u"", "1010", base=2), (10,)
>>> e.keypress(size, 'end')
>>> e.keypress(size, '1')
>>> assert e.edit_text == "10101"
>>> assert e.value() == Decimal("21")
>>> # HEX
>>> e, size = IntegerEdit(u"", "10", base=16), (10,)
>>> e.keypress(size, 'end')
>>> e.keypress(size, 'F')
>>> e.keypress(size, 'f')
>>> assert e.edit_text == "10Ff"
>>> assert e.keypress(size, 'G') == 'G' # unhandled key
>>> assert e.edit_text == "10Ff"
>>> # keep leading 0's when not base 10
>>> e, size = IntegerEdit(u"", "10FF", base=16), (10,)
>>> assert e.edit_text == "10FF"
>>> assert e.value() == Decimal("4351")
>>> e.keypress(size, 'home')
>>> e.keypress(size, 'delete')
>>> e.keypress(size, '0')
>>> assert e.edit_text == "00FF"
>>> # test exception on incompatible value for base
>>> e, size = IntegerEdit(u"", "10FG", base=16), (10,)
Traceback (most recent call last):
...
ValueError: invalid value: 10FG for base 16
>>> # test exception on float init value
>>> e, size = IntegerEdit(u"", 10.0), (10,)
Traceback (most recent call last):
...
ValueError: default: Only 'str', 'int', 'long' or Decimal input allowed
>>> e, size = IntegerEdit(u"", Decimal("10.0")), (10,)
Traceback (most recent call last):
...
ValueError: not an 'integer Decimal' instance
"""
self.base = base
val = ""
allowed_chars = self.ALLOWED[: self.base]
if default is not None:
if not isinstance(default, (int, str, Decimal)):
raise ValueError("default: Only 'str', 'int' or Decimal input allowed")
# convert to a long first, this will raise a ValueError
# in case a float is passed or some other error
if isinstance(default, str) and len(default):
# check if it is a valid initial value
validation_re = f"^[{allowed_chars}]+$"
if not re.match(validation_re, str(default), re.IGNORECASE):
raise ValueError(f"invalid value: {default} for base {base}")
elif isinstance(default, Decimal) and default.as_tuple()[2] != 0:
# a Decimal instance with no fractional part
raise ValueError("not an 'integer Decimal' instance")
# convert possible int, long or Decimal to str
val = str(default)
super().__init__(
allowed_chars,
caption,
val,
trim_leading_zeros=(self.base == 10),
allow_negative=allow_negative,
)
def value(self) -> Decimal | None:
"""
Return the numeric value of self.edit_text.
>>> e, size = IntegerEdit(), (10,)
>>> e.keypress(size, '5')
>>> e.keypress(size, '1')
>>> assert e.value() == 51
"""
if self.edit_text:
return Decimal(int(self.edit_text, self.base))
return None
def __int__(self) -> int:
"""Enforced int value return.
>>> e, size = IntegerEdit(allow_negative=True), (10,)
>>> assert int(e) == 0
>>> e.keypress(size, '-')
>>> e.keypress(size, '4')
>>> e.keypress(size, '2')
>>> assert int(e) == -42
"""
if self.edit_text:
return int(self.edit_text, self.base)
return 0
class FloatEdit(NumEdit):
"""Edit widget for float values."""
def __init__(
self,
caption="",
default: str | int | Decimal | None = None,
preserveSignificance: bool | None = None,
decimalSeparator: str | None = None,
*,
preserve_significance: bool = True,
decimal_separator: str = ".",
allow_negative: bool = False,
) -> None:
"""
caption -- caption markup
default -- default edit value
preserve_significance -- return value has the same signif. as default
decimal_separator -- use '.' as separator by default, optionally a ','
>>> FloatEdit(u"", "1.065434")
<FloatEdit selectable flow widget '1.065434' edit_pos=8>
>>> e, size = FloatEdit(u"", "1.065434"), (10,)
>>> e.keypress(size, 'home')
>>> e.keypress(size, 'delete')
>>> assert e.edit_text == ".065434"
>>> e.keypress(size, 'end')
>>> e.keypress(size, 'backspace')
>>> assert e.edit_text == ".06543"
>>> e, size = FloatEdit(), (10,)
>>> e.keypress(size, '5')
>>> e.keypress(size, '1')
>>> e.keypress(size, '.')
>>> e.keypress(size, '5')
>>> e.keypress(size, '1')
>>> assert e.value() == Decimal("51.51"), e.value()
>>> e, size = FloatEdit(decimal_separator=":"), (10,)
Traceback (most recent call last):
...
ValueError: invalid decimal separator: :
>>> e, size = FloatEdit(decimal_separator=","), (10,)
>>> e.keypress(size, '5')
>>> e.keypress(size, '1')
>>> e.keypress(size, ',')
>>> e.keypress(size, '5')
>>> e.keypress(size, '1')
>>> assert e.edit_text == "51,51"
>>> e, size = FloatEdit("", "3.1415", preserve_significance=True), (10,)
>>> e.keypress(size, 'end')
>>> e.keypress(size, 'backspace')
>>> e.keypress(size, 'backspace')
>>> assert e.edit_text == "3.14"
>>> assert e.value() == Decimal("3.1400")
>>> e.keypress(size, '1')
>>> e.keypress(size, '5')
>>> e.keypress(size, '9')
>>> assert e.value() == Decimal("3.1416"), e.value()
>>> e, size = FloatEdit("", ""), (10,)
>>> assert e.value() is None
>>> e, size = FloatEdit(u"", 10.0), (10,)
Traceback (most recent call last):
...
ValueError: default: Only 'str', 'int', 'long' or Decimal input allowed
"""
self.significance = None
self._decimal_separator = decimal_separator
if decimalSeparator is not None:
warnings.warn(
"'decimalSeparator' argument is deprecated. Use 'decimal_separator' keyword argument",
DeprecationWarning,
stacklevel=3,
)
self._decimal_separator = decimalSeparator
if self._decimal_separator not in {".", ","}:
raise ValueError(f"invalid decimal separator: {self._decimal_separator}")
if preserveSignificance is not None:
warnings.warn(
"'preserveSignificance' argument is deprecated. Use 'preserve_significance' keyword argument",
DeprecationWarning,
stacklevel=3,
)
preserve_significance = preserveSignificance
val = ""
if default is not None and default != "": # noqa: PLC1901,RUF100
if not isinstance(default, (int, str, Decimal)):
raise ValueError("default: Only 'str', 'int' or Decimal input allowed")
if isinstance(default, str) and default:
# check if it is a float, raises a ValueError otherwise
float(default)
default = Decimal(default)
if preserve_significance and isinstance(default, Decimal):
self.significance = default
val = str(default)
super().__init__(self.ALLOWED[0:10] + self._decimal_separator, caption, val, allow_negative=allow_negative)
def value(self) -> Decimal | None:
"""
Return the numeric value of self.edit_text.
"""
if self.edit_text:
normalized = Decimal(self.edit_text.replace(self._decimal_separator, "."))
if self.significance is not None:
return normalized.quantize(self.significance)
return normalized
return None
def __float__(self) -> float:
"""Enforced float value return.
>>> e, size = FloatEdit(allow_negative=True), (10,)
>>> assert float(e) == 0.
>>> e.keypress(size, '-')
>>> e.keypress(size, '4')
>>> e.keypress(size, '.')
>>> e.keypress(size, '2')
>>> assert float(e) == -4.2
"""
if self.edit_text:
return float(self.edit_text.replace(self._decimal_separator, "."))
return 0.0