From 2da1d03b5a096b968e16b0fc693a9a86243ad2bb Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Mon, 12 Oct 2026 19:43:39 +0200 Subject: [PATCH 01/12] feat: project serial counter for text templates Text templates can use {serial}, formatted like any other value ({serial:04d}, SN-{serial:05d}). The counter (current, start, step, optional end with wrap, auto-advance) is stored in the project. Each workpiece has a variable offset: a copy with offset n shows the value n steps after the current one, so array copies can carry consecutive numbers. When the counter changes, only workpieces whose templates read a document variable regenerate, keeping their scale. Refs #527 --- .../sketcher/core/sketch.py | 8 + .../tests/core/test_sketch_variable_text.py | 79 ++++++++ rayforge/core/doc.py | 55 ++++++ rayforge/core/variable_text.py | 172 ++++++++++++++++++ rayforge/core/workpiece.py | 126 ++++++++++++- tests/core/template_provider.py | 84 +++++++++ tests/core/test_variable_text.py | 135 ++++++++++++++ tests/core/test_workpiece_variable_text.py | 165 +++++++++++++++++ 8 files changed, 820 insertions(+), 4 deletions(-) create mode 100644 rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/test_sketch_variable_text.py create mode 100644 rayforge/core/variable_text.py create mode 100644 tests/core/template_provider.py create mode 100644 tests/core/test_variable_text.py create mode 100644 tests/core/test_workpiece_variable_text.py diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/sketch.py b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/sketch.py index 67bf84609..41cb08d9e 100644 --- a/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/sketch.py +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/sketch.py @@ -346,6 +346,14 @@ def get_geometry( resolved_text_cache.update(clone._resolved_text_cache) return geo, fills + def get_template_sources(self) -> list[str]: + """The raw contents of all text boxes, including templates.""" + return [ + e.content + for e in self.registry.entities + if isinstance(e, TextBoxEntity) and e.content + ] + def _clone_for_geometry(self) -> "Sketch": """ Lightweight clone for geometry generation. diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/test_sketch_variable_text.py b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/test_sketch_variable_text.py new file mode 100644 index 000000000..855e19a44 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/test_sketch_variable_text.py @@ -0,0 +1,79 @@ +from typing import cast + +from raygeo.geo.shape.text import FontConfig +from sketcher.core import Sketch +from sketcher.core.entities.text_box import TextBoxEntity + +from rayforge.core.doc import Doc +from rayforge.core.variable_text import SerialSettings +from rayforge.core.workpiece import WorkPiece + + +def _sketch_with_text(content: str) -> tuple[Sketch, TextBoxEntity]: + sketch = Sketch(name="plate") + origin = sketch.add_point(0, 0) + width_pt = sketch.add_point(10, 0) + height_pt = sketch.add_point(0, 10) + box_id = sketch.registry.add_text_box( + origin, width_pt, height_pt, content, FontConfig() + ) + return sketch, cast(TextBoxEntity, sketch.registry.get_entity(box_id)) + + +def _resolved(wp: WorkPiece, box: TextBoxEntity) -> str: + entry = wp._resolved_text_cache[box.id] + return entry[1] + + +def _place(doc: Doc, sketch: Sketch, offset: int = 0) -> WorkPiece: + doc.add_asset(sketch) + wp = WorkPiece.from_geometry_provider(sketch) + wp.variable_offset = offset + doc.active_layer.add_child(wp) + return wp + + +def test_template_sources_lists_text_box_contents(): + sketch, _box = _sketch_with_text("No. {serial:03d}") + assert sketch.get_template_sources() == ["No. {serial:03d}"] + + +def test_serial_is_resolved_in_document(): + doc = Doc() + doc.variable_text.serial = SerialSettings(current=7) + sketch, box = _sketch_with_text("No. {serial:03d}") + wp = _place(doc, sketch) + assert _resolved(wp, box) == "No. 007" + + +def test_copies_show_consecutive_numbers(): + doc = Doc() + doc.variable_text.serial = SerialSettings(current=41) + sketch, box = _sketch_with_text("{serial}") + first = _place(doc, sketch) + second = first.duplicate() + assert isinstance(second, WorkPiece) + second.variable_offset = 1 + doc.active_layer.add_child(second) + assert _resolved(first, box) == "41" + assert _resolved(second, box) == "42" + + +def test_counter_change_updates_text(): + doc = Doc() + sketch, box = _sketch_with_text("{serial:04d}") + wp = _place(doc, sketch) + doc.variable_text.serial = SerialSettings(current=12) + assert _resolved(wp, box) == "0012" + + +def test_saved_text_is_kept_on_load(): + doc = Doc() + doc.variable_text.serial = SerialSettings(current=5) + sketch, box = _sketch_with_text("{serial}-{uuid8()}") + wp = _place(doc, sketch) + before = _resolved(wp, box) + restored = Doc.from_dict(doc.to_dict()) + restored_wp = restored.all_workpieces[0] + assert restored_wp.boundaries is not None + assert _resolved(restored_wp, box) == before diff --git a/rayforge/core/doc.py b/rayforge/core/doc.py index c6a45d17e..d5c08213d 100644 --- a/rayforge/core/doc.py +++ b/rayforge/core/doc.py @@ -17,6 +17,7 @@ from .item import DocItem from .layer import Layer from .source_asset import SourceAsset +from .variable_text import VariableText from .workpiece import WorkPiece if TYPE_CHECKING: @@ -45,6 +46,11 @@ def __init__(self): self.assets: dict[str, IAsset] = {} self.asset_order: list[str] = [] + # Variables for text templates (serial number, ...) + self._variable_text = VariableText() + self._variable_text.changed.connect(self._on_variable_text_changed) + self.descendant_added.connect(self._on_item_added_for_templates) + # A new document starts with three empty workpiece layers for i in range(3): workpiece_layer = Layer(_("Layer {}").format(i + 1)) @@ -90,6 +96,7 @@ def _deserialize_item(item_data: dict) -> DocItem: doc = cls() doc.uid = data.get("uid", doc.uid) + doc.variable_text = VariableText.from_dict(data.get("variable_text")) # Clear the default layer created by __init__ doc.set_children([]) @@ -173,8 +180,56 @@ def to_dict(self) -> dict: "active_layer_index": self._active_layer_index, "children": [child.to_dict() for child in self.children], "assets": [asset.to_dict() for asset in self.get_all_assets()], + "variable_text": self._variable_text.to_dict(), } + @property + def variable_text(self) -> VariableText: + """The variables offered to text templates.""" + return self._variable_text + + @variable_text.setter + def variable_text(self, value: VariableText) -> None: + self._variable_text.changed.disconnect(self._on_variable_text_changed) + self._variable_text = value + value.changed.connect(self._on_variable_text_changed) + self._on_variable_text_changed(value) + + def _template_workpieces(self) -> list[WorkPiece]: + """Workpieces whose templates read a document variable.""" + names = self._variable_text.names + return [ + wp + for wp in self.all_workpieces + if wp.references_template_names(names) + ] + + def variable_text_copies(self) -> int: + """ + How many consecutive variable values one job uses: the highest + variable offset of a template workpiece plus one, or 0 if no + workpiece uses a document variable. + """ + offsets = [wp.variable_offset for wp in self._template_workpieces()] + if not offsets: + return 0 + return max(offsets) + 1 + + def _on_variable_text_changed(self, sender: VariableText) -> None: + for wp in self._template_workpieces(): + wp.refresh_template_values() + + def _on_item_added_for_templates( + self, sender: DocItem, *, origin: DocItem, parent_of_origin: DocItem + ) -> None: + if isinstance(origin, WorkPiece): + added = [origin] + else: + added = origin.get_descendants(of_type=WorkPiece) + for wp in added: + if wp.references_template_names(self._variable_text.names): + wp.sync_template_values() + def add_asset( self, asset: IAsset, index: int | None = None, silent: bool = False ): diff --git a/rayforge/core/variable_text.py b/rayforge/core/variable_text.py new file mode 100644 index 000000000..2c99f3212 --- /dev/null +++ b/rayforge/core/variable_text.py @@ -0,0 +1,172 @@ +""" +Document-level variables for text templates. + +A document carries a :class:`VariableText` instance whose values are +injected into the ``{...}`` templates of every geometry provider (for +example the text boxes of a sketch). Each workpiece adds its own +*variable offset*, so copies of the same design can show consecutive +serial numbers. +""" + +from __future__ import annotations + +import ast +import re +from collections.abc import Iterable +from dataclasses import dataclass, replace +from typing import Any + +from blinker import Signal + +SERIAL_NAME = "serial" + +_PLACEHOLDER_RE = re.compile(r"\{([^{}]+)\}") +_IDENTIFIER_RE = re.compile(r"[A-Za-z_][A-Za-z0-9_]*") + + +@dataclass(frozen=True) +class SerialSettings: + """ + An immutable serial counter. + + Attributes: + current: The value the next job starts with. + start: The value :meth:`reset` returns to, and where the + counter wraps to after ``end``. + step: How much the value grows per copy and per job. + end: Optional last value. Values past it wrap to ``start``. + auto_advance: Whether a finished job moves ``current`` on. + advance_on_export: Whether exporting G-code moves ``current`` + on as well, for jobs that run from a memory card. + """ + + current: int = 1 + start: int = 1 + step: int = 1 + end: int | None = None + auto_advance: bool = True + advance_on_export: bool = False + + def _range_length(self) -> int | None: + """Number of values in ``start..end``, or None if unbounded.""" + if self.end is None or self.step <= 0 or self.end < self.start: + return None + return (self.end - self.start) // self.step + 1 + + def value_at(self, offset: int) -> int: + """The value shown by a copy that is *offset* copies further.""" + value = self.current + offset * self.step + length = self._range_length() + if length is None: + return value + index = (value - self.start) // self.step + return self.start + (index % length) * self.step + + def values_for(self, copies: int) -> list[int]: + """The values of *copies* consecutive copies.""" + return [self.value_at(i) for i in range(max(copies, 0))] + + def advanced(self, copies: int = 1) -> SerialSettings: + """A counter moved on (or back, if negative) by *copies*.""" + return replace(self, current=self.value_at(copies)) + + def reset(self) -> SerialSettings: + """A counter whose current value is back at ``start``.""" + return replace(self, current=self.start) + + def to_dict(self) -> dict[str, Any]: + return { + "current": self.current, + "start": self.start, + "step": self.step, + "end": self.end, + "auto_advance": self.auto_advance, + "advance_on_export": self.advance_on_export, + } + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> SerialSettings: + default = cls() + start = int(data.get("start", default.start)) + end = data.get("end") + if end is not None: + end = int(end) + if end < start: + end = None + return cls( + current=int(data.get("current", default.current)), + start=start, + step=int(data.get("step", default.step)), + end=end, + auto_advance=bool(data.get("auto_advance", default.auto_advance)), + advance_on_export=bool( + data.get("advance_on_export", default.advance_on_export) + ), + ) + + +class VariableText: + """ + The variables a document offers to text templates. + + Emits :attr:`changed` whenever a value changes, so workpieces whose + templates reference a variable can regenerate. + """ + + def __init__(self): + self.changed = Signal() + self._serial = SerialSettings() + + @property + def serial(self) -> SerialSettings: + return self._serial + + @serial.setter + def serial(self, value: SerialSettings) -> None: + if value == self._serial: + return + self._serial = value + self.changed.send(self) + + @property + def names(self) -> set[str]: + """The template names this object provides.""" + return {SERIAL_NAME} + + def template_values(self, offset: int = 0) -> dict[str, Any]: + """The template values for a copy with the given offset.""" + return {SERIAL_NAME: self._serial.value_at(offset)} + + def to_dict(self) -> dict[str, Any]: + return {"serial": self._serial.to_dict()} + + @classmethod + def from_dict(cls, data: dict[str, Any] | None) -> VariableText: + instance = cls() + if data: + serial = data.get("serial") + if isinstance(serial, dict): + instance._serial = SerialSettings.from_dict(serial) + return instance + + +def _names_in_expression(expression: str) -> set[str]: + """Names an expression reads, ignoring string literals.""" + try: + tree = ast.parse(expression.strip(), mode="eval") + except SyntaxError: + return set(_IDENTIFIER_RE.findall(expression)) + return {node.id for node in ast.walk(tree) if isinstance(node, ast.Name)} + + +def referenced_template_names(templates: Iterable[str]) -> set[str]: + """ + Collects the names used inside the ``{...}`` placeholders of + *templates*. A format spec after the first colon is ignored. + """ + names: set[str] = set() + for template in templates: + for match in _PLACEHOLDER_RE.finditer(template or ""): + expression, _sep, _spec = match.group(1).partition(":") + names |= _names_in_expression(expression) + return names diff --git a/rayforge/core/workpiece.py b/rayforge/core/workpiece.py index 9fc954308..275e14dd0 100644 --- a/rayforge/core/workpiece.py +++ b/rayforge/core/workpiece.py @@ -34,6 +34,7 @@ from .item import DocItem from .source_asset_segment import SourceAssetSegment from .tab import Tab +from .variable_text import referenced_template_names from .vectorization_spec import TraceSpec if TYPE_CHECKING: @@ -130,6 +131,12 @@ def __init__( self._resolved_text_cache: dict = {} + # Shifts the document's template variables for this copy, so + # e.g. array copies show consecutive serial numbers. + self._variable_offset: int = 0 + # The template variables the cached geometry was generated with. + self._template_values_used: dict[str, Any] = {} + self.source_asset_uid: str | None = None self._tabs: list[Tab] = [] @@ -423,8 +430,9 @@ def boundaries(self) -> Geometry | None: f"WP {self.uid[:8]}: Getting geometry with " f"params: {self._geometry_provider_params}" ) + self._template_values_used = self.template_values() unnormalized_geo, unnormalized_fills = provider.get_geometry( - self._geometry_provider_params, + self._provider_params(), resolved_text_cache=self._resolved_text_cache, ) @@ -620,9 +628,11 @@ def in_world(self) -> WorkPiece: world_wp.tabs = deepcopy(self.tabs) world_wp.tabs_enabled = self.tabs_enabled world_wp.geometry_provider_uid = self.geometry_provider_uid - world_wp.geometry_provider_params = deepcopy( - self._geometry_provider_params + world_wp._geometry_provider_params = deepcopy( + self._world_provider_params() ) + world_wp._variable_offset = self._variable_offset + world_wp._template_values_used = deepcopy(self._template_values_used) world_wp.source_asset_uid = self.source_asset_uid world_wp._resolved_text_cache = dict(self._resolved_text_cache) @@ -975,6 +985,10 @@ def to_dict(self) -> dict[str, Any]: } if cache: state["resolved_text_cache"] = cache + if self._variable_offset: + state["variable_offset"] = self._variable_offset + if self._template_values_used: + state["template_values"] = self._template_values_used if self._data is not None: state["data"] = self._data if self._original_data is not None: @@ -1012,6 +1026,8 @@ def from_dict(cls, data: dict[str, Any]) -> WorkPiece: "geometry_provider_params", "source_asset_uid", "resolved_text_cache", + "variable_offset", + "template_values", "data", "original_data", "source_px_dims", @@ -1059,6 +1075,8 @@ def from_dict(cls, data: dict[str, Any]) -> WorkPiece: "geometry_provider_params", {} ) or data.get("sketch_params", {}) wp.source_asset_uid = data.get("source_asset_uid") + wp._variable_offset = int(data.get("variable_offset", 0)) + wp._template_values_used = dict(data.get("template_values") or {}) raw_cache = data.get("resolved_text_cache") if raw_cache: wp._resolved_text_cache = { @@ -1114,6 +1132,105 @@ def geometry_provider_params(self, new_params: dict[str, Any]): # Regenerate the internal geometry and natural size self.regenerate_from_geometry_provider() + @property + def variable_offset(self) -> int: + """ + The copy offset applied to the document's template variables. + + A copy with offset ``n`` shows the values ``n`` copies after + the current ones, e.g. serial number ``current + n * step``. + """ + return self._variable_offset + + @variable_offset.setter + def variable_offset(self, value: int) -> None: + value = int(value) + if value == self._variable_offset: + return + self._variable_offset = value + self.refresh_template_values() + + def template_values(self) -> dict[str, Any]: + """The document's template variables as seen by this copy.""" + doc = self.doc + if doc is None: + return {} + return doc.variable_text.template_values(self._variable_offset) + + def _provider_params(self) -> dict[str, Any]: + """ + Parameters for the geometry provider: the document's template + variables, overridden by this instance's own parameters. + """ + params = self.template_values() + params.update(self._geometry_provider_params or {}) + return params + + def _world_provider_params(self) -> dict[str, Any]: + """ + Parameters for a detached copy: the template variables are baked + in only when this workpiece's templates read them. + """ + doc = self.doc + if doc is not None and self.references_template_names( + doc.variable_text.names + ): + return self._provider_params() + return dict(self._geometry_provider_params or {}) + + def template_sources(self) -> list[str]: + """The raw template strings the geometry provider renders.""" + provider = self.get_geometry_provider() + get_sources = getattr(provider, "get_template_sources", None) + if callable(get_sources): + return [str(source) for source in cast(list, get_sources())] + return [ + entry[0] + for entry in self._resolved_text_cache.values() + if isinstance(entry, (tuple, list)) and entry + ] + + def references_template_names(self, names: set[str]) -> bool: + """Whether any template of this workpiece reads one of *names*.""" + if not self.geometry_provider_uid: + return False + used = referenced_template_names(self.template_sources()) + return bool(used & names) + + def refresh_template_values(self) -> None: + """ + Regenerates the geometry after a template variable changed. + + Only workpieces whose templates read one of the document's + variables are affected. The current scale relative to the + natural size is kept, so a longer text grows instead of being + squeezed into the old box. + """ + doc = self.doc + if doc is None or not self.references_template_names( + doc.variable_text.names + ): + return + old_w, old_h = self.natural_width_mm, self.natural_height_mm + size_w, size_h = self.size + self._resolved_text_cache = {} + self.clear_render_cache() + if self.boundaries is not None and old_w > 1e-9 and old_h > 1e-9: + self.set_size( + self.natural_width_mm * size_w / old_w, + self.natural_height_mm * size_h / old_h, + ) + self.updated.send(self) + + def sync_template_values(self) -> None: + """ + Refreshes the geometry if it was generated with other template + values than the document offers now, e.g. after the workpiece + was created outside the document or copied with a new offset. + """ + if self._template_values_used != self.template_values(): + self.refresh_template_values() + def get_natural_aspect_ratio(self) -> float | None: size = self.natural_size if size: @@ -1622,7 +1739,8 @@ def regenerate_from_geometry_provider(self) -> None: ) # Get geometry with current parameter overrides. - variable_overrides = self._geometry_provider_params or {} + variable_overrides = self._provider_params() + self._template_values_used = self.template_values() logger.debug( f"WP {self.uid[:8]}: Getting geometry with params: " f"{variable_overrides}" diff --git a/tests/core/template_provider.py b/tests/core/template_provider.py new file mode 100644 index 000000000..11231b5cc --- /dev/null +++ b/tests/core/template_provider.py @@ -0,0 +1,84 @@ +"""A fake geometry provider that renders one text template.""" + +import uuid +from typing import Any, ClassVar + +from blinker import Signal +from raygeo.geo import Geometry + +from rayforge.core.doc import Doc +from rayforge.core.expression import ExpressionMap +from rayforge.core.workpiece import WorkPiece + + +class TemplateProvider: + """A geometry provider rendering one template as a rectangle whose + width grows with the length of the resolved text.""" + + is_addable: ClassVar[bool] = False + asset_type_name: ClassVar[str] = "template-test" + display_icon_name: ClassVar[str] = "" + is_reorderable: ClassVar[bool] = False + is_draggable_to_canvas: ClassVar[bool] = False + type_display_name: ClassVar[str] = "Template" + can_edit: ClassVar[bool] = False + add_action: ClassVar[str | None] = None + activate_action: ClassVar[str | None] = None + edit_item_action: ClassVar[str | None] = None + + def __init__(self, template: str): + self.template = template + self._uid = str(uuid.uuid4()) + self.name = "template" + self.updated = Signal() + self.hidden = False + self.calls: list[dict[str, Any]] = [] + self.rendered: list[str] = [] + + @property + def uid(self) -> str: + return self._uid + + @property + def provider_type_name(self) -> str: + return "template-test" + + @property + def renderer(self): + return None + + def get_template_sources(self) -> list[str]: + return [self.template] + + def get_geometry(self, params=None, *, resolved_text_cache=None): + params = dict(params or {}) + self.calls.append(params) + text = ExpressionMap(params).format(self.template) + self.rendered.append(text) + width = 2.0 * len(text) + geo = Geometry() + geo.move_to(0.0, 0.0) + geo.line_to(width, 0.0) + geo.line_to(width, 5.0) + geo.line_to(0.0, 5.0) + geo.close_path() + return geo, [] + + def to_dict(self) -> dict[str, Any]: + return {"type": "template-test", "uid": self._uid} + + @classmethod + def from_dict(cls, data): + return cls("") + + def get_thumbnail(self, size: int): + return None + + +def place(doc: Doc, template: str, offset: int = 0) -> WorkPiece: + provider = TemplateProvider(template) + doc.add_asset(provider) + wp = WorkPiece.from_geometry_provider(provider) + wp.variable_offset = offset + doc.active_layer.add_child(wp) + return wp diff --git a/tests/core/test_variable_text.py b/tests/core/test_variable_text.py new file mode 100644 index 000000000..115327e17 --- /dev/null +++ b/tests/core/test_variable_text.py @@ -0,0 +1,135 @@ +from rayforge.core.variable_text import ( + SerialSettings, + VariableText, + referenced_template_names, +) + + +class TestSerialSettings: + def test_defaults(self): + s = SerialSettings() + assert s.current == 1 + assert s.start == 1 + assert s.step == 1 + assert s.end is None + assert s.auto_advance is True + assert s.advance_on_export is False + + def test_value_at_offset_uses_step(self): + s = SerialSettings(current=10, start=1, step=5) + assert s.value_at(0) == 10 + assert s.value_at(1) == 15 + assert s.value_at(3) == 25 + + def test_value_at_wraps_after_end(self): + s = SerialSettings(current=4, start=1, step=1, end=5) + assert [s.value_at(i) for i in range(4)] == [4, 5, 1, 2] + + def test_value_at_wraps_with_step(self): + s = SerialSettings(current=90, start=10, step=40, end=95) + # Range is 10, 50, 90; 130 is past the end and wraps to 10. + assert [s.value_at(i) for i in range(3)] == [90, 10, 50] + + def test_value_at_negative_offset_wraps_backwards(self): + s = SerialSettings(current=1, start=1, step=1, end=3) + assert s.value_at(-1) == 3 + + def test_zero_step_keeps_value(self): + s = SerialSettings(current=7, step=0) + assert s.value_at(5) == 7 + + def test_advanced_moves_current(self): + s = SerialSettings(current=3, step=2) + assert s.advanced(4).current == 11 + assert s.advanced().current == 5 + assert s.current == 3 + + def test_previous_moves_back_one_step(self): + s = SerialSettings(current=3, step=2) + assert s.advanced(-1).current == 1 + + def test_reset_returns_to_start(self): + s = SerialSettings(current=42, start=7) + assert s.reset().current == 7 + + def test_values_for_copies(self): + s = SerialSettings(current=8, step=1, end=9, start=1) + assert s.values_for(3) == [8, 9, 1] + + def test_round_trip(self): + s = SerialSettings( + current=12, + start=10, + step=2, + end=30, + auto_advance=False, + advance_on_export=True, + ) + assert SerialSettings.from_dict(s.to_dict()) == s + + def test_from_dict_ignores_missing_keys(self): + assert SerialSettings.from_dict({}) == SerialSettings() + + def test_from_dict_rejects_end_below_start(self): + s = SerialSettings.from_dict({"start": 10, "end": 5}) + assert s.end is None + + +class TestVariableText: + def test_template_values_with_offset(self): + vt = VariableText() + vt.serial = SerialSettings(current=100, step=10) + assert vt.template_values(0) == {"serial": 100} + assert vt.template_values(2) == {"serial": 120} + + def test_setting_serial_emits_changed(self): + vt = VariableText() + received = [] + + def on_changed(sender): + received.append(sender) + + vt.changed.connect(on_changed) + vt.serial = SerialSettings(current=5) + assert received == [vt] + + def test_setting_equal_serial_is_silent(self): + vt = VariableText() + received = [] + + def on_changed(sender): + received.append(sender) + + vt.changed.connect(on_changed) + vt.serial = SerialSettings() + assert received == [] + + def test_names(self): + assert "serial" in VariableText().names + + def test_round_trip(self): + vt = VariableText() + vt.serial = SerialSettings(current=3, start=2, step=4) + restored = VariableText.from_dict(vt.to_dict()) + assert restored.serial == vt.serial + + def test_from_dict_none(self): + assert VariableText.from_dict(None).serial == SerialSettings() + + +class TestReferencedTemplateNames: + def test_finds_names_in_placeholders(self): + names = referenced_template_names(["No. {serial:04d}", "{width * 2}"]) + assert {"serial", "width"} <= names + + def test_ignores_text_outside_placeholders(self): + assert "serial" not in referenced_template_names(["serial"]) + + def test_handles_function_calls_and_subscripts(self): + names = referenced_template_names(['{csv["Name"]} {today()}']) + assert {"csv", "today"} <= names + assert "Name" not in names + + def test_invalid_expression_falls_back_to_identifiers(self): + names = referenced_template_names(["{serial +}"]) + assert "serial" in names diff --git a/tests/core/test_workpiece_variable_text.py b/tests/core/test_workpiece_variable_text.py new file mode 100644 index 000000000..2ff9dc677 --- /dev/null +++ b/tests/core/test_workpiece_variable_text.py @@ -0,0 +1,165 @@ +import pytest + +from rayforge.core.doc import Doc +from rayforge.core.variable_text import SerialSettings +from rayforge.core.workpiece import WorkPiece +from tests.core.template_provider import TemplateProvider, place + + +@pytest.fixture +def doc(): + return Doc() + + +class TestVariableOffset: + def test_default_offset_is_zero(self): + assert WorkPiece("wp").variable_offset == 0 + + def test_offset_round_trip(self): + wp = WorkPiece("wp") + wp.variable_offset = 4 + data = wp.to_dict() + assert data["variable_offset"] == 4 + assert WorkPiece.from_dict(data).variable_offset == 4 + + def test_zero_offset_is_not_serialized(self): + assert "variable_offset" not in WorkPiece("wp").to_dict() + + def test_offset_is_not_an_extra_attribute(self): + wp = WorkPiece("wp") + wp.variable_offset = 2 + assert WorkPiece.from_dict(wp.to_dict()).extra == {} + + def test_duplicate_keeps_offset(self): + wp = WorkPiece("wp") + wp.variable_offset = 3 + copy = wp.duplicate() + assert isinstance(copy, WorkPiece) + assert copy.variable_offset == 3 + + +class TestTemplateValues: + def test_unparented_workpiece_has_no_values(self): + assert WorkPiece("wp").template_values() == {} + + def test_values_follow_doc_counter_and_offset(self, doc): + doc.variable_text.serial = SerialSettings(current=10, step=5) + wp = place(doc, "{serial}", offset=2) + assert wp.template_values() == {"serial": 20} + + def test_provider_receives_serial(self, doc): + doc.variable_text.serial = SerialSettings(current=7) + wp = place(doc, "No. {serial:03d}") + wp.clear_render_cache() + assert wp.boundaries is not None + provider = wp.get_geometry_provider() + assert isinstance(provider, TemplateProvider) + assert provider.rendered[-1] == "No. 007" + + def test_instance_params_win_over_doc_values(self, doc): + doc.variable_text.serial = SerialSettings(current=7) + wp = place(doc, "{serial}") + wp._geometry_provider_params = {"serial": 99} + wp.clear_render_cache() + assert wp.boundaries is not None + provider = wp.get_geometry_provider() + assert isinstance(provider, TemplateProvider) + assert provider.rendered[-1] == "99" + + def test_in_world_bakes_values(self, doc): + doc.variable_text.serial = SerialSettings(current=3) + wp = place(doc, "{serial}", offset=1) + world = wp.in_world() + assert world.geometry_provider_params["serial"] == 4 + + def test_in_world_leaves_plain_templates_alone(self, doc): + doc.variable_text.serial = SerialSettings(current=3) + wp = place(doc, "Hello") + assert "serial" not in wp.in_world().geometry_provider_params + + def test_references_variables(self, doc): + wp = place(doc, "{serial:04d}") + plain = place(doc, "Hello") + assert wp.references_template_names({"serial"}) + assert not plain.references_template_names({"serial"}) + + +class TestRefreshOnChange: + def test_counter_change_regenerates_text(self, doc): + wp = place(doc, "{serial}") + provider = wp.get_geometry_provider() + assert isinstance(provider, TemplateProvider) + doc.variable_text.serial = SerialSettings(current=1234) + assert provider.rendered[-1] == "1234" + + def test_counter_change_keeps_scale(self, doc): + doc.variable_text.serial = SerialSettings(current=9) + wp = place(doc, "{serial}") + wp.set_size(4.0, 10.0) + doc.variable_text.serial = SerialSettings(current=10) + width, height = wp.size + # The text doubled in length; the 2x scale of the + # workpiece is preserved. + assert width == pytest.approx(8.0) + assert height == pytest.approx(10.0) + + def test_counter_change_emits_updated(self, doc): + wp = place(doc, "{serial}") + received = [] + + def on_updated(sender, **kwargs): + received.append(sender) + + wp.updated.connect(on_updated) + doc.variable_text.serial = SerialSettings(current=5) + assert wp in received + + def test_unrelated_workpiece_is_not_regenerated(self, doc): + wp = place(doc, "Hello") + provider = wp.get_geometry_provider() + assert isinstance(provider, TemplateProvider) + calls = len(provider.calls) + doc.variable_text.serial = SerialSettings(current=5) + assert len(provider.calls) == calls + + def test_offset_change_regenerates(self, doc): + doc.variable_text.serial = SerialSettings(current=1) + wp = place(doc, "{serial}") + provider = wp.get_geometry_provider() + assert isinstance(provider, TemplateProvider) + wp.variable_offset = 9 + assert provider.rendered[-1] == "10" + + +class TestDocSerialization: + def test_doc_round_trip_keeps_counter(self, doc): + doc.variable_text.serial = SerialSettings(current=12, step=3) + restored = Doc.from_dict(doc.to_dict()) + assert restored.variable_text.serial == doc.variable_text.serial + + def test_old_documents_get_default_counter(self, doc): + data = doc.to_dict() + data.pop("variable_text", None) + restored = Doc.from_dict(data) + assert restored.variable_text.serial == SerialSettings() + + def test_restored_doc_refreshes_on_change(self, doc): + restored = Doc.from_dict(doc.to_dict()) + received = [] + + def on_changed(sender): + received.append(sender) + + restored.variable_text.changed.connect(on_changed) + restored.variable_text.serial = SerialSettings(current=2) + assert received == [restored.variable_text] + + def test_copies_count_uses_highest_offset(self, doc): + place(doc, "{serial}", offset=0) + place(doc, "{serial}", offset=5) + place(doc, "Hello", offset=9) + assert doc.variable_text_copies() == 6 + + def test_copies_count_without_template_users(self, doc): + place(doc, "Hello") + assert doc.variable_text_copies() == 0 From f9d18598c795fddab6bdc35c774045c09b50dbf2 Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Mon, 12 Oct 2026 19:44:42 +0200 Subject: [PATCH 02/12] feat: undoable serial counter commands and numbered array copies DocEditor.variable_text changes the counter (settings, Next, Previous, Reset) through the undo history, so the document is marked modified and an accidental change can be undone. Number Copies gives selected items the offsets 0, 1, 2, ... in reading order, and grid and circular arrays give their copies increasing offsets. Refs #527 --- rayforge/doceditor/array_cmd.py | 14 +- rayforge/doceditor/editor.py | 2 + rayforge/doceditor/variable_text_cmd.py | 163 ++++++++++++++++++++++ tests/doceditor/test_array_cmd.py | 53 +++++++ tests/doceditor/test_variable_text_cmd.py | 120 ++++++++++++++++ 5 files changed, 351 insertions(+), 1 deletion(-) create mode 100644 rayforge/doceditor/variable_text_cmd.py create mode 100644 tests/doceditor/test_variable_text_cmd.py diff --git a/rayforge/doceditor/array_cmd.py b/rayforge/doceditor/array_cmd.py index 5c3cbeb18..a1f5a81ae 100644 --- a/rayforge/doceditor/array_cmd.py +++ b/rayforge/doceditor/array_cmd.py @@ -135,7 +135,7 @@ def create_array( created: list[DocItem] = [] with history.transaction(_("Create Array")) as t: - for delta in deltas: + for index, delta in enumerate(deltas): if _is_identity(delta): # The original selection occupies this cell. continue @@ -147,6 +147,7 @@ def create_array( ) continue copy = self._make_copy_at_delta(item, delta) + _shift_variable_offsets(copy, index) cmd = ListItemCommand( owner_obj=layer, item=copy, @@ -173,6 +174,17 @@ def _make_copy_at_delta(item: DocItem, delta_world: Matrix) -> DocItem: return copy +def _shift_variable_offsets(item: DocItem, shift: int) -> None: + """Numbers an array copy: its workpieces show the template values + ``shift`` copies after the original's.""" + if isinstance(item, WorkPiece): + workpieces = [item] + else: + workpieces = item.get_descendants(of_type=WorkPiece) + for wp in workpieces: + wp.variable_offset += shift + + def _is_identity(matrix: Matrix) -> bool: """True if the matrix has no practical effect.""" return matrix.is_identity() diff --git a/rayforge/doceditor/editor.py b/rayforge/doceditor/editor.py index 41fc8405a..3f2a16b90 100644 --- a/rayforge/doceditor/editor.py +++ b/rayforge/doceditor/editor.py @@ -33,6 +33,7 @@ from .stock_cmd import StockCmd from .tab_cmd import TabCmd from .transform_cmd import TransformCmd +from .variable_text_cmd import VariableTextCmd if TYPE_CHECKING: from ..context import RayforgeContext @@ -160,6 +161,7 @@ def __init__( self.step = StepCmd(self) self.tab = TabCmd(self) self.transform = TransformCmd(self) + self.variable_text = VariableTextCmd(self) # Instantiate addon-registered commands. for name, cmd_class in command_registry.all_commands().items(): diff --git a/rayforge/doceditor/variable_text_cmd.py b/rayforge/doceditor/variable_text_cmd.py new file mode 100644 index 000000000..ff67bf990 --- /dev/null +++ b/rayforge/doceditor/variable_text_cmd.py @@ -0,0 +1,163 @@ +""" +VariableTextCmd: undoable edits of the document's template variables +(serial counter) and of the per-workpiece variable offsets. +""" + +from __future__ import annotations + +import logging +from collections.abc import Sequence +from gettext import gettext as _ +from typing import TYPE_CHECKING + +from ..core.item import DocItem +from ..core.undo import ChangePropertyCommand +from ..core.variable_text import SerialSettings +from ..core.workpiece import WorkPiece +from .layout.base import LayoutStrategy + +if TYPE_CHECKING: + from .editor import DocEditor + +logger = logging.getLogger(__name__) + + +class VariableTextCmd: + """Handles changes to the document's variable text settings.""" + + def __init__(self, editor: DocEditor): + self._editor = editor + + @property + def serial(self) -> SerialSettings: + return self._editor.doc.variable_text.serial + + def set_serial( + self, settings: SerialSettings, name: str | None = None + ) -> None: + """Replaces the serial counter settings in one undoable step.""" + variable_text = self._editor.doc.variable_text + if settings == variable_text.serial: + return + cmd = ChangePropertyCommand( + target=variable_text, + property_name="serial", + new_value=settings, + name=name or _("Change serial number"), + ) + self._editor.history_manager.execute(cmd) + + def next_serial(self) -> None: + """Moves the counter forward by one job.""" + copies = max(self._editor.doc.variable_text_copies(), 1) + self.set_serial(self.serial.advanced(copies), _("Next serial number")) + + def previous_serial(self) -> None: + """Moves the counter back by one job.""" + copies = max(self._editor.doc.variable_text_copies(), 1) + self.set_serial( + self.serial.advanced(-copies), _("Previous serial number") + ) + + def reset_serial(self) -> None: + """Puts the counter back at its start value.""" + self.set_serial(self.serial.reset(), _("Reset serial number")) + + def preview_serial_values(self) -> list[int]: + """The serial numbers the next job uses, one per copy.""" + copies = max(self._editor.doc.variable_text_copies(), 1) + return self.serial.values_for(copies) + + def advance_after_job(self, exported: bool = False) -> bool: + """ + Moves the counter on after a finished job (or a G-code export, + if *exported*), by one value per copy in the job. + + Returns: + True if the counter was advanced. + """ + serial = self.serial + enabled = serial.advance_on_export if exported else serial.auto_advance + copies = self._editor.doc.variable_text_copies() + if not enabled or copies == 0: + return False + self.set_serial( + serial.advanced(copies), _("Advance serial number after job") + ) + logger.info( + f"Serial number advanced by {copies} to " + f"{self._editor.doc.variable_text.serial.current}" + ) + return True + + def set_variable_offset( + self, workpieces: Sequence[WorkPiece], offset: int + ) -> None: + """Sets the same variable offset on all *workpieces*.""" + self._apply_offsets( + [(wp, offset) for wp in workpieces], _("Set variable offset") + ) + + def number_copies(self, items: Sequence[DocItem]) -> None: + """ + Gives *items* the offsets 0, 1, 2, ... in reading order: rows + from top to bottom, each row from left to right. All workpieces + inside a group share the group's offset. + """ + changes = [] + for index, item in enumerate(_reading_order(items)): + changes.extend((wp, index) for wp in _workpieces_of(item)) + self._apply_offsets(changes, _("Number copies")) + + def _apply_offsets( + self, changes: list[tuple[WorkPiece, int]], name: str + ) -> None: + history = self._editor.history_manager + with history.transaction(name) as t: + for wp, offset in changes: + if wp.variable_offset == offset: + continue + t.execute( + ChangePropertyCommand( + target=wp, + property_name="variable_offset", + new_value=offset, + name=name, + ) + ) + + +def _workpieces_of(item: DocItem) -> list[WorkPiece]: + if isinstance(item, WorkPiece): + return [item] + return item.get_descendants(of_type=WorkPiece) + + +def _reading_order(items: Sequence[DocItem]) -> list[DocItem]: + """Sorts items into rows (top first), then left to right.""" + boxes = [] + for item in items: + bbox = LayoutStrategy._get_item_world_bbox(item) + if bbox is None: + x, y = item.pos + bbox = (x, y, x, y) + x_min, y_min, x_max, y_max = bbox + boxes.append( + (item, (x_min + x_max) / 2, (y_min + y_max) / 2, y_max - y_min) + ) + boxes.sort(key=lambda b: -b[2]) + + rows: list[list[tuple[DocItem, float, float, float]]] = [] + for box in boxes: + if rows: + first = rows[-1][0] + tolerance = max(min(first[3], box[3]) / 2, 1e-6) + if abs(first[2] - box[2]) <= tolerance: + rows[-1].append(box) + continue + rows.append([box]) + + ordered: list[DocItem] = [] + for row in rows: + ordered.extend(b[0] for b in sorted(row, key=lambda b: b[1])) + return ordered diff --git a/tests/doceditor/test_array_cmd.py b/tests/doceditor/test_array_cmd.py index 2a1cb3a4f..6fc6ca3dc 100644 --- a/tests/doceditor/test_array_cmd.py +++ b/tests/doceditor/test_array_cmd.py @@ -8,7 +8,9 @@ import pytest +from rayforge.core.group import Group from rayforge.core.layer import Layer +from rayforge.core.variable_text import SerialSettings from rayforge.core.workpiece import WorkPiece from rayforge.doceditor.array import ( ArrayMode, @@ -19,6 +21,7 @@ SpacingMode, ) from rayforge.doceditor.array_cmd import ArrayCmd +from tests.core.template_provider import TemplateProvider, place @pytest.fixture @@ -224,3 +227,53 @@ def test_compute_plan_is_pure(array_cmd, multilayer_selection): assert deltas[0].is_identity() # No mutation happened. assert len(sel["layer_a"].get_content_items()) == before_count + + +@pytest.mark.asyncio +async def test_grid_array_numbers_copies(doc_editor, array_cmd): + """Copies get increasing variable offsets in array order, so + serial numbers count up across the sheet.""" + doc = doc_editor.doc + doc.variable_text.serial = SerialSettings(current=11) + wp = place(doc, "{serial}", offset=2) + params = ArrayParams( + mode=ArrayMode.GRID, + grid=GridArrayParams( + rows=2, + cols=2, + spacing_mode=SpacingMode.DISPLACEMENT, + col_spacing_mm=20.0, + row_spacing_mm=15.0, + ), + ) + created = array_cmd.create_array([wp], params) + assert [c.variable_offset for c in created] == [3, 4, 5] + last = created[-1] + provider = last.get_geometry_provider() + assert isinstance(provider, TemplateProvider) + assert last.template_values() == {"serial": 16} + assert provider.rendered[-1] == "16" + + +@pytest.mark.asyncio +async def test_array_of_group_numbers_member_workpieces(doc_editor, array_cmd): + doc = doc_editor.doc + wp = place(doc, "{serial}") + group = Group(name="plate") + layer = doc.active_layer + layer.remove_child(wp) + group.add_child(wp) + layer.add_child(group) + params = ArrayParams( + mode=ArrayMode.GRID, + grid=GridArrayParams( + rows=1, + cols=3, + spacing_mode=SpacingMode.DISPLACEMENT, + col_spacing_mm=20.0, + row_spacing_mm=15.0, + ), + ) + created = array_cmd.create_array([group], params) + offsets = [g.all_workpieces[0].variable_offset for g in created] + assert offsets == [1, 2] diff --git a/tests/doceditor/test_variable_text_cmd.py b/tests/doceditor/test_variable_text_cmd.py new file mode 100644 index 000000000..3c05a23fb --- /dev/null +++ b/tests/doceditor/test_variable_text_cmd.py @@ -0,0 +1,120 @@ +import pytest + +from rayforge.core.variable_text import SerialSettings +from rayforge.doceditor.variable_text_cmd import VariableTextCmd +from tests.core.template_provider import place + + +@pytest.fixture +def cmd(doc_editor): + return VariableTextCmd(doc_editor) + + +def _serial(doc_editor) -> SerialSettings: + return doc_editor.doc.variable_text.serial + + +@pytest.mark.asyncio +async def test_editor_exposes_command(doc_editor): + assert isinstance(doc_editor.variable_text, VariableTextCmd) + + +@pytest.mark.asyncio +async def test_set_serial_is_undoable(doc_editor, cmd): + cmd.set_serial(SerialSettings(current=5, step=2)) + assert _serial(doc_editor).current == 5 + assert not doc_editor.is_saved + doc_editor.history_manager.undo() + assert _serial(doc_editor) == SerialSettings() + + +@pytest.mark.asyncio +async def test_next_previous_reset(doc_editor, cmd): + cmd.set_serial(SerialSettings(current=10, start=3, step=5)) + cmd.next_serial() + assert _serial(doc_editor).current == 15 + cmd.previous_serial() + cmd.previous_serial() + assert _serial(doc_editor).current == 5 + cmd.reset_serial() + assert _serial(doc_editor).current == 3 + + +@pytest.mark.asyncio +async def test_advance_after_job_uses_copies(doc_editor, cmd): + doc = doc_editor.doc + place(doc, "{serial}", offset=0) + place(doc, "{serial}", offset=3) + cmd.set_serial(SerialSettings(current=1, step=2)) + assert cmd.advance_after_job() is True + assert _serial(doc_editor).current == 9 + + +@pytest.mark.asyncio +async def test_advance_skipped_without_serial_users(doc_editor, cmd): + place(doc_editor.doc, "Hello") + assert cmd.advance_after_job() is False + assert _serial(doc_editor).current == 1 + + +@pytest.mark.asyncio +async def test_advance_skipped_when_disabled(doc_editor, cmd): + place(doc_editor.doc, "{serial}") + cmd.set_serial(SerialSettings(auto_advance=False)) + assert cmd.advance_after_job() is False + assert _serial(doc_editor).current == 1 + + +@pytest.mark.asyncio +async def test_advance_after_export_needs_its_own_switch(doc_editor, cmd): + place(doc_editor.doc, "{serial}") + assert cmd.advance_after_job(exported=True) is False + cmd.set_serial(SerialSettings(advance_on_export=True)) + assert cmd.advance_after_job(exported=True) is True + assert _serial(doc_editor).current == 2 + + +@pytest.mark.asyncio +async def test_advance_is_undoable(doc_editor, cmd): + place(doc_editor.doc, "{serial}") + cmd.advance_after_job() + doc_editor.history_manager.undo() + assert _serial(doc_editor).current == 1 + + +@pytest.mark.asyncio +async def test_set_variable_offset_is_undoable(doc_editor, cmd): + wp = place(doc_editor.doc, "{serial}") + cmd.set_variable_offset([wp], 4) + assert wp.variable_offset == 4 + doc_editor.history_manager.undo() + assert wp.variable_offset == 0 + + +@pytest.mark.asyncio +async def test_number_copies_in_reading_order(doc_editor, cmd): + doc = doc_editor.doc + wps = [] + # Two rows of two; machine Y grows upwards, so the top row has + # the larger Y and comes first. + for x, y in [(30, 0), (0, 0), (30, 50), (0, 50)]: + wp = place(doc, "{serial}") + wp.pos = (x, y) + wps.append(wp) + cmd.number_copies(wps) + assert [wp.variable_offset for wp in wps] == [3, 2, 1, 0] + doc_editor.history_manager.undo() + assert all(wp.variable_offset == 0 for wp in wps) + + +@pytest.mark.asyncio +async def test_preview_values(doc_editor, cmd): + place(doc_editor.doc, "{serial}", offset=2) + cmd.set_serial(SerialSettings(current=7)) + assert cmd.preview_serial_values() == [7, 8, 9] + + +@pytest.mark.asyncio +async def test_preview_without_copies_shows_current(doc_editor, cmd): + cmd.set_serial(SerialSettings(current=7)) + assert cmd.preview_serial_values() == [7] From f5786c9d623168989743aa167d7b232b56d3b1dc Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Mon, 12 Oct 2026 19:46:18 +0200 Subject: [PATCH 03/12] feat: advance the serial counter after a finished job MachineCmd now knows whether a send job ran to its end: cancelling marks the running job, and the monitored run reports completion. Only then, and not for a pointer dry run, the editor advances the counter by one step per numbered copy. Framing and failed jobs never advance it. An optional setting also advances it after a G-code export, for jobs that run from a memory card. Refs #527 --- rayforge/doceditor/file_cmd.py | 4 + rayforge/machine/cmd.py | 21 ++- tests/doceditor/test_variable_text_job.py | 162 ++++++++++++++++++++++ 3 files changed, 184 insertions(+), 3 deletions(-) create mode 100644 tests/doceditor/test_variable_text_job.py diff --git a/rayforge/doceditor/file_cmd.py b/rayforge/doceditor/file_cmd.py index ce8cde6c4..e0c56c721 100644 --- a/rayforge/doceditor/file_cmd.py +++ b/rayforge/doceditor/file_cmd.py @@ -1062,6 +1062,10 @@ def _on_export_assembly_done( name=file_path.name ) self._editor.notification_requested.send(self, message=msg) + self._editor.task_manager.schedule_on_main_thread( + self._editor.variable_text.advance_after_job, + exported=True, + ) except Exception as e: logger.error( diff --git a/rayforge/machine/cmd.py b/rayforge/machine/cmd.py index 84bc523fc..fe2354f82 100644 --- a/rayforge/machine/cmd.py +++ b/rayforge/machine/cmd.py @@ -53,6 +53,11 @@ def __init__(self, editor: DocEditor): self.job_started = Signal() self._current_monitor: JobMonitor | None = None self._on_progress_callback: Callable[[dict], None] | None = None + # Set when the running job is cancelled, and whether the last + # send job ran to its end; a finished send advances the + # document's serial counter. + self._cancel_requested = False + self._send_completed = False @property def is_job_running(self) -> bool: @@ -94,11 +99,14 @@ async def _execute_monitored_job( Coroutine, ] | None = None, - ): + ) -> bool: """ Internal helper to execute a job on a driver while managing a JobMonitor for progress reporting. + Returns True if the job ran to its end, False if it had no + operations or was cancelled. + The job runs the encoded pipeline output, unless *execute* is given: then it calls ``execute(on_command_done)`` instead, which runs a driver-defined action (framing) under the same monitoring. @@ -116,7 +124,7 @@ async def _execute_monitored_job( logger.warning("Job has no operations. Skipping execution.") if machine.driver: machine.driver.job_finished.send(machine.driver) - return + return False # Store the callback self._on_progress_callback = on_progress @@ -159,6 +167,7 @@ def cleanup_monitor(): ) # Signal that the job has started. + self._cancel_requested = False self._scheduler(self.job_started.send, self) if execute is not None: @@ -202,6 +211,7 @@ def cleanup_monitor(): f"Job completed. Estimated time: {estimated_hours:.3f}h " f"added to machine hours." ) + return not self._cancel_requested finally: cleanup_monitor() @@ -328,7 +338,7 @@ async def _run_send_action( if not isinstance(artifact, JobArtifact): raise TypeError("_run_send_action received a non-JobArtifact") - await self._execute_monitored_job( + self._send_completed = await self._execute_monitored_job( artifact.ops, machine, on_progress=on_progress, @@ -452,12 +462,15 @@ async def send_job( trace does not burn the material. """ try: + self._send_completed = False await self._start_job( machine, final_job_action=self._run_send_action, on_progress=on_progress, pointer_dry_run=pointer_dry_run, ) + if self._send_completed and not pointer_dry_run: + self._scheduler(self._editor.variable_text.advance_after_job) except JobAlreadyRunningError as e: # An expected refusal, not a failure: the guard's message # already tells the user what to do. @@ -492,6 +505,8 @@ def set_hold(self, machine: Machine, is_requesting_hold: bool): def cancel_job(self, machine: Machine): """Adds a task to cancel the currently running job on the machine.""" + if self._current_monitor is not None: + self._cancel_requested = True driver = machine.driver self._editor.task_manager.add_coroutine( lambda ctx: driver.cancel(), key="cancel-job" diff --git a/tests/doceditor/test_variable_text_job.py b/tests/doceditor/test_variable_text_job.py new file mode 100644 index 000000000..f06c09edf --- /dev/null +++ b/tests/doceditor/test_variable_text_job.py @@ -0,0 +1,162 @@ +""" +The serial counter advances after a finished job (and, if enabled, +after a G-code export), but not after framing, pointer dry runs or a +cancelled job. +""" + +import asyncio + +import pytest +from raygeo.geo import Geometry + +from rayforge.context import get_context +from rayforge.core.variable_text import SerialSettings +from rayforge.machine.cmd import MachineCmd +from tests.core.template_provider import place + + +def _rect() -> Geometry: + geo = Geometry() + geo.move_to(0.0, 0.0) + geo.line_to(10.0, 0.0) + geo.line_to(10.0, 10.0) + geo.line_to(0.0, 10.0) + geo.close_path() + return geo + + +def _setup(doc_editor, contour_step_class, context, copies: int = 1): + step = contour_step_class.create(context, name="cut") + workflow = doc_editor.doc.active_layer.workflow + assert workflow is not None + workflow.add_child(step) + for offset in range(copies): + wp = place(doc_editor.doc, "{serial}", offset=offset) + wp._edited_boundaries = _rect() + wp.set_size(10.0, 10.0) + wp.pos = (offset * 20.0, 0.0) + doc_editor.doc.variable_text.serial = SerialSettings(current=5) + + +def _current(doc_editor) -> int: + return doc_editor.doc.variable_text.serial.current + + +async def _settle(doc_editor): + await doc_editor.wait_until_settled() + # Let callbacks scheduled on the main loop run. + await asyncio.sleep(0) + await asyncio.sleep(0) + + +@pytest.mark.asyncio +async def test_send_job_advances_by_copies( + context_initializer, doc_editor, contour_step_class +): + _setup(doc_editor, contour_step_class, context_initializer, copies=3) + await doc_editor.wait_until_settled() + machine = get_context().machine + assert machine is not None + + await MachineCmd(doc_editor).send_job(machine) + await _settle(doc_editor) + + assert _current(doc_editor) == 8 + + +@pytest.mark.asyncio +async def test_frame_job_does_not_advance( + context_initializer, doc_editor, contour_step_class +): + _setup(doc_editor, contour_step_class, context_initializer) + await doc_editor.wait_until_settled() + machine = get_context().machine + assert machine is not None + + await MachineCmd(doc_editor).frame_job(machine) + await _settle(doc_editor) + + assert _current(doc_editor) == 5 + + +@pytest.mark.asyncio +async def test_pointer_dry_run_does_not_advance( + context_initializer, doc_editor, contour_step_class +): + _setup(doc_editor, contour_step_class, context_initializer) + await doc_editor.wait_until_settled() + machine = get_context().machine + assert machine is not None + + await MachineCmd(doc_editor).send_job(machine, pointer_dry_run=True) + await _settle(doc_editor) + + assert _current(doc_editor) == 5 + + +@pytest.mark.asyncio +async def test_cancelled_job_does_not_advance( + context_initializer, doc_editor, contour_step_class, mocker +): + _setup(doc_editor, contour_step_class, context_initializer) + await doc_editor.wait_until_settled() + machine = get_context().machine + assert machine is not None + machine_cmd = MachineCmd(doc_editor) + + async def run_and_cancel(*args, **kwargs): + machine_cmd.cancel_job(machine) + + mocker.patch.object(machine.driver, "run", side_effect=run_and_cancel) + await machine_cmd.send_job(machine) + await _settle(doc_editor) + + assert _current(doc_editor) == 5 + + # The next, uncancelled job advances again. + mocker.patch.object(machine.driver, "run", return_value=None) + await machine_cmd.send_job(machine) + await _settle(doc_editor) + assert _current(doc_editor) == 6 + + +@pytest.mark.asyncio +async def test_gcode_export_advances_when_enabled( + context_initializer, doc_editor, contour_step_class, tmp_path +): + _setup(doc_editor, contour_step_class, context_initializer) + doc_editor.doc.variable_text.serial = SerialSettings( + current=5, advance_on_export=True + ) + await doc_editor.wait_until_settled() + + out = tmp_path / "job.gcode" + doc_editor.file.export_gcode_to_path(out) + for _i in range(200): + await _settle(doc_editor) + if out.exists() and _current(doc_editor) != 5: + break + await asyncio.sleep(0.01) + + assert out.exists() + assert _current(doc_editor) == 6 + + +@pytest.mark.asyncio +async def test_gcode_export_keeps_counter_by_default( + context_initializer, doc_editor, contour_step_class, tmp_path +): + _setup(doc_editor, contour_step_class, context_initializer) + await doc_editor.wait_until_settled() + + out = tmp_path / "job.gcode" + doc_editor.file.export_gcode_to_path(out) + for _i in range(200): + await _settle(doc_editor) + if out.exists(): + break + await asyncio.sleep(0.01) + await _settle(doc_editor) + + assert out.exists() + assert _current(doc_editor) == 5 From 065eeeca74276d8e8e1be78eff0f914688922670 Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Mon, 12 Oct 2026 19:47:21 +0200 Subject: [PATCH 04/12] feat: Variable Text dialog for the serial counter Tools > Variable Text opens a dialog with the counter settings, Previous / Next / Reset and a preview of the numbers the next job uses. Arrange > Array > Number Copies numbers a selection in reading order, and the workpiece properties show the variable offset of a template workpiece. Refs #527 --- rayforge/ui_gtk/actions.py | 20 ++ .../doceditor/property_providers/__init__.py | 3 + .../property_providers/variable_text.py | 94 ++++++++ .../ui_gtk/doceditor/variable_text_dialog.py | 228 ++++++++++++++++++ rayforge/ui_gtk/main_menu.py | 4 + .../doceditor/test_variable_text_dialog.py | 133 ++++++++++ 6 files changed, 482 insertions(+) create mode 100644 rayforge/ui_gtk/doceditor/property_providers/variable_text.py create mode 100644 rayforge/ui_gtk/doceditor/variable_text_dialog.py create mode 100644 tests/ui_gtk/doceditor/test_variable_text_dialog.py diff --git a/rayforge/ui_gtk/actions.py b/rayforge/ui_gtk/actions.py index a3561cf4f..8b9a84a22 100644 --- a/rayforge/ui_gtk/actions.py +++ b/rayforge/ui_gtk/actions.py @@ -24,6 +24,7 @@ from .doceditor.add_tabs_popover import AddTabsPopover from .doceditor.path_tolerance_dialog import PathToleranceDialog from .doceditor.stock_properties_dialog import StockPropertiesDialog +from .doceditor.variable_text_dialog import VariableTextDialog from .shared.keyboard import PRIMARY_ACCEL if TYPE_CHECKING: @@ -477,6 +478,10 @@ def register_actions(self): self._add_action("array-point-rotation", self.on_array_point_rotation) self._add_action("array-circular", self.on_array_circular) + # Variable text (serial numbers) + self._add_action("variable-text", self.on_variable_text) + self._add_action("number-copies", self.on_number_copies) + # Macro Actions self._add_action( "execute-macro", @@ -576,6 +581,9 @@ def update_action_states(self, *args, **kwargs): "delete-duplicates", ): self.actions[name].set_enabled(bool(selected_wps)) + self.actions["number-copies"].set_enabled( + len(self.win.surface.get_selected_items()) > 1 + ) self.actions["export-object"].set_enabled(len(selected_wps) == 1) sources = self.editor.reload.sources_of(list(selected_wps)) @@ -1042,6 +1050,18 @@ def on_array_circular(self, action, param): dialog.""" self._open_array_dialog(ArrayMode.CIRCULAR) + def on_variable_text(self, action, param): + """Handler for 'variable-text': opens the serial number dialog.""" + dialog = VariableTextDialog(self.win, self.editor) + dialog.present() + + def on_number_copies(self, action, param): + """Handler for 'number-copies': numbers the selected items in + reading order, so their serial numbers count up.""" + items = list(self.win.surface.get_selected_items()) + if items: + self.editor.variable_text.number_copies(items) + def _open_array_dialog(self, mode: ArrayMode) -> None: items = list(self.win.surface.get_selected_items()) if not items: diff --git a/rayforge/ui_gtk/doceditor/property_providers/__init__.py b/rayforge/ui_gtk/doceditor/property_providers/__init__.py index 6d5113873..ddb1827b0 100644 --- a/rayforge/ui_gtk/doceditor/property_providers/__init__.py +++ b/rayforge/ui_gtk/doceditor/property_providers/__init__.py @@ -1,5 +1,6 @@ from .base import PropertyProvider, property_provider_registry from .transform import TransformPropertyProvider +from .variable_text import VariableTextPropertyProvider from .workpiece import TabsPropertyProvider, WorkpieceInfoProvider @@ -8,12 +9,14 @@ def register_builtin_providers(): property_provider_registry.register(TransformPropertyProvider, "") property_provider_registry.register(WorkpieceInfoProvider, "") property_provider_registry.register(TabsPropertyProvider, "") + property_provider_registry.register(VariableTextPropertyProvider, "") __all__ = [ "PropertyProvider", "TabsPropertyProvider", "TransformPropertyProvider", + "VariableTextPropertyProvider", "WorkpieceInfoProvider", "property_provider_registry", "register_builtin_providers", diff --git a/rayforge/ui_gtk/doceditor/property_providers/variable_text.py b/rayforge/ui_gtk/doceditor/property_providers/variable_text.py new file mode 100644 index 000000000..3c3f73138 --- /dev/null +++ b/rayforge/ui_gtk/doceditor/property_providers/variable_text.py @@ -0,0 +1,94 @@ +import logging +from gettext import gettext as _ +from typing import TYPE_CHECKING + +from gi.repository import Gtk + +from ....core.item import DocItem +from ....core.variable_text import SERIAL_NAME +from ....core.workpiece import WorkPiece +from ...icons import get_icon +from ...shared.pref_rows import SpinRow +from .base import PropertyProvider + +if TYPE_CHECKING: + from ....doceditor.editor import DocEditor + +logger = logging.getLogger(__name__) + + +def _uses_variables(item: DocItem) -> bool: + doc = item.doc + return ( + isinstance(item, WorkPiece) + and doc is not None + and item.references_template_names(doc.variable_text.names) + ) + + +class VariableTextPropertyProvider(PropertyProvider): + """ + Shows the variable offset of workpieces whose text templates use a + document variable such as ``{serial}``. + """ + + priority = 30 + separate_group = True + group_title = _("Variable Text") + + def can_handle(self, items: list[DocItem]) -> bool: + return bool(items) and all(_uses_variables(i) for i in items) + + def create_widgets(self) -> list[Gtk.Widget]: + self.offset_row = SpinRow( + _("Variable Offset"), + lower=0, + upper=100_000, + numeric=True, + ) + self.offset_row.set_tooltip_text( + _( + "This copy shows the values this many copies after the " + "current ones" + ) + ) + self.offset_row.value_changed.connect(self._on_offset_changed) + + self.settings_button = Gtk.Button( + child=get_icon("settings-symbolic"), + valign=Gtk.Align.CENTER, + tooltip_text=_("Variable Text Settings"), + action_name="win.variable-text", + ) + self.offset_row.add_suffix(self.settings_button) + self._rows = [self.offset_row] + return self._rows + + def update_widgets(self, editor: "DocEditor", items: list[DocItem]): + self.editor = editor + self.items = items + workpieces = [i for i in items if isinstance(i, WorkPiece)] + if not workpieces: + return + first = workpieces[0] + self._in_update = True + try: + self.offset_row.set_value(first.variable_offset) + finally: + self._in_update = False + + if len(workpieces) > 1: + self.offset_row.set_subtitle(_("Applies to all selected items")) + return + serial = first.template_values().get(SERIAL_NAME) + self.offset_row.set_subtitle( + _("Serial number: {value}").format(value=serial) + ) + + def _on_offset_changed(self, row: SpinRow) -> None: + if self._in_update: + return + workpieces = [i for i in self.items if isinstance(i, WorkPiece)] + self.editor.variable_text.set_variable_offset( + workpieces, row.get_int_value() + ) diff --git a/rayforge/ui_gtk/doceditor/variable_text_dialog.py b/rayforge/ui_gtk/doceditor/variable_text_dialog.py new file mode 100644 index 000000000..222eb9eb0 --- /dev/null +++ b/rayforge/ui_gtk/doceditor/variable_text_dialog.py @@ -0,0 +1,228 @@ +import logging +from gettext import gettext as _ +from typing import TYPE_CHECKING + +from gi.repository import Adw, Gtk + +from ...core.variable_text import SerialSettings, VariableText +from ..shared.patched_dialog_window import PatchedDialogWindow +from ..shared.pref_rows import SpinRow + +if TYPE_CHECKING: + from ...core.doc import Doc + from ...doceditor.editor import DocEditor + +logger = logging.getLogger(__name__) + +_PREVIEW_LIST_MAX = 5 +_INT_LIMIT = 999_999_999 + + +def format_preview(values: list[int]) -> str: + """A short text for the serial numbers of the next job.""" + if len(values) <= _PREVIEW_LIST_MAX: + return ", ".join(str(v) for v in values) + return _("{first} … {last} ({count} copies)").format( + first=values[0], last=values[-1], count=len(values) + ) + + +class VariableTextDialog(PatchedDialogWindow): + """ + A non-modal window for the document's serial number: its settings, + Previous/Next/Reset buttons and a preview of the next job. + """ + + def __init__(self, parent: Gtk.Window, editor: "DocEditor"): + super().__init__(transient_for=parent) + self.editor = editor + self._doc: Doc = editor.doc + self._in_update = False + + self.set_title(_("Variable Text")) + self.set_default_size(520, -1) + self.set_modal(False) + + main_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + main_box.append(Adw.HeaderBar()) + self.set_content(main_box) + + page = Adw.PreferencesPage() + page.set_vexpand(True) + main_box.append(page) + + page.add(self._build_serial_group()) + page.add(self._build_job_group()) + + self._doc.variable_text.changed.connect(self._on_model_changed) + self._doc.descendant_updated.connect(self._on_doc_items_changed) + self._doc.descendant_added.connect(self._on_doc_items_changed) + self._doc.descendant_removed.connect(self._on_doc_items_changed) + self.connect("destroy", self._on_destroy) + self._update_from_model() + + def _build_serial_group(self) -> Adw.PreferencesGroup: + group = Adw.PreferencesGroup( + title=_("Serial Number"), + description=_( + "Use {serial} in a text box, e.g. No. {serial:04d} " + "for four digits with leading zeros." + ), + ) + self.current_row = SpinRow( + _("Current Value"), + _("The number the next job starts with"), + lower=-_INT_LIMIT, + upper=_INT_LIMIT, + numeric=True, + ) + self.start_row = SpinRow( + _("Start Value"), + _("Reset returns here; the range wraps back to it"), + lower=-_INT_LIMIT, + upper=_INT_LIMIT, + numeric=True, + ) + self.step_row = SpinRow( + _("Advance By"), + _("Increment per copy and per job"), + lower=0, + upper=_INT_LIMIT, + numeric=True, + ) + self.limit_row = Adw.SwitchRow( + title=_("Wrap at End Value"), + subtitle=_("Continue at the start value after the end value"), + ) + self.end_row = SpinRow( + _("End Value"), + lower=-_INT_LIMIT, + upper=_INT_LIMIT, + numeric=True, + ) + self.auto_advance_row = Adw.SwitchRow( + title=_("Advance After Each Job"), + subtitle=_("When a job sent to the machine has finished"), + ) + self.export_row = Adw.SwitchRow( + title=_("Advance After G-code Export"), + subtitle=_("For jobs that run from a memory card"), + ) + + for row in (self.current_row, self.start_row, self.step_row): + row.value_changed.connect(self._on_value_row_changed) + self.end_row.value_changed.connect(self._on_value_row_changed) + for switch in (self.limit_row, self.auto_advance_row, self.export_row): + switch.connect("notify::active", self._on_switch_changed) + + for row in ( + self.current_row, + self.start_row, + self.step_row, + self.limit_row, + self.end_row, + self.auto_advance_row, + self.export_row, + ): + group.add(row) + return group + + def _build_job_group(self) -> Adw.PreferencesGroup: + group = Adw.PreferencesGroup(title=_("Next Job")) + self.preview_row = Adw.ActionRow(title=_("Serial Numbers")) + self.preview_row.add_css_class("property") + group.add(self.preview_row) + + button_box = Gtk.Box(spacing=6, halign=Gtk.Align.END) + button_box.set_margin_top(12) + self.previous_button = Gtk.Button(label=_("Previous")) + self.previous_button.set_tooltip_text( + _("Go back by the numbers of one job") + ) + self.next_button = Gtk.Button(label=_("Next")) + self.next_button.set_tooltip_text( + _("Skip ahead by the numbers of one job") + ) + self.reset_button = Gtk.Button(label=_("Reset")) + self.reset_button.set_tooltip_text(_("Go back to the start value")) + self.previous_button.connect("clicked", self._on_previous_clicked) + self.next_button.connect("clicked", self._on_next_clicked) + self.reset_button.connect("clicked", self._on_reset_clicked) + for button in ( + self.reset_button, + self.previous_button, + self.next_button, + ): + button_box.append(button) + group.add(button_box) + return group + + def _settings_from_widgets(self) -> SerialSettings: + start = self.start_row.get_int_value() + end = self.end_row.get_int_value() + limited = self.limit_row.get_active() and end >= start + return SerialSettings( + current=self.current_row.get_int_value(), + start=start, + step=self.step_row.get_int_value(), + end=end if limited else None, + auto_advance=self.auto_advance_row.get_active(), + advance_on_export=self.export_row.get_active(), + ) + + def _commit(self) -> None: + if self._in_update: + return + self.editor.variable_text.set_serial(self._settings_from_widgets()) + + def _on_value_row_changed(self, row: SpinRow) -> None: + self._commit() + + def _on_switch_changed(self, row: Adw.SwitchRow, _pspec) -> None: + self.end_row.set_sensitive(self.limit_row.get_active()) + self._commit() + + def _on_previous_clicked(self, button: Gtk.Button) -> None: + self.editor.variable_text.previous_serial() + + def _on_next_clicked(self, button: Gtk.Button) -> None: + self.editor.variable_text.next_serial() + + def _on_reset_clicked(self, button: Gtk.Button) -> None: + self.editor.variable_text.reset_serial() + + def _on_model_changed(self, sender: VariableText) -> None: + self._update_from_model() + + def _on_doc_items_changed(self, sender, **kwargs) -> None: + self._update_preview() + + def _update_from_model(self) -> None: + serial = self._doc.variable_text.serial + self._in_update = True + try: + self.current_row.set_value(serial.current) + self.start_row.set_value(serial.start) + self.step_row.set_value(serial.step) + self.limit_row.set_active(serial.end is not None) + if serial.end is not None: + self.end_row.set_value(serial.end) + elif self.end_row.get_int_value() < serial.start: + self.end_row.set_value(serial.start) + self.end_row.set_sensitive(serial.end is not None) + self.auto_advance_row.set_active(serial.auto_advance) + self.export_row.set_active(serial.advance_on_export) + finally: + self._in_update = False + self._update_preview() + + def _update_preview(self) -> None: + values = self.editor.variable_text.preview_serial_values() + self.preview_row.set_subtitle(format_preview(values)) + + def _on_destroy(self, widget) -> None: + variable_text = self._doc.variable_text + variable_text.changed.disconnect(self._on_model_changed) + self._doc.descendant_updated.disconnect(self._on_doc_items_changed) + self._doc.descendant_added.disconnect(self._on_doc_items_changed) + self._doc.descendant_removed.disconnect(self._on_doc_items_changed) diff --git a/rayforge/ui_gtk/main_menu.py b/rayforge/ui_gtk/main_menu.py index bf0425778..5d501d2d7 100644 --- a/rayforge/ui_gtk/main_menu.py +++ b/rayforge/ui_gtk/main_menu.py @@ -173,6 +173,7 @@ def __init__(self): array_submenu.append(_("Grid"), "win.array-grid") array_submenu.append(_("Point Rotation"), "win.array-point-rotation") array_submenu.append(_("Circular"), "win.array-circular") + array_submenu.append(_("Number Copies"), "win.number-copies") arrange_menu.append_submenu(_("Array"), array_submenu) self._layout_group = Gio.Menu() @@ -182,6 +183,9 @@ def __init__(self): # Tools Menu tools_menu = Gio.Menu() + variable_text_group = Gio.Menu() + variable_text_group.append(_("Variable Text…"), "win.variable-text") + tools_menu.append_section(None, variable_text_group) # Addon section for Tools menu self._addon_sections["tools"] = Gio.Menu() diff --git a/tests/ui_gtk/doceditor/test_variable_text_dialog.py b/tests/ui_gtk/doceditor/test_variable_text_dialog.py new file mode 100644 index 000000000..6b40f845c --- /dev/null +++ b/tests/ui_gtk/doceditor/test_variable_text_dialog.py @@ -0,0 +1,133 @@ +# flake8: noqa: E402 +import gi + +gi.require_version("Gtk", "4.0") +gi.require_version("Adw", "1") + +import pytest +from gi.repository import Gtk + +from rayforge.core.variable_text import SerialSettings +from rayforge.doceditor.editor import DocEditor +from rayforge.ui_gtk.doceditor.property_providers.variable_text import ( + VariableTextPropertyProvider, +) +from rayforge.ui_gtk.doceditor.variable_text_dialog import ( + VariableTextDialog, + format_preview, +) +from tests.core.template_provider import place + + +@pytest.fixture +def editor(ui_context_initializer, ui_task_mgr): + editor = DocEditor( + task_manager=ui_task_mgr, context=ui_context_initializer + ) + yield editor + editor.cleanup() + + +@pytest.fixture +def dialog(editor): + parent = Gtk.Window() + dlg = VariableTextDialog(parent, editor) + yield dlg + dlg.destroy() + + +def _serial(editor) -> SerialSettings: + return editor.doc.variable_text.serial + + +@pytest.mark.ui +def test_format_preview(): + assert format_preview([7]) == "7" + assert format_preview([7, 8, 9]) == "7, 8, 9" + assert format_preview(list(range(1, 21))) == "1 … 20 (20 copies)" + + +@pytest.mark.ui +def test_dialog_shows_current_settings(editor): + editor.doc.variable_text.serial = SerialSettings( + current=42, start=10, step=2, auto_advance=False + ) + dlg = VariableTextDialog(Gtk.Window(), editor) + assert dlg.current_row.get_int_value() == 42 + assert dlg.start_row.get_int_value() == 10 + assert dlg.step_row.get_int_value() == 2 + assert dlg.auto_advance_row.get_active() is False + dlg.destroy() + + +@pytest.mark.ui +def test_editing_current_value_is_undoable(editor, dialog): + dialog.current_row.set_value(77) + dialog.current_row.value_changed.send(dialog.current_row) + assert _serial(editor).current == 77 + editor.history_manager.undo() + assert _serial(editor).current == 1 + + +@pytest.mark.ui +def test_end_value_only_applies_when_enabled(editor, dialog): + dialog.end_row.set_value(9) + dialog.end_row.value_changed.send(dialog.end_row) + assert _serial(editor).end is None + dialog.limit_row.set_active(True) + assert _serial(editor).end == 9 + dialog.limit_row.set_active(False) + assert _serial(editor).end is None + + +@pytest.mark.ui +def test_next_previous_reset_buttons(editor, dialog): + place(editor.doc, "{serial}", offset=1) + dialog.next_button.emit("clicked") + assert _serial(editor).current == 3 + dialog.previous_button.emit("clicked") + assert _serial(editor).current == 1 + editor.doc.variable_text.serial = SerialSettings(current=50, start=4) + dialog.reset_button.emit("clicked") + assert _serial(editor).current == 4 + + +@pytest.mark.ui +def test_preview_follows_model(editor, dialog): + place(editor.doc, "{serial}", offset=2) + editor.doc.variable_text.serial = SerialSettings(current=5) + assert dialog.preview_row.get_subtitle() == "5, 6, 7" + assert dialog.current_row.get_int_value() == 5 + + +@pytest.mark.ui +def test_switches_update_model(editor, dialog): + dialog.auto_advance_row.set_active(False) + dialog.export_row.set_active(True) + assert _serial(editor).auto_advance is False + assert _serial(editor).advance_on_export is True + + +@pytest.mark.ui +def test_property_provider_handles_template_workpieces(editor): + wp = place(editor.doc, "{serial:03d}") + plain = place(editor.doc, "Hello") + provider = VariableTextPropertyProvider() + assert provider.can_handle([wp]) + assert not provider.can_handle([plain]) + assert not provider.can_handle([]) + + +@pytest.mark.ui +def test_property_provider_sets_offset(editor): + editor.doc.variable_text.serial = SerialSettings(current=10) + wp = place(editor.doc, "{serial}") + provider = VariableTextPropertyProvider() + provider.create_widgets() + provider.update_widgets(editor, [wp]) + assert "10" in provider.offset_row.get_subtitle() + provider.offset_row.set_value(3) + provider.offset_row.value_changed.send(provider.offset_row) + assert wp.variable_offset == 3 + provider.update_widgets(editor, [wp]) + assert "13" in provider.offset_row.get_subtitle() From e801bb37b292417274926c81e0aa8751cce64d2f Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Mon, 12 Oct 2026 19:47:35 +0200 Subject: [PATCH 05/12] docs: document Variable Text and serial numbers Refs #527 --- CHANGELOG.md | 3 ++ website/docs/features/sketcher/expressions.md | 3 ++ website/docs/features/variable-text.md | 51 +++++++++++++++++++ website/sidebars.js | 1 + 4 files changed, 58 insertions(+) create mode 100644 website/docs/features/variable-text.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b8252f80..5a5359c88 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -99,6 +99,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 its layer; generated content like the Material Test Grid restricts its step to the workpiece it creates, and that selection is now visible instead of hidden +- Variable Text: a `{serial}` variable for text templates, with a + project counter that advances after every finished job and consecutive + numbers for array copies (Tools > Variable Text) (#527) ### Changed diff --git a/website/docs/features/sketcher/expressions.md b/website/docs/features/sketcher/expressions.md index 008d5ea38..a17feca4b 100644 --- a/website/docs/features/sketcher/expressions.md +++ b/website/docs/features/sketcher/expressions.md @@ -117,6 +117,9 @@ registered for them (see [below](#custom-template-functions)). | `{uuid8()}` | `str` | Alias for `uuid4()` | | `{uuid()}` | `str` | Full UUID v4 string (36 chars) | +For serial numbers that advance after every job, use the project variable `{serial}` (see +[Variable Text](../variable-text.md)). + Typical uses include unique serial numbers per solve (`Part # {uuid4()}`), live dimension labels (`W={width:.1f} H={height:.1f}`), date stamps (`Date: {today()}`), production counters (`{name} - {count:.0f}pcs`), or Unix timestamps for production logging (`{timestamp():.0f}`). diff --git a/website/docs/features/variable-text.md b/website/docs/features/variable-text.md new file mode 100644 index 000000000..f995eb3ce --- /dev/null +++ b/website/docs/features/variable-text.md @@ -0,0 +1,51 @@ +--- +description: + "Serial numbers in text templates: a project-level counter that advances after every job, with + consecutive numbers for array copies." +--- + +# Variable Text + +Text boxes resolve `{...}` templates (see +[Parameters and Expressions](sketcher/expressions.md#template-expressions-in-text-boxes)). On top of +the sketch parameters and the built-in functions, the project offers variables that change from job +to job, so you can engrave numbered plates, tags and labels without editing the text each time. + +## Serial numbers + +Use `{serial}` in a text box. It is a whole number, so format specs control its appearance: + +- `{serial}` — `7` +- `{serial:04d}` — `0007`, four digits with leading zeros +- `No. {serial:03d}` — `No. 007` + +Open **Tools → Variable Text…** to set up the counter: + +- **Current Value** — the number the next job starts with. +- **Start Value** — where **Reset** returns to. +- **Advance By** — the increment per copy and per job. +- **Wrap at End Value** / **End Value** — after the end value the counter continues at the start + value. +- **Advance After Each Job** — when a job sent to the machine has finished, the counter moves on. + Framing, pointer dry runs and cancelled jobs leave it alone. +- **Advance After G-code Export** — also move on when you export G-code, for jobs that run from a + memory card. + +The **Next Job** group previews the numbers the next job will use. **Previous** and **Next** move +the counter by one job, **Reset** returns to the start value. Every change, including the automatic +advance, is part of the undo history and is saved in the project. + +## Numbering copies + +Each workpiece has a **Variable Offset** (in the properties panel, under **Variable Text**). A copy +with offset _n_ shows the number _n_ copies after the current one. A +[grid or circular array](arrays.md) gives its copies increasing offsets automatically, so one array +of 20 plates engraves 20 consecutive numbers, and the next job continues with the 21st. + +Arrays made inside a sketch are different: their copies belong to one workpiece and all show the +same number. Make the array on the canvas instead to number the copies. + +For copies you placed yourself, select them all and choose **Arrange → Array → Number Copies**: the +offsets are assigned in reading order, rows from top to bottom and each row from left to right. + +The counter advances by the number of copies in the job: the highest variable offset plus one. diff --git a/website/sidebars.js b/website/sidebars.js index 52e3e43bd..d4d6e3663 100644 --- a/website/sidebars.js +++ b/website/sidebars.js @@ -105,6 +105,7 @@ module.exports = { }, 'features/arrays', 'features/path-cleanup', + 'features/variable-text', 'features/multi-layer', 'features/stock-handling', 'features/framing-your-job', From fec3c47c6a8d1bea1d633dd12fa888151bc9af0a Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Mon, 12 Oct 2026 19:56:07 +0200 Subject: [PATCH 06/12] test: guard optional subtitles in the variable text dialog tests Refs #527 --- tests/ui_gtk/doceditor/test_variable_text_dialog.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/ui_gtk/doceditor/test_variable_text_dialog.py b/tests/ui_gtk/doceditor/test_variable_text_dialog.py index 6b40f845c..be94795aa 100644 --- a/tests/ui_gtk/doceditor/test_variable_text_dialog.py +++ b/tests/ui_gtk/doceditor/test_variable_text_dialog.py @@ -125,9 +125,9 @@ def test_property_provider_sets_offset(editor): provider = VariableTextPropertyProvider() provider.create_widgets() provider.update_widgets(editor, [wp]) - assert "10" in provider.offset_row.get_subtitle() + assert "10" in (provider.offset_row.get_subtitle() or "") provider.offset_row.set_value(3) provider.offset_row.value_changed.send(provider.offset_row) assert wp.variable_offset == 3 provider.update_widgets(editor, [wp]) - assert "13" in provider.offset_row.get_subtitle() + assert "13" in (provider.offset_row.get_subtitle() or "") From f91f825cac7ce0e6af8abd800c611de92b562ffb Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Mon, 12 Oct 2026 19:51:04 +0200 Subject: [PATCH 07/12] feat: merge CSV rows into text templates A project can carry the rows of a CSV file (stored in the project, with the file path for reloading). Text templates read the columns through {csv["Name"]} or by position {csv[0]}, and {row} is the 1-based row number. A copy shows row current + variable offset; past the last row every column reads as empty. The delimiter is detected, and the first row holds the column names unless it is all numbers. Refs #528 --- rayforge/core/variable_text.py | 207 ++++++++++++++++++++- tests/core/test_variable_text.py | 4 +- tests/core/test_variable_text_csv.py | 179 ++++++++++++++++++ tests/core/test_workpiece_variable_text.py | 2 +- tests/doceditor/test_array_cmd.py | 2 +- 5 files changed, 386 insertions(+), 8 deletions(-) create mode 100644 tests/core/test_variable_text_csv.py diff --git a/rayforge/core/variable_text.py b/rayforge/core/variable_text.py index 2c99f3212..bf3fdbc1d 100644 --- a/rayforge/core/variable_text.py +++ b/rayforge/core/variable_text.py @@ -5,20 +5,27 @@ injected into the ``{...}`` templates of every geometry provider (for example the text boxes of a sketch). Each workpiece adds its own *variable offset*, so copies of the same design can show consecutive -serial numbers. +serial numbers or consecutive rows of a merged CSV file. """ from __future__ import annotations import ast +import csv +import io import re from collections.abc import Iterable from dataclasses import dataclass, replace +from gettext import gettext as _ from typing import Any from blinker import Signal SERIAL_NAME = "serial" +CSV_NAME = "csv" +ROW_NAME = "row" + +_CSV_DELIMITERS = ",;\t|" _PLACEHOLDER_RE = re.compile(r"\{([^{}]+)\}") _IDENTIFIER_RE = re.compile(r"[A-Za-z_][A-Za-z0-9_]*") @@ -105,6 +112,176 @@ def from_dict(cls, data: dict[str, Any]) -> SerialSettings: ) +class CsvRow(dict): + """ + One CSV row as templates see it: ``csv["Name"]`` by column name or + ``csv[0]`` by position. A missing column reads as the empty string. + """ + + def __missing__(self, key: Any) -> str: + if isinstance(key, int) and not isinstance(key, bool): + values = list(self.values()) + if -len(values) <= key < len(values): + return values[key] + return "" + + +def _sniff_dialect(text: str) -> type[csv.Dialect] | csv.Dialect: + try: + return csv.Sniffer().sniff(text[:65536], delimiters=_CSV_DELIMITERS) + except csv.Error: + return csv.excel + + +def _is_number(cell: str) -> bool: + try: + float(cell.replace(",", ".")) + except ValueError: + return False + return True + + +def parse_csv( + text: str, +) -> tuple[tuple[str, ...], tuple[tuple[str, ...], ...]]: + """ + Splits CSV text into a header and rows, detecting the delimiter and + whether the first row is a header. Without a header the columns are + named "Column 1", "Column 2", ... Blank lines are skipped. + """ + text = text.removeprefix("\ufeff") + if not text.strip(): + return (), () + dialect = _sniff_dialect(text) + records = [ + tuple(cell.strip() for cell in record) + for record in csv.reader(io.StringIO(text), dialect) + if any(cell.strip() for cell in record) + ] + if not records: + return (), () + width = max(len(record) for record in records) + if not all(_is_number(cell) for cell in records[0]): + header = records[0] + ("",) * (width - len(records[0])) + header = tuple( + name or _("Column {number}").format(number=i + 1) + for i, name in enumerate(header) + ) + return header, tuple(records[1:]) + header = tuple( + _("Column {number}").format(number=i + 1) for i in range(width) + ) + return header, tuple(records) + + +def read_csv_file(path: str) -> CsvMerge: + """ + Reads a UTF-8 CSV file (with or without BOM) into a merge source. + + Raises: + OSError: If the file cannot be read. + ValueError: If the file is not valid UTF-8. + """ + with open(path, encoding="utf-8-sig", newline="") as f: + header, rows = parse_csv(f.read()) + return CsvMerge(header=header, rows=rows, path=path) + + +@dataclass(frozen=True) +class CsvMerge: + """ + The rows of a CSV file merged into text templates. + + Attributes: + header: The column names. + rows: The rows, stored in the project so it stays + self-contained. + path: The file the rows came from, for reloading. + current: The 1-based row the next job starts with. One past the + last row means every row has been used. + auto_advance: Whether a finished job moves ``current`` on. + """ + + header: tuple[str, ...] = () + rows: tuple[tuple[str, ...], ...] = () + path: str | None = None + current: int = 1 + auto_advance: bool = True + + @property + def is_loaded(self) -> bool: + return bool(self.rows) + + @property + def row_count(self) -> int: + return len(self.rows) + + @property + def remaining(self) -> int: + """Rows from ``current`` to the end.""" + return max(self.row_count - self.current + 1, 0) + + @property + def is_exhausted(self) -> bool: + """Whether every row has been used.""" + return self.is_loaded and self.remaining == 0 + + def row_number(self, offset: int) -> int: + """The 1-based row a copy that is *offset* copies further shows.""" + return self.current + offset + + def row_at(self, offset: int) -> CsvRow: + """The row a copy shows; past the end every column is empty.""" + index = self.row_number(offset) - 1 + if 0 <= index < self.row_count: + cells = self.rows[index] + else: + cells = () + return CsvRow( + (name, cells[i] if i < len(cells) else "") + for i, name in enumerate(self.header) + ) + + def with_current(self, row: int) -> CsvMerge: + """The same rows with ``current`` clamped to the valid range.""" + last = self.row_count + 1 if self.is_loaded else 1 + return replace(self, current=min(max(row, 1), last)) + + def advanced(self, copies: int = 1) -> CsvMerge: + """Moved on (or back, if negative); stops after the last row.""" + return self.with_current(self.current + copies) + + def with_rows( + self, header: tuple[str, ...], rows: tuple[tuple[str, ...], ...] + ) -> CsvMerge: + """New file contents; ``current`` is kept when still valid.""" + return replace(self, header=header, rows=rows).with_current( + self.current + ) + + def to_dict(self) -> dict[str, Any]: + return { + "header": list(self.header), + "rows": [list(row) for row in self.rows], + "path": self.path, + "current": self.current, + "auto_advance": self.auto_advance, + } + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> CsvMerge: + merge = cls( + header=tuple(str(name) for name in data.get("header", ())), + rows=tuple( + tuple(str(cell) for cell in row) + for row in data.get("rows", ()) + ), + path=data.get("path"), + auto_advance=bool(data.get("auto_advance", True)), + ) + return merge.with_current(int(data.get("current", 1))) + + class VariableText: """ The variables a document offers to text templates. @@ -116,6 +293,7 @@ class VariableText: def __init__(self): self.changed = Signal() self._serial = SerialSettings() + self._csv = CsvMerge() @property def serial(self) -> SerialSettings: @@ -128,17 +306,35 @@ def serial(self, value: SerialSettings) -> None: self._serial = value self.changed.send(self) + @property + def csv(self) -> CsvMerge: + return self._csv + + @csv.setter + def csv(self, value: CsvMerge) -> None: + if value == self._csv: + return + self._csv = value + self.changed.send(self) + @property def names(self) -> set[str]: """The template names this object provides.""" - return {SERIAL_NAME} + return {SERIAL_NAME, CSV_NAME, ROW_NAME} def template_values(self, offset: int = 0) -> dict[str, Any]: """The template values for a copy with the given offset.""" - return {SERIAL_NAME: self._serial.value_at(offset)} + return { + SERIAL_NAME: self._serial.value_at(offset), + CSV_NAME: self._csv.row_at(offset), + ROW_NAME: self._csv.row_number(offset), + } def to_dict(self) -> dict[str, Any]: - return {"serial": self._serial.to_dict()} + data: dict[str, Any] = {"serial": self._serial.to_dict()} + if self._csv != CsvMerge(): + data["csv"] = self._csv.to_dict() + return data @classmethod def from_dict(cls, data: dict[str, Any] | None) -> VariableText: @@ -147,6 +343,9 @@ def from_dict(cls, data: dict[str, Any] | None) -> VariableText: serial = data.get("serial") if isinstance(serial, dict): instance._serial = SerialSettings.from_dict(serial) + merge = data.get("csv") + if isinstance(merge, dict): + instance._csv = CsvMerge.from_dict(merge) return instance diff --git a/tests/core/test_variable_text.py b/tests/core/test_variable_text.py index 115327e17..e183ee059 100644 --- a/tests/core/test_variable_text.py +++ b/tests/core/test_variable_text.py @@ -79,8 +79,8 @@ class TestVariableText: def test_template_values_with_offset(self): vt = VariableText() vt.serial = SerialSettings(current=100, step=10) - assert vt.template_values(0) == {"serial": 100} - assert vt.template_values(2) == {"serial": 120} + assert vt.template_values(0)["serial"] == 100 + assert vt.template_values(2)["serial"] == 120 def test_setting_serial_emits_changed(self): vt = VariableText() diff --git a/tests/core/test_variable_text_csv.py b/tests/core/test_variable_text_csv.py new file mode 100644 index 000000000..93219280e --- /dev/null +++ b/tests/core/test_variable_text_csv.py @@ -0,0 +1,179 @@ +import pytest + +from rayforge.core.doc import Doc +from rayforge.core.variable_text import ( + CsvMerge, + CsvRow, + VariableText, + parse_csv, +) +from tests.core.template_provider import TemplateProvider, place + +PEOPLE = CsvMerge( + header=("Name", "City"), + rows=(("Ann", "Gent"), ("Bob", "Hasselt"), ("Cas", "Tongeren")), + path="/tmp/people.csv", +) + + +class TestCsvRow: + def test_by_name_and_position(self): + row = CsvRow({"Name": "Ann", "City": "Gent"}) + assert row["Name"] == "Ann" + assert row[0] == "Ann" + assert row[1] == "Gent" + assert row[-1] == "Gent" + + def test_missing_columns_are_empty(self): + row = CsvRow({"Name": "Ann"}) + assert row["Price"] == "" + assert row[5] == "" + + +class TestParseCsv: + def test_comma_with_header(self): + header, rows = parse_csv("Name,City\nAnn,Gent\nBob,Hasselt\n") + assert header == ("Name", "City") + assert rows == (("Ann", "Gent"), ("Bob", "Hasselt")) + + def test_semicolon_and_bom(self): + header, rows = parse_csv("Name;Price\nAnn;3,50\nBob;4,00\n") + assert header == ("Name", "Price") + assert rows[0] == ("Ann", "3,50") + + def test_tab_separated(self): + header, rows = parse_csv("Name\tCity\nAnn\tGent\n") + assert header == ("Name", "City") + assert rows == (("Ann", "Gent"),) + + def test_quoted_fields(self): + _header, rows = parse_csv('Name,Note\nAnn,"Hello, world"\n') + assert rows == (("Ann", "Hello, world"),) + + def test_without_header_columns_are_numbered(self): + header, rows = parse_csv("12,34\n56,78\n90,12\n") + assert header == ("Column 1", "Column 2") + assert rows[0] == ("12", "34") + + def test_first_row_is_the_header_unless_numeric(self): + header, rows = parse_csv("Ann\nBob\n") + assert header == ("Ann",) + assert rows == (("Bob",),) + + def test_blank_lines_are_skipped(self): + _header, rows = parse_csv("Name\nAnn\n\nBob\n") + assert rows == (("Ann",), ("Bob",)) + + def test_empty_text(self): + assert parse_csv("") == ((), ()) + + +class TestCsvMerge: + def test_default_is_not_loaded(self): + assert not CsvMerge().is_loaded + + def test_row_at_offset(self): + merge = PEOPLE.with_current(2) + assert merge.row_at(0)["Name"] == "Bob" + assert merge.row_at(1)["Name"] == "Cas" + assert merge.row_number(1) == 3 + + def test_past_the_end_is_empty(self): + row = PEOPLE.with_current(3).row_at(1) + assert row["Name"] == "" + assert row[1] == "" + + def test_short_rows_are_padded(self): + merge = CsvMerge(header=("A", "B"), rows=(("x",),)) + assert merge.row_at(0)["B"] == "" + + def test_advance_stops_after_the_last_row(self): + merge = PEOPLE.advanced(2) + assert merge.current == 3 + assert not merge.is_exhausted + merge = merge.advanced(5) + assert merge.current == 4 + assert merge.is_exhausted + assert merge.remaining == 0 + + def test_back_stops_at_the_first_row(self): + assert PEOPLE.with_current(2).advanced(-5).current == 1 + + def test_remaining(self): + assert PEOPLE.remaining == 3 + assert PEOPLE.with_current(3).remaining == 1 + + def test_round_trip(self): + merge = PEOPLE.with_current(2) + assert CsvMerge.from_dict(merge.to_dict()) == merge + + def test_replacing_rows_keeps_the_current_row(self): + merge = PEOPLE.with_current(2).with_rows( + ("Name",), (("Ann",), ("Bob",), ("Cas",), ("Dirk",)) + ) + assert merge.current == 2 + assert merge.path == PEOPLE.path + + def test_shorter_rows_clamp_the_current_row(self): + merge = PEOPLE.with_current(3).with_rows(("Name",), (("Ann",),)) + assert merge.current == 2 + assert merge.is_exhausted + + +class TestVariableTextCsv: + def test_names(self): + assert {"csv", "row", "serial"} <= VariableText().names + + def test_template_values(self): + variables = VariableText() + variables.csv = PEOPLE.with_current(2) + values = variables.template_values(1) + assert values["csv"]["City"] == "Tongeren" + assert values["row"] == 3 + + def test_setting_csv_signals(self): + variables = VariableText() + seen = [] + variables.changed.connect(seen.append, weak=False) + variables.csv = PEOPLE + assert seen == [variables] + + def test_round_trip(self): + variables = VariableText() + variables.csv = PEOPLE.with_current(2) + restored = VariableText.from_dict(variables.to_dict()) + assert restored.csv == variables.csv + + def test_old_projects_have_no_csv(self): + restored = VariableText.from_dict({"serial": {"current": 4}}) + assert not restored.csv.is_loaded + + +class TestTemplates: + @pytest.fixture + def doc(self): + doc = Doc() + doc.variable_text.csv = PEOPLE + return doc + + def _rendered(self, wp): + provider = wp.get_geometry_provider() + assert isinstance(provider, TemplateProvider) + return provider.rendered[-1] + + def test_column_by_name(self, doc): + wp = place(doc, '{csv["Name"]} from {csv["City"]}') + assert self._rendered(wp) == "Ann from Gent" + + def test_column_by_position_and_row(self, doc): + wp = place(doc, "{row}: {csv[0]}", offset=2) + assert self._rendered(wp) == "3: Cas" + + def test_copy_past_the_end_is_empty(self, doc): + wp = place(doc, '[{csv["Name"]}]', offset=5) + assert self._rendered(wp) == "[]" + + def test_changing_the_row_regenerates(self, doc): + wp = place(doc, '{csv["Name"]}') + doc.variable_text.csv = doc.variable_text.csv.with_current(2) + assert self._rendered(wp) == "Bob" diff --git a/tests/core/test_workpiece_variable_text.py b/tests/core/test_workpiece_variable_text.py index 2ff9dc677..903c22680 100644 --- a/tests/core/test_workpiece_variable_text.py +++ b/tests/core/test_workpiece_variable_text.py @@ -45,7 +45,7 @@ def test_unparented_workpiece_has_no_values(self): def test_values_follow_doc_counter_and_offset(self, doc): doc.variable_text.serial = SerialSettings(current=10, step=5) wp = place(doc, "{serial}", offset=2) - assert wp.template_values() == {"serial": 20} + assert wp.template_values()["serial"] == 20 def test_provider_receives_serial(self, doc): doc.variable_text.serial = SerialSettings(current=7) diff --git a/tests/doceditor/test_array_cmd.py b/tests/doceditor/test_array_cmd.py index 6fc6ca3dc..7bccc67fe 100644 --- a/tests/doceditor/test_array_cmd.py +++ b/tests/doceditor/test_array_cmd.py @@ -251,7 +251,7 @@ async def test_grid_array_numbers_copies(doc_editor, array_cmd): last = created[-1] provider = last.get_geometry_provider() assert isinstance(provider, TemplateProvider) - assert last.template_values() == {"serial": 16} + assert last.template_values()["serial"] == 16 assert provider.rendered[-1] == "16" From f304fac17afa3ca5ff02d0609d97a983ba7cabc5 Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Mon, 12 Oct 2026 19:53:21 +0200 Subject: [PATCH 08/12] feat: undoable CSV loading, row navigation and job advance DocEditor.variable_text loads and reloads the linked CSV file (reload keeps the current row), and moves the row with First / Previous / Next, all through the undo history. After a finished job the serial counter and the CSV row advance in one undo step, each only when a workpiece uses it. The row stops after the last row and a notification says that every row has been used. Refs #528 --- rayforge/core/doc.py | 18 +- rayforge/doceditor/variable_text_cmd.py | 156 ++++++++++++++-- tests/doceditor/test_variable_text_csv_cmd.py | 168 ++++++++++++++++++ 3 files changed, 324 insertions(+), 18 deletions(-) create mode 100644 tests/doceditor/test_variable_text_csv_cmd.py diff --git a/rayforge/core/doc.py b/rayforge/core/doc.py index d5c08213d..560ffdcaa 100644 --- a/rayforge/core/doc.py +++ b/rayforge/core/doc.py @@ -195,22 +195,28 @@ def variable_text(self, value: VariableText) -> None: value.changed.connect(self._on_variable_text_changed) self._on_variable_text_changed(value) - def _template_workpieces(self) -> list[WorkPiece]: - """Workpieces whose templates read a document variable.""" - names = self._variable_text.names + def _template_workpieces( + self, names: set[str] | None = None + ) -> list[WorkPiece]: + """Workpieces whose templates read a document variable, or one + of *names* if given.""" + if names is None: + names = self._variable_text.names return [ wp for wp in self.all_workpieces if wp.references_template_names(names) ] - def variable_text_copies(self) -> int: + def variable_text_copies(self, names: set[str] | None = None) -> int: """ How many consecutive variable values one job uses: the highest variable offset of a template workpiece plus one, or 0 if no - workpiece uses a document variable. + workpiece uses a document variable (or one of *names*). """ - offsets = [wp.variable_offset for wp in self._template_workpieces()] + offsets = [ + wp.variable_offset for wp in self._template_workpieces(names) + ] if not offsets: return 0 return max(offsets) + 1 diff --git a/rayforge/doceditor/variable_text_cmd.py b/rayforge/doceditor/variable_text_cmd.py index ff67bf990..e1ca8e489 100644 --- a/rayforge/doceditor/variable_text_cmd.py +++ b/rayforge/doceditor/variable_text_cmd.py @@ -1,6 +1,7 @@ """ VariableTextCmd: undoable edits of the document's template variables -(serial counter) and of the per-workpiece variable offsets. +(serial counter, merged CSV rows) and of the per-workpiece variable +offsets. """ from __future__ import annotations @@ -12,7 +13,14 @@ from ..core.item import DocItem from ..core.undo import ChangePropertyCommand -from ..core.variable_text import SerialSettings +from ..core.variable_text import ( + CSV_NAME, + ROW_NAME, + SERIAL_NAME, + CsvMerge, + SerialSettings, + read_csv_file, +) from ..core.workpiece import WorkPiece from .layout.base import LayoutStrategy @@ -70,26 +78,150 @@ def preview_serial_values(self) -> list[int]: def advance_after_job(self, exported: bool = False) -> bool: """ - Moves the counter on after a finished job (or a G-code export, - if *exported*), by one value per copy in the job. + Moves the variables on after a finished job (or a G-code export, + if *exported*), by one value per copy in the job, in one undo + step. The serial counter and the CSV row only move when a + workpiece uses them; the CSV row is not moved by an export. Returns: - True if the counter was advanced. + True if anything was advanced. """ - serial = self.serial + variables = self._editor.doc.variable_text + serial, merge = variables.serial, variables.csv + new_serial = self._advanced_serial(serial, exported) + new_merge = None if exported else self._advanced_csv(merge) + if new_serial is None and new_merge is None: + return False + history = self._editor.history_manager + with history.transaction(_("Advance variable text after job")) as t: + if new_serial is not None: + t.execute(self._change("serial", new_serial)) + if new_merge is not None: + t.execute(self._change("csv", new_merge)) + if new_merge is not None and new_merge.is_exhausted: + self._notify( + _("All {count} CSV rows have been used.").format( + count=new_merge.row_count + ) + ) + logger.info( + f"Variables advanced after job: serial {serial.current} -> " + f"{variables.serial.current}, CSV row {merge.current} -> " + f"{variables.csv.current}" + ) + return True + + def _advanced_serial( + self, serial: SerialSettings, exported: bool + ) -> SerialSettings | None: enabled = serial.advance_on_export if exported else serial.auto_advance - copies = self._editor.doc.variable_text_copies() + copies = self._editor.doc.variable_text_copies({SERIAL_NAME}) if not enabled or copies == 0: + return None + return serial.advanced(copies) + + def _advanced_csv(self, merge: CsvMerge) -> CsvMerge | None: + copies = self._csv_copies() + if not merge.is_loaded or not merge.auto_advance or copies == 0: + return None + advanced = merge.advanced(copies) + return None if advanced == merge else advanced + + def _change(self, prop: str, value: object) -> ChangePropertyCommand: + return ChangePropertyCommand( + target=self._editor.doc.variable_text, + property_name=prop, + new_value=value, + ) + + def _notify(self, message: str) -> None: + self._editor.notification_requested.send(self, message=message) + + @property + def csv(self) -> CsvMerge: + return self._editor.doc.variable_text.csv + + def _csv_copies(self) -> int: + return self._editor.doc.variable_text_copies({CSV_NAME, ROW_NAME}) + + def set_csv(self, merge: CsvMerge, name: str | None = None) -> None: + """Replaces the merged CSV in one undoable step.""" + variable_text = self._editor.doc.variable_text + if merge == variable_text.csv: + return + cmd = ChangePropertyCommand( + target=variable_text, + property_name="csv", + new_value=merge, + name=name or _("Change CSV merge"), + ) + self._editor.history_manager.execute(cmd) + + def _read(self, path: str) -> CsvMerge | None: + try: + return read_csv_file(path) + except (OSError, ValueError) as e: + logger.warning(f"Could not read CSV file {path}: {e}") + self._notify( + _("Could not read the CSV file {path}: {error}").format( + path=path, error=e + ) + ) + return None + + def load_csv(self, path: str) -> bool: + """Links a CSV file and stores its rows in the project.""" + loaded = self._read(path) + if loaded is None: return False - self.set_serial( - serial.advanced(copies), _("Advance serial number after job") + merge = self.csv + self.set_csv( + CsvMerge( + header=loaded.header, + rows=loaded.rows, + path=path, + auto_advance=merge.auto_advance, + ), + _("Load CSV file"), ) - logger.info( - f"Serial number advanced by {copies} to " - f"{self._editor.doc.variable_text.serial.current}" + return True + + def reload_csv(self) -> bool: + """Reads the linked file again, keeping the current row.""" + merge = self.csv + if not merge.path: + return False + loaded = self._read(merge.path) + if loaded is None: + return False + self.set_csv( + merge.with_rows(loaded.header, loaded.rows), _("Reload CSV file") ) return True + def first_row(self) -> None: + """Goes back to the first row.""" + self.set_csv(self.csv.with_current(1), _("First CSV row")) + + def next_row(self) -> None: + """Moves the row forward by one job.""" + copies = max(self._csv_copies(), 1) + self.set_csv(self.csv.advanced(copies), _("Next CSV row")) + + def previous_row(self) -> None: + """Moves the row back by one job.""" + copies = max(self._csv_copies(), 1) + self.set_csv(self.csv.advanced(-copies), _("Previous CSV row")) + + def preview_rows(self) -> list[int]: + """The 1-based rows the next job uses, one per copy.""" + copies = max(self._csv_copies(), 1) + return [self.csv.row_number(i) for i in range(copies)] + + def copies_needed(self) -> int: + """How many copies cover the rows that are left.""" + return self.csv.remaining + def set_variable_offset( self, workpieces: Sequence[WorkPiece], offset: int ) -> None: diff --git a/tests/doceditor/test_variable_text_csv_cmd.py b/tests/doceditor/test_variable_text_csv_cmd.py new file mode 100644 index 000000000..f59b8be7f --- /dev/null +++ b/tests/doceditor/test_variable_text_csv_cmd.py @@ -0,0 +1,168 @@ +from pathlib import Path + +import pytest + +from rayforge.core.variable_text import CsvMerge, SerialSettings +from rayforge.doceditor.variable_text_cmd import VariableTextCmd +from tests.core.template_provider import place + +ROWS = "Name,City\nAnn,Gent\nBob,Hasselt\nCas,Tongeren\nDirk,Genk\n" + + +@pytest.fixture +def cmd(doc_editor): + return VariableTextCmd(doc_editor) + + +@pytest.fixture +def csv_file(tmp_path: Path) -> Path: + path = tmp_path / "people.csv" + path.write_text(ROWS, encoding="utf-8") + return path + + +def _csv(doc_editor) -> CsvMerge: + return doc_editor.doc.variable_text.csv + + +def _notifications(doc_editor) -> list[str]: + messages: list[str] = [] + doc_editor.notification_requested.connect( + lambda sender, message, **kw: messages.append(message), weak=False + ) + return messages + + +@pytest.mark.asyncio +async def test_load_csv_is_undoable(doc_editor, cmd, csv_file): + assert cmd.load_csv(str(csv_file)) + merge = _csv(doc_editor) + assert merge.header == ("Name", "City") + assert merge.row_count == 4 + assert merge.path == str(csv_file) + doc_editor.history_manager.undo() + assert not _csv(doc_editor).is_loaded + + +@pytest.mark.asyncio +async def test_load_failure_notifies(doc_editor, cmd, tmp_path): + messages = _notifications(doc_editor) + assert not cmd.load_csv(str(tmp_path / "missing.csv")) + bad = tmp_path / "latin1.csv" + bad.write_bytes("Naam\nJos\xe9\n".encode("latin-1")) + assert not cmd.load_csv(str(bad)) + assert len(messages) == 2 + assert not _csv(doc_editor).is_loaded + + +@pytest.mark.asyncio +async def test_reload_keeps_the_current_row(doc_editor, cmd, csv_file): + cmd.load_csv(str(csv_file)) + cmd.set_csv(_csv(doc_editor).with_current(3)) + csv_file.write_text(ROWS + "Eva,Bree\n", encoding="utf-8") + assert cmd.reload_csv() + assert _csv(doc_editor).row_count == 5 + assert _csv(doc_editor).current == 3 + + +@pytest.mark.asyncio +async def test_row_navigation_moves_by_copies(doc_editor, cmd, csv_file): + place(doc_editor.doc, '{csv["Name"]}', offset=0) + place(doc_editor.doc, '{csv["Name"]}', offset=1) + cmd.load_csv(str(csv_file)) + cmd.next_row() + assert _csv(doc_editor).current == 3 + cmd.previous_row() + assert _csv(doc_editor).current == 1 + cmd.next_row() + cmd.first_row() + assert _csv(doc_editor).current == 1 + + +@pytest.mark.asyncio +async def test_preview_rows(doc_editor, cmd, csv_file): + place(doc_editor.doc, '{csv["Name"]}', offset=0) + place(doc_editor.doc, '{csv["Name"]}', offset=2) + cmd.load_csv(str(csv_file)) + assert cmd.preview_rows() == [1, 2, 3] + + +@pytest.mark.asyncio +async def test_copies_needed(doc_editor, cmd, csv_file): + cmd.load_csv(str(csv_file)) + assert cmd.copies_needed() == 4 + cmd.set_csv(_csv(doc_editor).with_current(4)) + assert cmd.copies_needed() == 1 + + +@pytest.mark.asyncio +async def test_job_advances_the_row(doc_editor, cmd, csv_file): + place(doc_editor.doc, '{csv["Name"]}', offset=0) + place(doc_editor.doc, '{csv["Name"]}', offset=1) + cmd.load_csv(str(csv_file)) + assert cmd.advance_after_job() + assert _csv(doc_editor).current == 3 + + +@pytest.mark.asyncio +async def test_job_does_not_advance_unused_variables( + doc_editor, cmd, csv_file +): + place(doc_editor.doc, '{csv["Name"]}') + cmd.load_csv(str(csv_file)) + cmd.advance_after_job() + assert doc_editor.doc.variable_text.serial == SerialSettings() + assert _csv(doc_editor).current == 2 + + +@pytest.mark.asyncio +async def test_serial_only_leaves_the_row_alone(doc_editor, cmd, csv_file): + place(doc_editor.doc, "{serial}") + cmd.load_csv(str(csv_file)) + cmd.advance_after_job() + assert doc_editor.doc.variable_text.serial.current == 2 + assert _csv(doc_editor).current == 1 + + +@pytest.mark.asyncio +async def test_advance_stops_at_the_end_and_notifies( + doc_editor, cmd, csv_file +): + messages = _notifications(doc_editor) + place(doc_editor.doc, '{csv["Name"]}', offset=0) + place(doc_editor.doc, '{csv["Name"]}', offset=2) + cmd.load_csv(str(csv_file)) + cmd.advance_after_job() + assert _csv(doc_editor).current == 4 + assert messages == [] + cmd.advance_after_job() + assert _csv(doc_editor).current == 5 + assert _csv(doc_editor).is_exhausted + assert len(messages) == 1 + + +@pytest.mark.asyncio +async def test_job_advance_is_one_undo_step(doc_editor, cmd, csv_file): + place(doc_editor.doc, '{serial} {csv["Name"]}') + cmd.load_csv(str(csv_file)) + cmd.advance_after_job() + assert doc_editor.doc.variable_text.serial.current == 2 + assert _csv(doc_editor).current == 2 + doc_editor.history_manager.undo() + assert doc_editor.doc.variable_text.serial.current == 1 + assert _csv(doc_editor).current == 1 + + +@pytest.mark.asyncio +async def test_csv_auto_advance_can_be_off(doc_editor, cmd, csv_file): + place(doc_editor.doc, '{csv["Name"]}') + cmd.load_csv(str(csv_file)) + cmd.set_csv( + CsvMerge( + header=_csv(doc_editor).header, + rows=_csv(doc_editor).rows, + auto_advance=False, + ) + ) + assert not cmd.advance_after_job() + assert _csv(doc_editor).current == 1 From 4b2d9b329b4220a0949a21df463997ff6a0ec47b Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Mon, 12 Oct 2026 19:54:57 +0200 Subject: [PATCH 09/12] feat: CSV section in the Variable Text dialog The dialog gets a CSV Merge section: choose or reload the linked file, the columns and row count, the current row, auto-advance, a preview of the rows the next job uses, the number of copies that cover the remaining rows, and First / Previous / Next buttons. Refs #528 --- .../doceditor/variable_text_csv_group.py | 221 ++++++++++++++++++ .../ui_gtk/doceditor/variable_text_dialog.py | 9 +- .../doceditor/test_variable_text_csv_group.py | 104 +++++++++ 3 files changed, 332 insertions(+), 2 deletions(-) create mode 100644 rayforge/ui_gtk/doceditor/variable_text_csv_group.py create mode 100644 tests/ui_gtk/doceditor/test_variable_text_csv_group.py diff --git a/rayforge/ui_gtk/doceditor/variable_text_csv_group.py b/rayforge/ui_gtk/doceditor/variable_text_csv_group.py new file mode 100644 index 000000000..fce05aab0 --- /dev/null +++ b/rayforge/ui_gtk/doceditor/variable_text_csv_group.py @@ -0,0 +1,221 @@ +import logging +from gettext import gettext as _ +from pathlib import Path +from typing import TYPE_CHECKING + +from gi.repository import Adw, Gio, GLib, Gtk + +from ...core.variable_text import CsvMerge +from ..shared.pref_rows import SpinRow + +if TYPE_CHECKING: + from ...doceditor.editor import DocEditor + +logger = logging.getLogger(__name__) + +_ROW_LIMIT = 999_999_999 +_PREVIEW_ROWS_MAX = 3 + + +def format_rows(merge: CsvMerge, rows: list[int]) -> str: + """A short text for the rows the next job uses.""" + if not merge.is_loaded: + return _("No CSV file loaded") + if merge.is_exhausted: + return _("All rows have been used") + shown = [] + for number in rows[:_PREVIEW_ROWS_MAX]: + if number > merge.row_count: + shown.append(_("{row} (empty)").format(row=number)) + continue + first_cell = merge.rows[number - 1][0] if merge.header else "" + shown.append(f"{number}: {first_cell}") + if len(rows) > _PREVIEW_ROWS_MAX: + shown.append(_("… {count} copies").format(count=len(rows))) + return ", ".join(shown) + + +class CsvMergeGroup(Adw.PreferencesGroup): + """The CSV merge settings of the Variable Text dialog.""" + + def __init__(self, editor: "DocEditor"): + super().__init__( + title=_("CSV Merge"), + description=_( + 'Use {csv["Name"]} or {csv[0]} for a column of the ' + "current row, and {row} for its number." + ), + ) + self.editor = editor + self._in_update = False + + self.file_row = Adw.ActionRow(title=_("CSV File")) + self.choose_button = Gtk.Button( + label=_("Choose…"), valign=Gtk.Align.CENTER + ) + self.reload_button = Gtk.Button( + icon_name="view-refresh-symbolic", valign=Gtk.Align.CENTER + ) + self.reload_button.set_tooltip_text(_("Read the file again")) + self.file_row.add_suffix(self.reload_button) + self.file_row.add_suffix(self.choose_button) + self.columns_row = Adw.ActionRow(title=_("Columns")) + self.columns_row.add_css_class("property") + self.current_row = SpinRow( + _("Current Row"), + _("The row the next job starts with"), + lower=1, + upper=_ROW_LIMIT, + numeric=True, + ) + self.auto_advance_row = Adw.SwitchRow( + title=_("Advance After Each Job"), + subtitle=_("Stops after the last row"), + ) + self.preview_row = Adw.ActionRow(title=_("Next Job")) + self.preview_row.add_css_class("property") + self.copies_row = Adw.ActionRow( + title=_("Copies Needed"), + subtitle=_("Copies that cover the remaining rows in one job"), + ) + self.copies_label = Gtk.Label() + self.copies_row.add_suffix(self.copies_label) + + for row in ( + self.file_row, + self.columns_row, + self.current_row, + self.auto_advance_row, + self.preview_row, + self.copies_row, + ): + self.add(row) + self.add(self._build_buttons()) + + self.choose_button.connect("clicked", self._on_choose_clicked) + self.reload_button.connect("clicked", self._on_reload_clicked) + self.current_row.value_changed.connect(self._on_current_changed) + self.auto_advance_row.connect( + "notify::active", self._on_auto_advance_changed + ) + + def _build_buttons(self) -> Gtk.Box: + box = Gtk.Box(spacing=6, halign=Gtk.Align.END) + box.set_margin_top(12) + self.first_button = Gtk.Button(label=_("First")) + self.first_button.set_tooltip_text(_("Go back to the first row")) + self.previous_button = Gtk.Button(label=_("Previous")) + self.previous_button.set_tooltip_text( + _("Go back by the rows of one job") + ) + self.next_button = Gtk.Button(label=_("Next")) + self.next_button.set_tooltip_text( + _("Skip ahead by the rows of one job") + ) + commands = self.editor.variable_text + self.first_button.connect("clicked", lambda b: commands.first_row()) + self.previous_button.connect( + "clicked", lambda b: commands.previous_row() + ) + self.next_button.connect("clicked", lambda b: commands.next_row()) + for button in ( + self.first_button, + self.previous_button, + self.next_button, + ): + box.append(button) + return box + + def update(self) -> None: + """Shows the document's CSV merge.""" + merge = self.editor.variable_text.csv + self._in_update = True + try: + self.file_row.set_subtitle( + GLib.markup_escape_text(merge.path or _("No file linked")) + ) + self.reload_button.set_sensitive(bool(merge.path)) + self.columns_row.set_subtitle( + GLib.markup_escape_text( + _("{columns} ({rows} rows)").format( + columns=", ".join(merge.header) or "-", + rows=merge.row_count, + ) + ) + ) + self.current_row.set_value(merge.current) + self.auto_advance_row.set_active(merge.auto_advance) + for widget in ( + self.current_row, + self.auto_advance_row, + self.first_button, + self.previous_button, + self.next_button, + ): + widget.set_sensitive(merge.is_loaded) + finally: + self._in_update = False + self.update_preview() + + def update_preview(self) -> None: + """Shows the rows the next job uses.""" + commands = self.editor.variable_text + merge = commands.csv + self.preview_row.set_subtitle( + GLib.markup_escape_text( + format_rows(merge, commands.preview_rows()) + ) + ) + self.copies_label.set_label(str(commands.copies_needed())) + + def _on_current_changed(self, row: SpinRow) -> None: + if self._in_update: + return + commands = self.editor.variable_text + commands.set_csv(commands.csv.with_current(row.get_int_value())) + + def _on_auto_advance_changed(self, row: Adw.SwitchRow, _pspec) -> None: + if self._in_update: + return + commands = self.editor.variable_text + merge = commands.csv + commands.set_csv( + CsvMerge( + header=merge.header, + rows=merge.rows, + path=merge.path, + current=merge.current, + auto_advance=row.get_active(), + ) + ) + + def _on_reload_clicked(self, button: Gtk.Button) -> None: + self.editor.variable_text.reload_csv() + + def _on_choose_clicked(self, button: Gtk.Button) -> None: + dialog = Gtk.FileDialog.new() + dialog.set_title(_("Choose a CSV File")) + csv_filter = Gtk.FileFilter() + csv_filter.set_name(_("CSV files")) + csv_filter.add_pattern("*.csv") + csv_filter.add_pattern("*.txt") + csv_filter.add_mime_type("text/csv") + filters = Gio.ListStore.new(Gtk.FileFilter) + filters.append(csv_filter) + dialog.set_filters(filters) + path = self.editor.variable_text.csv.path + if path and Path(path).parent.is_dir(): + folder = Gio.File.new_for_path(str(Path(path).parent)) + dialog.set_initial_folder(folder) + root = self.get_root() + parent = root if isinstance(root, Gtk.Window) else None + dialog.open(parent, None, self._on_file_chosen) + + def _on_file_chosen(self, dialog: Gtk.FileDialog, result) -> None: + try: + file = dialog.open_finish(result) + except GLib.Error as e: + logger.debug(f"CSV file choice cancelled: {e.message}") + return + if file is not None and file.get_path(): + self.editor.variable_text.load_csv(file.get_path()) diff --git a/rayforge/ui_gtk/doceditor/variable_text_dialog.py b/rayforge/ui_gtk/doceditor/variable_text_dialog.py index 222eb9eb0..e032322c0 100644 --- a/rayforge/ui_gtk/doceditor/variable_text_dialog.py +++ b/rayforge/ui_gtk/doceditor/variable_text_dialog.py @@ -7,6 +7,7 @@ from ...core.variable_text import SerialSettings, VariableText from ..shared.patched_dialog_window import PatchedDialogWindow from ..shared.pref_rows import SpinRow +from .variable_text_csv_group import CsvMergeGroup if TYPE_CHECKING: from ...core.doc import Doc @@ -29,8 +30,8 @@ def format_preview(values: list[int]) -> str: class VariableTextDialog(PatchedDialogWindow): """ - A non-modal window for the document's serial number: its settings, - Previous/Next/Reset buttons and a preview of the next job. + A non-modal window for the document's serial number and CSV merge: + their settings, navigation buttons and a preview of the next job. """ def __init__(self, parent: Gtk.Window, editor: "DocEditor"): @@ -53,6 +54,8 @@ def __init__(self, parent: Gtk.Window, editor: "DocEditor"): page.add(self._build_serial_group()) page.add(self._build_job_group()) + self.csv_group = CsvMergeGroup(editor) + page.add(self.csv_group) self._doc.variable_text.changed.connect(self._on_model_changed) self._doc.descendant_updated.connect(self._on_doc_items_changed) @@ -196,6 +199,7 @@ def _on_model_changed(self, sender: VariableText) -> None: def _on_doc_items_changed(self, sender, **kwargs) -> None: self._update_preview() + self.csv_group.update_preview() def _update_from_model(self) -> None: serial = self._doc.variable_text.serial @@ -215,6 +219,7 @@ def _update_from_model(self) -> None: finally: self._in_update = False self._update_preview() + self.csv_group.update() def _update_preview(self) -> None: values = self.editor.variable_text.preview_serial_values() diff --git a/tests/ui_gtk/doceditor/test_variable_text_csv_group.py b/tests/ui_gtk/doceditor/test_variable_text_csv_group.py new file mode 100644 index 000000000..5c6e462c8 --- /dev/null +++ b/tests/ui_gtk/doceditor/test_variable_text_csv_group.py @@ -0,0 +1,104 @@ +# flake8: noqa: E402 +import gi + +gi.require_version("Gtk", "4.0") +gi.require_version("Adw", "1") + +from pathlib import Path + +import pytest +from gi.repository import Gtk + +from rayforge.core.variable_text import CsvMerge +from rayforge.doceditor.editor import DocEditor +from rayforge.ui_gtk.doceditor.variable_text_csv_group import format_rows +from rayforge.ui_gtk.doceditor.variable_text_dialog import ( + VariableTextDialog, +) +from tests.core.template_provider import place + +ROWS = "Name,City\nAnn,Gent\nBob,Hasselt\nCas,Tongeren\n" +MERGE = CsvMerge( + header=("Name", "City"), + rows=(("Ann", "Gent"), ("Bob", "Hasselt"), ("Cas", "Tongeren")), +) + + +@pytest.fixture +def editor(ui_context_initializer, ui_task_mgr): + editor = DocEditor( + task_manager=ui_task_mgr, context=ui_context_initializer + ) + yield editor + editor.cleanup() + + +@pytest.fixture +def group(editor): + parent = Gtk.Window() + dialog = VariableTextDialog(parent, editor) + yield dialog.csv_group + dialog.destroy() + + +@pytest.fixture +def csv_file(tmp_path: Path) -> Path: + path = tmp_path / "people.csv" + path.write_text(ROWS, encoding="utf-8") + return path + + +def _csv(editor) -> CsvMerge: + return editor.doc.variable_text.csv + + +def test_format_rows(): + assert format_rows(CsvMerge(), [1]) == "No CSV file loaded" + assert format_rows(MERGE, [1, 2]) == "1: Ann, 2: Bob" + assert format_rows(MERGE, [3, 4]) == "3: Cas, 4 (empty)" + assert format_rows(MERGE, [1, 2, 3, 4]).endswith("… 4 copies") + assert format_rows(MERGE.with_current(4), [4]) == ( + "All rows have been used" + ) + + +@pytest.mark.ui +def test_empty_group_is_disabled(group): + assert group.file_row.get_subtitle() == "No file linked" + assert not group.reload_button.get_sensitive() + assert not group.next_button.get_sensitive() + assert group.preview_row.get_subtitle() == "No CSV file loaded" + + +@pytest.mark.ui +def test_loading_updates_the_group(editor, group, csv_file): + place(editor.doc, '{csv["Name"]}', offset=0) + place(editor.doc, '{csv["Name"]}', offset=1) + editor.variable_text.load_csv(str(csv_file)) + assert group.file_row.get_subtitle() == str(csv_file) + assert group.reload_button.get_sensitive() + assert group.columns_row.get_subtitle() == "Name, City (3 rows)" + assert group.preview_row.get_subtitle() == "1: Ann, 2: Bob" + assert group.copies_label.get_label() == "3" + + +@pytest.mark.ui +def test_next_and_first_buttons(editor, group, csv_file): + editor.variable_text.load_csv(str(csv_file)) + group.next_button.emit("clicked") + assert _csv(editor).current == 2 + assert group.current_row.get_int_value() == 2 + group.first_button.emit("clicked") + assert _csv(editor).current == 1 + + +@pytest.mark.ui +def test_current_row_and_switch_commit(editor, group, csv_file): + editor.variable_text.load_csv(str(csv_file)) + group.current_row.set_value(3) + group.current_row.value_changed.send(group.current_row) + assert _csv(editor).current == 3 + group.auto_advance_row.set_active(False) + assert _csv(editor).auto_advance is False + editor.history_manager.undo() + assert _csv(editor).auto_advance is True From f81173cb0ad85d98daa5175f089f0f883a6a30af Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Mon, 12 Oct 2026 19:55:33 +0200 Subject: [PATCH 10/12] docs: document CSV merge in Variable Text Refs #528 --- CHANGELOG.md | 3 +++ website/docs/features/variable-text.md | 31 ++++++++++++++++++++++++-- 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a5359c88..c8e68e307 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -102,6 +102,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Variable Text: a `{serial}` variable for text templates, with a project counter that advances after every finished job and consecutive numbers for array copies (Tools > Variable Text) (#527) +- Variable Text: merge a CSV file into text templates with + `{csv["Name"]}`, `{csv[0]}` and `{row}`, one row per copy or per job + (#528) ### Changed diff --git a/website/docs/features/variable-text.md b/website/docs/features/variable-text.md index f995eb3ce..c05efbe47 100644 --- a/website/docs/features/variable-text.md +++ b/website/docs/features/variable-text.md @@ -1,7 +1,7 @@ --- description: - "Serial numbers in text templates: a project-level counter that advances after every job, with - consecutive numbers for array copies." + "Serial numbers and CSV merge in text templates: values that advance after every job, with + consecutive values for array copies." --- # Variable Text @@ -49,3 +49,30 @@ For copies you placed yourself, select them all and choose **Arrange → Array offsets are assigned in reading order, rows from top to bottom and each row from left to right. The counter advances by the number of copies in the job: the highest variable offset plus one. + +## Merging a CSV file + +For name tags, product labels and other lists, link a spreadsheet saved as CSV. Open **Tools → +Variable Text…** and choose the file under **CSV Merge**. The rows are stored in the project, so it +still works when the file is gone; **Reload** reads the file again after you changed it and keeps +the current row. + +In a text box, refer to a column of the current row by name or by position, and to the row number: + +- `{csv["Name"]}` — the `Name` column +- `{csv[0]}` — the first column +- `{row}` — the row number, starting at 1 + +The delimiter (comma, semicolon, tab or `|`) is detected and the file must be UTF-8. The first row +holds the column names, unless it consists only of numbers: then the columns are named `Column 1`, +`Column 2`, and so on, and can also be read by position. + +Copies work like they do for serial numbers: a copy with variable offset _n_ shows the row _n_ rows +after the current one, so a grid array puts one row on each copy. **Copies Needed** tells you how +many copies cover the remaining rows in one job. Copies past the last row show empty columns. + +With **Advance After Each Job** on, a finished job moves the current row on by the number of +copies, so for one row per job you just start the next job. After the last row it stops instead of +wrapping, and Rayforge tells you that every row has been used. **First**, **Previous** and **Next** +move the current row by hand. The serial number and the CSV row only advance when a text box uses +them. From 6237bfc325c2f47304398a22b3595321db572907 Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Mon, 12 Oct 2026 20:30:21 +0200 Subject: [PATCH 11/12] fix: narrow the chosen CSV path before loading it Refs #528 --- rayforge/ui_gtk/doceditor/variable_text_csv_group.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/rayforge/ui_gtk/doceditor/variable_text_csv_group.py b/rayforge/ui_gtk/doceditor/variable_text_csv_group.py index fce05aab0..d17bc6b25 100644 --- a/rayforge/ui_gtk/doceditor/variable_text_csv_group.py +++ b/rayforge/ui_gtk/doceditor/variable_text_csv_group.py @@ -217,5 +217,6 @@ def _on_file_chosen(self, dialog: Gtk.FileDialog, result) -> None: except GLib.Error as e: logger.debug(f"CSV file choice cancelled: {e.message}") return - if file is not None and file.get_path(): - self.editor.variable_text.load_csv(file.get_path()) + path = file.get_path() if file is not None else None + if path: + self.editor.variable_text.load_csv(path) From e139e7c494860d8958ee38c698b875f36e8378f1 Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Mon, 12 Oct 2026 21:02:58 +0200 Subject: [PATCH 12/12] test: let the editor settle after the CSV command tests Refs #528 --- tests/doceditor/test_variable_text_csv_cmd.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tests/doceditor/test_variable_text_csv_cmd.py b/tests/doceditor/test_variable_text_csv_cmd.py index f59b8be7f..9319645b9 100644 --- a/tests/doceditor/test_variable_text_csv_cmd.py +++ b/tests/doceditor/test_variable_text_csv_cmd.py @@ -1,6 +1,7 @@ from pathlib import Path import pytest +import pytest_asyncio from rayforge.core.variable_text import CsvMerge, SerialSettings from rayforge.doceditor.variable_text_cmd import VariableTextCmd @@ -9,9 +10,10 @@ ROWS = "Name,City\nAnn,Gent\nBob,Hasselt\nCas,Tongeren\nDirk,Genk\n" -@pytest.fixture -def cmd(doc_editor): - return VariableTextCmd(doc_editor) +@pytest_asyncio.fixture +async def cmd(doc_editor): + yield VariableTextCmd(doc_editor) + await doc_editor.wait_until_settled() @pytest.fixture