diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b8252f80..c8e68e307 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -99,6 +99,12 @@ 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) +- 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/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..560ffdcaa 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,62 @@ 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, 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, 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 (or one of *names*). + """ + offsets = [ + wp.variable_offset for wp in self._template_workpieces(names) + ] + 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..bf3fdbc1d --- /dev/null +++ b/rayforge/core/variable_text.py @@ -0,0 +1,371 @@ +""" +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 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_]*") + + +@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 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. + + 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() + self._csv = CsvMerge() + + @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 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, 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), + CSV_NAME: self._csv.row_at(offset), + ROW_NAME: self._csv.row_number(offset), + } + + def to_dict(self) -> dict[str, Any]: + 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: + instance = cls() + if data: + 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 + + +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/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/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/doceditor/variable_text_cmd.py b/rayforge/doceditor/variable_text_cmd.py new file mode 100644 index 000000000..e1ca8e489 --- /dev/null +++ b/rayforge/doceditor/variable_text_cmd.py @@ -0,0 +1,295 @@ +""" +VariableTextCmd: undoable edits of the document's template variables +(serial counter, merged CSV rows) 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 ( + CSV_NAME, + ROW_NAME, + SERIAL_NAME, + CsvMerge, + SerialSettings, + read_csv_file, +) +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 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 anything was advanced. + """ + 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({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 + merge = self.csv + self.set_csv( + CsvMerge( + header=loaded.header, + rows=loaded.rows, + path=path, + auto_advance=merge.auto_advance, + ), + _("Load CSV file"), + ) + 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: + """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/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/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_csv_group.py b/rayforge/ui_gtk/doceditor/variable_text_csv_group.py new file mode 100644 index 000000000..d17bc6b25 --- /dev/null +++ b/rayforge/ui_gtk/doceditor/variable_text_csv_group.py @@ -0,0 +1,222 @@ +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 + path = file.get_path() if file is not None else None + if path: + self.editor.variable_text.load_csv(path) 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..e032322c0 --- /dev/null +++ b/rayforge/ui_gtk/doceditor/variable_text_dialog.py @@ -0,0 +1,233 @@ +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 +from .variable_text_csv_group import CsvMergeGroup + +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 and CSV merge: + their settings, navigation 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.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) + 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() + self.csv_group.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() + self.csv_group.update() + + 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/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..e183ee059 --- /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_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 new file mode 100644 index 000000000..903c22680 --- /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 diff --git a/tests/doceditor/test_array_cmd.py b/tests/doceditor/test_array_cmd.py index 2a1cb3a4f..7bccc67fe 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] 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..9319645b9 --- /dev/null +++ b/tests/doceditor/test_variable_text_csv_cmd.py @@ -0,0 +1,170 @@ +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 +from tests.core.template_provider import place + +ROWS = "Name,City\nAnn,Gent\nBob,Hasselt\nCas,Tongeren\nDirk,Genk\n" + + +@pytest_asyncio.fixture +async def cmd(doc_editor): + yield VariableTextCmd(doc_editor) + await doc_editor.wait_until_settled() + + +@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 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 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 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..be94795aa --- /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() 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() or "") 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..c05efbe47 --- /dev/null +++ b/website/docs/features/variable-text.md @@ -0,0 +1,78 @@ +--- +description: + "Serial numbers and CSV merge in text templates: values that advance after every job, with + consecutive values 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. + +## 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. 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',