diff --git a/README.md b/README.md index 25d244b..a62b728 100644 --- a/README.md +++ b/README.md @@ -24,9 +24,8 @@ source .venv/bin/activate pip install -r requirements.txt ``` -Python **3.12+** is expected. Dependencies are pinned in -`requirements.txt` and include PySide6, PyYAML, simplejson, gmpy2, -python-dateutil, tzdata, pytest, and `jsonschema[format]`. +Python **3.12+** is expected. Dependencies are pinned in `requirements.txt` and include PySide6, PyYAML, simplejson, +gmpy2, python-dateutil, tzdata, pytest, and `jsonschema[format]`. ### Run the app diff --git a/editors/inline/affix_composite.py b/editors/inline/affix_composite.py index cf3b276..e40bbdb 100644 --- a/editors/inline/affix_composite.py +++ b/editors/inline/affix_composite.py @@ -2,8 +2,9 @@ from typing import Iterable +from gmpy2 import mpq from PySide6.QtGui import QFont, QFontMetrics -from PySide6.QtWidgets import QComboBox, QHBoxLayout, QLabel, QPushButton, QSizePolicy, QWidget +from PySide6.QtWidgets import QComboBox, QHBoxLayout, QLabel, QPushButton, QSizePolicy, QSpinBox, QWidget from editors.inline.bigint_spinbox import QBigIntSpinBox from editors.inline.mpq_spinbox import QMpqSpinBox @@ -20,6 +21,8 @@ class AffixCompositeEditor(QWidget): def __init__(self, parent: QWidget, *, kind: AffixKind, is_integer: bool, mru_items: Iterable[str]) -> None: super().__init__(parent) self.kind = kind + self._integral_digits = 0 + self._fractional_digits = -1 layout = QHBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) @@ -42,10 +45,43 @@ def __init__(self, parent: QWidget, *, kind: AffixKind, is_integer: bool, mru_it self.space_button.toggled.connect(self._on_space_toggled) self._update_space_button_width() + self.plus_button = QPushButton("Plain", parent=self) + self.plus_button.setCheckable(True) + self.plus_button.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Preferred) + self.plus_button.setToolTip("Preserve explicit leading plus for positive values") + self.plus_button.toggled.connect(self._on_plus_toggled) + + self.width_button = QPushButton("Width", parent=self) + self.width_button.setCheckable(True) + self.width_button.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Preferred) + self.width_button.setToolTip("Preserve leading zeros") + self.width_button.toggled.connect(self._on_width_toggled) + + self.width_spinbox = QSpinBox(self) + self.width_spinbox.setRange(1, 999_999) + self.width_spinbox.setValue(1) + self.width_spinbox.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Preferred) + self.width_spinbox.setToolTip("Number of integral digits to preserve") + self.width_spinbox.setVisible(False) + if is_integer: self.number_editor = QBigIntSpinBox(self) + self.precision_button = None + self.precision_spinbox = None else: self.number_editor = QMpqSpinBox(self) + self.precision_button = QPushButton("Precision", parent=self) + self.precision_button.setCheckable(True) + self.precision_button.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Preferred) + self.precision_button.setToolTip("Preserve trailing zeros") + self.precision_button.toggled.connect(self._on_precision_toggled) + self.precision_spinbox = QSpinBox(self) + self.precision_spinbox.setRange(0, 999_999) + self.precision_spinbox.setValue(0) + self.precision_spinbox.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Preferred) + self.precision_spinbox.setToolTip("Number of fractional digits to preserve") + self.precision_spinbox.setVisible(False) + self.precision_spinbox.valueChanged.connect(self._on_precision_digits_changed) self.number_editor.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred) self.value_label = QLabel("Value: ", self) @@ -59,6 +95,12 @@ def __init__(self, parent: QWidget, *, kind: AffixKind, is_integer: bool, mru_it self.affix_combo.addItem(affix) layout.addWidget(self.space_button) + layout.addWidget(self.plus_button) + layout.addWidget(self.width_button) + layout.addWidget(self.width_spinbox) + if self.precision_button: + layout.addWidget(self.precision_button) + layout.addWidget(self.precision_spinbox) layout.addWidget(self.affix_label) layout.addWidget(self.affix_combo) layout.addWidget(self.value_label) @@ -71,6 +113,51 @@ def _space_button_text(spaced: bool) -> str: def _on_space_toggled(self, checked: bool) -> None: self.space_button.setText(self._space_button_text(bool(checked))) + def _on_plus_toggled(self, checked: bool) -> None: + self.plus_button.setText("Plus" if checked else "Plain") + + def _on_width_toggled(self, checked: bool) -> None: + self.width_button.setText("Width" if checked else "Short") + self.width_spinbox.setVisible(bool(checked)) + if checked and self._integral_digits <= 0 and self.width_spinbox.value() == 1: + self.width_spinbox.setValue(self._infer_integral_digits()) + + def _on_precision_toggled(self, checked: bool) -> None: + self.precision_button.setText("Precision" if checked else "Strip") + self.precision_spinbox.setVisible(bool(checked)) + if checked and self._fractional_digits < 0 and self.precision_spinbox.value() == 0: + self.precision_spinbox.setValue(self._infer_fractional_digits()) + self._apply_fractional_step() + + def _on_precision_digits_changed(self, _value: int) -> None: + self._apply_fractional_step() + + def _infer_integral_digits(self) -> int: + text = self.number_editor.cleanText() + digits = text.lstrip("+-").split(".", 1)[0] + return max(1, len(digits)) + + def _infer_fractional_digits(self) -> int: + text = self.number_editor.cleanText() + parts = text.split(".", 1) + if len(parts) == 2: + return len(parts[1]) + return 0 + + def _apply_fractional_step(self) -> None: + if not isinstance(self.number_editor, QMpqSpinBox): + return + + digits = self._infer_fractional_digits() + if self.precision_button and self.precision_button.isChecked() and self.precision_spinbox is not None: + digits = self.precision_spinbox.value() + + if digits <= 0: + self.number_editor.setSingleStep(mpq(1)) + return + + self.number_editor.setSingleStep(mpq(1, 10**digits)) + def _update_space_button_width(self) -> None: metrics = QFontMetrics(self.space_button.font()) width = ( @@ -106,14 +193,41 @@ def set_value(self, value: NumberAffix) -> None: if self.affix_combo.lineEdit() is not None: self.affix_combo.lineEdit().setText(text) self.space_button.setChecked(bool(value.space)) + self.plus_button.setChecked(bool(value.explicit_plus)) + self.number_editor.setValue(value.number) self.set_invalid(False) + self._integral_digits = value.integral_digits + self._fractional_digits = value.fractional_digits + + self.width_spinbox.setValue( + self._integral_digits if self._integral_digits > 0 else self._infer_integral_digits() + ) + self.width_button.setChecked(self._integral_digits > 0) + + if self.precision_button: + self.precision_spinbox.setValue( + self._fractional_digits if self._fractional_digits >= 0 else self._infer_fractional_digits() + ) + self.precision_button.setChecked(self._fractional_digits >= 0) + + self._apply_fractional_step() + def build_value(self) -> NumberAffix: affix = self.affix_combo.currentText() + new_integral = self.width_spinbox.value() if self.width_button.isChecked() else 0 + + new_fractional = -1 + if self.precision_button and self.precision_button.isChecked() and self.precision_spinbox is not None: + new_fractional = self.precision_spinbox.value() + return NumberAffix( kind=self.kind, affix=affix, space=self.space_button.isChecked(), number=self.number_editor.value(), + integral_digits=new_integral, + fractional_digits=new_fractional, + explicit_plus=self.plus_button.isChecked() and self.number_editor.value() >= 0, ) diff --git a/tests/test_number_affix.py b/tests/test_number_affix.py index 7f85286..1a65a83 100644 --- a/tests/test_number_affix.py +++ b/tests/test_number_affix.py @@ -11,7 +11,7 @@ def test_parse_prefix_no_space_int() -> None: def test_parse_prefix_with_space_int() -> None: parsed = parse_number_affix("$ 1234") - assert parsed == NumberAffix(AffixKind.CURRENCY, "$", True, 1234) + assert parsed == NumberAffix(AffixKind.CURRENCY, "$", True, 1234, 0, -1) def test_parse_units_float_no_space() -> None: @@ -29,8 +29,32 @@ def test_parse_units_float_with_space_and_exponent() -> None: assert parsed.number == mpq("-314") +def test_parse_negative_currency_without_space_rejected() -> None: + # "abc-1" should be rejected as currency, requires "abc -1" + assert parse_number_affix("abc-1") is None + assert parse_number_affix("abc -1") is not None + + +def test_parse_currency_affix_ending_with_dash_for_zero_padded_int() -> None: + parsed = parse_number_affix("abc-001") + assert parsed == NumberAffix(AffixKind.CURRENCY, "abc-", False, 1, 3, -1) + assert format_number_affix(parsed) == "abc-001" + + +def test_parse_currency_with_explicit_plus_round_trips() -> None: + parsed = parse_number_affix("pepe+777") + assert parsed == NumberAffix(AffixKind.CURRENCY, "pepe", False, 777, 0, -1, True) + assert format_number_affix(parsed) == "pepe+777" + + +def test_parse_spaced_currency_with_explicit_plus_round_trips() -> None: + parsed = parse_number_affix("pepe +777") + assert parsed == NumberAffix(AffixKind.CURRENCY, "pepe", True, 777, 0, -1, True) + assert format_number_affix(parsed) == "pepe +777" + + def test_rejected_examples() -> None: - for s in ("$1234 USD", "1234", "", " 1234", "$\t1234", "$ 1234"): + for s in ("$1234 USD", "1234", "", " 1234", "$\t1234", "$ 1234", "$-1"): assert parse_number_affix(s) is None @@ -40,7 +64,21 @@ def test_affix_max_len() -> None: def test_round_trip_samples() -> None: - samples = ("$1234", "$ 1234", "99.95%", "-314 m/s", "0.5kg", "12 V") + samples = ( + "$1234", + "$ 1234", + "99.95%", + "-314 m/s", + "0.5kg", + "12 V", + "xyz 001", + "abc-001", + "pepe+777", + "pepe +777", + "000123.456000%", + "$ 0.001", + "abc -1", + ) for s in samples: parsed = parse_number_affix(s) assert parsed is not None diff --git a/tests/test_number_affix_delegate.py b/tests/test_number_affix_delegate.py index 7af7634..ecfb7f7 100644 --- a/tests/test_number_affix_delegate.py +++ b/tests/test_number_affix_delegate.py @@ -1,5 +1,5 @@ from gmpy2 import mpq -from PySide6.QtCore import QModelIndex +from PySide6.QtCore import QModelIndex, Qt from PySide6.QtWidgets import QStyleOptionViewItem from documents.tab import JsonTab @@ -24,12 +24,34 @@ def test_currency_affix_editor_commit(qtbot): tab.view_controller.value_delegate.setEditorData(editor, tab.view_controller.source_to_view(index)) editor.affix_combo.setCurrentText("$") - editor.number_editor.setValue(1234) + qtbot.mouseClick(editor.width_button, Qt.MouseButton.LeftButton) + editor.width_spinbox.setValue(4) + editor.number_editor.setValue(12) editor.space_button.setChecked(True) tab.view_controller.value_delegate.setModelData(editor, tab.model, tab.view_controller.source_to_view(index)) item = tab.model.get_item(tab.model.index(0, 0, QModelIndex())) - assert item.value == NumberAffix(AffixKind.CURRENCY, "$", True, 1234) + assert item.value == NumberAffix(AffixKind.CURRENCY, "$", True, 12, 4, -1) + + +def test_currency_affix_editor_commit_with_explicit_plus(qtbot): + tab = JsonTab(lambda *_: None, data={"v": NumberAffix(AffixKind.CURRENCY, "pepe", False, 7)}) + qtbot.addWidget(tab) + + index = _value_index(tab) + editor = tab.view_controller.value_delegate.createEditor( + tab.view, QStyleOptionViewItem(), tab.view_controller.source_to_view(index) + ) + assert isinstance(editor, AffixCompositeEditor) + tab.view_controller.value_delegate.setEditorData(editor, tab.view_controller.source_to_view(index)) + + editor.affix_combo.setCurrentText("pepe") + qtbot.mouseClick(editor.plus_button, Qt.MouseButton.LeftButton) + editor.number_editor.setValue(777) + tab.view_controller.value_delegate.setModelData(editor, tab.model, tab.view_controller.source_to_view(index)) + + item = tab.model.get_item(tab.model.index(0, 0, QModelIndex())) + assert item.value == NumberAffix(AffixKind.CURRENCY, "pepe", False, 777, 0, -1, True) def test_units_affix_editor_commit_float(qtbot): @@ -44,12 +66,91 @@ def test_units_affix_editor_commit_float(qtbot): tab.view_controller.value_delegate.setEditorData(editor, tab.view_controller.source_to_view(index)) editor.affix_combo.setCurrentText("%") + assert editor.precision_button is not None + assert editor.precision_spinbox is not None + qtbot.mouseClick(editor.precision_button, Qt.MouseButton.LeftButton) + editor.precision_spinbox.setValue(3) editor.number_editor.setValue(mpq("1999/20")) editor.space_button.setChecked(False) tab.view_controller.value_delegate.setModelData(editor, tab.model, tab.view_controller.source_to_view(index)) item = tab.model.get_item(tab.model.index(0, 0, QModelIndex())) - assert item.value == NumberAffix(AffixKind.UNITS, "%", False, mpq("1999/20")) + assert item.value == NumberAffix(AffixKind.UNITS, "%", False, mpq("1999/20"), 0, 3) + + +def test_integer_affix_editor_shows_width_spinbox_and_builds_selected_width(qtbot): + editor = AffixCompositeEditor( + None, + kind=AffixKind.CURRENCY, + is_integer=True, + mru_items=["abc-"], + ) + qtbot.addWidget(editor) + editor.show() + + editor.set_value(NumberAffix(AffixKind.CURRENCY, "abc-", False, 1, 3, -1)) + + assert editor.width_button.isChecked() + assert not editor.width_spinbox.isHidden() + assert editor.width_spinbox.value() == 3 + + qtbot.mouseClick(editor.width_button, Qt.MouseButton.LeftButton) + assert editor.width_spinbox.isHidden() + + qtbot.mouseClick(editor.width_button, Qt.MouseButton.LeftButton) + editor.number_editor.setValue(12) + editor.width_spinbox.setValue(5) + + built = editor.build_value() + assert built == NumberAffix(AffixKind.CURRENCY, "abc-", False, 12, 5, -1) + + +def test_affix_editor_plus_toggle_round_trips_explicit_positive_sign(qtbot): + editor = AffixCompositeEditor( + None, + kind=AffixKind.CURRENCY, + is_integer=True, + mru_items=["pepe"], + ) + qtbot.addWidget(editor) + editor.show() + + editor.set_value(NumberAffix(AffixKind.CURRENCY, "pepe", False, 777, 0, -1, True)) + + assert editor.plus_button.isChecked() + assert editor.plus_button.text() == "Plus" + + qtbot.mouseClick(editor.plus_button, Qt.MouseButton.LeftButton) + assert editor.build_value() == NumberAffix(AffixKind.CURRENCY, "pepe", False, 777) + + qtbot.mouseClick(editor.plus_button, Qt.MouseButton.LeftButton) + assert editor.build_value() == NumberAffix(AffixKind.CURRENCY, "pepe", False, 777, 0, -1, True) + + +def test_float_affix_editor_shows_precision_spinbox_and_updates_step(qtbot): + editor = AffixCompositeEditor( + None, + kind=AffixKind.UNITS, + is_integer=False, + mru_items=["%"], + ) + qtbot.addWidget(editor) + editor.show() + + editor.set_value(NumberAffix(AffixKind.UNITS, "%", False, mpq("15432/1250"), 0, 6)) + + assert editor.precision_button is not None + assert editor.precision_spinbox is not None + assert editor.precision_button.isChecked() + assert not editor.precision_spinbox.isHidden() + assert editor.precision_spinbox.value() == 6 + assert editor.number_editor.singleStep() == mpq(1, 10**6) + + editor.precision_spinbox.setValue(3) + + built = editor.build_value() + assert editor.number_editor.singleStep() == mpq(1, 1000) + assert built == NumberAffix(AffixKind.UNITS, "%", False, mpq("15432/1250"), 0, 3) def test_invalid_affix_refuses_commit(qtbot): diff --git a/units/number_affix.py b/units/number_affix.py index aede4a1..dcf26b9 100644 --- a/units/number_affix.py +++ b/units/number_affix.py @@ -9,7 +9,7 @@ from core.safe_mpq import safe_mpq_from_text from settings import INFERENCE_MAX_AFFIX_CHARS -_AFFIX_FORBIDDEN_TOUCH_CHARS = set("+-.") +_AFFIX_FORBIDDEN_TOUCH_CHARS = set(".") _NUMBER_RE = r"[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?" _CURRENCY_RE = re.compile(rf"^(?P[^\d\s+\-.][^\s]*?)(?P ?)(?P{_NUMBER_RE})$") @@ -27,6 +27,9 @@ class NumberAffix: affix: str space: bool number: int | mpq + integral_digits: int = 0 + fractional_digits: int = -1 + explicit_plus: bool = False def __str__(self) -> str: try: @@ -62,6 +65,27 @@ def _parse_number(num_text: str) -> int | mpq: return parsed +def _split_squashed_currency_sign( + affix: str, + num_text: str, + *, + has_space: bool, + max_affix_len: int, +) -> tuple[str, str] | None: + if has_space or not num_text or num_text[0] != "-": + return None + + unsigned = num_text[1:] + if not re.fullmatch(r"0\d+", unsigned): + return None + + shifted_affix = affix + num_text[0] + if not _is_valid_affix(shifted_affix, kind=AffixKind.CURRENCY, max_affix_len=max_affix_len): + return None + + return shifted_affix, unsigned + + def _format_mpq_decimal(value: mpq) -> str: # Inputs accepted by parse_number_affix are finite base-10 numerals, # so this exact decimal conversion is lossless. @@ -100,37 +124,60 @@ def parse_number_affix(s: str, *, max_affix_len: int = 16, allow_expensive: bool if not allow_expensive and len(s) > INFERENCE_MAX_AFFIX_CHARS: return None - m = _CURRENCY_RE.fullmatch(s) - if m is not None: - affix = m.group("affix") - if not _is_valid_affix(affix, kind=AffixKind.CURRENCY, max_affix_len=max_affix_len): - return None - try: - number = _parse_number(m.group("num")) - except ValueError: - return None - return NumberAffix( - kind=AffixKind.CURRENCY, - affix=affix, - space=(m.group("sp") == " "), - number=number, - ) - - m = _UNITS_RE.fullmatch(s) - if m is not None: - affix = m.group("affix") - if not _is_valid_affix(affix, kind=AffixKind.UNITS, max_affix_len=max_affix_len): - return None - try: - number = _parse_number(m.group("num")) - except ValueError: - return None - return NumberAffix( - kind=AffixKind.UNITS, - affix=affix, - space=(m.group("sp") == " "), - number=number, - ) + for regex, kind in [(_CURRENCY_RE, AffixKind.CURRENCY), (_UNITS_RE, AffixKind.UNITS)]: + m = regex.fullmatch(s) + if m is not None: + affix = m.group("affix") + if not _is_valid_affix(affix, kind=kind, max_affix_len=max_affix_len): + return None + num_text = m.group("num") + + if kind == AffixKind.CURRENCY: + squashed = _split_squashed_currency_sign( + affix, + num_text, + has_space=(m.group("sp") == " "), + max_affix_len=max_affix_len, + ) + if squashed is not None: + affix, num_text = squashed + elif m.group("sp") == "" and num_text.startswith("-"): + return None + + if not _is_valid_affix(affix, kind=kind, max_affix_len=max_affix_len): + return None + + try: + number = _parse_number(num_text) + except ValueError: + return None + + digits_str = num_text.lstrip("+-") + integral_digits = 0 + fractional_digits = -1 + + if "." in digits_str or "e" in digits_str.lower(): + parts = digits_str.lower().split("e")[0].split(".") + int_part = parts[0] + if int_part.startswith("0") and len(int_part) > 1: + integral_digits = len(int_part) + if len(parts) > 1: + frac_part = parts[1] + if frac_part.endswith("0"): + fractional_digits = len(frac_part) + else: + if digits_str.startswith("0") and len(digits_str) > 1: + integral_digits = len(digits_str) + + return NumberAffix( + kind=kind, + affix=affix, + space=(m.group("sp") == " "), + number=number, + integral_digits=integral_digits, + fractional_digits=fractional_digits, + explicit_plus=num_text.startswith("+"), + ) return None @@ -139,7 +186,42 @@ def format_number_affix(na: NumberAffix) -> str: if not _is_valid_affix(na.affix, kind=na.kind, max_affix_len=len(na.affix)): raise ValueError("Invalid affix") - number_text = str(na.number) if isinstance(na.number, int) else _format_mpq_decimal(na.number) + if isinstance(na.number, int): + number_text = str(na.number) + if na.integral_digits > 0: + if na.number < 0: + number_text = "-" + number_text[1:].zfill(na.integral_digits) + else: + number_text = number_text.zfill(na.integral_digits) + else: + number_text = _format_mpq_decimal(na.number) + parts = number_text.split(".") + int_part = parts[0] + frac_part = parts[1] if len(parts) > 1 else "" + + if na.integral_digits > 0: + sign = "-" if int_part.startswith("-") else "" + digits = int_part.lstrip("+-") + if len(digits) < na.integral_digits: + int_part = sign + digits.zfill(na.integral_digits) + + if na.fractional_digits >= 0: + if len(frac_part) < na.fractional_digits: + frac_part = frac_part.ljust(na.fractional_digits, "0") + + if na.fractional_digits >= 0 or frac_part: + if not frac_part and na.fractional_digits > 0: + frac_part = "0" * na.fractional_digits + if frac_part: + number_text = f"{int_part}.{frac_part}" + else: + number_text = int_part + else: + number_text = int_part + + if na.explicit_plus and na.number >= 0 and not number_text.startswith(("+", "-")): + number_text = "+" + number_text + gap = " " if na.space else "" if na.kind is AffixKind.CURRENCY: