286 lines
10 KiB
Python
286 lines
10 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
|
|
from collections.abc import Container
|
|
from decimal import Decimal
|
|
|
|
from urwid import Edit
|
|
|
|
|
|
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 = True,
|
|
):
|
|
super().__init__(caption, default)
|
|
self._allowed = allowed
|
|
self.trimLeadingZeros = trimLeadingZeros
|
|
|
|
def valid_char(self, ch: str) -> bool:
|
|
"""
|
|
Return true for allowed characters.
|
|
"""
|
|
return len(ch) == 1 and ch.upper() in self._allowed
|
|
|
|
def keypress(self, size: tuple[int], key: str) -> str | None:
|
|
"""
|
|
Handle editing keystrokes. Remove leading zeros.
|
|
|
|
>>> e, size = NumEdit("0123456789", 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"
|
|
>>> # binary only
|
|
>>> e, size = NumEdit("01", u"", ""), (10,)
|
|
>>> assert e.edit_text == ""
|
|
>>> e.keypress(size, '1')
|
|
>>> e.keypress(size, '0')
|
|
>>> e.keypress(size, '1')
|
|
>>> assert e.edit_text == "101"
|
|
"""
|
|
unhandled = super().keypress(size, key)
|
|
|
|
if not unhandled and self.trimLeadingZeros:
|
|
# 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,
|
|
) -> 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):
|
|
# a Decimal instance with no fractional part
|
|
if default.as_tuple()[2] != 0:
|
|
raise ValueError("not an 'integer Decimal' instance")
|
|
|
|
# convert possible int, long or Decimal to str
|
|
val = str(default)
|
|
|
|
super().__init__(allowed_chars, caption, val, trimLeadingZeros=(self.base == 10))
|
|
|
|
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
|
|
|
|
|
|
class FloatEdit(NumEdit):
|
|
"""Edit widget for float values."""
|
|
|
|
def __init__(
|
|
self,
|
|
caption="",
|
|
default: str | int | Decimal | None = None,
|
|
preserveSignificance: bool = True,
|
|
decimalSeparator: str = '.',
|
|
) -> None:
|
|
"""
|
|
caption -- caption markup
|
|
default -- default edit value
|
|
preserveSignificance -- return value has the same signif. as default
|
|
decimalSeparator -- 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(decimalSeparator=":"), (10,)
|
|
Traceback (most recent call last):
|
|
...
|
|
ValueError: invalid decimalSeparator: :
|
|
>>> e, size = FloatEdit(decimalSeparator=","), (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", preserveSignificance=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._decimalSeparator = decimalSeparator
|
|
if decimalSeparator not in ['.', ',']:
|
|
raise ValueError(f"invalid decimalSeparator: {decimalSeparator}")
|
|
|
|
val = ""
|
|
if default is not None and default != "":
|
|
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 preserveSignificance and isinstance(default, Decimal):
|
|
self.significance = default
|
|
|
|
val = str(default)
|
|
|
|
super().__init__(self.ALLOWED[0:10] + decimalSeparator,
|
|
caption, val)
|
|
|
|
def value(self) -> Decimal | None:
|
|
"""
|
|
Return the numeric value of self.edit_text.
|
|
"""
|
|
if self.edit_text:
|
|
normalized = Decimal(self.edit_text.replace(self._decimalSeparator, '.'))
|
|
if self.significance is not None:
|
|
return normalized.quantize(self.significance)
|
|
return normalized
|
|
|
|
return None
|