diff --git a/PLAN.md b/PLAN.md index 2975ac92..2bbc907b 100644 --- a/PLAN.md +++ b/PLAN.md @@ -48,23 +48,26 @@ is to measure retained memory across the whole workspace and budget its caches. **Completion evidence:** cache growth is bounded under the chosen workload, with before/after memory and latency measurements and coverage for eviction/recovery. -## 3. Tekton authoring — next feature work - -View editing and preview reconciliation are established. Extend that workflow -through typed document operations while preserving invalid drafts, undo/redo, -selection, and compatible preview identities. - -- [ ] Start non-view editing with constraints and guides: typed insert, remove, - move, and replace operations, including grouped transactions and validation. -- [ ] Add structured inspectors for those resources and the property metadata - they need, using runtime descriptors for enum choices. -- [ ] Connect inspectors to direct layout authoring: guide overlays, anchor - handles, and constraint constant, priority, and activation editing. Surface - conflicts and ambiguity in the same workflow. - -**Completion evidence:** a user can create, edit, undo, save, and reload a -constrained layout, including recovery from invalid input, without losing the -last valid preview or selection. +## 3. Tekton authoring — remaining work + +View, guide, constraint, and flat resource editing now share undo, validation, and +save/reload workflows. Property edits reuse the preview graph; guide outlines, +constraint endpoint highlights, and unsatisfied-constraint diagnostics are available. +See [the review and measurements](docs/tekton-review.md). + +- [ ] Add tree operations and editable inspectors for controllers and menus, and + collection editors for localization strings, key bindings, and theme rules. +- [ ] Add visual reparenting, anchor handles, multi-selection, alignment, and snapping. +- [ ] Add inspection and change tracking for an attached running application's + resource graph, including custom view and controller types. +- [ ] Add ambiguity diagnostics and runtime conflict attribution to generated layout + inputs; current diagnostics report authored constraints that remain unsatisfied. +- [ ] Extend the palette to the remaining container/model-backed controls with + explicit serialization contracts, and add application command/outlet wiring. + +**Completion evidence:** users can author a multi-window interface, wire its +controllers and actions, and inspect the running application without losing history +or live object identities. ## Validation diff --git a/README.md b/README.md index e840b9d6..70d3bde2 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,21 @@ FigDraw is compiled into the application. Merenda's experimental `useNativeDynlib` mode and `build_dynlib` task have been removed; omit `-d:useNativeDynlib` from existing build commands. +## Tekton interface builder + +Build a resource document with Tekton's widget palette, property inspector, and live +preview. Add layout guides and constraints, pin views to their parent, undo changes, +and save the result for your Merenda app. Use **Interact** to try controls in the preview. + +```sh +nim r src/merenda/tekton.nim +# Or open an existing interface: +nim r src/merenda/tekton.nim path/to/interface.cbor +``` + +See [Tekton's authoring workflow](docs/resources.md#tekton-resource-editor) for supported +resources and how to load the saved interface in your app. + ## A few small apps ### Hello, Merenda diff --git a/docs/resources.md b/docs/resources.md index b514f0a8..93dd03c9 100644 --- a/docs/resources.md +++ b/docs/resources.md @@ -218,34 +218,61 @@ discard document.showWindows(app) app.run() ``` -The 13-kind palette and editable view inspector are driven by the same registry used -for runtime construction. Selecting a non-view hierarchy item shows path-addressed, -read-only details for layout, controller/window ownership, target/action connections, -menus, commands, images, localization, key bindings, and themes. +The view palette follows the runtime registry, including custom registered kinds, +sliders, and steppers. The resource picker below it adds layout guides, constraints, +windows, commands, images, localization catalogs, key binding tables, and themes. +Select a view before adding a guide, constraint, or window to use it as the owner or +content view. Newly added resources with missing references remain editable and show +diagnostics until their required fields are filled in. + +The inspector edits guide insets, constraint endpoints and anchors, relation, +multiplier, constant, priority, and activation; window titles and connections; command +targets; image sources; and catalog/theme metadata. Enum fields offer choices and +boolean fields use checkboxes. Controllers, menu trees, key binding entries, +localization strings, and theme rules still have read-only detail rows. The view inspector chooses controls from each `ResourcePropertyDescriptor`: boolean properties use checkboxes, enum-backed properties use combo boxes populated from the registry, colors use a popup color well, and open-ended values retain text editing. If typed text cannot be parsed, it is committed as a string value so the exact draft text remains visible and validation can diagnose the mismatch. Valid revisions are -reconciled while preserving compatible view and controller identities; layout -constraints are rebuilt against the resulting view map. Invalid drafts never replace +reconciled while preserving compatible view and controller identities. Property edits +stage only changed widgets; edits to layout records rebuild constraints against the +existing views. Unchanged hierarchies, assets, and constraints are retained. Invalid drafts never replace the last working preview. Hierarchy clicks and preview clicks both update selection by -`ResourceId`, and preview selection uses `installViewSelection` and -`installSelectionRing` rather than changing serialized records or preview state. +`ResourceId`. Design mode captures input at the canvas, so selecting a checkbox does +not toggle it or invoke application actions. Enable **Interact** to run the controls. +The inspector displays live defaults without adding them to the document; authored +values, including invalid text, take precedence. Invalid boolean/enum drafts switch +to text editing so they can be corrected. + +Select a guide to see its inset outline, or a constraint to highlight its endpoints. +**Pin to Parent** creates four edge constraints for a selected child of a freeform +container as one undo action. Active layout constraints disable freeform dragging +and keyboard resizing. After layout, unsatisfied authored constraints appear in +Diagnostics, including conflicts with a control's size limits. Invalid numeric text +in typed resource fields stays in the field editor with an error; validly parsed but +inconsistent records remain drafts and keep the last valid preview. Palette buttons insert into selected containers and beside selected leaf views. The -Delete and Backspace keys remove the selected view and restore selection to its parent; -the toolbar also exposes the same deletion operation. +Delete and Backspace keys remove the selected view or flat resource. Deleting a view +also removes its guides and touching constraints in the same undo action, and selects +the nearest sibling or parent. Use Duplicate, Up, and Down for view duplication and +ordering; Option-arrow moves freeform views, and Shortcut-Option-arrow resizes them +(Shift changes the step from one to ten points). The editor installs `DocumentFileProtocol` for canonical CBOR reads and writes and shares the resource draft's `UndoManager` with the application document. A save or revert updates the manager's clean state and the normal document edited indicator. +**Save** prompts for a destination for new documents, **Save As** writes another file, +and **Open** opens a file in a separate editor window. Save commits the active field +first and stops if its text is invalid. Resource image paths are resolved relative to +the opened document unless the caller supplies an explicit asset base directory. Run the complete vertical slice with: ```sh -nim r src/merenda/tekton/tekton.nim +nim r src/merenda/tekton.nim ``` The app module exports `tektonStarterBundle`, `newTektonDocument`, and `runTekton`, so @@ -254,6 +281,17 @@ an existing CBOR path to load it at startup. The reusable editor, preview reconc and resource-value editing APIs are available through `import merenda/tekton`; they are kept separate from the backend-neutral `merenda/nimkit/resources` API. +For typed operations, `ResourceDocument.insertResource`, `replaceResource`, +`removeResource`, and `moveResource` support flat resource records. Insert and replace +also have `ResourceInsertOperation[T]` and `ResourceReplaceOperation[T]` forms. Identity +and index errors reject an operation; semantic errors stay in the draft. Use the +shared undo manager's `beginUndoGrouping`/`endUndoGrouping` to group operations. + +Tekton edits resource documents; it does not attach to a separate running process or +generate application logic. For inspection inside an existing app, use NimKit's +`showViewInspector(root, app)` (see `examples/view_inspector_demo.nim`). The remaining +work toward a complete Interface Builder is recorded in [the Tekton review](tekton-review.md). + ## Resource Limits `ResourceLoadLimits` controls the maximum encoded bytes, node count, tree depth, and diff --git a/docs/tekton-editor.png b/docs/tekton-editor.png new file mode 100644 index 00000000..f5243bb8 Binary files /dev/null and b/docs/tekton-editor.png differ diff --git a/docs/tekton-review.md b/docs/tekton-review.md new file mode 100644 index 00000000..7595beaa --- /dev/null +++ b/docs/tekton-review.md @@ -0,0 +1,86 @@ +# Tekton review + +Reviewed on 2026-09-25 against `5513ba37`. This change improves resource authoring; +Tekton still needs the work listed below to become a complete Interface Builder. + +![Tekton with its resource hierarchy, scrolling palette, themed preview, and inspector](tekton-editor.png) + +## Confirmed problems and fixes + +| Problem | Evidence and change | +| --- | --- | +| A title edit rebuilt every widget, rewired the hierarchy, and replaced constraints. | A factory-count regression constructed 100 widgets for one edit. It now constructs one; layout-only changes construct none. Property and layout edits preserve the view hierarchy. Structural changes retain transactional reconstruction. | +| Selection repeatedly rebuilt the resource outline; synchronization repeatedly rebuilt property editors. | Selection now reuses the outline by document revision. Unchanged synchronization does not read inspector properties again. Hover updates only the hover label and ring. | +| Loading another document at revision zero left the previous preview visible. | A save/load regression reproduced the stale title. Synchronization now tracks document identity as well as revision, and recreates asset contexts on file changes. | +| Inspector checkboxes displayed false when a property's default was true. | Unauthored `enabled` now reads its live getter. Reading defaults does not change the resource document. Invalid authored values remain visible as text. | +| Selecting preview controls triggered their actions. | A canvas click toggled a checkbox in the original implementation. The canvas now intercepts design input. Interact explicitly enables normal control behavior. | +| Failed setters could mutate the preview before raising. | A setter that changes a property and then raises is now rolled back, including the attempted property. The preview revision remains unchanged. | +| Decimal input could be accepted as an empty value for numeric properties. | Failed integer parsing now clears partial results before trying the float parser. Slider and stepper edits exercise this path. | +| A preview root could shrink when constraints were added to its children. | Canvas constraints retain authored root frames at priority 999, allowing required authored constraints to override them. The pin-to-parent workflow checks rendered geometry. | +| New documents had no working Save flow. | Save commits the active editor and prompts for a CBOR destination; Save As and Open are available in the toolbar. | +| Non-view resources could only be inspected. | Typed flat-record operations and inspectors now cover guides, constraints, windows, commands, image source metadata, catalog metadata, and theme metadata. Grouped undo, invalid draft recovery, and save/reload are covered by the shared Tekton runner. | + +Preview mouse picking uses an identity lookup table instead of scanning all resource +views at every ancestor. Property conversion contexts retain only images and +localization data. Structural reconciliation retires the staging layout and replaces +its identity maps so the preview does not retain an unused second view graph. + +## Measurements + +`nim r tests/benchmark_tekton.nim` creates a document, selects 40 widgets, performs +10 title edits, and synchronizes an unchanged document 40 times. These are model and +editor timings, without a native window or GPU rendering. Measured on macOS arm64, +Nim 2.2.12, release ARC, using the same Atlas dependencies for both revisions. +The table reports medians of three alternating original/updated runs. Other local +builds were active, so absolute timings include contention. + +| Workload | Original, 100 widgets | Updated, 100 widgets | Original, 300 widgets | Updated, 300 widgets | +| --- | ---: | ---: | ---: | ---: | +| Open document | 153.28 ms | 179.12 ms | 242.43 ms | 187.84 ms | +| Select widget | 5.490 ms | 3.700 ms | 17.909 ms | 3.443 ms | +| Edit title | 37.754 ms | 3.978 ms | 154.158 ms | 7.743 ms | +| Unchanged synchronization | 5.815 ms | 0.001 ms | 17.339 ms | 0.001 ms | + +Timings are observations, not CI thresholds. The regression suite asserts bounded +construction work and preserved identities independently of machine speed. + +## Authoring workflow + +1. Run `nim r src/merenda/tekton.nim`, or pass an existing CBOR document path. +2. Select a container and insert controls. Inspect default and authored properties. +3. Use the resource picker to add guides, constraints, windows, commands, or assets. +4. Edit layout endpoints, anchors, relation, multiplier, constant, priority, and + activation. Selecting a guide outlines its insets; a constraint highlights its + endpoint views. Unsatisfied constraints appear after layout in Diagnostics. +5. Pin a freeform child to its parent with one undo action. Deleting a view removes + its guides and touching constraints in the same action. +6. Choose a preview theme and enable Interact to exercise controls. These preview + settings do not modify the document. Return to design mode for positioning. +7. Save or Save As, then reopen. Invalid numeric field text must be corrected before + saving; semantically invalid resource drafts retain the last valid preview. + +## Remaining gaps + +- Editing controller/menu trees and collection contents: localization strings, + key binding entries, and theme rules remain read-only. +- Attaching Tekton to an existing running application, preserving its state, and + synchronizing external mutations. The separate NimKit `showViewInspector` remains + available for inspection inside an application. +- Application logic generation and controller/outlet/action connection authoring. +- Visual reparenting, multi-selection, anchor dragging, alignment, snapping, and + palette coverage for the remaining model-backed controls. +- Ambiguity analysis and attribution of solver conflicts to generated constraints. +- Broader performance measurements with complex layouts, assets, and native rendering. + +See [PLAN.md](../PLAN.md) for follow-up work. The focused checks live in +`tests/tekton/regressions.nim` and `tests/tekton/layout_authoring.nim`, imported by +`tests/ttekton.nim`. + +## Verification + +All four shared runners passed on macOS arm64 with Nim 2.2.12. The full +`atlas-run tests` run identified an outdated expected registry-kind list in NimKit; +after adding slider and stepper, `atlas-run tests tests/tnimkit.nim` passed. +All 41 Tekton tests also passed with address and undefined-behavior sanitizers under +ARC and ORC. The example bundle compiled, the shared-runner import check passed, +and the screenshot above was rendered using Metal. diff --git a/merenda.nimble b/merenda.nimble index 1d85b656..9ac9607e 100644 --- a/merenda.nimble +++ b/merenda.nimble @@ -12,8 +12,7 @@ requires "chroniclers >= 0.6" requires "crunchy >= 0.1.11" # Native damage must request onRender: https://github.com/levovix0/siwin/pull/55 requires "gh:elcritch/siwin#fdec8e4" -# Render ownership and GPU completion; use >= 0.43.0 once released. -requires "gh:elcritch/figdraw#fix/acyclic-render-ownership [siwin, harfbuzz]" +requires "gh:elcritch/figdraw >= 0.43.0 [siwin, harfbuzz]" requires "sigils >= 0.31.0 [sigNameAsString, closures, siwin, chronos]" requires "gh:elcritch/variant#fix/ic-type-ids" requires "kiwiberry" diff --git a/src/merenda/nimkit/resources/resrcconstruction.nim b/src/merenda/nimkit/resources/resrcconstruction.nim index 71825ad1..14b6c40b 100644 --- a/src/merenda/nimkit/resources/resrcconstruction.nim +++ b/src/merenda/nimkit/resources/resrcconstruction.nim @@ -63,6 +63,19 @@ proc initResourceInstance(): ResourceInstance = keyBindingsValue: initTable[ResourceId, KeyBindingTable](), ) +proc rebindResourceIdentities*( + instance: var ResourceInstance, + views: Table[ResourceId, View], + controllers: Table[ResourceId, ViewController], + layout: ResourceLayoutInstance, +) = + ## Retires a staging layout and installs reconciled identity maps. Callers must + ## first reconnect controllers, windows, menus, and view hierarchy to these maps. + instance.layoutValue.deactivate() + instance.viewsValue = views + instance.controllersValue = controllers + instance.layoutValue = layout + func instantiated*(instantiation: ResourceInstantiationResult): bool = not instantiation.diagnostics.hasErrors @@ -215,16 +228,17 @@ proc initResourcePropertyContext*( ## Image getters are mapped back to stable resource identifiers by identity; ## localized getter values are intentionally returned as their resolved string. let - bundleCopy = bundle - instanceCopy = instance + bundleCopy = + ResourceBundle(images: bundle.images, localizations: bundle.localizations) + images = instance.imagesValue locale = context.locale imageIdFor = proc(image: ImageResource): ResourceId = for asset in bundleCopy.images: - if instanceCopy.findImage(asset.id) == image: + if images.getOrDefault(asset.id) == image: return asset.id ResourcePropertyContext( imageFor: proc(id: ResourceId): ImageResource = - instanceCopy.findImage(id), + images.getOrDefault(id), imageIdFor: imageIdFor, textFor: proc(key, fallback: string): string = bundleCopy.localizedValue(locale, key, fallback), diff --git a/src/merenda/nimkit/resources/resrcdocument.nim b/src/merenda/nimkit/resources/resrcdocument.nim index 0cab2228..135b3071 100644 --- a/src/merenda/nimkit/resources/resrcdocument.nim +++ b/src/merenda/nimkit/resources/resrcdocument.nim @@ -89,6 +89,21 @@ type property*: ResourceProperty actionName*: string + ResourceRecord* = + ResourceLayoutGuide | ResourceLayoutConstraint | WindowResource | CommandResource | + ImageAssetResource | LocalizedCatalogResource | KeyBindingTableResource | + ThemeFragmentResource + + ResourceInsertOperation*[T: ResourceRecord] = object + node*: T + index*: Option[Natural] + actionName*: string + + ResourceReplaceOperation*[T: ResourceRecord] = object + id*: ResourceId + node*: T + actionName*: string + ResourceIndexEntry = object path: ResourceNodePath parent: Option[ResourceNodePath] @@ -1064,3 +1079,183 @@ proc removeViewProperty*( ) document.replaceDraft(candidate, actionName) document.appliedEdit(rekRemove, viewId, previousPath, previousDiagnosticPath) + +func recordKind[T: ResourceRecord](_: typedesc[T]): ResourceNodeKind = + when T is ResourceLayoutGuide: + rnkLayoutGuide + elif T is ResourceLayoutConstraint: + rnkLayoutConstraint + elif T is WindowResource: + rnkWindow + elif T is CommandResource: + rnkCommand + elif T is ImageAssetResource: + rnkImage + elif T is LocalizedCatalogResource: + rnkLocalization + elif T is KeyBindingTableResource: + rnkKeyBindings + elif T is ThemeFragmentResource: + rnkTheme + +template records(bundle: ResourceBundle, T: typedesc): untyped = + when T is ResourceLayoutGuide: + bundle.layoutGuides + elif T is ResourceLayoutConstraint: + bundle.layoutConstraints + elif T is WindowResource: + bundle.windows + elif T is CommandResource: + bundle.commands + elif T is ImageAssetResource: + bundle.images + elif T is LocalizedCatalogResource: + bundle.localizations + elif T is KeyBindingTableResource: + bundle.keyBindings + elif T is ThemeFragmentResource: + bundle.themes + +proc apply*[T: ResourceRecord]( + document: ResourceDocument, operation: ResourceInsertOperation[T] +): ResourceEditResult {.discardable.} = + let id = operation.node.id + if id.isEmpty: + return document.rejectedEdit( + rekInsert, id, reeIdentifierMissing, "inserted resources require identifiers" + ) + if document.contains(id): + return document.rejectedEdit( + rekInsert, id, reeIdentifierDuplicate, "resource identifier is already in use" + ) + let + count = document.xDraft.records(T).len + index = operation.index.resolvedIndex(count) + if index > count: + return document.rejectedEdit( + rekInsert, id, reeIndexOutOfBounds, "resource insertion index is out of bounds" + ) + var candidate = document.xDraft + candidate.records(T).insert(operation.node, index) + document.replaceDraft(candidate, operation.actionName) + document.appliedEdit(rekInsert, id, none(ResourceNodePath), "") + +proc apply*[T: ResourceRecord]( + document: ResourceDocument, operation: ResourceReplaceOperation[T] +): ResourceEditResult {.discardable.} = + let path = document.findNodePath(operation.id) + if path.isNone or path.get().kind != recordKind(T): + return document.rejectedEdit( + rekReplace, operation.id, reeResourceUnavailable, "resource is unavailable" + ) + if operation.node.id != operation.id: + return document.rejectedEdit( + rekReplace, operation.id, reeIdentifierMismatch, + "replacement resource must preserve its identifier", + ) + let previousDiagnosticPath = document.diagnosticPath(path.get()) + var candidate = document.xDraft + for record in candidate.records(T).mitems: + if record.id == operation.id: + if record == operation.node: + return document.rejectedEdit( + rekReplace, operation.id, reeUnchanged, "resource is unchanged" + ) + record = operation.node + break + document.replaceDraft(candidate, operation.actionName) + document.appliedEdit(rekReplace, operation.id, path, previousDiagnosticPath) + +proc insertResource*[T: ResourceRecord]( + document: ResourceDocument, + node: T, + index = none(Natural), + actionName = "Insert Resource", +): ResourceEditResult {.discardable.} = + ## Inserts a layout or other flat resource record. Semantic errors remain + ## editable drafts, while identity and index errors reject the operation. + document.apply( + ResourceInsertOperation[T](node: node, index: index, actionName: actionName) + ) + +proc replaceResource*[T: ResourceRecord]( + document: ResourceDocument, id: ResourceId, node: T, actionName = "Change Resource" +): ResourceEditResult {.discardable.} = + document.apply( + ResourceReplaceOperation[T](id: id, node: node, actionName: actionName) + ) + +proc editRecordSequence[T]( + records: var seq[T], id: ResourceId, kind: ResourceEditKind, index: Natural +): ResourceEditError = + for previousIndex, record in records: + if record.id == id: + if kind == rekMove: + if index.int >= records.len: + return reeIndexOutOfBounds + if index.int == previousIndex: + return reeUnchanged + let moved = record + records.delete(previousIndex) + records.insert(moved, index.int) + else: + records.delete(previousIndex) + return reeNone + reeResourceUnavailable + +proc editRecord( + document: ResourceDocument, + id: ResourceId, + kind: ResourceEditKind, + index: Natural, + actionName: string, +): ResourceEditResult = + let path = document.findNodePath(id) + if path.isNone: + return + document.rejectedEdit(kind, id, reeResourceUnavailable, "resource is unavailable") + let previousDiagnosticPath = document.diagnosticPath(path.get()) + var candidate = document.xDraft + let error = + case path.get().kind + of rnkLayoutGuide: + candidate.layoutGuides.editRecordSequence(id, kind, index) + of rnkLayoutConstraint: + candidate.layoutConstraints.editRecordSequence(id, kind, index) + of rnkWindow: + candidate.windows.editRecordSequence(id, kind, index) + of rnkCommand: + candidate.commands.editRecordSequence(id, kind, index) + of rnkImage: + candidate.images.editRecordSequence(id, kind, index) + of rnkLocalization: + candidate.localizations.editRecordSequence(id, kind, index) + of rnkKeyBindings: + candidate.keyBindings.editRecordSequence(id, kind, index) + of rnkTheme: + candidate.themes.editRecordSequence(id, kind, index) + else: + reeResourceUnavailable + if error != reeNone: + return + document.rejectedEdit(kind, id, error, "resource operation could not be applied") + document.replaceDraft(candidate, actionName) + document.appliedEdit(kind, id, path, previousDiagnosticPath) + +proc removeResource*( + document: ResourceDocument, id: ResourceId, actionName = "Remove Resource" +): ResourceEditResult {.discardable.} = + ## Removes a view subtree or a flat record; dangling references become diagnostics. + let path = document.findNodePath(id) + if path.isSome and path.get().kind == rnkView: + return document.removeView(id, actionName) + document.editRecord(id, rekRemove, 0, actionName) + +proc moveResource*( + document: ResourceDocument, + id: ResourceId, + index: Natural, + actionName = "Reorder Resource", +): ResourceEditResult {.discardable.} = + ## Reorders a flat record within its resource collection. + document.editRecord(id, rekMove, index, actionName) diff --git a/src/merenda/nimkit/resources/resrcregistry.nim b/src/merenda/nimkit/resources/resrcregistry.nim index 347d5191..7e179fef 100644 --- a/src/merenda/nimkit/resources/resrcregistry.nim +++ b/src/merenda/nimkit/resources/resrcregistry.nim @@ -6,7 +6,8 @@ import sigils/selectors import ../app/viewcontrollers import ../containers/[boxes, splitviews, stackviews] -import ../controls/[buttons, controls, progressindicators, switchbuttons] +import + ../controls/[buttons, controls, progressindicators, sliders, steppers, switchbuttons] import ../drawing/images import ../foundation/types import ../text/textfields @@ -56,6 +57,10 @@ type view: View, value: ResourceValue, context: ResourcePropertyContext ): bool {.closure.} + ResourceViewPropertyGetter* = proc( + view: View, context: ResourcePropertyContext + ): ResourcePropertyReadResult {.closure.} + ResourcePropertyValueApplier = proc( view: View, selectorName: SigilName, @@ -82,6 +87,7 @@ type aliasOf: string valueType*: string setter*: ResourceViewPropertySetter + getter*: ResourceViewPropertyGetter ResourceViewRegistration = object baseKind: string @@ -127,11 +133,14 @@ proc registerViewProperty*( kind, name: string, acceptedKinds: set[ResourceValueKind], setter: ResourceViewPropertySetter, + getter: ResourceViewPropertyGetter = nil, + nimTypeName = "", ) = registry.viewProperties.mgetOrPut( kind, initTable[string, ResourceViewPropertyRegistration]() - )[name] = - ResourceViewPropertyRegistration(acceptedKinds: acceptedKinds, setter: setter) + )[name] = ResourceViewPropertyRegistration( + acceptedKinds: acceptedKinds, setter: setter, getter: getter, valueType: nimTypeName + ) proc registerResourceValueType*[T]( registry: var ResourceRegistry, @@ -450,19 +459,21 @@ proc readViewProperty*( name: string, context = ResourcePropertyContext(), ): ResourcePropertyReadResult = - ## Reads a supported protocol property through its Sigils getter selector. + ## Reads a property through its custom getter or Sigils getter selector. ## ## The registry converts the returned Nim value into a backend-neutral ## `ResourceValue`; it never reaches into a view's backing fields. var registration: ResourceViewPropertyRegistration declaredKind: string - if registry.findViewProperty(kind, name, registration, declaredKind) and - registry.propertyValueTypes.hasKey(registration.valueType): - let valueType = registry.propertyValueTypes[registration.valueType] - if not valueType.read.isNil: - result.read = - valueType.read(view, registration.getterSelector, context, result.value) + if registry.findViewProperty(kind, name, registration, declaredKind): + if not registration.getter.isNil: + return registration.getter(view, context) + if registry.propertyValueTypes.hasKey(registration.valueType): + let valueType = registry.propertyValueTypes[registration.valueType] + if not valueType.read.isNil: + result.read = + valueType.read(view, registration.getterSelector, context, result.value) proc attachChild*(registry: ResourceRegistry, kind: string, parent, child: View) = if registry.viewKinds.hasKey(kind) and not registry.viewKinds[kind].attachChild.isNil: @@ -760,6 +771,18 @@ proc initNimKitResourceRegistry*(): ResourceRegistry = newProgressIndicator(frame = frame), baseKind = "control", ) + result.registerViewKind( + "slider", + proc(frame: Rect): View = + newSlider(frame = frame), + baseKind = "control", + ) + result.registerViewKind( + "stepper", + proc(frame: Rect): View = + newStepper(frame = frame), + baseKind = "control", + ) result.registerViewKind( "box", proc(frame: Rect): View = @@ -788,6 +811,52 @@ proc initNimKitResourceRegistry*(): ResourceRegistry = ) result.registerViewProtocolProperties("view", ViewProtocol) + result.registerViewProperty( + "view", + "translatesAutoresizingMaskIntoConstraints", + {rvBool}, + setter = proc( + view: View, value: ResourceValue, context: ResourcePropertyContext + ): bool = + view.translatesAutoresizingMaskIntoConstraints = value.boolValue + true, + getter = proc( + view: View, context: ResourcePropertyContext + ): ResourcePropertyReadResult = + ResourcePropertyReadResult( + read: true, + value: resourceValue(view.translatesAutoresizingMaskIntoConstraints()), + ), + nimTypeName = "bool", + ) + template scalarProperty(kindName: string, Widget: typedesc, property: untyped) = + result.registerViewProperty( + kindName, + astToStr(property), + {rvFloat, rvInt}, + setter = proc( + view: View, value: ResourceValue, context: ResourcePropertyContext + ): bool = + Widget(view).property = + if value.kind == rvInt: value.intValue.float32 else: value.floatValue + true, + getter = proc( + view: View, context: ResourcePropertyContext + ): ResourcePropertyReadResult = + ResourcePropertyReadResult( + read: true, value: resourceValue(Widget(view).property()) + ), + nimTypeName = "float32", + ) + + scalarProperty("slider", Slider, minValue) + scalarProperty("slider", Slider, maxValue) + scalarProperty("slider", Slider, value) + scalarProperty("slider", Slider, stepValue) + scalarProperty("stepper", Stepper, minValue) + scalarProperty("stepper", Stepper, maxValue) + scalarProperty("stepper", Stepper, value) + scalarProperty("stepper", Stepper, increment) result.registerViewPropertyAlias("view", "background", "backgroundColor") result.registerViewPropertyAlias("view", "alpha", "alphaValue") result.registerViewProtocolProperties("control", ControlProtocol) diff --git a/src/merenda/tekton/editor.nim b/src/merenda/tekton/editor.nim index eda12fd4..4f4d8b1a 100644 --- a/src/merenda/tekton/editor.nim +++ b/src/merenda/tekton/editor.nim @@ -5,12 +5,12 @@ import std/[algorithm, options, os, sets, strutils, tables] import sigils/[core, selectors] import ../nimkit/accessibility/accessibility -import ../nimkit/app/[application, documents, windowcontrollers, windows] -import ../nimkit/containers/[gridviews, outlineviews, tableviews] +import ../nimkit/app/[application, documents, panels, windowcontrollers, windows] +import ../nimkit/containers/[gridviews, outlineviews, scrollviews, tableviews] import ../nimkit/controls/[buttons, colorpicker, comboboxes] -import ../nimkit/debug/[selectionrings, viewselection] +import ../nimkit/debug/selectionrings import ../nimkit/foundation/events -import ../nimkit/foundation/[selectors as nimkitSelectors, types, undomanagers] +import ../nimkit/foundation/[selectors as nimkitSelectors, types, undomanagers, urls] import ../nimkit/responder/keybindings import ../nimkit/resources/[ @@ -20,7 +20,7 @@ import import ../nimkit/text/textfields import ../nimkit/themes import ../nimkit/view/views -import ./[preview, valueediting] +import ./[preview, recordediting, valueediting] export valueediting @@ -54,6 +54,8 @@ type xInstantiationContext: ResourceInstantiationContext xValidationOptions: ResourceValidationOptions xIoDiagnostics: ResourceDiagnostics + xExplicitAssetBasePath: bool + xExplicitValidationBasePath: bool ResourceEditor* = ref object of Responder xDocument: ResourceEditorDocument @@ -77,13 +79,21 @@ type xDiagnosticRows: seq[ResourceDiagnostic] xPreview: ResourcePreview xPreviewDiagnostics: ResourceDiagnostics + xLayoutDiagnostics: ResourceDiagnostics xPreviewRevision: int xHasPreview: bool + xObservedResources: ResourceDocument + xSynchronizedRevision: int + xInspectorSelection: Option[ResourceId] + xHierarchyItems: seq[OutlineItem] + xHierarchyRevision: int + xSelectionText: string + xInteractivePreview: bool + xCanvasConstraints: Table[ResourceId, seq[LayoutConstraint]] xSynchronizingSelection: bool - xPreviewSelection: ViewSelection xSelectionRing: SelectionRing + xRelatedSelectionRing: SelectionRing xHoverRing: SelectionRing - xHoverTokens: seq[SwizzleToken] xHoveredResourceId: Option[ResourceId] xHoverGeometry: ResourcePreviewGeometry xSelectionFallbackId: Option[ResourceId] @@ -104,6 +114,7 @@ const ResourceEditorPaletteKinds* = [ "view", "control", "button", "checkBox", "radioButton", "textField", "label", "imageView", "stackView", "switchButton", "progressIndicator", "box", "splitView", + "slider", "stepper", ] proc newResourceEditor*(document: ResourceEditorDocument): ResourceEditor @@ -124,11 +135,22 @@ proc resizeSelectedView*( editor: ResourceEditor, delta: Size ): ResourceEditResult {.discardable.} +proc saveEditorDocument*( + editor: ResourceEditor, choosePath = false +): bool {.discardable.} + proc updatePreviewHover*( editor: ResourceEditor, point: Point ): ResourcePreviewHit {.discardable.} proc clearPreviewHover*(editor: ResourceEditor) +proc interactivePreview*(editor: ResourceEditor): bool = + editor.xInteractivePreview + +proc `interactivePreview=`*(editor: ResourceEditor, value: bool) = + ## Design mode captures canvas input; interaction mode runs the preview controls. + editor.xInteractivePreview = value + editor.clearPreviewHover() proc beginPreviewDrag( editor: ResourceEditor, view: View, point: Point @@ -143,29 +165,21 @@ protocol ResourceEditorDocumentEvents: document: ResourceEditorDocument, sender: ResourceEditorDocument ) {.signal.} -func localFilePath(fileUrl: string): string = - result = fileUrl - let queryStart = result.find('?') - if queryStart >= 0: - result.setLen(queryStart) - let fragmentStart = result.find('#') - if fragmentStart >= 0: - result.setLen(fragmentStart) - if result.startsWith("file://"): - result = result[7 .. ^1] +proc localFilePath(fileUrl: string): string = + initUrl(fileUrl).localFilePath(getCurrentDir()) proc replaceResources( document: ResourceEditorDocument, bundle: sink ResourceBundle, assetBasePath = "" ) = let manager = document.undoManagerFor() - manager.clearAll() - if assetBasePath.len > 0 and document.xValidationOptions.assetBasePath.len == 0: + if not document.xExplicitValidationBasePath: document.xValidationOptions.assetBasePath = assetBasePath - if assetBasePath.len > 0 and document.xInstantiationContext.assetBasePath.len == 0: + if not document.xExplicitAssetBasePath: document.xInstantiationContext.assetBasePath = assetBasePath document.xResources = newResourceDocument( bundle, document.xRegistry, document.xValidationOptions, manager ) + manager.clearAll() emit document.resourcesDidChange(document) protocol ResourceEditorDocumentIo of DocumentFileProtocol: @@ -225,14 +239,23 @@ proc newResourceEditorDocument*( xRegistry: registry, xInstantiationContext: instantiationContext, xValidationOptions: validationOptions, + xExplicitAssetBasePath: instantiationContext.assetBasePath.len > 0, + xExplicitValidationBasePath: validationOptions.assetBasePath.len > 0, ) result.initDocument( fileUrl = fileUrl, fileType = if fileUrl.len == 0: ResourceEditorDocumentType else: "", fileName = if fileUrl.len == 0: "Untitled Resources.cbor" else: "", ) - result.xResources = - newResourceDocument(bundle, registry, validationOptions, result.undoManagerFor()) + if fileUrl.len > 0: + let basePath = fileUrl.localFilePath().parentDir() + if not result.xExplicitAssetBasePath: + result.xInstantiationContext.assetBasePath = basePath + if not result.xExplicitValidationBasePath: + result.xValidationOptions.assetBasePath = basePath + result.xResources = newResourceDocument( + bundle, registry, result.xValidationOptions, result.undoManagerFor() + ) result.setUndoManager(result.xResources.undoManager()) discard result.withProtocol(ResourceEditorDocumentIo) discard result.withProtocol(ResourceEditorDocumentWindows) @@ -333,6 +356,14 @@ proc previewHitTest*(editor: ResourceEditor, point: Point): ResourcePreviewHit = func propertyEditorKind*(row: ResourceEditorPropertyRow): ResourcePropertyEditorKind = if not row.descriptor.editable: rpekReadOnly + elif row.value.isSome and ( + ( + row.descriptor.acceptedKinds != {} and + row.value.get().kind notin row.descriptor.acceptedKinds + ) or + (row.descriptor.options.len > 0 and row.value.get() notin row.descriptor.options) + ): + rpekText elif row.descriptor.options.len > 0: rpekComboBox elif row.descriptor.acceptedKinds == {rvBool}: @@ -396,16 +427,19 @@ proc refreshHierarchy(editor: ResourceEditor) = expanded.add parentId parent = document.findParentPath(parent.get()) var paths: seq[ResourceNodePath] - for path in document: - paths.add path + if editor.xHierarchyRevision != document.revision().int: + for path in document: + paths.add path + var parents = initHashSet[ResourceId]() + for path in paths: + let parent = document.findParentPath(path) + if parent.isSome: + parents.incl parent.get().id var items: seq[OutlineItem] for path in paths: let parent = document.findParentPath(path) - var expandable = false - for candidate in paths: - if document.findParentPath(candidate) == some(path): - expandable = true + let expandable = path.id in parents items.add initOutlineItem( $path.id, document.nodeTitle(path), @@ -421,7 +455,11 @@ proc refreshHierarchy(editor: ResourceEditor) = editor.xSynchronizingSelection = true try: - editor.xHierarchyView.outlineItems = items + if editor.xHierarchyRevision != document.revision().int: + if items != editor.xHierarchyItems: + editor.xHierarchyView.outlineItems = items + editor.xHierarchyItems = move items + editor.xHierarchyRevision = document.revision().int editor.xHierarchyView.expandedItemIdentifiers = expanded var selected: seq[string] for id in document.selectedResourceIds(): @@ -598,6 +636,20 @@ proc refreshPropertyRows(editor: ResourceEditor) = editor.xSelectionLabel.text = path.kind.resourceNodeKindTitle() & " " & $id & " · " & nodePath editor.refreshResourceDetailRows(path) + for field in document.recordFields(id): + var found = false + let row = ResourceEditorPropertyRow( + descriptor: field.descriptor, + value: some(field.value), + text: field.value.formatResourceValue(), + diagnosticPath: nodePath & "." & field.descriptor.name, + ) + for existing in editor.xPropertyRows.mitems: + if existing.descriptor.name == field.descriptor.name: + existing = row + found = true + if not found: + editor.xPropertyRows.add row editor.xPropertyInspector.reloadData() return @@ -606,7 +658,11 @@ proc refreshPropertyRows(editor: ResourceEditor) = var knownNames: seq[string] for descriptor in editor.xDocument.xRegistry.viewProperties(node.kind): - let property = document.findViewProperty(id, descriptor.name) + var property = document.findViewProperty(id, descriptor.name) + if property.isNone: + let effective = editor.xPreview.readViewProperty(id, descriptor.name) + if effective.read: + property = some(resourceProperty(descriptor.name, effective.value)) editor.xPropertyRows.add ResourceEditorPropertyRow( descriptor: descriptor, value: @@ -639,6 +695,7 @@ proc refreshPropertyRows(editor: ResourceEditor) = editor.xPropertyInspector.reloadData() proc refreshDiagnosticRows(editor: ResourceEditor) = + let previous = editor.xDiagnosticRows editor.xDiagnosticRows.setLen(0) for diagnostic in editor.xDocument.xResources.diagnostics(): editor.xDiagnosticRows.add diagnostic @@ -646,14 +703,17 @@ proc refreshDiagnosticRows(editor: ResourceEditor) = editor.xDiagnosticRows.add diagnostic for diagnostic in editor.xPreviewDiagnostics: editor.xDiagnosticRows.add diagnostic + for diagnostic in editor.xLayoutDiagnostics: + editor.xDiagnosticRows.add diagnostic editor.xDiagnosticsTitle.text = "Diagnostics (" & $editor.xDiagnosticRows.len & ")" - editor.xDiagnosticsView.reloadData() + if previous != editor.xDiagnosticRows: + editor.xDiagnosticsView.reloadData() proc clearPreviewHover*(editor: ResourceEditor) = discard editor.xHoverRing.uninstall() editor.xHoveredResourceId = none(ResourceId) editor.xHoverGeometry = ResourcePreviewGeometry() - editor.refreshPropertyRows() + editor.xSelectionLabel.text = editor.xSelectionText func previewHoverRingStyle(): SelectionRingStyle = initSelectionRingStyle( @@ -697,9 +757,45 @@ proc updatePreviewHover*( "Hover " & $result.resourceId & " · x " & $frame.x & " y " & $frame.y & " w " & $frame.w & " h " & $frame.h else: - editor.refreshPropertyRows() + editor.xSelectionLabel.text = editor.xSelectionText + +protocol ResourceEditorPreviewSurfacePicking of ViewProtocol: + method hitTest(surface: ResourceEditorPreviewSurface, point: Point): View = + if surface.isHidden() or not views.pointInside(surface, point): + return + if not surface.xEditor.isNil and surface.xEditor.xInteractivePreview: + let hit = surface.xEditor.previewHitTest(point) + if hit.found: + return hit.hitView + surface + +protocol ResourceEditorPreviewSurfaceLayout of ViewLayoutProtocol: + method layout(surface: ResourceEditorPreviewSurface) = + if not surface.xEditor.isNil: + let editor = surface.xEditor + let diagnostics = editor.xPreview.layoutDiagnostics(surface) + if diagnostics != editor.xLayoutDiagnostics: + editor.xLayoutDiagnostics = diagnostics + editor.refreshDiagnosticRows() protocol ResourceEditorPreviewSurfaceEvents of ResponderEventProtocol: + method mouseDown(surface: ResourceEditorPreviewSurface, event: MouseEvent): bool = + if not surface.xEditor.isNil and event.button == mbPrimary: + let hit = surface.xEditor.previewHitTest(event.location) + if hit.found: + discard surface.xEditor.selectResource(hit.resourceId) + discard surface.xEditor.beginPreviewDrag(hit.resourceView, event.location) + return true + + method mouseDragged(surface: ResourceEditorPreviewSurface, event: MouseEvent): bool = + if not surface.xEditor.isNil and event.button == mbPrimary: + return surface.xEditor.updatePreviewDrag(event.location) + + method mouseUp(surface: ResourceEditorPreviewSurface, event: MouseEvent): bool = + if not surface.xEditor.isNil and event.button == mbPrimary: + discard surface.xEditor.endPreviewDrag() + return true + method mouseMoved(surface: ResourceEditorPreviewSurface, event: MouseEvent): bool = if not surface.xEditor.isNil: discard surface.xEditor.updatePreviewHover(event.location) @@ -715,6 +811,12 @@ proc freeformParent(document: ResourceDocument, id: ResourceId): bool = let path = document.findNodePath(id) if path.isNone or path.get().kind != rnkView: return + for constraint in document.bundle().layoutConstraints: + if constraint.active and ( + (constraint.firstItem.kind == rliView and constraint.firstItem.id == id) or + (constraint.secondItem.kind == rliView and constraint.secondItem.id == id) + ): + return let parent = document.findParentPath(path.get()) if parent.isNone: return true @@ -755,6 +857,10 @@ proc updatePreviewDrag*(editor: ResourceEditor, point: Point): bool = ) editor.xDragDidMove = editor.xDragDidMove or delta != Point() editor.xDraggedView.frame = nextFrame + let id = editor.xDraggedResourceId.get() + if editor.xCanvasConstraints.hasKey(id): + editor.xCanvasConstraints[id][0].constant = nextFrame.x + editor.xCanvasConstraints[id][1].constant = nextFrame.y result = true proc endPreviewDrag*(editor: ResourceEditor): bool = @@ -783,7 +889,12 @@ proc endPreviewDrag*(editor: ResourceEditor): bool = protocol ResourceEditorHierarchyEvents of ResponderEventProtocol: method keyDown(hierarchy: ResourceEditorHierarchyView, event: KeyEvent): bool = if not hierarchy.xEditor.isNil: - if event.key in {keyBackspace, keyDelete} and event.modifiers == {}: + if event.key == keyS and + event.modifiers in [shortcutModifiers(), shortcutModifiers() + {kmShift}]: + discard + hierarchy.xEditor.saveEditorDocument(choosePath = kmShift in event.modifiers) + return true + elif event.key in {keyBackspace, keyDelete} and event.modifiers == {}: if hierarchy.xEditor.removeSelectedView().applied: return true elif event.key == keyD and event.modifiers == shortcutModifiers(): @@ -836,91 +947,14 @@ protocol ResourceEditorHierarchyEvents of ResponderEventProtocol: else: false -proc uninstallPreviewHoverTracking(editor: ResourceEditor) = - for index in countdown(editor.xHoverTokens.high, 0): - discard editor.xHoverTokens[index].popMethod() - editor.xHoverTokens.setLen(0) - -proc installPreviewHoverTracking(editor: ResourceEditor, view: View) = - if view.isNil: - return - let editorCopy = editor - let movedWrapper: AroundMethod = proc( - self: DynamicAgent, invocation: var Invocation, next: DynamicMethod - ) = - if not next.isNil: - next(self, invocation) - let - event = invocation.argsAs(MouseEvent) - localView = View(self) - point = localView.pointToView(event.location, editorCopy.xPreviewSurface) - discard editorCopy.updatePreviewHover(point) - let exitedWrapper: AroundMethod = proc( - self: DynamicAgent, invocation: var Invocation, next: DynamicMethod - ) = - discard self - if not next.isNil: - next(self, invocation) - if editorCopy.xHoveredResourceId.isSome: - editorCopy.clearPreviewHover() - let downWrapper: AroundMethod = proc( - self: DynamicAgent, invocation: var Invocation, next: DynamicMethod - ) = - if not next.isNil: - next(self, invocation) - let - event = invocation.argsAs(MouseEvent) - localView = View(self) - point = localView.pointToView(event.location, editorCopy.xPreviewSurface) - if event.button == mbPrimary and editorCopy.beginPreviewDrag(localView, point): - invocation.setResult(true) - let draggedWrapper: AroundMethod = proc( - self: DynamicAgent, invocation: var Invocation, next: DynamicMethod - ) = - if not next.isNil: - next(self, invocation) - let - event = invocation.argsAs(MouseEvent) - localView = View(self) - point = localView.pointToView(event.location, editorCopy.xPreviewSurface) - if event.button == mbPrimary and editorCopy.updatePreviewDrag(point): - invocation.setResult(true) - let upWrapper: AroundMethod = proc( - self: DynamicAgent, invocation: var Invocation, next: DynamicMethod - ) = - if not next.isNil: - next(self, invocation) - let event = invocation.argsAs(MouseEvent) - if event.button == mbPrimary and editorCopy.endPreviewDrag(): - invocation.setResult(true) - editor.xHoverTokens.add DynamicAgent(view).pushMethod( - nimkitSelectors.mouseMoved(), movedWrapper - ) - editor.xHoverTokens.add DynamicAgent(view).pushMethod( - nimkitSelectors.mouseExited(), exitedWrapper - ) - editor.xHoverTokens.add DynamicAgent(view).pushMethod( - nimkitSelectors.mouseDown(), downWrapper - ) - editor.xHoverTokens.add DynamicAgent(view).pushMethod( - nimkitSelectors.mouseDragged(), draggedWrapper - ) - editor.xHoverTokens.add DynamicAgent(view).pushMethod( - nimkitSelectors.mouseUp(), upWrapper - ) - for child in view.subviews(): - editor.installPreviewHoverTracking(child) - -proc installPreviewHoverTracking(editor: ResourceEditor) = - editor.uninstallPreviewHoverTracking() - for view in editor.xPreview.rootViews(): - editor.installPreviewHoverTracking(view) - proc clearPreview(editor: ResourceEditor) = - editor.uninstallPreviewHoverTracking() - discard editor.xPreviewSelection.uninstall() + editor.xLayoutDiagnostics = ResourceDiagnostics() + for constraints in editor.xCanvasConstraints.values: + constraints.deactivate() + editor.xCanvasConstraints.clear() discard editor.xHoverRing.uninstall() discard editor.xSelectionRing.uninstall() + discard editor.xRelatedSelectionRing.uninstall() while editor.xPreviewSurface.subviews().len > 0: editor.xPreviewSurface.subviews()[^1].removeFromSuperview() editor.xPreview = newResourcePreview( @@ -936,11 +970,26 @@ proc clearPreview(editor: ResourceEditor) = proc selectPreviewView(editor: ResourceEditor) = discard editor.xHoverRing.uninstall() discard editor.xSelectionRing.uninstall() + discard editor.xRelatedSelectionRing.uninstall() let selected = editor.selectedViewId() if selected.isSome and editor.xHasPreview: let view = editor.xPreview.findView(selected.get()) if not view.isNil: editor.xSelectionRing = view.installSelectionRing() + elif editor.xHasPreview: + let id = editor.selectedResourceId() + if id.isSome: + var guide: LayoutGuide + if editor.xPreview.findLayoutGuide(id.get(), guide): + editor.xSelectionRing = guide.owningView().installSelectionRing( + initSelectionRingStyle(insets = guide.insets(), cornerRadius = 0) + ) + else: + let constraint = editor.xPreview.findLayoutConstraint(id.get()) + if not constraint.isNil: + editor.xSelectionRing = constraint.firstItem().installSelectionRing() + editor.xRelatedSelectionRing = + constraint.secondItem().installSelectionRing(previewHoverRingStyle()) editor.installPreviewHoverRing() proc previewResourceId(editor: ResourceEditor, selectedView: View): ResourceId = @@ -956,14 +1005,11 @@ proc selectResource*(editor: ResourceEditor, id: ResourceId): bool = if previous != some(id): editor.xSelectionFallbackId = previous editor.refreshHierarchy() - editor.refreshPropertyRows() - editor.selectPreviewView() - -proc previewViewSelected(editor: ResourceEditor, view: View, event: MouseEvent) = - discard event - let id = editor.previewResourceId(view) - if not id.isEmpty: - discard editor.selectResource(id) + if previous != some(id): + editor.refreshPropertyRows() + editor.xSelectionText = editor.xSelectionLabel.text + editor.xInspectorSelection = some(id) + editor.selectPreviewView() proc rebuildPreview(editor: ResourceEditor) = let document = editor.xDocument.xResources @@ -975,10 +1021,8 @@ proc rebuildPreview(editor: ResourceEditor) = let revision = document.lastValidRevision().int if editor.xHasPreview and revision == editor.xPreviewRevision: - editor.selectPreviewView() return - editor.uninstallPreviewHoverTracking() discard editor.xHoverRing.uninstall() editor.xHoveredResourceId = none(ResourceId) editor.xHoverGeometry = ResourcePreviewGeometry() @@ -987,51 +1031,88 @@ proc rebuildPreview(editor: ResourceEditor) = ) editor.xPreviewDiagnostics = update.diagnostics if not update.applied: - editor.installPreviewHoverTracking() return editor.xPreviewRevision = revision editor.xHasPreview = true - editor.installPreviewHoverTracking() - if not editor.xPreviewSelection.installed(): - editor.xPreviewSelection = installViewSelection( - editor.xPreviewSurface, - proc(view: View, event: MouseEvent) = - editor.previewViewSelected(view, event), - initViewSelectionOptions(includeRoot = false), - ) + var constraints: Table[ResourceId, seq[LayoutConstraint]] + for node in document.lastValidBundle().views: + let view = editor.xPreview.findView(node.id) + for property in node.properties: + if property.name == "frame" and property.value.kind == rvRect and not view.isNil: + let frame = property.value.rectValue + var current = editor.xCanvasConstraints.getOrDefault(node.id) + if current.len > 0 and current[0].firstItem() != view: + current.deactivate() + current.setLen(0) + if current.len == 0: + current = + @[ + view[atLeft].equalTo( + editor.xPreviewSurface[atLeft], + constant = frame.x, + priority = LayoutPriority(999), + ), + view[atTop].equalTo( + editor.xPreviewSurface[atTop], + constant = frame.y, + priority = LayoutPriority(999), + ), + view[atWidth].equalTo(frame.w, priority = LayoutPriority(999)), + view[atHeight].equalTo(frame.h, priority = LayoutPriority(999)), + ] + current.activate() + else: + current[0].constant = frame.x + current[1].constant = frame.y + current[2].constant = frame.w + current[3].constant = frame.h + constraints[node.id] = current + for id, previous in editor.xCanvasConstraints: + if not constraints.hasKey(id): + previous.deactivate() + editor.xCanvasConstraints = move constraints editor.selectPreviewView() proc refreshStatus(editor: ResourceEditor) = let resources = editor.xDocument.xResources - edited = if editor.xDocument.isDocumentEdited(): "edited" else: "saved" - if not resources.draftIsValid(): - let preview = - if editor.xHasPreview: - $editor.xPreviewRevision + edited = + if editor.xDocument.isDocumentEdited(): + "Unsaved changes" + elif editor.xDocument.fileUrl().len == 0: + "Untitled document" else: - "none" - editor.xStatusLabel.text = - "Draft " & $resources.revision() & " is invalid · preview " & preview & - " unchanged · " & edited + "All changes saved" + if not resources.draftIsValid(): + editor.xStatusLabel.text = "Fix errors to update the preview · " & edited elif editor.xHasPreview: - editor.xStatusLabel.text = - "Draft " & $resources.revision() & " · preview " & $editor.xPreviewRevision & - " · " & edited + editor.xStatusLabel.text = edited else: - editor.xStatusLabel.text = - "Draft " & $resources.revision() & " is valid · preview unavailable · " & edited + editor.xStatusLabel.text = "Preview unavailable · " & edited proc synchronize*(editor: ResourceEditor) = ## Synchronizes hierarchy, inspector, diagnostics, and the valid preview. let resources = editor.xDocument.xResources + if resources != editor.xObservedResources: + editor.clearPreview() + editor.xHasPreview = false + editor.xPreviewRevision = -1 + editor.xSynchronizedRevision = -1 + editor.xHierarchyRevision = -1 + editor.xSelectionFallbackId = none(ResourceId) + editor.xObservedResources = resources if resources.selectedResourceIds().len == 0 and editor.xSelectionFallbackId.isSome and resources.contains(editor.xSelectionFallbackId.get()): discard resources.selectResource(editor.xSelectionFallbackId.get()) + let changed = resources.revision().int != editor.xSynchronizedRevision editor.rebuildPreview() - editor.refreshHierarchy() - editor.refreshPropertyRows() + if changed or editor.selectedResourceId() != editor.xInspectorSelection: + editor.refreshHierarchy() + editor.refreshPropertyRows() + editor.xInspectorSelection = editor.selectedResourceId() + editor.xSelectionText = editor.xSelectionLabel.text + editor.xSynchronizedRevision = resources.revision().int editor.refreshDiagnosticRows() editor.refreshStatus() @@ -1078,8 +1159,15 @@ proc commitPropertyText*( document = editor.xDocument.xResources node = document.findView(viewId) if node.isNone: - result.edit = - document.setViewProperty(viewId, resourceProperty(name, resourceValue(text))) + let edited = document.editRecordField(viewId, name, text) + result = ResourcePropertyEditResult( + parsed: edited.parsed, + value: edited.value, + message: edited.message, + edit: edited.edit, + ) + if result.edit.applied: + editor.synchronize() return let @@ -1111,10 +1199,10 @@ proc commitPropertyText*( proc commitSelectedPropertyText*( editor: ResourceEditor, name, text: string ): ResourcePropertyEditResult = - let selected = editor.selectedViewId() + let selected = editor.selectedResourceId() if selected.isSome: return editor.commitPropertyText(selected.get(), name, text) - result.message = "no view resource is selected" + result.message = "no resource is selected" proc nextViewIdentifier(document: ResourceDocument, kind: string): ResourceId = var index = 1 @@ -1122,6 +1210,105 @@ proc nextViewIdentifier(document: ResourceDocument, kind: string): ResourceId = inc index result = resourceId(kind & "." & $index) +proc insertResourceKind*( + editor: ResourceEditor, kind: ResourceNodeKind +): ResourceEditResult = + ## Adds an identified resource using the selected view as its initial owner. + let document = editor.xDocument.xResources + var owner = editor.selectedViewId().get(ResourceId("")) + let selected = editor.selectedResourceId() + if owner.isEmpty and selected.isSome: + let parent = document.findParentPath(document.nodePath(selected.get())) + if parent.isSome and parent.get().kind == rnkView: + owner = parent.get().id + let id = document.nextViewIdentifier(kind.resourceNodeKindTitle().replace(" ", "")) + case kind + of rnkLayoutGuide: + result = document.insertResource(initResourceLayoutGuide(id, owner, insets(8.0))) + of rnkLayoutConstraint: + var width = 180.0'f32 + let view = editor.xPreview.findView(owner) + if not view.isNil: + width = view.frame().w + result = document.insertResource( + initResourceLayoutConstraint( + id, owner, resourceLayoutItem(owner), rlaWidth, constant = width + ) + ) + of rnkWindow: + result = document.insertResource( + WindowResource( + id: id, + title: resourceText("Window"), + frame: rect(80, 80, 640, 480), + contentViewId: owner, + ) + ) + of rnkCommand: + result = document.insertResource(CommandResource(id: id, selector: "performClick")) + of rnkImage: + result = document.insertResource(ImageAssetResource(id: id, sourceKind: risFile)) + of rnkLocalization: + result = document.insertResource(LocalizedCatalogResource(id: id, locale: "en")) + of rnkKeyBindings: + result = document.insertResource(KeyBindingTableResource(id: id)) + of rnkTheme: + result = document.insertResource(ThemeFragmentResource(id: id)) + else: + result.message = "this resource kind cannot be inserted here" + result.error = reeResourceUnavailable + if result.applied: + editor.xSelectionFallbackId = selected + discard editor.selectResource(id) + editor.synchronize() + +proc pinSelectedView*(editor: ResourceEditor, padding = 16.0'f32): ResourceEditResult = + ## Pins all four edges to the parent in one undo group. + let + document = editor.xDocument.xResources + selected = editor.selectedViewId() + if selected.isNone: + result.message = "select a child view to pin to its parent" + result.error = reeResourceUnavailable + return + let id = selected.get() + let parent = document.findParentPath(document.nodePath(id)) + if parent.isNone or parent.get().kind != rnkView or not document.freeformParent(id): + result.message = "select an unconstrained child of a freeform container" + result.error = reeParentUnavailable + return + let manager = document.undoManager() + manager.beginUndoGrouping() + try: + discard document.setViewProperty( + id, + resourceProperty( + "translatesAutoresizingMaskIntoConstraints", resourceValue(false) + ), + ) + for anchor in [rlaLeft, rlaTop, rlaRight, rlaBottom]: + let constraintId = document.nextViewIdentifier($id & "." & $anchor) + let constant = + if anchor in {rlaLeft, rlaTop}: + padding + else: + -padding + result = document.insertResource( + initResourceLayoutConstraint( + constraintId, + parent.get().id, + resourceLayoutItem(id), + anchor, + resourceLayoutItem(parent.get().id), + anchor, + constant = constant, + ) + ) + manager.setActionName("Pin to Parent") + finally: + discard manager.endUndoGrouping() + editor.synchronize() + type SelectedViewLocation = object id: ResourceId parentId: ResourceId @@ -1450,14 +1637,14 @@ proc removeSelectedView*(editor: ResourceEditor): ResourceEditResult = revision: document.revision(), ) let path = document.findNodePath(selected.get()) - if path.isNone or path.get().kind != rnkView: - return ResourceEditResult( - kind: rekRemove, - error: reeResourceUnavailable, - message: "the selected resource is not a view", - resourceId: selected.get(), - revision: document.revision(), - ) + if path.isSome and path.get().kind != rnkView: + let parent = document.findParentPath(path.get()) + result = document.removeResource(selected.get()) + if result.applied: + if parent.isSome: + discard document.selectResource(parent.get().id) + editor.synchronize() + return let parent = document.findParentPath(path.get()) parentId = @@ -1479,7 +1666,33 @@ proc removeSelectedView*(editor: ResourceEditor): ResourceEditResult = elif index > 0: nextSelection = siblings[index - 1].id break - result = document.removeView(selected.get(), actionName = "Delete View") + var removedIds = initHashSet[ResourceId]() + proc collectIds(node: ViewNodeResource) = + removedIds.incl node.id + for child in node.children: + collectIds(child) + + collectIds(document.view(selected.get())) + var guides, constraints: seq[ResourceId] + for guide in document.bundle().layoutGuides: + if guide.owningViewId in removedIds: + removedIds.incl guide.id + guides.add guide.id + for constraint in document.bundle().layoutConstraints: + if constraint.owningViewId in removedIds or constraint.firstItem.id in removedIds or + constraint.secondItem.id in removedIds: + constraints.add constraint.id + let manager = document.undoManager() + manager.beginUndoGrouping() + try: + for id in constraints: + discard document.removeResource(id) + for id in guides: + discard document.removeResource(id) + result = document.removeView(selected.get(), actionName = "Delete View") + manager.setActionName("Delete View") + finally: + discard manager.endUndoGrouping() if not result.applied: return if not nextSelection.isEmpty and document.selectResource(nextSelection): @@ -1573,7 +1786,7 @@ proc newPropertyColorWell( colorWell.action = action proc propertyEditorView(editor: ResourceEditor, row: ResourceEditorPropertyRow): View = - let selected = editor.selectedViewId() + let selected = editor.selectedResourceId() if selected.isNone: return case row.propertyEditorKind() @@ -1631,6 +1844,23 @@ protocol ResourceEditorTableSource of TableViewDataSource: "" protocol ResourceEditorTableDelegate of TableViewDelegate: + method validationErrorForCell( + editor: ResourceEditor, + tableView: TableView, + row: int, + column: TableColumn, + value: string, + ): string = + let selected = editor.selectedResourceId() + if tableView == editor.xPropertyInspector and column.identifier() == "value" and + row in 0 ..< editor.xPropertyRows.len and selected.isSome and + editor.xDocument.xResources.nodePath(selected.get()).kind != rnkView: + let checked = editor.xDocument.xResources.editRecordField( + selected.get(), editor.xPropertyRows[row].descriptor.name, value, commit = false + ) + if not checked.parsed: + return checked.message + method viewForCell( editor: ResourceEditor, tableView: TableView, row: int, column: TableColumn ): View = @@ -1672,7 +1902,7 @@ protocol ResourceEditorTableDelegate of TableViewDelegate: if tableView != editor.xPropertyInspector or column.identifier() != "value" or row notin 0 ..< editor.xPropertyRows.len: return - let selected = editor.selectedViewId() + let selected = editor.selectedResourceId() if selected.isSome: discard editor.commitPropertyText( selected.get(), editor.xPropertyRows[row].descriptor.name, value @@ -1705,7 +1935,27 @@ proc configureDiagnostics(tableView: TableView) = proc newPaletteButton(editor: ResourceEditor, kind: string): Button = let action = nimkitSelectors.actionSelector("resourceEditorInsert" & kind) - result = newButton(kind) + let title = + case kind + of "checkBox": + "Checkbox" + of "radioButton": + "Radio Button" + of "textField": + "Text Field" + of "imageView": + "Image" + of "stackView": + "Stack" + of "switchButton": + "Switch" + of "progressIndicator": + "Progress" + of "splitView": + "Split View" + else: + kind.capitalizeAscii() + result = newButton(title) result.target = newActionTarget( action, proc(sender: DynamicAgent) = @@ -1722,7 +1972,33 @@ proc configurePalette(editor: ResourceEditor) = for index, kind in editor.xPaletteKinds: let button = editor.newPaletteButton(kind) editor.xPaletteButtons.add button - editor.xPaletteView.addSubview(button, row = index div 3, col = index mod 3) + editor.xPaletteView.addSubview(button, row = index div 2, col = index mod 2) + editor.xPaletteView.frame = + rect(0, 0, 236, ((editor.xPaletteKinds.len + 1) div 2).float32 * 34 + 8) + +proc saveEditorDocument*(editor: ResourceEditor, choosePath = false): bool = + ## Commits the current field and prompts for a destination for untitled documents. + if editor.xPropertyInspector.editingState().active and + not editor.xPropertyInspector.commitEditingCell(""): + return + if choosePath or editor.xDocument.fileUrl().len == 0: + let panel = newSavePanel() + panel.allowedFileTypes = @["cbor"] + panel.nameFieldStringValue = + if editor.xDocument.fileUrl().len == 0: + "Untitled Resources.cbor" + else: + editor.xDocument.fileUrl().localFilePath().extractFilename() + panel.directoryUrl = + if editor.xDocument.fileUrl().len == 0: + getCurrentDir() + else: + editor.xDocument.fileUrl().localFilePath().parentDir() + if sharedApplication().runModal(panel) == PanelResponseOk: + result = editor.xDocument.saveAs(panel.selectedUrl(), "cbor") + else: + result = editor.xDocument.save() + editor.synchronize() proc configureToolbar( editor: ResourceEditor, @@ -1741,8 +2017,7 @@ proc configureToolbar( saveAction, proc(sender: DynamicAgent) = discard sender - discard editor.xDocument.save() - editor.synchronize(), + discard editor.saveEditorDocument(), ) saveButton.action = saveAction undoButton.target = newActionTarget( @@ -1818,12 +2093,17 @@ proc newResourceEditor*(document: ResourceEditorDocument): ResourceEditor = previewSurface.xEditor = result hierarchyView.xEditor = result discard previewSurface.withProtocol(ResourceEditorPreviewSurfaceEvents) + discard previewSurface.withProtocol(ResourceEditorPreviewSurfacePicking) + discard previewSurface.withProtocol(ResourceEditorPreviewSurfaceLayout) discard result.withProtocol(ResourceEditorTableSource) discard result.withProtocol(ResourceEditorTableDelegate) for kind in ResourceEditorPaletteKinds: if document.xRegistry.hasViewKind(kind): result.xPaletteKinds.add kind + for descriptor in document.xRegistry.viewKinds(): + if descriptor.kind notin result.xPaletteKinds: + result.xPaletteKinds.add descriptor.kind result.xHierarchyView.configureHierarchy() result.xPropertyInspector.configurePropertyInspector() @@ -1833,6 +2113,7 @@ proc newResourceEditor*(document: ResourceEditorDocument): ResourceEditor = result.xDiagnosticsView.dataSource = result result.xDiagnosticsView.delegate = result result.xPreviewSurface.backgroundColor = color(0.96, 0.97, 0.99, 1.0) + result.xPreviewSurface.appearance = initAppearance(initAquaTheme()) result.xPreviewSurface.clipsToBounds = true result.xPreviewSurface.accessibilityRole = arGroup result.xPreviewSurface.accessibilityLabel = "Resource preview" @@ -1840,7 +2121,7 @@ proc newResourceEditor*(document: ResourceEditorDocument): ResourceEditor = let hierarchyTitle = newHeadingLabel("Resource Hierarchy") paletteTitle = newHeadingLabel("View Palette") - previewTitle = newHeadingLabel("Valid Revision Preview") + previewTitle = newHeadingLabel("Preview") inspectorTitle = newHeadingLabel("Resource Inspector") saveButton = newButton("Save") undoButton = newButton("Undo") @@ -1849,8 +2130,103 @@ proc newResourceEditor*(document: ResourceEditorDocument): ResourceEditor = duplicateButton = newButton("Duplicate") moveEarlierButton = newButton("Up") moveLaterButton = newButton("Down") + interactButton = newCheckBox("Interact") + openButton = newButton("Open…") + saveAsButton = newButton("Save As…") + pinButton = newButton("Pin to Parent") + resourcePicker = newComboBox( + [ + "Layout Guide", "Layout Constraint", "Window", "Command", "Image", + "Localization", "Key Bindings", "Theme", + ] + ) + addResourceButton = newButton("Add") + paletteScroll = newScrollView(documentView = result.xPaletteView) + themePicker = newComboBox( + ["Aqua", "Dark BSD", "macOS", "macOS Dark", "Nebula", "Peachy", "Synthwave83"] + ) + + let editor = result + themePicker.editable = false + themePicker.selectedIndex = 0 + themePicker.toolTip = "Preview theme (does not change the resource document)" + let themeAction = nimkitSelectors.actionSelector("resourceEditorPreviewTheme") + themePicker.target = newActionTarget( + themeAction, + proc(sender: DynamicAgent) = + let names = + ["aqua", "darkbsd", "macos", "macos-dark", "nebula", "peachy", "synthwave83"] + let index = themePicker.indexOfSelectedItem() + if index in 0 ..< names.len: + editor.xPreviewSurface.appearance = + initAppearance(initThemeByName(names[index])) + , + ) + themePicker.action = themeAction + let interactAction = nimkitSelectors.actionSelector("resourceEditorInteract") + interactButton.target = newActionTarget( + interactAction, + proc(sender: DynamicAgent) = + editor.interactivePreview = Button(sender).state() == bsOn, + ) + interactButton.action = interactAction + interactButton.toolTip = "Run preview controls; uncheck to select and position views" + resourcePicker.editable = false + resourcePicker.selectedIndex = 0 + let addAction = nimkitSelectors.actionSelector("resourceEditorAddResource") + addResourceButton.target = newActionTarget( + addAction, + proc(sender: DynamicAgent) = + let kinds = [ + rnkLayoutGuide, rnkLayoutConstraint, rnkWindow, rnkCommand, rnkImage, + rnkLocalization, rnkKeyBindings, rnkTheme, + ] + let index = resourcePicker.indexOfSelectedItem() + if index in 0 ..< kinds.len: + discard editor.insertResourceKind(kinds[index]) + , + ) + addResourceButton.action = addAction + let pinAction = nimkitSelectors.actionSelector("resourceEditorPin") + pinButton.target = newActionTarget( + pinAction, + proc(sender: DynamicAgent) = + let edit = editor.pinSelectedView() + if not edit.applied: + editor.xStatusLabel.text = edit.message + , + ) + pinButton.action = pinAction + let saveAsAction = nimkitSelectors.actionSelector("resourceEditorSaveAs") + saveAsButton.target = newActionTarget( + saveAsAction, + proc(sender: DynamicAgent) = + discard editor.saveEditorDocument(choosePath = true), + ) + saveAsButton.action = saveAsAction + let openAction = nimkitSelectors.actionSelector("resourceEditorOpen") + openButton.target = newActionTarget( + openAction, + proc(sender: DynamicAgent) = + let panel = newOpenPanel() + panel.allowedFileTypes = @["cbor"] + if sharedApplication().runModal(panel) == PanelResponseOk: + let opened = newResourceEditorDocument() + if opened.readFromFileUrl(panel.selectedUrl()): + for path in opened.resources(): + discard opened.resources().selectResource(path.id) + break + discard opened.showWindows(sharedApplication()) + else: + editor.xStatusLabel.text = "Could not open the resource file" + , + ) + openButton.action = openAction result.configurePalette() + paletteScroll.hasHorizontalScroller = false + paletteScroll.hasVerticalScroller = true + paletteScroll.autohidesScrollers = true result.xUndoButton = undoButton result.xRedoButton = redoButton result.xDuplicateButton = duplicateButton @@ -1862,15 +2238,36 @@ proc newResourceEditor*(document: ResourceEditorDocument): ResourceEditor = ) result.xRootView.addSubviews( autoNames( - hierarchyTitle, result.xHierarchyView, paletteTitle, result.xPaletteView, - previewTitle, result.xStatusLabel, result.xPreviewSurface, inspectorTitle, + hierarchyTitle, result.xHierarchyView, paletteTitle, paletteScroll, previewTitle, + result.xStatusLabel, result.xPreviewSurface, inspectorTitle, result.xSelectionLabel, result.xPropertyInspector, result.xDiagnosticsTitle, result.xDiagnosticsView, saveButton, undoButton, redoButton, deleteButton, - duplicateButton, moveEarlierButton, moveLaterButton, + duplicateButton, moveEarlierButton, moveLaterButton, interactButton, openButton, + saveAsButton, pinButton, resourcePicker, addResourceButton, themePicker, ) ) activateConstraints: + interactButton[atTop] == saveButton[atTop] + interactButton[atLeft] == result.xRootView[atLeft] + 18.0 + interactButton[atWidth] == 110.0 + interactButton[atHeight] == saveButton[atHeight] + openButton[atTop] == saveButton[atTop] + openButton[atLeft] == interactButton[atRight] + 8.0 + openButton[atWidth] == 70.0 + openButton[atHeight] == saveButton[atHeight] + saveAsButton[atTop] == saveButton[atTop] + saveAsButton[atLeft] == openButton[atRight] + 8.0 + saveAsButton[atWidth] == 90.0 + saveAsButton[atHeight] == saveButton[atHeight] + pinButton[atTop] == saveButton[atTop] + pinButton[atLeft] == saveAsButton[atRight] + 8.0 + pinButton[atWidth] == 110.0 + pinButton[atHeight] == saveButton[atHeight] + themePicker[atTop] == saveButton[atTop] + themePicker[atLeft] == pinButton[atRight] + 12.0 + themePicker[atWidth] == 140.0 + themePicker[atHeight] == saveButton[atHeight] saveButton[atTop] == result.xRootView[atTop] + 14.0 saveButton[atRight] == result.xRootView[atRight] - 18.0 saveButton[atWidth] == 72.0 @@ -1910,12 +2307,20 @@ proc newResourceEditor*(document: ResourceEditorDocument): ResourceEditor = result.xHierarchyView[atBottom] == paletteTitle[atTop] - 10.0 paletteTitle[atLeft] == hierarchyTitle[atLeft] paletteTitle[atWidth] == hierarchyTitle[atWidth] - paletteTitle[atBottom] == result.xPaletteView[atTop] - 6.0 + paletteTitle[atBottom] == paletteScroll[atTop] - 6.0 paletteTitle[atHeight] == 24.0 - result.xPaletteView[atLeft] == hierarchyTitle[atLeft] - result.xPaletteView[atWidth] == hierarchyTitle[atWidth] - result.xPaletteView[atBottom] == result.xRootView[atBottom] - 18.0 - result.xPaletteView[atHeight] == 170.0 + paletteScroll[atLeft] == hierarchyTitle[atLeft] + paletteScroll[atWidth] == hierarchyTitle[atWidth] + paletteScroll[atBottom] == resourcePicker[atTop] - 8.0 + paletteScroll[atHeight] == 220.0 + resourcePicker[atLeft] == hierarchyTitle[atLeft] + resourcePicker[atWidth] == 180.0 + resourcePicker[atBottom] == result.xRootView[atBottom] - 18.0 + resourcePicker[atHeight] == 30.0 + addResourceButton[atLeft] == resourcePicker[atRight] + 6.0 + addResourceButton[atRight] == hierarchyTitle[atRight] + addResourceButton[atTop] == resourcePicker[atTop] + addResourceButton[atHeight] == resourcePicker[atHeight] previewTitle[atTop] == hierarchyTitle[atTop] previewTitle[atLeft] == result.xHierarchyView[atRight] + 18.0 diff --git a/src/merenda/tekton/preview.nim b/src/merenda/tekton/preview.nim index 4f68c1b0..d8df710d 100644 --- a/src/merenda/tekton/preview.nim +++ b/src/merenda/tekton/preview.nim @@ -81,8 +81,11 @@ type xBundle: ResourceBundle xInstance: ResourceInstance xViews: Table[ResourceId, View] + xViewIds: Table[pointer, ResourceId] + xViewSnapshots: Table[ResourceId, ViewSnapshot] xControllers: Table[ResourceId, ViewController] xLayout: ResourceLayoutInstance + xHost: View xRevision: Natural xHasRevision: bool @@ -211,7 +214,11 @@ proc collectViews( ) = for index, node in nodes: let nodePath = path & "[" & $index & "]" - snapshots[node.id] = ViewSnapshot(node: node, parentId: parentId, path: nodePath) + snapshots[node.id] = ViewSnapshot( + node: ViewNodeResource(id: node.id, kind: node.kind, properties: node.properties), + parentId: parentId, + path: nodePath, + ) collectViews(node.children, node.id, nodePath & ".children", snapshots) proc viewSnapshots(bundle: ResourceBundle): Table[ResourceId, ViewSnapshot] = @@ -582,7 +589,7 @@ proc addChanges( change.kinds.incl rpckRemoved elif id in reused: change.kinds.incl rpckReused - if previous[id].parentId != next[id].parentId: + if previous[id].parentId != next[id].parentId or previous[id].path != next[id].path: change.kinds.incl rpckMoved if id in updated: change.kinds.incl rpckUpdated @@ -637,6 +644,168 @@ proc addChanges( change.kinds.incl rpckReplaced changes.add change +proc sameNonViewResources(previous, next: ResourceBundle): bool = + previous.format == next.format and previous.version == next.version and + previous.namespace == next.namespace and previous.controllers == next.controllers and + previous.windows == next.windows and previous.menus == next.menus and + previous.commands == next.commands and previous.images == next.images and + previous.localizations == next.localizations and + previous.keyBindings == next.keyBindings and previous.themes == next.themes + +proc updateProperties( + preview: ResourcePreview, + bundle: ResourceBundle, + revision: Natural, + snapshots: Table[ResourceId, ViewSnapshot], + updateResult: var ResourcePreviewUpdateResult, +): bool = + ## Handles revisions that keep the resource graph and all non-view resources. + ## Stage just the changed views, then apply getter-backed values transactionally. + if not preview.xHasRevision or not sameNonViewResources(preview.xBundle, bundle) or + snapshots.len != preview.xViewSnapshots.len: + return + for id, snapshot in snapshots: + if not preview.xViewSnapshots.hasKey(id): + return + let previous = preview.xViewSnapshots[id] + if snapshot.node.kind != previous.node.kind or snapshot.path != previous.path: + return + + var options = preview.xValidationOptions + if options.assetBasePath.len == 0: + options.assetBasePath = preview.xContext.assetBasePath + updateResult.diagnostics = bundle.validateResources(preview.xRegistry, options) + if updateResult.diagnostics.hasErrors: + return true + + let context = initResourcePropertyContext(bundle, preview.xInstance, preview.xContext) + var + updates: seq[PropertyUpdate] + reused = initHashSet[ResourceId]() + updated = initHashSet[ResourceId]() + let layoutChanged = + preview.xBundle.layoutGuides != bundle.layoutGuides or + preview.xBundle.layoutConstraints != bundle.layoutConstraints + var nextLayout = preview.xLayout + if layoutChanged: + let views = preview.xViews + nextLayout = bundle.instantiateResourceLayout( + proc(id: ResourceId): View = + views.getOrDefault(id), + activate = false, + ) + for diagnostic in nextLayout.diagnostics(): + updateResult.diagnostics.entries.add diagnostic + if not nextLayout.instantiated: + return true + for id, snapshot in snapshots: + reused.incl id + let previous = preview.xViewSnapshots[id] + if previous.node.properties != snapshot.node.properties: + var names: seq[string] + for name in preview.xRegistry.changedPropertyNames(previous.node, snapshot.node): + if preview.xRegistry.propertyValue(previous.node, name) != + preview.xRegistry.propertyValue(snapshot.node, name): + names.add name + if names.len > 0: + let currentView = preview.xViews[id] + var staged: View + try: + var frame = AutoRect + for property in snapshot.node.properties: + if property.name == "frame" and property.value.kind == rvRect: + frame = property.value.rectValue + staged = preview.xRegistry.constructView(snapshot.node.kind, frame) + if staged.isNil: + return false + for property in snapshot.node.properties: + if not preview.xRegistry.applyViewProperty( + snapshot.node.kind, staged, property, context + ): + updateResult.diagnostics.add( + rdsError, + "resource.preview.propertyPreflightFailed", + "property '" & property.name & "' failed preview preflight", + path = snapshot.path & ".properties." & property.name, + resourceId = id, + ) + return true + for name in names: + let + current = preview.xRegistry.readViewProperty( + snapshot.node.kind, currentView, name, context + ) + desired = preview.xRegistry.readViewProperty( + snapshot.node.kind, staged, name, context + ) + if not current.read or not desired.read: + return false + updates.add PropertyUpdate( + resourceId: id, + kind: snapshot.node.kind, + name: name, + view: currentView, + value: desired.value, + rollbackValue: current.value, + path: snapshot.path & ".properties." & name, + ) + updated.incl id + except CatchableError as error: + updateResult.diagnostics.add( + rdsError, + "resource.preview.propertyPreflightFailed", + "preview property preflight failed: " & error.msg, + path = snapshot.path, + resourceId = id, + ) + return true + + for index, update in updates: + try: + if not preview.xRegistry.applyViewProperty( + update.kind, update.view, resourceProperty(update.name, update.value), context + ): + raise newException(ValueError, "property setter rejected the value") + except CatchableError as error: + # A setter can mutate its view before failing, so include the attempted setter. + preview.rollbackProperties(updates, index, context, updateResult.diagnostics) + updateResult.diagnostics.add( + rdsError, + "resource.preview.propertyApplyFailed", + "property '" & update.name & "' reconciliation failed: " & error.msg, + path = update.path, + resourceId = update.resourceId, + ) + return true + + if layoutChanged: + try: + preview.xLayout.deactivate() + nextLayout.activate() + except CatchableError as error: + nextLayout.deactivate() + preview.xLayout.activate() + preview.rollbackProperties( + updates, updates.high, context, updateResult.diagnostics + ) + updateResult.diagnostics.add( + rdsError, + "resource.preview.layoutApplyFailed", + "layout reconciliation failed: " & error.msg, + ) + return true + preview.xLayout = nextLayout + preview.xInstance.rebindResourceIdentities( + preview.xViews, preview.xControllers, nextLayout + ) + addChanges(preview.xViewSnapshots, snapshots, reused, updated, updateResult.changes) + preview.xBundle = bundle + preview.xViewSnapshots = snapshots + preview.xRevision = revision + updateResult.revision = revision + updateResult.applied = true + true + proc update*( preview: ResourcePreview, bundle: ResourceBundle, @@ -645,11 +814,16 @@ proc update*( ): ResourcePreviewUpdateResult = ## Reconciles one valid resource revision into the installed preview. ## - ## Full construction is used as a preflight graph. Existing view and controller + ## Property edits stage only changed views and retain the installed hierarchy. + ## Structural edits use full construction as a preflight graph. View and controller ## identities are committed only after getter-backed property updates and all ## hierarchy changes succeed. On failure, mappings and the installed graph stay ## at the previous revision. result.revision = if preview.hasRevision: preview.xRevision else: revision + let nextViewSnapshots = bundle.viewSnapshots() + if host == preview.xHost and + preview.updateProperties(bundle, revision, nextViewSnapshots, result): + return var construction: ResourceInstantiationResult try: construction = bundle.instantiateResources( @@ -667,8 +841,7 @@ proc update*( return let - previousViewSnapshots = preview.xBundle.viewSnapshots() - nextViewSnapshots = bundle.viewSnapshots() + previousViewSnapshots = preview.xViewSnapshots previousControllerSnapshots = preview.xBundle.controllerSnapshots() nextControllerSnapshots = bundle.controllerSnapshots() previousPropertyContext = @@ -761,6 +934,7 @@ proc update*( var lastApplied = -1 for index, update in propertyUpdates: + lastApplied = index try: if not preview.xRegistry.applyViewProperty( update.kind, @@ -779,7 +953,6 @@ proc update*( propertyUpdates, lastApplied, previousPropertyContext, result.diagnostics ) return - lastApplied = index except CatchableError as error: result.diagnostics.add( rdsError, @@ -846,10 +1019,16 @@ proc update*( result.changes, ) preview.xBundle = bundle - preview.xInstance = construction.instance + construction.instance.rebindResourceIdentities(nextViews, nextControllers, nextLayout) + preview.xInstance = move construction.instance preview.xViews = move nextViews + preview.xViewSnapshots = nextViewSnapshots + preview.xViewIds.clear() + for id, view in preview.xViews: + preview.xViewIds[cast[pointer](view)] = id preview.xControllers = move nextControllers preview.xLayout = nextLayout + preview.xHost = host preview.xRevision = revision preview.xHasRevision = true result.applied = true @@ -861,22 +1040,21 @@ proc readViewProperty*( ## Reads a live, runtime-normalized property value through the registry getter. if preview.isNil or not preview.xHasRevision: return - let - snapshot = preview.xBundle.findView(id) - view = preview.xViews.getOrDefault(id) - if snapshot.isSome and not view.isNil: + let view = preview.xViews.getOrDefault(id) + if preview.xViewSnapshots.hasKey(id) and not view.isNil: let context = initResourcePropertyContext(preview.xBundle, preview.xInstance, preview.xContext) - result = - preview.xRegistry.readViewProperty(snapshot.get().kind, view, name, context) + result = preview.xRegistry.readViewProperty( + preview.xViewSnapshots[id].node.kind, view, name, context + ) proc resourceIdForView*(preview: ResourcePreview, view: View): Option[ResourceId] = ## Maps a hit view or one of its implementation subviews to a resource id. var candidate = view while not candidate.isNil: - for id, resourceView in preview.xViews.pairs: - if resourceView == candidate: - return some(id) + let key = cast[pointer](candidate) + if preview.xViewIds.hasKey(key): + return some(preview.xViewIds[key]) candidate = candidate.superview() none(ResourceId) @@ -895,10 +1073,24 @@ proc geometry*( ) proc hitTest*(preview: ResourcePreview, host: View, point: Point): ResourcePreviewHit = - ## Hit-tests a preview host and maps implementation subviews to resources. - if preview.isNil or host.isNil: + ## Picks preview content even when the editor host intercepts design input. + if preview.isNil or host.isNil or + (host.clipsToBounds() and not host.pointInside(point)): return - let hit = host.hitTest(point) + var hit: View + var level = low(int) + let children = host.subviews() + for index in countdown(children.high, 0): + let child = children[index] + let candidate = child.hitTest(child.pointFromView(point, host)) + if not candidate.isNil: + let candidateLevel = max( + child.hitTestLevel(child.pointFromView(point, host)), + candidate.hitTestLevel(candidate.pointFromView(point, host)), + ) + if hit.isNil or candidateLevel > level: + hit = candidate + level = candidateLevel if hit.isNil: return let id = preview.resourceIdForView(hit) @@ -911,3 +1103,68 @@ proc hitTest*(preview: ResourcePreview, host: View, point: Point): ResourcePrevi resourceView: preview.findView(id.get()), geometry: preview.geometry(id.get(), host), ) + +proc anchorPosition(view: View, attribute: LayoutAttribute, reference: View): float32 = + let frame = view.rectToView(view.bounds(), reference) + case attribute + of atLeft, atLeading: + frame.x + of atRight, atTrailing: + frame.x + frame.w + of atTop: + frame.y + of atBottom: + frame.y + frame.h + of atWidth: + frame.w + of atHeight: + frame.h + of atCenterX: + frame.x + frame.w / 2 + of atCenterY: + frame.y + frame.h / 2 + of atFirstBaseline: + view.pointToView(initPoint(0, view.firstBaselineOffset()), reference).y + of atLastBaseline: + view.pointToView( + initPoint(0, view.bounds().h - view.lastBaselineOffset()), reference + ).y + of atNotAnAttribute: + 0.0'f32 + +proc layoutDiagnostics*( + preview: ResourcePreview, reference: View +): ResourceDiagnostics = + ## Evaluates authored constraints after layout, including constraints the solver + ## could not satisfy because of conflicting constraints or control size limits. + if preview.isNil or not preview.hasRevision: + return + for index, resource in preview.xBundle.layoutConstraints: + let constraint = preview.findLayoutConstraint(resource.id) + if not constraint.isNil and constraint.active(): + let first = + constraint.firstItem().anchorPosition(constraint.firstAttribute(), reference) + let second = + if constraint.secondItem().isNil: + 0.0'f32 + else: + constraint.secondItem().anchorPosition( + constraint.secondAttribute(), reference + ) + let delta = first - (second * constraint.multiplier() + constraint.constant()) + let satisfied = + case constraint.relation() + of lrEqual: + abs(delta) <= 0.5'f32 + of lrLessThanOrEqual: + delta <= 0.5'f32 + of lrGreaterThanOrEqual: + delta >= -0.5'f32 + if not satisfied: + result.add( + rdsWarning, + "resource.layout.unsatisfied", + "constraint is not satisfied; check its anchors, priority, and the view's size limits", + path = "layoutConstraints[" & $index & "]", + resourceId = resource.id, + ) diff --git a/src/merenda/tekton/recordediting.nim b/src/merenda/tekton/recordediting.nim new file mode 100644 index 00000000..835b758f --- /dev/null +++ b/src/merenda/tekton/recordediting.nim @@ -0,0 +1,180 @@ +## Typed inspectors for layout, window, command, asset, and other flat resources. + +import std/[options, strutils, typetraits] + +import ../nimkit/foundation/types +import ../nimkit/themes +import ../nimkit/resources/[resrccore, resrcdocument, resrcregistry] +import ./valueediting + +type + ResourceRecordField* = object + descriptor*: ResourcePropertyDescriptor + value*: ResourceValue + + ResourceRecordFieldEdit* = object + parsed*: bool + message*: string + value*: ResourceValue + edit*: ResourceEditResult + +proc fieldValue[T](value: T): ResourceValue = + when T is ResourceId: + resourceValue($value) + elif T is enum: + resourceValue($value) + elif T is ResourceLayoutItemReference: + resourceValue( + resourceReference(if value.kind == rliGuide: rrLayoutGuide else: rrView, value.id) + ) + else: + resourceValue(value) + +proc addField[T](fields: var seq[ResourceRecordField], propertyName: string, value: T) = + let encoded = fieldValue(value) + var descriptor = ResourcePropertyDescriptor( + name: propertyName, + nimTypeName: name(T), + acceptedKinds: {encoded.kind}, + editable: true, + ) + when T is enum: + for choice in T: + descriptor.options.add fieldValue(choice) + fields.add ResourceRecordField(descriptor: descriptor, value: encoded) + +proc fieldsFor[T](record: T): seq[ResourceRecordField] = + for propertyName, value in fieldPairs(record): + if propertyName != "id": + when value is ResourceText: + result.addField(propertyName, value.fallback) + result.addField(propertyName & ".key", value.key) + elif value is + string | bool | int | float32 | enum | ResourceId | ResourceLayoutItemReference | + Rect | Size | EdgeInsets | Color: + result.addField(propertyName, value) + +proc recordFields*( + document: ResourceDocument, id: ResourceId +): seq[ResourceRecordField] = + let path = document.findNodePath(id) + if path.isNone: + return + case path.get().kind + of rnkLayoutGuide: + result = fieldsFor(document.layoutGuide(id)) + of rnkLayoutConstraint: + result = fieldsFor(document.layoutConstraint(id)) + of rnkWindow: + result = fieldsFor(document.window(id)) + of rnkCommand: + result = fieldsFor(document.command(id)) + of rnkImage: + result = fieldsFor(document.image(id)) + of rnkLocalization: + result = fieldsFor(document.localization(id)) + of rnkKeyBindings: + result = fieldsFor(document.keyBindings(id)) + of rnkTheme: + result = fieldsFor(document.theme(id)) + else: + discard + +proc parseField[T](text: string, field: var T): ResourceValueParseResult = + let current = fieldValue(field) + result = parseResourceValue(text, {current.kind}, current) + if not result.parsed: + return + let value = result.value + when T is string: + field = value.stringValue + elif T is bool: + field = value.boolValue + elif T is int: + field = value.intValue + elif T is float32: + field = value.floatValue + elif T is ResourceId: + field = resourceId(value.stringValue) + elif T is enum: + try: + field = parseEnum[T](value.stringValue) + except ValueError: + result.parsed = false + result.message = "choose a valid " & name(T) & " value" + elif T is ResourceLayoutItemReference: + if value.referenceValue.kind in {rrView, rrLayoutGuide}: + field = resourceLayoutItem( + value.referenceValue.id, + if value.referenceValue.kind == rrLayoutGuide: rliGuide else: rliView, + ) + else: + result.parsed = false + result.message = "use a view or layout guide reference" + elif T is Rect: + field = value.rectValue + elif T is Size: + field = value.sizeValue + elif T is EdgeInsets: + field = value.insetsValue + elif T is Color: + field = value.colorValue + +proc editField[T: ResourceRecord]( + document: ResourceDocument, record: T, propertyName, text: string, commit: bool +): ResourceRecordFieldEdit = + var candidate = record + var parsed: ResourceValueParseResult + for fieldName, value in fieldPairs(candidate): + if fieldName != "id": + when value is ResourceText: + if propertyName == fieldName: + parsed = parseField(text, value.fallback) + elif propertyName == fieldName & ".key": + parsed = parseField(text, value.key) + elif value is + string | bool | int | float32 | enum | ResourceId | ResourceLayoutItemReference | + Rect | Size | EdgeInsets | Color: + if propertyName == fieldName: + parsed = parseField(text, value) + result.parsed = parsed.parsed + result.value = parsed.value + result.message = parsed.message + if result.parsed: + if commit: + result.edit = + document.replaceResource(record.id, candidate, "Change " & propertyName) + elif result.message.len == 0: + result.message = "property is unavailable or cannot be edited" + +proc editRecordField*( + document: ResourceDocument, + id: ResourceId, + propertyName, text: string, + commit = true, +): ResourceRecordFieldEdit = + ## With commit=false, validates input without touching history or the draft. + let path = document.findNodePath(id) + if path.isNone: + result.message = "resource is unavailable" + return + case path.get().kind + of rnkLayoutGuide: + result = document.editField(document.layoutGuide(id), propertyName, text, commit) + of rnkLayoutConstraint: + result = + document.editField(document.layoutConstraint(id), propertyName, text, commit) + of rnkWindow: + result = document.editField(document.window(id), propertyName, text, commit) + of rnkCommand: + result = document.editField(document.command(id), propertyName, text, commit) + of rnkImage: + result = document.editField(document.image(id), propertyName, text, commit) + of rnkLocalization: + result = document.editField(document.localization(id), propertyName, text, commit) + of rnkKeyBindings: + result = document.editField(document.keyBindings(id), propertyName, text, commit) + of rnkTheme: + result = document.editField(document.theme(id), propertyName, text, commit) + else: + result.message = "resource does not have editable fields" diff --git a/src/merenda/tekton/tkapp.nim b/src/merenda/tekton/tkapp.nim index d7f0454b..63b693ce 100644 --- a/src/merenda/tekton/tkapp.nim +++ b/src/merenda/tekton/tkapp.nim @@ -1,7 +1,5 @@ ## Tekton, Merenda's resource-document builder application. -import std/os - import ../nimkit import ./editor @@ -75,11 +73,14 @@ proc tektonStarterBundle*(): ResourceBundle = proc newTektonDocument*(fileUrl = ""): ResourceEditorDocument = ## Creates a starter document or loads an existing canonical CBOR resource file. result = newResourceEditorDocument(tektonStarterBundle(), fileUrl = fileUrl) - if fileUrl.len > 0 and fileExists(fileUrl): + if fileUrl.len > 0: discard result.readFromFileUrl(fileUrl) elif fileUrl.len == 0: result.displayName = "Tekton — Untitled Resources" - discard result.resources().selectResource(TektonRootResourceId) + if not result.resources().selectResource(TektonRootResourceId): + for path in result.resources(): + discard result.resources().selectResource(path.id) + break proc runTekton*(fileUrl = "") = let @@ -87,4 +88,3 @@ proc runTekton*(fileUrl = "") = document = newTektonDocument(fileUrl) discard document.showWindows(app) app.run() - diff --git a/src/merenda/tekton/valueediting.nim b/src/merenda/tekton/valueediting.nim index 01e8db20..1e8de1cc 100644 --- a/src/merenda/tekton/valueediting.nim +++ b/src/merenda/tekton/valueediting.nim @@ -1,6 +1,6 @@ ## Text formatting and parsing for Tekton resource property editors. -import std/[sequtils, strutils] +import std/[math, sequtils, strutils] import ../nimkit/foundation/types import ../nimkit/resources/resrccore @@ -59,7 +59,10 @@ proc parseFloatParts(text: string, expected: Positive): seq[float32] = return try: for part in parts: - result.add parseFloat(part.strip()).float32 + let value = parseFloat(part.strip()).float32 + if classify(value) in {fcNan, fcInf, fcNegInf}: + return @[] + result.add value except ValueError: result.setLen(0) @@ -78,9 +81,9 @@ proc parseResourceValueAs( parsed: true, value: resourceValue(parseInt(text.strip())) ) of rvFloat: - result = ResourceValueParseResult( - parsed: true, value: resourceValue(parseFloat(text.strip()).float32) - ) + let value = parseFloat(text.strip()).float32 + if classify(value) notin {fcNan, fcInf, fcNegInf}: + result = ResourceValueParseResult(parsed: true, value: resourceValue(value)) of rvBool: case text.strip().toLowerAscii() of "true": @@ -141,7 +144,7 @@ proc parseResourceValueAs( ), ) except ValueError: - discard + result = ResourceValueParseResult() proc parseResourceValue*( text: string, acceptedKinds: set[ResourceValueKind], preferred = ResourceValue() diff --git a/tests/benchmark_tekton.nim b/tests/benchmark_tekton.nim new file mode 100644 index 00000000..0f4101ed --- /dev/null +++ b/tests/benchmark_tekton.nim @@ -0,0 +1,46 @@ +## Repeatable editor workloads; timings are diagnostic rather than CI thresholds. +## Run: nim r tests/benchmark_tekton.nim +import std/[monotimes, strformat, times] +import merenda/nimkit +import merenda/tekton + +proc measure(count: int) = + var bundle = initResourceBundle("bench.tekton") + var children: seq[ViewNodeResource] + for index in 0 ..< count: + children.add initViewNodeResource( + resourceId("button." & $index), + "button", + [ + resourceProperty("title", resourceValue("Button " & $index)), + resourceProperty("frame", resourceValue(rect(10, index.float32 * 35, 140, 30))), + ], + ) + bundle.views = + @[ + initViewNodeResource( + resourceId("root"), + properties = [resourceProperty("frame", resourceValue(rect(0, 0, 600, 600)))], + children = children, + ) + ] + let start = getMonoTime() + let document = newResourceEditorDocument(bundle) + let editor = newResourceEditor(document) + let opened = getMonoTime() + for index in 0 ..< 40: + doAssert editor.selectResource(resourceId("button." & $(index mod count))) + let selected = getMonoTime() + for index in 0 ..< 10: + doAssert editor.commitSelectedPropertyText("title", "Edited " & $index).edit.applied + let edited = getMonoTime() + for index in 0 ..< 40: + editor.synchronize() + let synchronized = getMonoTime() + echo &"{count} widgets: open {(opened-start).inMicroseconds.float/1000:.2f} ms; " & + &"select {(selected-opened).inMicroseconds.float/40000:.3f} ms/op; " & + &"edit {(edited-selected).inMicroseconds.float/10000:.3f} ms/op; " & + &"unchanged sync {(synchronized-edited).inMicroseconds.float/40000:.3f} ms/op" + +measure(100) +measure(300) diff --git a/tests/nimkit/resources.nim b/tests/nimkit/resources.nim index 545d38a3..17d7b07e 100644 --- a/tests/nimkit/resources.nim +++ b/tests/nimkit/resources.nim @@ -220,8 +220,8 @@ suite "NimKit resources": check kindNames == @[ "box", "button", "checkBox", "control", "imageView", "label", - "progressIndicator", "radioButton", "splitView", "stackView", "switchButton", - "textField", "view", + "progressIndicator", "radioButton", "slider", "splitView", "stackView", + "stepper", "switchButton", "textField", "view", ] check registry.viewKindDescriptor("button").baseKind == "control" check registry.findViewKindDescriptor("missing").isNone diff --git a/tests/tekton/editor.nim b/tests/tekton/editor.nim index 70912672..46a74f65 100644 --- a/tests/tekton/editor.nim +++ b/tests/tekton/editor.nim @@ -90,6 +90,7 @@ suite "Tekton resource editor": check editor.previewRevision() == 0 check editor.previewInstance().view(buttonId) == previewButton check editor.propertyRow("enabled").get().text == "not a bool" + check editor.propertyRow("enabled").get().propertyEditorKind() == rpekText check editor.diagnosticRows().len > 0 check editor.hasPreviewSelection() @@ -255,7 +256,12 @@ suite "Tekton resource editor": let row = editor.propertyRow(rowName) check row.isSome check expected in row.get().text - check not row.get().descriptor.editable + check row.get().descriptor.editable == ( + document.resources().nodePath(id).kind in { + rnkLayoutConstraint, rnkLayoutGuide, rnkWindow, rnkCommand, rnkImage, + rnkLocalization, + } + ) test "CBOR document save and revert share undo clean state": let path = diff --git a/tests/tekton/layout_authoring.nim b/tests/tekton/layout_authoring.nim new file mode 100644 index 00000000..fe26af3c --- /dev/null +++ b/tests/tekton/layout_authoring.nim @@ -0,0 +1,210 @@ +import std/[options, os, unittest] + +import merenda/nimkit +import merenda/tekton + +proc layoutBundle(): ResourceBundle = + result = initResourceBundle("tests.tekton.layout-authoring") + result.views = + @[ + initViewNodeResource( + resourceId("canvas"), + properties = [resourceProperty("frame", resourceValue(rect(20, 20, 400, 300)))], + children = [ + initViewNodeResource( + resourceId("child"), + "button", + [ + resourceProperty("frame", resourceValue(rect(20, 20, 180, 44))), + resourceProperty("title", resourceValue("Build")), + ], + ) + ], + ) + ] + +proc rowIndex(editor: ResourceEditor, name: string): int = + for index, row in editor.propertyRows(): + if row.descriptor.name == name: + return index + -1 + +suite "Tekton layout authoring": + test "typed record operations validate identity and support grouped undo": + let + document = newResourceDocument(layoutBundle()) + owner = resourceId("canvas") + first = initResourceLayoutGuide(resourceId("guide.1"), owner, insets(8.0)) + second = initResourceLayoutGuide(resourceId("guide.2"), owner, insets(16.0)) + manager = document.undoManager() + manager.beginUndoGrouping() + check document.insertResource(first).applied + check document.insertResource(second).applied + check manager.endUndoGrouping() + check document.draftIsValid() + check document.insertResource(first).error == reeIdentifierDuplicate + check document.insertResource(initResourceLayoutGuide(resourceId("canvas"), owner)).error == + reeIdentifierDuplicate + check document.insertResource(initResourceLayoutGuide(ResourceId(""), owner)).error == + reeIdentifierMissing + check document.insertResource( + initResourceLayoutGuide(resourceId("outside"), owner), some(9.Natural) + ).error == reeIndexOutOfBounds + check document.replaceResource(first.id, second).error == reeIdentifierMismatch + check document.moveResource(second.id, 2).error == reeIndexOutOfBounds + check manager.performUndo() + check document.bundle().layoutGuides.len == 0 + check manager.performRedo() + check document.bundle().layoutGuides.len == 2 + check document.moveResource(second.id, 0).applied + check document.bundle().layoutGuides[0].id == second.id + check manager.performUndo() + check document.bundle().layoutGuides[0].id == first.id + check document.removeResource(first.id).applied + check not document.contains(first.id) + check manager.performUndo() + check document.layoutGuide(first.id) == first + + test "guides and constraints can be created edited saved and reloaded": + let file = getTempDir() / ("tekton-layout-" & $getCurrentProcessId() & ".cbor") + defer: + if fileExists(file): + removeFile(file) + let + document = newResourceEditorDocument(layoutBundle(), fileUrl = file) + editor = newResourceEditor(document) + window = editor.newResourceEditorWindow() + childId = resourceId("child") + child = editor.previewInstance().view(childId) + check editor.selectResource(resourceId("canvas")) + let guideEdit = editor.insertResourceKind(rnkLayoutGuide) + check guideEdit.applied + check editor.hasPreviewSelection() + check editor.commitSelectedPropertyText("insets", "12, 14, 16, 18").edit.applied + check document.resources().layoutGuide(guideEdit.resourceId).insets == + insets(12, 14, 16, 18) + check editor.selectResource(childId) + let constraintEdit = editor.insertResourceKind(rnkLayoutConstraint) + check constraintEdit.applied + check editor.hasPreviewSelection() + check editor.commitSelectedPropertyText("constant", "220").edit.applied + check editor.commitSelectedPropertyText("priority", "750").edit.applied + check editor.commitSelectedPropertyText("active", "false").edit.applied + check not editor + .previewInstance() + .layoutConstraint(constraintEdit.resourceId) + .active() + check editor.previewInstance().view(childId) == child + check document.undoManagerFor().performUndo() + check editor.previewInstance().layoutConstraint(constraintEdit.resourceId).active() + check editor.commitSelectedPropertyText("priority", "2000").edit.applied + check not document.resources().draftIsValid() + let validRevision = editor.previewRevision() + check editor.commitSelectedPropertyText("priority", "900").edit.applied + check document.resources().draftIsValid() + check editor.previewRevision() > validRevision + discard window.buildRenders() + check editor.saveEditorDocument() + let loaded = newTektonDocument(file) + check loaded.resources().draftIsValid() + check loaded.resources().layoutGuide(guideEdit.resourceId).insets == + insets(12, 14, 16, 18) + check loaded.resources().layoutConstraint(constraintEdit.resourceId).constant == 220 + check loaded.resources().layoutConstraint(constraintEdit.resourceId).priority == 900 + check editor.removeSelectedView().applied + check not document.resources().contains(constraintEdit.resourceId) + check document.undoManagerFor().performUndo() + check document.resources().contains(constraintEdit.resourceId) + + test "invalid numeric text remains in the field editor and does not change the draft": + let + document = newResourceEditorDocument(layoutBundle()) + editor = newResourceEditor(document) + window = editor.newResourceEditorWindow() + check editor.selectResource(resourceId("child")) + let inserted = editor.insertResourceKind(rnkLayoutConstraint) + let original = document.resources().layoutConstraint(inserted.resourceId) + discard window.buildRenders() + let + table = editor.propertyInspector() + row = editor.rowIndex("constant") + column = table.columnWithIdentifier("value") + check table.beginEditingCell(row, column) + check not table.commitEditingCell("not a number") + check table.editingState().active + check table.editingValidationError().len > 0 + check document.resources().layoutConstraint(inserted.resourceId) == original + check table.commitEditingCell("240") + check document.resources().layoutConstraint(inserted.resourceId).constant == 240 + + test "pinning a child creates four constraints as one undoable action": + var bundle = layoutBundle() + bundle.views[0].children[0].kind = "view" + bundle.views[0].children[0].properties.setLen(1) + let + document = newResourceEditorDocument(bundle) + editor = newResourceEditor(document) + window = editor.newResourceEditorWindow() + childId = resourceId("child") + child = editor.previewInstance().view(childId) + check editor.selectResource(childId) + check editor.pinSelectedView().applied + check document.resources().draftIsValid() + check document.resources().bundle().layoutConstraints.len == 4 + check document.undoManagerFor().undoActionName() == "Pin to Parent" + discard window.buildRenders() + check child.frame() == rect(16, 16, 368, 268) + check not editor.nudgeSelectedView(initPoint(10, 0)).applied + check not editor.resizeSelectedView(initSize(10, 0)).applied + check document.undoManagerFor().performUndo() + check document.resources().bundle().layoutConstraints.len == 0 + check document + .resources() + .findViewProperty(childId, "translatesAutoresizingMaskIntoConstraints").isNone + check editor.previewInstance().view(childId) == child + check document.undoManagerFor().performRedo() + check document.resources().bundle().layoutConstraints.len == 4 + check editor.removeSelectedView().applied + check document.resources().draftIsValid() + check document.resources().bundle().layoutConstraints.len == 0 + check document.undoManagerFor().performUndo() + check document.resources().contains(childId) + check document.resources().bundle().layoutConstraints.len == 4 + + test "window and command inspectors round trip editable fields": + let + document = newResourceEditorDocument(layoutBundle()) + editor = newResourceEditor(document) + check editor.selectResource(resourceId("canvas")) + let window = editor.insertResourceKind(rnkWindow) + check window.applied + check editor.commitSelectedPropertyText("title", "My app").edit.applied + check editor.commitSelectedPropertyText("frame", "60, 80, 720, 480").edit.applied + check document.resources().window(window.resourceId).title.fallback == "My app" + let command = editor.insertResourceKind(rnkCommand) + check command.applied + check editor.commitSelectedPropertyText("selector", "saveDocument").edit.applied + check document.resources().command(command.resourceId).selector == "saveDocument" + check document.resources().draftIsValid() + + test "conflicting authored constraints appear in diagnostics after layout": + var bundle = layoutBundle() + for index, width in [100.0'f32, 200.0'f32]: + bundle.layoutConstraints.add initResourceLayoutConstraint( + resourceId("width." & $index), + resourceId("canvas"), + resourceLayoutItem(resourceId("child")), + rlaWidth, + constant = width, + ) + let + document = newResourceEditorDocument(bundle) + editor = newResourceEditor(document) + window = editor.newResourceEditorWindow() + discard window.buildRenders() + var reported = false + for diagnostic in editor.diagnosticRows(): + if diagnostic.code == "resource.layout.unsatisfied": + reported = true + check diagnostic.resourceId in [resourceId("width.0"), resourceId("width.1")] + check reported diff --git a/tests/tekton/regressions.nim b/tests/tekton/regressions.nim new file mode 100644 index 00000000..8ef6bede --- /dev/null +++ b/tests/tekton/regressions.nim @@ -0,0 +1,261 @@ +import std/[options, os, unittest] + +import merenda/nimkit +import merenda/tekton + +proc regressionBundle(title = "Original"): ResourceBundle = + result = initResourceBundle("tests.tekton.regressions") + result.views = + @[ + initViewNodeResource( + resourceId("canvas"), + properties = [resourceProperty("frame", resourceValue(rect(0, 0, 400, 300)))], + children = [ + initViewNodeResource( + resourceId("button"), + "checkBox", + [ + resourceProperty("frame", resourceValue(rect(20, 20, 180, 44))), + resourceProperty("title", resourceValue(title)), + ], + ) + ], + ) + ] + +proc rowIndex(editor: ResourceEditor, name: string): int = + for index, row in editor.propertyRows(): + if row.descriptor.name == name: + return index + -1 + +suite "Tekton responsiveness and editing regressions": + test "property edits construct only the changed view and preserve layout identities": + var + constructed = 0 + registry = initNimKitResourceRegistry() + bundle = initResourceBundle("tests.tekton.large-preview") + registry.registerViewKind( + "button", + proc(frame: Rect): View = + inc constructed + newButton(frame = frame), + baseKind = "control", + ) + for index in 0 ..< 100: + bundle.views.add initViewNodeResource( + resourceId("button." & $index), + "button", + [resourceProperty("title", resourceValue("Button " & $index))], + ) + bundle.layoutConstraints.add initResourceLayoutConstraint( + resourceId("width"), + resourceId("button.0"), + resourceLayoutItem(resourceId("button.0")), + rlaWidth, + constant = 100, + ) + let + preview = newResourcePreview(registry) + host = newView() + check preview.update(bundle, 0, host).applied + let + original = preview.view(resourceId("button.0")) + constraint = preview.layoutConstraint(resourceId("width")) + constructed = 0 + bundle.views[0].properties[0].value = resourceValue("Edited") + check preview.update(bundle, 1, host).applied + check constructed == 1 + check preview.view(resourceId("button.0")) == original + check preview.layoutConstraint(resourceId("width")) == constraint + check constraint.active() + check Button(original).title() == "Edited" + check original.constraints().len == 1 + constructed = 0 + bundle.layoutConstraints[0].constant = 120 + check preview.update(bundle, 2, host).applied + check constructed == 0 + check preview.view(resourceId("button.0")) == original + check original.constraints().len == 1 + check preview.layoutConstraint(resourceId("width")).constant() == 120 + + test "loading another revision zero document replaces the preview": + let path = getTempDir() / ("tekton-reload-" & $getCurrentProcessId() & ".cbor") + defer: + if fileExists(path): + removeFile(path) + let + document = newResourceEditorDocument(regressionBundle()) + editor = newResourceEditor(document) + writeFile(path, regressionBundle("Reloaded").encodeResourceBundle()) + check document.readFromFileUrl(path) + check document.resources().revision() == 0 + check Button(editor.previewInstance().view(resourceId("button"))).title() == + "Reloaded" + + test "inspector shows runtime defaults without authoring them": + let + document = newResourceEditorDocument(regressionBundle()) + editor = newResourceEditor(document) + check editor.selectResource(resourceId("button")) + let index = editor.rowIndex("enabled") + check index >= 0 + check editor.propertyRows()[index].text == "true" + check editor.propertyRows()[index].value.get().boolValue + check document.resources().findViewProperty(resourceId("button"), "enabled").isNone + check not document.isDocumentEdited() + + test "hover and repeated synchronization preserve an active inspector edit": + let + document = newResourceEditorDocument(regressionBundle()) + editor = newResourceEditor(document) + window = editor.newResourceEditorWindow() + discard window.buildRenders() + check editor.selectResource(resourceId("button")) + let + table = editor.propertyInspector() + column = table.columnWithIdentifier("value") + row = editor.rowIndex("title") + check table.beginEditingCell(row, column) + editor.clearPreviewHover() + editor.synchronize() + check table.editingState().active + check table.commitEditingCell("Still editing") + check Button(editor.previewInstance().view(resourceId("button"))).title() == + "Still editing" + + test "unchanged synchronization and selection do not rebuild inspector rows": + var reads = 0 + var registry = initNimKitResourceRegistry() + registry.registerViewProperty( + "button", + "probe", + {rvBool}, + setter = proc( + view: View, value: ResourceValue, context: ResourcePropertyContext + ): bool = + true, + getter = proc( + view: View, context: ResourcePropertyContext + ): ResourcePropertyReadResult = + inc reads + ResourcePropertyReadResult(read: true, value: resourceValue(true)), + ) + let document = newResourceEditorDocument(regressionBundle(), registry = registry) + let editor = newResourceEditor(document) + check editor.selectResource(resourceId("button")) + check reads > 0 + reads = 0 + editor.clearPreviewHover() + editor.synchronize() + check editor.selectResource(resourceId("button")) + check reads == 0 + + test "design selection does not toggle preview controls": + let + document = newResourceEditorDocument(regressionBundle()) + editor = newResourceEditor(document) + window = editor.newResourceEditorWindow() + button = Button(editor.previewInstance().view(resourceId("button"))) + discard window.buildRenders() + let point = button.pointToWindow(initPoint(40, 20)) + check button.state() == bsOff + check window.mouseDownAt(point) + check window.mouseUpAt(point) + check document.resources().selectedResourceIds() == @[resourceId("button")] + check button.state() == bsOff + + test "nonfinite numbers stay invalid editable input": + for text in ["nan", "inf", "-inf", "1e100"]: + check not parseResourceValue(text, {rvFloat}).parsed + check not parseResourceValue("0, 0, " & text & ", 30", {rvRect}).parsed + + test "decimal input falls back from integer to float without accepting a partial parse": + let parsed = parseResourceValue("0.75", {rvInt, rvFloat}) + check parsed.parsed + check parsed.value == resourceValue(0.75'f32) + check not parseResourceValue("invalid", {rvInt}).parsed + + test "interaction mode enables control behavior explicitly": + let + document = newResourceEditorDocument(regressionBundle()) + editor = newResourceEditor(document) + window = editor.newResourceEditorWindow() + button = Button(editor.previewInstance().view(resourceId("button"))) + discard window.buildRenders() + editor.interactivePreview = true + let point = button.pointToWindow(initPoint(40, 20)) + check window.mouseDownAt(point) + check window.mouseUpAt(point) + check button.state() == bsOn + check not document.isDocumentEdited() + + test "sliders and steppers are editable through the registry and palette": + let + document = newResourceEditorDocument(regressionBundle()) + editor = newResourceEditor(document) + check editor.selectResource(resourceId("canvas")) + let slider = editor.insertViewKind("slider") + check slider.applied + check editor.commitSelectedPropertyText("value", "0.75").edit.applied + require document.resources().draftIsValid() + check Slider(editor.previewInstance().view(slider.resourceId)).value() == 0.75 + let stepper = editor.insertViewKind("stepper") + check stepper.applied + check editor.commitSelectedPropertyText("increment", "5").edit.applied + check editor.commitSelectedPropertyText("value", "25").edit.applied + require document.resources().draftIsValid() + check Stepper(editor.previewInstance().view(stepper.resourceId)).value() == 25 + check Stepper(editor.previewInstance().view(stepper.resourceId)).increment() == 5 + + test "failed setters that mutate first are rolled back": + var + registry = initNimKitResourceRegistry() + live: View + bundle = regressionBundle() + registry.registerViewProperty( + "checkBox", + "probe", + {rvString}, + setter = proc( + view: View, value: ResourceValue, context: ResourcePropertyContext + ): bool = + view.identifier = value.stringValue + if view == live and value.stringValue == "Fail after mutation": + raise newException(ValueError, "intentional failure after setting identifier") + true, + getter = proc( + view: View, context: ResourcePropertyContext + ): ResourcePropertyReadResult = + ResourcePropertyReadResult(read: true, value: resourceValue(view.identifier())), + ) + bundle.views[0].children[0].properties.add resourceProperty( + "probe", resourceValue("Original") + ) + let + preview = newResourcePreview(registry) + host = newView() + check preview.update(bundle, 0, host).applied + live = preview.view(resourceId("button")) + bundle.views[0].children[0].properties[^1].value = + resourceValue("Fail after mutation") + let update = preview.update(bundle, 1, host) + check not update.applied + check preview.revision() == 0 + check live.identifier() == "Original" + + test "property removal restores defaults without resetting unrelated live state": + var bundle = regressionBundle() + bundle.views[0].children[0].properties.add resourceProperty( + "enabled", resourceValue(false) + ) + let + preview = newResourcePreview() + host = newView() + check preview.update(bundle, 0, host).applied + let button = Button(preview.view(resourceId("button"))) + button.state = bsOn + bundle.views[0].children[0].properties.setLen(2) + check preview.update(bundle, 1, host).applied + check button.enabled() + check button.state() == bsOn diff --git a/tests/ttekton.nim b/tests/ttekton.nim index 5b373a74..68069e4b 100644 --- a/tests/ttekton.nim +++ b/tests/ttekton.nim @@ -1,4 +1,5 @@ ## Tekton's shared test runner and complete public API smoke test. import merenda/tekton -import tekton/[application, editor, preview, user_workflows] +import + tekton/[application, editor, layout_authoring, preview, regressions, user_workflows]