diff --git a/.github/workflows/release-extension.yml b/.github/workflows/release-extension.yml index ba67c81..7b75ae8 100644 --- a/.github/workflows/release-extension.yml +++ b/.github/workflows/release-extension.yml @@ -65,9 +65,9 @@ jobs: run: | set -euo pipefail cp ./*.py blender_manifest.toml LICENCE README.md staging/ - cp -R blender semantic extensions examples staging/ + cp -R blender semantic extensions examples editor staging/ # Only these code directories are part of the extension (no tests or .git). - test -d staging/blender && test -d staging/extensions && test -d staging/semantic + test -d staging/blender && test -d staging/extensions && test -d staging/semantic && test -d staging/editor find staging -name __pycache__ -type d -prune -exec rm -rf {} + python3 - <<'PY' from pathlib import Path diff --git a/README.md b/README.md index 7910a82..92602d6 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,39 @@ To change the generated group, edit the text, select the generated group node, a See the [Get Started guide](https://denikryt.github.io/NodeForgeDocs/latest/GET_STARTED/) for the complete beginner workflow. +## Text Editor completion (prototype) + +Enable **NodeForge** in the Blender Text Editor header to show completion and +signature hints while editing. The toggle applies to Text Editors in the current +Blender window manager. No external editor or language server is required. + +- Type `from packages import math`, then `math.` to list installed package + functions. Namespace aliases such as `from packages import math as m` work. +- Type `from local import ...` or `from local.some_folder import ...` to browse + available Local names. Imported Local functions and directory aliases such as + `from local import shapes as s` support `s.` completion. +- Type a function call such as `math.layout_grid(` or `my_shape(` to see its + declared parameters. Parameters appear once each on separate rows; long + signatures scroll to keep the active parameter visible. The popup shows + the return type, or separate named and typed outputs for multi-output calls. + After a comma, type a parameter prefix to complete `parameter_name=`. + Top-level `def` signatures in the current Text datablock are also available + before the file is saved. +- Use **Up/Down** to navigate suggestions, **Tab** to insert the selected name, + and **Esc** to dismiss the current popup. The **Refresh** icon beside the + toggle reloads the installed package and Local inventories. + +The prototype derives package members and Local selection from the compiler's +resolved environment, and extension signatures from Extension API declarations. +For `.nf` sources, signature display currently recognizes explicit `input_*()` +assignments with literal names; computed labels and inferred inputs may not be +shown. Source-backed return types are inferred lazily by the existing semantic +frontend for the selected function and cached; if analysis fails, source output +labels remain available with unknown types. Extension result tuples are listed +in order. Core DSL name completion covers all registered built-ins, while +parameter hints and return types cover a small subset. The helper never builds +Geometry Nodes. It does not provide diagnostics or type-aware overload ranking. + ## The language at a glance A small script can mix group inputs, ordinary expressions, built-in Geometry Nodes wrappers, local functions, and direct access to Blender nodes: diff --git a/__init__.py b/__init__.py index fc695ff..76ce2d7 100644 --- a/__init__.py +++ b/__init__.py @@ -1,6 +1,6 @@ """NodeForge Blender addon package.""" -NODEFORGE_VERSION = (0, 72, 7) +NODEFORGE_VERSION = (0, 73, 0) bl_info = { "name": "NodeForge", diff --git a/blender_manifest.toml b/blender_manifest.toml index cc6ef37..971b1d3 100644 --- a/blender_manifest.toml +++ b/blender_manifest.toml @@ -1,6 +1,6 @@ schema_version = "1.0.0" id = "nodeforge" -version = "0.72.7" +version = "0.73.0" name = "NodeForge" tagline = "Compile a procedural DSL into Blender Geometry Nodes" maintainer = "nachitima" diff --git a/dev/CHANGELOG.md b/dev/CHANGELOG.md index bdf3009..7c7521d 100644 --- a/dev/CHANGELOG.md +++ b/dev/CHANGELOG.md @@ -532,3 +532,8 @@ lifecycle. - Require integration tests to pass before publishing the saved release ZIP and deploying the saved Pages artifact. - Bundle the latest published Math and L-System package ZIPs at release build time, using each package's minimum NodeForge version for compatibility checks. - Centralize bundled-library IDs and Release repositories in one registry shared by the Extension workflow and tests. +- Added opt-in Text Editor completion for Core names, installed package namespaces, Local imports/directories, and local `def` functions. +- Added cursor-positioned signature and keyword-argument hints, keyboard selection, and explicit catalog refresh; reuses compiler-selected catalog identities without building nodes. +- Added focused pure-Python and Blender 5.2 UI/insertion regression checks. +- Refined Text Editor popup: one vertical row per parameter, bounded layout, active argument, and typed results including multiple outputs; source output types reuse semantic callable preparation on demand. +- Include Text Editor modules in the Extension release ZIP and adapt package-backed completion tests to the current package installer. diff --git a/editor/__init__.py b/editor/__init__.py new file mode 100644 index 0000000..e062cb3 --- /dev/null +++ b/editor/__init__.py @@ -0,0 +1 @@ +"""Read-only NodeForge editor intelligence and Blender Text Editor integration.""" diff --git a/editor/blender_ui.py b/editor/blender_ui.py new file mode 100644 index 0000000..09ab4e0 --- /dev/null +++ b/editor/blender_ui.py @@ -0,0 +1,359 @@ +"""Opt-in Text Editor completion UI; Blender events stay outside language services. + +The draw handler presents passive hints, while one lightweight modal handler per +participating Text Editor handles only popup navigation/acceptance. Ordinary +text.insert keymaps remain untouched, unlike some third-party completers. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +import bpy +import blf +import gpu +from gpu_extras.batch import batch_for_shader + +from .intelligence import Assistance, SymbolIndex, cursor_assistance + + +@dataclass +class _EditorState: + """Track the last cursor snapshot and popup selection for one editor area.""" + + key: tuple | None = None + help: Assistance = Assistance() + selection: int = 0 + suppressed: tuple | None = None + error: str = "" + + +_STATES: dict[int, _EditorState] = {} +_RUNNING_AREAS: set[int] = set() +_SYMBOLS: SymbolIndex | None = None +_DRAW_HANDLE = None +_ENABLED = False + + +def _refresh_state(area) -> None: + """Update assistance only when the text/caret changes; no compile side effects.""" + space = area.spaces.active + if space.type != 'TEXT_EDITOR' or not bpy.context.window_manager.nodeforge_assist_enabled or space.text is None: + _STATES.pop(area.as_pointer(), None) + return + text = space.text + source = text.as_string() + key = (text.as_pointer(), source, text.current_line_index, text.current_character) + state = _STATES.setdefault(area.as_pointer(), _EditorState()) + if state.key == key: + return + state.key = key + state.selection = 0 + try: + global _SYMBOLS + if _SYMBOLS is None: + from ..environment_resolution import resolve_environment + _SYMBOLS = SymbolIndex(resolve_environment()) + state.help = cursor_assistance(source, text.current_line_index, text.current_character, _SYMBOLS) + state.error = "" + except Exception as exc: + state.help = Assistance() + state.error = str(exc) + for region in area.regions: + if region.type == 'WINDOW': + region.tag_redraw() + + +def _editor_tick(): + """Poll opt-in Text Editors without modifying Blender's insertion keymap.""" + if not _ENABLED: + return None + active = set() + for window in bpy.context.window_manager.windows: + for area in window.screen.areas: + if area.type != 'TEXT_EDITOR' or not bpy.context.window_manager.nodeforge_assist_enabled: + continue + pointer = area.as_pointer() + active.add(pointer) + _refresh_state(area) + if pointer in _RUNNING_AREAS: + continue + region = next((r for r in area.regions if r.type == 'WINDOW'), None) + if region is None: + continue + try: + with bpy.context.temp_override(window=window, area=area, region=region): + bpy.ops.nodeforge.editor_session('INVOKE_DEFAULT') + except RuntimeError: + continue + for pointer in tuple(_STATES): + if pointer not in active: + _STATES.pop(pointer, None) + _RUNNING_AREAS.intersection_update(active) + return 0.25 + + +def _rect(shader, x, y, width, height, color): + """Draw one GPU rectangle in Text Editor region coordinates.""" + coords = ((x, y), (x + width, y), (x + width, y + height), (x, y + height)) + batch = batch_for_shader(shader, 'TRIS', {'pos': coords}, indices=((0, 1, 2), (0, 2, 3))) + shader.bind() + shader.uniform_float('color', color) + batch.draw(shader) + + +def _popup_rows(hint: Assistance, selection: int, region_height: int): + """Build compact, scroll-windowed rows without duplicating keyword parameters.""" + rows = [] + parameter_candidates = hint.candidates and all(item.kind == "parameter" for item in hint.candidates) + if hint.signatures: + signature = hint.signatures[0] + outputs = signature.outputs + output_lines = min(len(outputs), 4) + footer_lines = 1 + (1 if len(outputs) <= 1 else output_lines + 2) + other_candidates = () if parameter_candidates else hint.candidates[:8] + available = max(1, (region_height - 28) // 21 - footer_lines - 1 - len(other_candidates)) + visible = min(12, available, len(signature.parameters)) + + focus = hint.active_parameter + if parameter_candidates and hint.candidates: + selected_keyword = hint.candidates[selection].insert.removesuffix("=") + for i, parameter in enumerate(signature.parameters): + if parameter.keyword == selected_keyword: + focus = i + break + start = max(0, min(focus - visible // 2, len(signature.parameters) - visible)) + end = start + visible + title = f"{signature.name}(" + if start or end < len(signature.parameters): + title += f" {start + 1}-{end}/{len(signature.parameters)}" + rows.append((title, False, "heading")) + for index in range(start, end): + parameter = signature.parameters[index] + # Display the actual insertable spelling rather than repeating the + # human-readable source label after a second keyword prefix. + name = parameter.keyword or parameter.name + label = name + if parameter.typ: + label += f": {parameter.typ}" + if parameter.default is not None: + label += f" = {parameter.default}" + selected = (parameter_candidates and parameter.keyword is not None + and hint.candidates[selection].insert == parameter.keyword + "=") + role = "active" if index == hint.active_parameter else "parameter" + rows.append((" " + label, selected, role)) + rows.append((")", False, "heading")) + if len(outputs) == 1: + result = outputs[0] + rows.append((f"→ {result.name}: {result.typ}", False, "result")) + elif len(outputs) > 1: + rows.append((f"→ {len(outputs)} outputs:", False, "result")) + for output in outputs[:output_lines]: + rows.append((f" {output.name}: {output.typ}", False, "result")) + if len(outputs) > output_lines: + rows.append((f" … +{len(outputs) - output_lines} outputs", False, "muted")) + else: + rows.append(("→ type unknown", False, "muted")) + if len(hint.signatures) > 1: + rows.append((f"+{len(hint.signatures) - 1} overload(s)", False, "muted")) + if parameter_candidates: + # Keywords unique to another overload must remain selectable. + visible_keys = {param.keyword for param in signature.parameters} + for index, item in enumerate(hint.candidates): + if item.insert.removesuffix("=") not in visible_keys: + rows.append((item.label + " (other overload)", index == selection, "option")) + else: + other_candidates = hint.candidates[:8] + + if not parameter_candidates: + start = max(0, min(selection - 4, len(hint.candidates) - 8)) + if len(hint.candidates) > 8: + rows.append((f"Suggestions {start + 1}-{min(start + 8, len(hint.candidates))} / {len(hint.candidates)}", False, "muted")) + for index in range(start, min(start + 8, len(hint.candidates))): + item = hint.candidates[index] + label = item.label + (f" {item.detail}" if item.detail else "") + rows.append((label, index == selection, "option")) + return rows + + +def _fit_text(label: str, max_width: float) -> str: + """Clip individual long fields by their rendered width, preserving layout.""" + if blf.dimensions(0, label)[0] <= max_width: + return label + ellipsis = "…" + low, high = 0, len(label) + while low < high: + mid = (low + high + 1) // 2 + if blf.dimensions(0, label[:mid] + ellipsis)[0] <= max_width: + low = mid + else: + high = mid - 1 + return label[:low] + ellipsis + + +def _draw_overlay(): + """Draw parameter rows and typed results beside the Blender text cursor.""" + context = bpy.context + area = context.area + region = context.region + if area is None or region is None: + return + state = _STATES.get(area.as_pointer()) + if state is None or state.suppressed == state.key: + return + hint = state.help + if not hint.candidates and not hint.signatures and not state.error: + return + space = area.spaces.active + text = space.text + if text is None: + return + x, y = space.region_location_from_cursor(text.current_line_index, text.current_character) + if x < 0 or y < 0: + return + blf.size(0, 12) + rows = _popup_rows(hint, state.selection, region.height) + if state.error: + rows.insert(0, ("NodeForge: " + state.error, False, "muted")) + if not rows: + return + width = min(520, max(180, region.width - 12)) + row_height = 21 + height = row_height * len(rows) + 8 + x = min(max(6, x), max(6, region.width - width - 6)) + y = max(6, min(y - height - 8, region.height - height - 6)) + shader = gpu.shader.from_builtin('UNIFORM_COLOR') + gpu.state.blend_set('ALPHA') + try: + _rect(shader, x, y, width, height, (0.08, 0.10, 0.14, 0.96)) + for i, (label, selected, role) in enumerate(rows): + baseline = y + height - row_height * (i + 1) - 1 + if selected: + _rect(shader, x + 3, baseline - 4, width - 6, row_height, (0.21, 0.35, 0.52, 0.90)) + if role == "active": + color = (0.74, 0.88, 1.0, 1.0) + elif role in {"heading", "result"}: + color = (0.65, 0.82, 1.0, 1.0) + elif role == "muted": + color = (0.65, 0.68, 0.73, 1.0) + else: + color = (0.94, 0.94, 0.94, 1.0) + blf.position(0, x + 9, baseline, 0) + blf.color(0, *color) + blf.draw(0, _fit_text(label, width - 18)) + finally: + gpu.state.blend_set('NONE') + + +class NODEFORGE_OT_editor_session(bpy.types.Operator): + """Receive popup navigation while allowing normal Text Editor keys through.""" + + bl_idname = 'nodeforge.editor_session' + bl_label = 'NodeForge Completion Session' + bl_options = {'INTERNAL'} + + def invoke(self, context, event): + """Start one modal listener per Text Editor area.""" + self.area_pointer = context.area.as_pointer() + if self.area_pointer in _RUNNING_AREAS: + return {'CANCELLED'} + _RUNNING_AREAS.add(self.area_pointer) + context.window_manager.modal_handler_add(self) + return {'RUNNING_MODAL'} + + def modal(self, context, event): + """Accept Tab, navigate Up/Down, dismiss Escape; pass every other event.""" + pointer = self.area_pointer + if not _ENABLED or pointer not in _RUNNING_AREAS: + _RUNNING_AREAS.discard(pointer) + return {'CANCELLED'} + if context.area is None or context.area.as_pointer() != pointer: + return {'PASS_THROUGH'} + space = context.area.spaces.active + if space.type != 'TEXT_EDITOR' or not bpy.context.window_manager.nodeforge_assist_enabled or space.text is None: + _RUNNING_AREAS.discard(pointer) + _STATES.pop(pointer, None) + return {'CANCELLED'} + state = _STATES.get(pointer) + if state is None or not state.help.candidates or state.suppressed == state.key: + return {'PASS_THROUGH'} + if event.value != 'PRESS': + return {'PASS_THROUGH'} + if event.type in {'UP_ARROW', 'DOWN_ARROW'}: + count = len(state.help.candidates) + state.selection = (state.selection + (1 if event.type == 'DOWN_ARROW' else -1)) % count + context.area.tag_redraw() + return {'RUNNING_MODAL'} + if event.type == 'ESC': + state.suppressed = state.key + context.area.tag_redraw() + return {'RUNNING_MODAL'} + if event.type == 'TAB' and not (event.ctrl or event.alt or event.oskey): + hint = state.help + text = space.text + if text.current_line_index != hint.line or text.current_character != hint.replace_end: + return {'PASS_THROUGH'} + suggestion = hint.candidates[state.selection] + for _ in range(hint.replace_end - hint.replace_start): + bpy.ops.text.delete(type='PREVIOUS_CHARACTER') + bpy.ops.text.insert(text=suggestion.insert) + state.suppressed = state.key + context.area.tag_redraw() + return {'RUNNING_MODAL'} + return {'PASS_THROUGH'} + + +class NODEFORGE_OT_refresh_editor_symbols(bpy.types.Operator): + """Refresh installed package/Local catalogs for text completion.""" + + bl_idname = 'nodeforge.refresh_editor_symbols' + bl_label = 'Refresh NodeForge Completions' + + def execute(self, context): + """Discard captured environment and cursor assistance snapshots.""" + global _SYMBOLS + _SYMBOLS = None + for state in _STATES.values(): + state.key = None + state.suppressed = None + return {'FINISHED'} + + +def _draw_header(self, context): + """Expose one window-manager toggle and explicit catalog refresh.""" + layout = self.layout + layout.prop(context.window_manager, 'nodeforge_assist_enabled', text='NodeForge', toggle=True) + if context.window_manager.nodeforge_assist_enabled: + layout.operator('nodeforge.refresh_editor_symbols', text='', icon='FILE_REFRESH') + + +def register(): + """Register Text Editor UI without replacing any global editing keymaps.""" + global _ENABLED, _DRAW_HANDLE + bpy.types.WindowManager.nodeforge_assist_enabled = bpy.props.BoolProperty( + name='NodeForge Completion', description='Show NodeForge call and argument suggestions', default=False) + bpy.utils.register_class(NODEFORGE_OT_editor_session) + bpy.utils.register_class(NODEFORGE_OT_refresh_editor_symbols) + bpy.types.TEXT_HT_header.append(_draw_header) + _DRAW_HANDLE = bpy.types.SpaceTextEditor.draw_handler_add(_draw_overlay, (), 'WINDOW', 'POST_PIXEL') + _ENABLED = True + if not bpy.app.background: + bpy.app.timers.register(_editor_tick, first_interval=0.2) + + +def unregister(): + """Remove handlers, timer and runtime state when disabling NodeForge.""" + global _ENABLED, _DRAW_HANDLE, _SYMBOLS + _ENABLED = False + if bpy.app.timers.is_registered(_editor_tick): + bpy.app.timers.unregister(_editor_tick) + if _DRAW_HANDLE is not None: + bpy.types.SpaceTextEditor.draw_handler_remove(_DRAW_HANDLE, 'WINDOW') + _DRAW_HANDLE = None + bpy.types.TEXT_HT_header.remove(_draw_header) + bpy.utils.unregister_class(NODEFORGE_OT_refresh_editor_symbols) + bpy.utils.unregister_class(NODEFORGE_OT_editor_session) + del bpy.types.WindowManager.nodeforge_assist_enabled + _STATES.clear() + _RUNNING_AREAS.clear() + _SYMBOLS = None diff --git a/editor/intelligence.py b/editor/intelligence.py new file mode 100644 index 0000000..6358d01 --- /dev/null +++ b/editor/intelligence.py @@ -0,0 +1,564 @@ +"""Blender-independent symbol completion and signature help for incomplete DSL text. + +Catalog selection comes from the same ResolvedEnvironment used by the compiler. +Only the cursor context and preliminary display of source input declarations are +editor-specific; this module neither compiles a group nor executes DSL source. +""" + +from __future__ import annotations + +import ast +import inspect +import io +import keyword +import re +import tokenize +from dataclasses import dataclass, field, replace +from pathlib import Path + +from ..semantic.builtin_registry import BUILTIN_NAMES +from ..semantic.callable_contracts import normalize_callable_keyword + + +@dataclass(frozen=True) +class Parameter: + """One displayable parameter with an optional usable keyword spelling.""" + + name: str + typ: str = "" + default: str | None = None + keyword: str | None = None + + def label(self) -> str: + """Format a compact signature parameter.""" + return f"{self.name}{': ' + self.typ if self.typ else ''}{' = ' + self.default if self.default is not None else ''}" + + +@dataclass(frozen=True) +class Output: + """One named return value with a compiler-confirmed or unknown type.""" + + name: str + typ: str + + +@dataclass(frozen=True) +class Signature: + """Editor projection of one public callable alternative.""" + + name: str + parameters: tuple[Parameter, ...] = () + outputs: tuple[Output, ...] = () + source_record: object | None = field(default=None, compare=False, repr=False) + + +@dataclass(frozen=True) +class Suggestion: + """Selectable completion with a source replacement span in the current line.""" + + label: str + insert: str + kind: str + detail: str = "" + + +@dataclass(frozen=True) +class Assistance: + """Cursor-scoped suggestions and signature help for one text snapshot.""" + + candidates: tuple[Suggestion, ...] = () + signatures: tuple[Signature, ...] = () + active_parameter: int = 0 + replace_start: int = 0 + replace_end: int = 0 + line: int = 0 + + +# A small explicit core subset, not a second exhaustive representation of the +# compiler's built-in call contracts. All other builtins still complete by name. +_CORE_SIGNATURES = { + "cube": ("size: Vector | Float = 1.0",), + "points": ("count: Int",), + "vector": ("x: Float", "y: Float", "z: Float"), + "transform": ("geometry: Geometry", "translation: Vector", "rotation: Vector", "scale: Vector | Float"), + "set_position": ("geometry: Geometry", "position: Vector", "selection: Bool"), + "output": ("name: String", "value"), + "input_float": ("name: String", "default: Float = 0.0"), + "input_int": ("name: String", "default: Int = 0"), + "input_vector": ("name: String", "default: Vector"), + "input_bool": ("name: String", "default: Bool = False"), + "join": ("geometries",), +} + + +# Only stable, unambiguous core results are declared here. The authoritative +# runtime contract remains in the semantic frontend. +_CORE_OUTPUT_TYPES = { + "cube": "Geometry", "points": "Geometry", "vector": "Vector", + "transform": "Geometry", "set_position": "Geometry", "join": "Geometry", + "input_float": "Float", "input_int": "Int", "input_vector": "Vector", + "input_bool": "Bool", +} + + +def _core_signature(name: str) -> Signature: + """Return a curated core signature or an intentionally unspecified one.""" + params = [] + for label in _CORE_SIGNATURES.get(name, ()): + head, separator, default = label.partition(" = ") + key, colon, typ = head.partition(": ") + positional_only = (name == "points" or name == "join" or + (name == "set_position" and key != "selection") or + (name == "transform" and key == "geometry") or + name == "output") + params.append(Parameter(key, typ if colon else "", default if separator else None, + None if positional_only else key)) + typ = _CORE_OUTPUT_TYPES.get(name) + outputs = (Output("Value", typ),) if typ else () + return Signature(name, tuple(params), outputs) + + +def _display_keyword(label: str) -> str | None: + """Render a readable legal keyword with the compiler's normalization semantics.""" + name = re.sub(r"\W+", "_", label.casefold()).strip("_") + if not name.isidentifier() or keyword.iskeyword(name): + return None + return name if normalize_callable_keyword(name) == normalize_callable_keyword(label) else None + + +def _source_signature(name: str, source: str) -> Signature: + """Inspect declarations without executing source or building Blender nodes. + + This provisional display handles literal input labels and defaults; richer + compile-time labels remain opaque until a future semantic-only projection. + """ + try: + body = ast.parse(source).body + except SyntaxError: + return Signature(name) + params = [] + outputs = [] + for statement in body: + if isinstance(statement, ast.Assign) and len(statement.targets) == 1: + call = statement.value + if not isinstance(call, ast.Call) or not isinstance(call.func, ast.Name): + continue + if not call.func.id.startswith("input_") or not call.args: + continue + label_expr = call.args[0] + if not isinstance(label_expr, ast.Constant) or not isinstance(label_expr.value, str): + continue + label = label_expr.value + default_arg = next((arg.value for arg in call.keywords if arg.arg == "default"), None) + default = ast.unparse(default_arg) if default_arg is not None else None + typ = call.func.id.removeprefix("input_").capitalize() + params.append(Parameter(label, typ, default, _display_keyword(label))) + elif isinstance(statement, ast.Expr) and isinstance(statement.value, ast.Call): + call = statement.value + if isinstance(call.func, ast.Name) and call.func.id == "output" and len(call.args) >= 2: + label = call.args[0] + if isinstance(label, ast.Constant) and isinstance(label.value, str): + outputs.append(Output(label.value, "?")) + keys = [normalize_callable_keyword(p.name) for p in params] + # A repeated normalized input label must be supplied positionally. + params = [Parameter(p.name, p.typ, p.default, p.keyword if keys.count(keys[i]) == 1 else None) + for i, p in enumerate(params)] + return Signature(name, tuple(params), tuple(outputs)) + + +def _def_signatures(source: str) -> dict[str, tuple[Signature, ...]]: + """Collect top-level local function headers even when later text is incomplete.""" + result = {} + # AST parse of a standalone header accepts annotations/defaults without + # requiring the remainder of the currently edited file to be valid. + for line in source.splitlines(): + if not line.startswith("def ") or not line.rstrip().endswith(":"): + continue + try: + node = ast.parse(line + "\n pass").body[0] + except SyntaxError: + continue + if not isinstance(node, ast.FunctionDef): + continue + all_args = node.args.posonlyargs + node.args.args + offset = len(all_args) - len(node.args.defaults) + parameters = [] + for i, arg in enumerate(all_args): + typ = ast.unparse(arg.annotation) if arg.annotation else "" + default = ast.unparse(node.args.defaults[i - offset]) if i >= offset else None + parameters.append(Parameter(arg.arg, typ, default, arg.arg)) + returns = () + if node.returns is not None: + returns = (Output("Value", ast.unparse(node.returns)),) + result[node.name] = (Signature(node.name, tuple(parameters), returns),) + return result + + +def _typespec_label(spec) -> str: + """Convert an extension's frozen TypeSpec to a concise display label.""" + if spec.kind == "NF_SET": + return " | ".join(sorted(t.value.title() for t in spec.nf_types)) + if spec.kind == "TUPLE_FIXED": + return "tuple[" + ", ".join(_typespec_label(item) for item in spec.items) + "]" + if spec.kind == "RECORD": + return spec.record_type.name + if spec.kind == "PY_SCALAR": + return spec.python_scalar_kind.value.title() + return spec.kind.title() + + +def _extension_signatures(name: str, specs) -> tuple[Signature, ...]: + """Project all overloads of an Extension API callable without importing backends.""" + result = [] + for spec in specs: + parameters = [] + for param in spec.parameters: + default = None if param.default is inspect.Parameter.empty else repr(param.default) + kw = None if param.kind is inspect.Parameter.POSITIONAL_ONLY else param.name + parameters.append(Parameter(param.name, _typespec_label(param.type_spec), default, kw)) + if spec.result.kind == "TUPLE_FIXED": + outputs = tuple(Output(f"Value {i + 1}", _typespec_label(item)) + for i, item in enumerate(spec.result.items)) + else: + outputs = (Output("Value", _typespec_label(spec.result)),) + result.append(Signature(name, tuple(parameters), outputs)) + return tuple(result) + + +def _return_preview(signature: Signature) -> str: + """Show short verified result types beside member-completion names.""" + types = [value.typ for value in signature.outputs] + if not types or "?" in types: + return "" + if len(types) == 1: + return "→ " + types[0] + return "→ (" + ", ".join(types) + ")" + + +class SymbolIndex: + """Expose names and signatures selected by a compiler environment snapshot.""" + + def __init__(self, environment): + """Retain one immutable environment; resolve imported names on demand.""" + self.environment = environment + self._source_cache: dict[Path, tuple[int, Signature]] = {} + self._typed_cache: dict[Path, tuple[int, tuple[Output, ...] | None]] = {} + + def _record_signatures(self, record, name: str) -> tuple[Signature, ...]: + """Read a selected source function's public parameters lazily.""" + path = getattr(record, "source_path", None) + if path is None: + return () + try: + stamp = path.stat().st_mtime_ns + cached = self._source_cache.get(path) + if cached is None or cached[0] != stamp: + signature = _source_signature(name, path.read_text(encoding="utf-8")) + self._source_cache[path] = (stamp, signature) + else: + signature = cached[1] + except OSError: + return () + return (Signature(name, signature.parameters, signature.outputs, record),) + + def _typed_signature(self, signature: Signature) -> Signature: + """Resolve source output types lazily using the existing semantic frontend. + + Member inventories remain cheap: this runs only for the function at the + caret, not for every exported function in a package or Local folder. + Failed semantic preparation keeps the untyped source labels visible. + """ + record = signature.source_record + if record is None: + return signature + path = record.source_path + try: + stamp = path.stat().st_mtime_ns + except OSError: + return signature + cached = self._typed_cache.get(path) + if cached is not None and cached[0] == stamp: + outputs = cached[1] + else: + try: + from ..compiler_identities import GroupCompilationIdentity, library_function_id_for_record + from ..semantic.source_callable_session import SourceCallableSession + + function_id = library_function_id_for_record(record) + key = function_id.stable_key() + identity = GroupCompilationIdentity(None, f"EDITOR/{key}", key, key) + prepared = SourceCallableSession(resolved_environment=self.environment).prepare_library( + function_id=function_id, identity=identity, record=record + ) + outputs = tuple(Output(item.display_name, item.typ.value.title()) + for item in prepared.contract.outputs) + except Exception: + # Incomplete dependencies must not break editing. Remember the + # failure until the source changes or the user refreshes catalogs. + outputs = None + self._typed_cache[path] = (stamp, outputs) + return replace(signature, outputs=outputs) if outputs is not None else signature + + def _export_signatures(self, export, name: str) -> tuple[Signature, ...]: + """Read source-backed or Extension API signatures of a selected export.""" + if export.extension_callable_id is not None: + specs = self.environment.extension_registry.callable_specs(export.extension_callable_id) + return _extension_signatures(name, specs) + if export.record is not None: + return self._record_signatures(export.record, name) + return () + + def symbols(self, source: str) -> dict[str, dict[str, tuple[Signature, ...]]]: + """Bind imported packages and Local functions with the compiler's catalog rules. + + The result maps the empty qualifier to top-level names and namespace + aliases to their member tables. Invalid/ambiguous names are omitted. + """ + env = self.environment + top = {name: (_core_signature(name),) for name in BUILTIN_NAMES} + top.update(_def_signatures(source)) + namespaces: dict[str, dict[str, tuple[Signature, ...]]] = {"": top} + local_index = env.catalog("local").local_index + # One-line imports are valid even if the expression at the caret isn't. + for line in source.splitlines(): + if not line.lstrip().startswith("from "): + continue + try: + statement = ast.parse(line.strip()).body[0] + except SyntaxError: + continue + if not isinstance(statement, ast.ImportFrom): + continue + module = statement.module or "" + if module == "packages": + for item in statement.names: + namespace = env.package_by_import_name(item.name) + if namespace is None: + continue + alias = item.asname or item.name + namespaces[alias] = { + name: self._export_signatures(export, name) + for name, export in namespace.exports.items() + } + elif module == "local" or module.startswith("local."): + if local_index is None: + continue + for item in statement.names: + alias = item.asname or item.name + if item.name == "*": + for name in local_index.flat_names(): + try: + record = local_index.find_flat_function(name) + except Exception: + continue + if record is not None: + top[name] = self._record_signatures(record, name) + continue + try: + if module == "local": + selected = local_index.resolve_root_import(item.name) + else: + directory = local_index.resolve_directory(tuple(module.split(".")[1:])) + selected = local_index.resolve_member(directory, item.name) + except Exception: + continue + if selected is None: + continue + from ..local_index import LocalDirectoryCandidate + if isinstance(selected, LocalDirectoryCandidate): + members = {} + for member_name in local_index.path_members.get(selected.import_path, ()): + try: + member = local_index.resolve_member(selected, member_name) + except Exception: + continue + if hasattr(member, "source_path"): + members[member_name] = self._record_signatures(member, member_name) + if item.asname is None and member.source_path.suffix == ".nf": + top.setdefault(member_name, members[member_name]) + namespaces[alias] = members + else: + top[alias] = self._record_signatures(selected, alias) + elif module == "examples": + for item in statement.names: + try: + record = env.catalog("examples").find(item.name) + except Exception: + continue + if record is not None: + top[item.asname or item.name] = self._record_signatures(record, item.asname or item.name) + return namespaces + + +def _tokens_before(source: str, offset: int): + """Yield tokens from the incomplete source prefix, ignoring tokenizer EOF errors.""" + text = source[:offset] + try: + yield from tokenize.generate_tokens(io.StringIO(text).readline) + except (tokenize.TokenError, IndentationError): + return + + +def cursor_assistance(source: str, line: int, column: int, index: SymbolIndex) -> Assistance: + """Return member/name/keyword completions and signature help at the caret. + + Token depth, rather than comma counting in raw text, identifies the current + argument even with nested calls, lists and strings. Parsing incomplete + Python is intentionally not required. + """ + lines = source.split("\n") + if not 0 <= line < len(lines): + return Assistance() + column = min(column, len(lines[line])) + starts = [0] + for item in lines: + starts.append(starts[-1] + len(item) + 1) + offset = starts[line] + column + prefix = source[:offset] + tokens = list(_tokens_before(source, offset)) + for token in tokens: + if token.start[0] - 1 > line: + continue + if token.type == tokenize.COMMENT and token.end[0] - 1 == line: + return Assistance(line=line) + if token.type == tokenize.ERRORTOKEN and token.string in {"'", '"'}: + # An unmatched quote before the caret is an incomplete string. + return Assistance(line=line) + if token.type == tokenize.STRING: + first = starts[token.start[0] - 1] + token.start[1] + last = starts[token.end[0] - 1] + token.end[1] + if first <= offset < last: + return Assistance(line=line) + # Inside an unfinished call tokenize may stop at '(' before emitting the + # unterminated string. Check only the un-tokenized tail for an open quote. + last_token_end = 0 + if tokens: + last = tokens[-1] + if 0 < last.end[0] <= len(lines): + last_token_end = starts[last.end[0] - 1] + last.end[1] + quote = None + escaped = False + for character in prefix[last_token_end:]: + if quote: + if escaped: + escaped = False + elif character == "\\": + escaped = True + elif character == quote: + quote = None + elif character in {"'", '"'}: + quote = character + elif character == "#": + break + if quote: + return Assistance(line=line) + + # Import completion uses the same frozen Local index and package inventory + # as compilation, without requiring the import line to be parseable yet. + import_match = re.fullmatch( + r"\s*from\s+(packages|local(?:\.[A-Za-z_]\w*)*|examples)\s+import\s*([A-Za-z_]\w*)?", + prefix.split("\n")[-1], + ) + if import_match: + module, partial = import_match.groups() + partial = partial or "" + env = index.environment + available = set() + if module == "packages": + available = {ns.import_name for ns in env.package_namespaces.values()} + elif module == "examples": + available = set(env.catalog("examples").names()) + else: + local_index = env.catalog("local").local_index + if local_index is not None: + if module == "local": + available.update(local_index.flat_names()) + available.update(local_index.path_members.get((), {})) + def selected(name): + """Resolve one Local root import without accepting collisions.""" + return local_index.resolve_root_import(name) + else: + directory = local_index.resolve_directory(tuple(module.split(".")[1:])) + available.update(local_index.path_members.get(directory.import_path, {})) + def selected(name): + """Resolve one qualified Local member without accepting collisions.""" + return local_index.resolve_member(directory, name) + unique = set() + for name in available: + try: + if selected(name) is not None: + unique.add(name) + except Exception: + continue + available = unique + candidates = tuple(Suggestion(name, name, "import") for name in sorted(available) if name.startswith(partial) and name != partial) + return Assistance(candidates, (), 0, column - len(partial), column, line) + + names = index.symbols(source) + frames: list[dict] = [] + for tok in tokens: + if tok.type != tokenize.OP: + continue + position = starts[tok.start[0] - 1] + tok.start[1] + if tok.string in "([{": + before = source[:position] + match = re.search(r"([A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*)\s*$", before) + name = match.group(1) if tok.string == "(" and match else None + frames.append({"open": tok.string, "name": name, "position": 0, + "arg_start": position + 1, "used": set()}) + elif tok.string in ")]}": + if frames: + frames.pop() + elif tok.string == "," and frames and frames[-1]["open"] == "(": + frame = frames[-1] + segment = source[frame["arg_start"]:position] + previous = re.match(r"\s*([A-Za-z_]\w*)\s*=", segment) + if previous: + frame["used"].add(previous.group(1)) + frame["position"] += 1 + frame["arg_start"] = position + 1 + + frame = next((item for item in reversed(frames) if item["name"]), None) + signatures = () + active_parameter = 0 + if frame is not None: + path = frame["name"].split(".") + signatures = tuple(index._typed_signature(signature) for signature in + names.get(".".join(path[:-1]), {}).get(path[-1], ())) + active_parameter = frame["position"] + segment = prefix[frame["arg_start"]:] + named = re.match(r"\s*([A-Za-z_]\w*)\s*=", segment) + used = set(frame["used"]) + if named: + used.add(named.group(1)) + for signature in signatures: + for i, parameter in enumerate(signature.parameters): + if parameter.keyword == named.group(1): + active_parameter = i + break + if signatures and not named: + partial_match = re.fullmatch(r"\s*([A-Za-z_]\w*)?", segment) + if partial_match: + partial = partial_match.group(1) or "" + seen = {} + for signature in signatures: + for parameter in signature.parameters: + key = parameter.keyword + if key and key not in used and key.startswith(partial): + seen[key] = Suggestion(key + "=", key + "=", "parameter", parameter.label()) + candidates = tuple(seen.values()) + return Assistance(candidates, signatures, active_parameter, column - len(partial), column, line) + + suffix = prefix.split("\n")[-1] + match = re.search(r"(?= 2: + candidates = tuple( + Suggestion(name, name, "function", _return_preview(sigs[0]) if sigs else "") + for name, sigs in sorted(table.items()) if name.startswith(partial) and name != partial + ) + return Assistance(candidates, signatures, active_parameter, column - len(partial), column, line) + return Assistance((), signatures, active_parameter, column, column, line) diff --git a/tests/blender/test_editor_popup.py b/tests/blender/test_editor_popup.py new file mode 100644 index 0000000..2b64079 --- /dev/null +++ b/tests/blender/test_editor_popup.py @@ -0,0 +1,45 @@ +"""Blender-bound popup layout checks: no repeated parameters or clipped signature line.""" + +from NodeForge.editor.blender_ui import _popup_rows +from NodeForge.editor.intelligence import Assistance, Output, Parameter, Signature, Suggestion + + +def test_parameter_rows_show_single_keyword_and_typed_return(): + """Keyword candidates reuse signature rows instead of repeating label and detail.""" + sig = Signature('circle_points', ( + Parameter('Count', 'Int', '16', 'count'), + Parameter('Radius', 'Float', '1.0', 'radius'), + Parameter('Start Angle', 'Float', '0.0', 'start_angle'), + ), (Output('Geometry', 'Geometry'),)) + candidates = tuple(Suggestion(p.keyword + '=', p.keyword + '=', 'parameter', p.label()) + for p in sig.parameters) + rows = _popup_rows(Assistance(candidates=candidates, signatures=(sig,)), 1, 600) + labels = [row[0] for row in rows] + assert labels == [ + 'circle_points(', ' count: Int = 16', ' radius: Float = 1.0', + ' start_angle: Float = 0.0', ')', '→ Geometry: Geometry', + ] + assert rows[2][1] is True + + +def test_many_parameters_fit_vertically_and_keep_focus_visible(): + """A long signature limits its parameter rows and centers the active field.""" + sig = Signature('large', tuple(Parameter(f'param{i}', 'Int', keyword=f'param{i}') + for i in range(24)), (Output('Result', 'Float'),)) + rows = _popup_rows(Assistance(signatures=(sig,), active_parameter=17), 0, 240) + labels = [row[0] for row in rows] + assert labels[0].startswith('large( ') + assert ' param17: Int' in labels + assert labels[-1] == '→ Result: Float' + assert 21 * len(rows) + 8 <= 240 + + +def test_multiple_outputs_remain_separate_rows(): + """Multiple result sockets retain their names, types, and public ordering.""" + sig = Signature('split', (Parameter('mesh', 'Geometry', keyword='mesh'),), ( + Output('Mesh', 'Geometry'), Output('Distance', 'Float'), Output('Valid', 'Bool'), + )) + rows = _popup_rows(Assistance(signatures=(sig,)), 0, 480) + assert [row[0] for row in rows][-4:] == [ + '→ 3 outputs:', ' Mesh: Geometry', ' Distance: Float', ' Valid: Bool', + ] diff --git a/tests/unit/test_editor_intelligence.py b/tests/unit/test_editor_intelligence.py new file mode 100644 index 0000000..c35a25e --- /dev/null +++ b/tests/unit/test_editor_intelligence.py @@ -0,0 +1,195 @@ +"""Editor intelligence must agree with selected package and Local namespaces.""" + +from pathlib import Path + +import pytest + +from NodeForge import catalog, packages +from NodeForge.environment_resolution import resolve_environment +from NodeForge.editor.intelligence import SymbolIndex, cursor_assistance + +pytestmark = pytest.mark.unit + + +@pytest.fixture +def editor_index(tmp_path, monkeypatch): + """Build real frozen Local/package catalogs without touching user storage.""" + local = tmp_path / "local" + (local / "terrain").mkdir(parents=True) + (local / "terrain" / "move.nf").write_text( + 'scale = input_float("Scale XY", default=2.0)\n' + 'geometry = input_geometry("Geometry")\n' + 'output("Geometry", geometry)\n', encoding="utf-8" + ) + (local / "simple.nf").write_text( + 'radius = input_float("Radius", default=1.0)\n' + 'output("Radius", radius)\n', encoding="utf-8" + ) + monkeypatch.setattr("NodeForge.local_sources._catalog_input_paths", lambda: ((local,), ())) + packages.set_packages_dir_for_tests(tmp_path / "packages") + yield SymbolIndex(resolve_environment()), tmp_path + packages.set_packages_dir_for_tests(None) + + +def help_at_caret(source, index): + """Analyze a caret denoted by a vertical bar in a source snippet.""" + offset = source.index("|") + before = source[:offset] + text = source[:offset] + source[offset + 1:] + return cursor_assistance(text, before.count("\n"), len(before.split("\n")[-1]), index) + + +def test_directory_members_and_import_alias(editor_index): + """Local directory aliases expose selected members, not arbitrary flat names.""" + index, _ = editor_index + hint = help_at_caret('from local import terrain as t\nx = t.mo|', index) + assert [c.label for c in hint.candidates] == ["move"] + hint = help_at_caret('from local.terrain import move as go\nx = go(|', index) + assert hint.signatures[0].name == "go" + assert [p.keyword for p in hint.signatures[0].parameters] == ["scale_xy", "geometry"] + assert "scale_xy=" in [c.insert for c in hint.candidates] + + +def test_local_flat_keywords_and_active_parameter(editor_index): + """Imported Local source labels supply public keywords and call position.""" + index, _ = editor_index + hint = help_at_caret('from local import simple\nx = simple(ra|', index) + assert [c.insert for c in hint.candidates] == ["radius="] + hint = help_at_caret('from local import simple\nx = simple(radius=|', index) + assert hint.active_parameter == 0 + assert not hint.candidates + + +def test_builtins_nested_calls_and_comments(editor_index): + """Nested commas don't advance the outer call; comment text is ignored.""" + index, _ = editor_index + hint = help_at_caret('x = vector(1, 2,|', index) + assert hint.signatures and hint.active_parameter == 2 + hint = help_at_caret('x = vector(1, vector(2, 3, 4),|', index) + assert hint.active_parameter == 2 + hint = help_at_caret('x = 2 # cube(|', index) + assert not hint.candidates and not hint.signatures + + +def test_unsaved_local_def(editor_index): + """A local def becomes available without saving or compiling source.""" + index, _ = editor_index + source = 'def spin(angle: Float, offset: Vector):\n return angle\nx = spin(of|' + hint = help_at_caret(source, index) + assert hint.signatures[0].name == 'spin' + assert [c.insert for c in hint.candidates] == ['offset='] + + +def test_package_source_and_extensions(editor_index): + """The installed math package contributes source and Extension API callables.""" + index, tmp_path = editor_index + import os + archive = os.environ.get('NODEFORGE_MATH_TEST_ZIP') + if archive is None: + pytest.skip('math archive not supplied') + packages.install_package_zip(Path(archive)) + index = SymbolIndex(resolve_environment()) + names = index.symbols('from packages import math')['math'] + assert 'layout_grid' in names + assert 'sin' in names + hint = help_at_caret('from packages import math\nx = math.la|', index) + assert 'layout_grid' in [c.label for c in hint.candidates] + hint = help_at_caret('from packages import math\nx = math.layout_grid(geometry, sp|', index) + assert 'spacing=' in [c.insert for c in hint.candidates] + hint = help_at_caret('from packages import math\nx = math.sin(|', index) + assert hint.signatures and hint.signatures[0].parameters + + +def test_import_statement_completions(editor_index): + """Complete Local/package import names before an import statement is valid.""" + index, _ = editor_index + hint = help_at_caret('from local import te|', index) + assert [item.insert for item in hint.candidates] == ['terrain'] + hint = help_at_caret('from local.terrain import mo|', index) + assert [item.insert for item in hint.candidates] == ['move'] + hint = help_at_caret('from local import si|', index) + assert [item.insert for item in hint.candidates] == ['simple'] + + +def test_keyword_accounting_and_string_context(editor_index): + """Nested keyword arguments cannot mark outer call inputs as already used.""" + index, _ = editor_index + hint = help_at_caret('from local import simple\nx = simple(radius=vector(x=1), |', index) + assert hint.signatures and not hint.candidates + hint = help_at_caret('from local import simple\nx = simple("cu|', index) + assert not hint.signatures and not hint.candidates + + +def test_ambiguous_local_file_omitted(editor_index): + """An ambiguous flat Local name must not be presented as a callable.""" + index, tmp_path = editor_index + (tmp_path / 'local' / 'terrain' / 'simple.nf').write_text('output("x", 1.0)\n', encoding='utf-8') + index = SymbolIndex(resolve_environment()) + hint = help_at_caret('from local import sim|', index) + assert 'simple' not in [item.label for item in hint.candidates] + + +def test_typed_single_and_multiple_local_outputs(editor_index): + """Use semantic output contracts for the selected call, including output order.""" + index, tmp_path = editor_index + (tmp_path / 'local' / 'two_outputs.nf').write_text( + 'geo = input_geometry("Geometry")\n' + 'weight = input_float("Weight", default=2.0)\n' + 'output("Shape", geo)\n' + 'output("Weight", weight)\n', encoding='utf-8' + ) + index = SymbolIndex(resolve_environment()) + hint = help_at_caret('from local import two_outputs\nx = two_outputs(|', index) + assert [(item.name, item.typ) for item in hint.signatures[0].outputs] == [ + ('Shape', 'Geometry'), ('Weight', 'Float') + ] + # Repeated requests must reuse the semantic result rather than prepare again. + assert len(index._typed_cache) == 1 + help_at_caret('from local import two_outputs\nx = two_outputs(ge|', index) + assert len(index._typed_cache) == 1 + + +def test_source_fallback_keeps_named_outputs_if_analysis_fails(editor_index): + """Broken source dependencies must not suppress the signature and output labels.""" + index, tmp_path = editor_index + (tmp_path / 'local' / 'broken.nf').write_text( + 'x = input_float("X")\noutput("First", non_existing_function(x))\n' + 'output("Second", x)\n', encoding='utf-8' + ) + index = SymbolIndex(resolve_environment()) + hint = help_at_caret('from local import broken\nx = broken(|', index) + assert [(item.name, item.typ) for item in hint.signatures[0].outputs] == [ + ('First', '?'), ('Second', '?') + ] + + +def test_tuple_extension_result_is_split_into_ordered_outputs(): + """Extension tuple results must show individual types, not one long type string.""" + from types import SimpleNamespace + from NodeForge.editor.intelligence import _extension_signatures + from NodeForge.extensions.contracts import TypeSpec + from NodeForge.nf_types import NFType + + result = TypeSpec('TUPLE_FIXED', items=( + TypeSpec('NF_SET', nf_types={NFType.GEOMETRY}), + TypeSpec('NF_SET', nf_types={NFType.FLOAT}), + )) + signatures = _extension_signatures('separate', [SimpleNamespace(parameters=(), result=result)]) + assert [(item.name, item.typ) for item in signatures[0].outputs] == [ + ('Value 1', 'Geometry'), ('Value 2', 'Float') + ] + + +def test_selected_math_source_return_type(editor_index): + """math.circle_points produces a real Geometry contract, not a label guess.""" + import os + archive = os.environ.get('NODEFORGE_MATH_TEST_ZIP') + if not archive: + pytest.skip('math archive not supplied') + index, _ = editor_index + packages.install_package_zip(Path(archive)) + index = SymbolIndex(resolve_environment()) + hint = help_at_caret('from packages import math\nx = math.circle_points(|', index) + assert [(item.name, item.typ) for item in hint.signatures[0].outputs] == [ + ('Geometry', 'Geometry') + ] diff --git a/tests/unit/test_release_workflow_publication.py b/tests/unit/test_release_workflow_publication.py index 6c1d9ca..f3c4cd6 100644 --- a/tests/unit/test_release_workflow_publication.py +++ b/tests/unit/test_release_workflow_publication.py @@ -74,6 +74,13 @@ def test_library_release_assets_are_downloaded_from_latest_releases(): assert "len(staged_archives) == len(BUNDLED_PACKAGES)" in build +def test_extension_staging_includes_text_editor_modules(): + """The release ZIP includes the editor code registered by the add-on UI.""" + stage = _step(_job("build"), "name: Stage extension contents") + assert "cp -R blender semantic extensions examples editor staging/" in stage + assert "test -d staging/editor" in stage + + def test_publishing_jobs_depend_on_one_verified_build(): """The release and Pages deployment reuse the validated build, not rebuilt ZIPs.""" build = _job("build") diff --git a/ui.py b/ui.py index 5af4f94..a6e4d46 100644 --- a/ui.py +++ b/ui.py @@ -1322,6 +1322,8 @@ def register(): # collections on the next event-loop tick instead of touching bpy.data here. if not bpy.app.timers.is_registered(_refresh_startup_browser_items): bpy.app.timers.register(_refresh_startup_browser_items, first_interval=0.0) + from .editor import blender_ui + blender_ui.register() def unregister(): """Unregister NodeForge UI and operators without mutating generated node-group data.""" @@ -1338,6 +1340,8 @@ def unregister(): pass if hasattr(bpy.types.Scene, "gn_script_mvp"): del bpy.types.Scene.gn_script_mvp + from .editor import blender_ui + blender_ui.unregister() for cls in reversed(classes): try: bpy.utils.unregister_class(cls)