Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 2 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
116 changes: 115 additions & 1 deletion editors/inline/affix_composite.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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 = (
Expand Down Expand Up @@ -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,
)
44 changes: 41 additions & 3 deletions tests/test_number_affix.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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


Expand All @@ -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
Expand Down
109 changes: 105 additions & 4 deletions tests/test_number_affix_delegate.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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):
Expand All @@ -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):
Expand Down
Loading
Loading