From 94b90517d58c916d70a95fd626c4a29466b9e34d Mon Sep 17 00:00:00 2001 From: sr9000 Date: Wed, 1 Jul 2026 09:39:22 +0300 Subject: [PATCH 1/7] fix(units): parser rejecting valid inputs, implement integer and fraction digit tracking for format scaling --- tests/test_number_affix.py | 23 +++++++- units/number_affix.py | 109 ++++++++++++++++++++++++++----------- 2 files changed, 97 insertions(+), 35 deletions(-) diff --git a/tests/test_number_affix.py b/tests/test_number_affix.py index 7f85286..3eb0aa4 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,14 @@ 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_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 +46,18 @@ 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", + "000123.456000%", + "$ 0.001", + "abc -1", + ) for s in samples: parsed = parse_number_affix(s) assert parsed is not None diff --git a/units/number_affix.py b/units/number_affix.py index aede4a1..ec34abd 100644 --- a/units/number_affix.py +++ b/units/number_affix.py @@ -27,6 +27,8 @@ class NumberAffix: affix: str space: bool number: int | mpq + integral_digits: int = 0 + fractional_digits: int = -1 def __str__(self) -> str: try: @@ -100,37 +102,48 @@ 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 and num_text.startswith(("-", "+")) and m.group("sp") == "": + # Bug 1: values like `abc-1` require whitespace before minus, e.g. `abc -1` + 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, + ) return None @@ -139,7 +152,39 @@ 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 + gap = " " if na.space else "" if na.kind is AffixKind.CURRENCY: From 7a0178da171a4d8b3dc9603ce30ef23ab0fa6fca Mon Sep 17 00:00:00 2001 From: sr9000 Date: Wed, 1 Jul 2026 09:43:58 +0300 Subject: [PATCH 2/7] feat(ui): affix_composite toggles and dynamic rounding logic --- editors/inline/affix_composite.py | 45 +++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/editors/inline/affix_composite.py b/editors/inline/affix_composite.py index cf3b276..e160b85 100644 --- a/editors/inline/affix_composite.py +++ b/editors/inline/affix_composite.py @@ -42,10 +42,22 @@ 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.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) + if is_integer: self.number_editor = QBigIntSpinBox(self) + self.precision_button = 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.number_editor.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred) self.value_label = QLabel("Value: ", self) @@ -59,6 +71,9 @@ 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.width_button) + if self.precision_button: + layout.addWidget(self.precision_button) layout.addWidget(self.affix_label) layout.addWidget(self.affix_combo) layout.addWidget(self.value_label) @@ -71,6 +86,12 @@ 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_width_toggled(self, checked: bool) -> None: + self.width_button.setText("Width" if checked else "Short") + + def _on_precision_toggled(self, checked: bool) -> None: + self.precision_button.setText("Precision" if checked else "Strip") + def _update_space_button_width(self) -> None: metrics = QFontMetrics(self.space_button.font()) width = ( @@ -106,14 +127,38 @@ 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)) + + if isinstance(self.number_editor, QMpqSpinBox): + if value.fractional_digits >= 0: + self.number_editor.setSingleStep(mpq(1, 10**value.fractional_digits)) + self.number_editor.setValue(value.number) self.set_invalid(False) + # Store for unmodified round-trip + self._integral_digits = value.integral_digits + self._fractional_digits = value.fractional_digits + self.width_button.setChecked(self._integral_digits > 0) + self._on_width_toggled(self._integral_digits > 0) + if self.precision_button: + self.precision_button.setChecked(self._fractional_digits >= 0) + self._on_precision_toggled(self._fractional_digits >= 0) + def build_value(self) -> NumberAffix: affix = self.affix_combo.currentText() + new_integral = self._integral_digits if self.width_button.isChecked() else 0 + + new_fractional = -1 + if self.precision_button and self.precision_button.isChecked(): + new_fractional = self._fractional_digits + if new_fractional < 0: + new_fractional = 1 + 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, ) From fb73ac83e66895ccde546a1f676d7258233e0027 Mon Sep 17 00:00:00 2001 From: sr9000 Date: Wed, 1 Jul 2026 10:00:15 +0300 Subject: [PATCH 3/7] fix(units): prevent currency affix from greedily matching sign without space padding, revert precision tracking via getattr reflection bug --- editors/inline/affix_composite.py | 2 ++ units/number_affix.py | 13 ++++++++++--- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/editors/inline/affix_composite.py b/editors/inline/affix_composite.py index e160b85..435c0c5 100644 --- a/editors/inline/affix_composite.py +++ b/editors/inline/affix_composite.py @@ -129,6 +129,8 @@ def set_value(self, value: NumberAffix) -> None: self.space_button.setChecked(bool(value.space)) if isinstance(self.number_editor, QMpqSpinBox): + from gmpy2 import mpq + if value.fractional_digits >= 0: self.number_editor.setSingleStep(mpq(1, 10**value.fractional_digits)) diff --git a/units/number_affix.py b/units/number_affix.py index ec34abd..d9abac8 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})$") @@ -110,8 +110,15 @@ def parse_number_affix(s: str, *, max_affix_len: int = 16, allow_expensive: bool return None num_text = m.group("num") - if kind == AffixKind.CURRENCY and num_text.startswith(("-", "+")) and m.group("sp") == "": - # Bug 1: values like `abc-1` require whitespace before minus, e.g. `abc -1` + if kind == AffixKind.CURRENCY and m.group("sp") == "" and num_text.startswith(("-", "+")): + # When integer-type affix string meets a negative directly, reject parsing. + # e.g., "abc-001" fails integer matching gracefully because "abc" must not + # eat the sign and leave "001". + # To be precise, currency and sign should just completely fail when squished + # unless explicitly configured differently elsewhere (but we are reverting early). + return None + + if not _is_valid_affix(affix, kind=kind, max_affix_len=max_affix_len): return None try: From 4073c5f9a6b5f11bdd7c8fd92d7981180de702bc Mon Sep 17 00:00:00 2001 From: sr9000 Date: Thu, 2 Jul 2026 09:20:11 +0300 Subject: [PATCH 4/7] fix(units): restore abc-001 affix parsing --- tests/test_number_affix.py | 7 +++++++ units/number_affix.py | 39 +++++++++++++++++++++++++++++++------- 2 files changed, 39 insertions(+), 7 deletions(-) diff --git a/tests/test_number_affix.py b/tests/test_number_affix.py index 3eb0aa4..cf12b02 100644 --- a/tests/test_number_affix.py +++ b/tests/test_number_affix.py @@ -35,6 +35,12 @@ def test_parse_negative_currency_without_space_rejected() -> 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_rejected_examples() -> None: for s in ("$1234 USD", "1234", "", " 1234", "$\t1234", "$ 1234", "$-1"): assert parse_number_affix(s) is None @@ -54,6 +60,7 @@ def test_round_trip_samples() -> None: "0.5kg", "12 V", "xyz 001", + "abc-001", "000123.456000%", "$ 0.001", "abc -1", diff --git a/units/number_affix.py b/units/number_affix.py index d9abac8..5261835 100644 --- a/units/number_affix.py +++ b/units/number_affix.py @@ -64,6 +64,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] not in "+-": + 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. @@ -110,13 +131,17 @@ def parse_number_affix(s: str, *, max_affix_len: int = 16, allow_expensive: bool return None num_text = m.group("num") - if kind == AffixKind.CURRENCY and m.group("sp") == "" and num_text.startswith(("-", "+")): - # When integer-type affix string meets a negative directly, reject parsing. - # e.g., "abc-001" fails integer matching gracefully because "abc" must not - # eat the sign and leave "001". - # To be precise, currency and sign should just completely fail when squished - # unless explicitly configured differently elsewhere (but we are reverting early). - return None + 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 From cd79df4d068f311cecc05b16c5ddb20657002cc0 Mon Sep 17 00:00:00 2001 From: sr9000 Date: Thu, 2 Jul 2026 09:25:19 +0300 Subject: [PATCH 5/7] feat(ui): add affix width and precision spinboxes --- editors/inline/affix_composite.py | 85 ++++++++++++++++++++++++----- tests/test_number_affix_delegate.py | 67 +++++++++++++++++++++-- 2 files changed, 133 insertions(+), 19 deletions(-) diff --git a/editors/inline/affix_composite.py b/editors/inline/affix_composite.py index 435c0c5..16acd4d 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) @@ -48,9 +51,17 @@ def __init__(self, parent: QWidget, *, kind: AffixKind, is_integer: bool, mru_it 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) @@ -58,6 +69,13 @@ def __init__(self, parent: QWidget, *, kind: AffixKind, is_integer: bool, mru_it 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) @@ -72,8 +90,10 @@ def __init__(self, parent: QWidget, *, kind: AffixKind, is_integer: bool, mru_it layout.addWidget(self.space_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) @@ -88,9 +108,45 @@ def _on_space_toggled(self, checked: bool) -> None: 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()) @@ -128,33 +184,32 @@ def set_value(self, value: NumberAffix) -> None: self.affix_combo.lineEdit().setText(text) self.space_button.setChecked(bool(value.space)) - if isinstance(self.number_editor, QMpqSpinBox): - from gmpy2 import mpq - - if value.fractional_digits >= 0: - self.number_editor.setSingleStep(mpq(1, 10**value.fractional_digits)) - self.number_editor.setValue(value.number) self.set_invalid(False) - # Store for unmodified round-trip 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) - self._on_width_toggled(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._on_precision_toggled(self._fractional_digits >= 0) + + self._apply_fractional_step() def build_value(self) -> NumberAffix: affix = self.affix_combo.currentText() - new_integral = self._integral_digits if self.width_button.isChecked() else 0 + 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(): - new_fractional = self._fractional_digits - if new_fractional < 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, diff --git a/tests/test_number_affix_delegate.py b/tests/test_number_affix_delegate.py index 7af7634..f762c13 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,14 @@ 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_units_affix_editor_commit_float(qtbot): @@ -44,12 +46,69 @@ 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_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): From b54c4dedbcbdfcd229124c5830d4d415455fa442 Mon Sep 17 00:00:00 2001 From: sr9000 Date: Thu, 2 Jul 2026 09:33:47 +0300 Subject: [PATCH 6/7] feat(ui): preserve explicit plus in affix values --- editors/inline/affix_composite.py | 12 +++++++++ tests/test_number_affix.py | 14 ++++++++++ tests/test_number_affix_delegate.py | 42 +++++++++++++++++++++++++++++ units/number_affix.py | 11 +++++--- 4 files changed, 76 insertions(+), 3 deletions(-) diff --git a/editors/inline/affix_composite.py b/editors/inline/affix_composite.py index 16acd4d..e40bbdb 100644 --- a/editors/inline/affix_composite.py +++ b/editors/inline/affix_composite.py @@ -45,6 +45,12 @@ 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) @@ -89,6 +95,7 @@ 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: @@ -106,6 +113,9 @@ 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)) @@ -183,6 +193,7 @@ 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) @@ -218,4 +229,5 @@ def build_value(self) -> NumberAffix: 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 cf12b02..1a65a83 100644 --- a/tests/test_number_affix.py +++ b/tests/test_number_affix.py @@ -41,6 +41,18 @@ def test_parse_currency_affix_ending_with_dash_for_zero_padded_int() -> None: 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", "$-1"): assert parse_number_affix(s) is None @@ -61,6 +73,8 @@ def test_round_trip_samples() -> None: "12 V", "xyz 001", "abc-001", + "pepe+777", + "pepe +777", "000123.456000%", "$ 0.001", "abc -1", diff --git a/tests/test_number_affix_delegate.py b/tests/test_number_affix_delegate.py index f762c13..ecfb7f7 100644 --- a/tests/test_number_affix_delegate.py +++ b/tests/test_number_affix_delegate.py @@ -34,6 +34,26 @@ def test_currency_affix_editor_commit(qtbot): 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): tab = JsonTab(lambda *_: None, data={"v": NumberAffix(AffixKind.UNITS, "%", False, mpq("1/2"))}) qtbot.addWidget(tab) @@ -85,6 +105,28 @@ def test_integer_affix_editor_shows_width_spinbox_and_builds_selected_width(qtbo 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, diff --git a/units/number_affix.py b/units/number_affix.py index 5261835..dcf26b9 100644 --- a/units/number_affix.py +++ b/units/number_affix.py @@ -29,6 +29,7 @@ class NumberAffix: number: int | mpq integral_digits: int = 0 fractional_digits: int = -1 + explicit_plus: bool = False def __str__(self) -> str: try: @@ -71,7 +72,7 @@ def _split_squashed_currency_sign( has_space: bool, max_affix_len: int, ) -> tuple[str, str] | None: - if has_space or not num_text or num_text[0] not in "+-": + if has_space or not num_text or num_text[0] != "-": return None unsigned = num_text[1:] @@ -140,7 +141,7 @@ def parse_number_affix(s: str, *, max_affix_len: int = 16, allow_expensive: bool ) if squashed is not None: affix, num_text = squashed - elif m.group("sp") == "" and num_text.startswith(("-", "+")): + elif m.group("sp") == "" and num_text.startswith("-"): return None if not _is_valid_affix(affix, kind=kind, max_affix_len=max_affix_len): @@ -175,6 +176,7 @@ def parse_number_affix(s: str, *, max_affix_len: int = 16, allow_expensive: bool number=number, integral_digits=integral_digits, fractional_digits=fractional_digits, + explicit_plus=num_text.startswith("+"), ) return None @@ -199,7 +201,7 @@ def format_number_affix(na: NumberAffix) -> str: if na.integral_digits > 0: sign = "-" if int_part.startswith("-") else "" - digits = int_part.lstrip("-") + digits = int_part.lstrip("+-") if len(digits) < na.integral_digits: int_part = sign + digits.zfill(na.integral_digits) @@ -217,6 +219,9 @@ def format_number_affix(na: NumberAffix) -> str: 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: From 051ede8fbae36435a566e89d36946d7b2c691485 Mon Sep 17 00:00:00 2001 From: sr9000 Date: Thu, 2 Jul 2026 10:02:33 +0300 Subject: [PATCH 7/7] bump --- README.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) 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