From 4afcbd330397e61adae8bfb6abd2980baccee8cb Mon Sep 17 00:00:00 2001 From: Jaremy Creechley Date: Fri, 25 Sep 2026 03:58:25 +0300 Subject: [PATCH 1/2] Improve Tekton responsiveness and resource authoring --- PLAN.md | 37 +- README.md | 15 + docs/resources.md | 60 +- docs/tekton-editor.png | Bin 0 -> 122685 bytes docs/tekton-review.md | 86 ++ .../nimkit/resources/resrcconstruction.nim | 22 +- .../nimkit/resources/resrcdocument.nim | 195 +++++ .../nimkit/resources/resrcregistry.nim | 89 +- src/merenda/tekton/editor.nim | 763 ++++++++++++++---- src/merenda/tekton/preview.nim | 295 ++++++- src/merenda/tekton/recordediting.nim | 180 +++++ src/merenda/tekton/tkapp.nim | 10 +- src/merenda/tekton/valueediting.nim | 15 +- tests/benchmark_tekton.nim | 46 ++ tests/nimkit/resources.nim | 4 +- tests/tekton/editor.nim | 8 +- tests/tekton/layout_authoring.nim | 210 +++++ tests/tekton/regressions.nim | 261 ++++++ tests/ttekton.nim | 3 +- 19 files changed, 2044 insertions(+), 255 deletions(-) create mode 100644 docs/tekton-editor.png create mode 100644 docs/tekton-review.md create mode 100644 src/merenda/tekton/recordediting.nim create mode 100644 tests/benchmark_tekton.nim create mode 100644 tests/tekton/layout_authoring.nim create mode 100644 tests/tekton/regressions.nim 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 0000000000000000000000000000000000000000..f5243bb8add52d0d88f8ebd4baf0c257d418ae2e GIT binary patch literal 122685 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 Ns4vL70L7!_o}&j=edxoP zB9W1K2Ty-aYdgJvf8G;hs{E!ckAf&@nD$31MQr7NNxzb1mVL~!+6#s5FQ1;~a z9o~HXsxDJj=qM%TbSFeXntCmw`w+Sh@cfetyv$>_{M#a;-N!Dw5B2es;{uMF90GXy zFbQ6mPYhu>hu_(J=GD}&#J-oiXvsFaNln*)=^^8-U7DvDR(KTCbOJCV*w|J#@Cb`h= z@lI*QhDZ1sOT`}D_QL_)BL$ChJy$VWAj5xjGk_cj4qkl@_blp<7o9yAeIdGP$CdH( zJ7QDdA#Op0xxf~BQwMp^&|Xy}njZDW@rYTB)Eym$L7`0yU2##EqD@xfVbdozQr?TjIXxoPZ0LXZ4T5;N^qQ~l` zYz4HDZsr|f)8j#Or&{D=>*)A$Ax*-z{~^RnWb~Y?>Oy!z{$5IV!%

osH#AmL*An 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 literal 0 HcmV?d00001 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/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] From 238f254ffb7f55b949a57e19e269c5901b9a93cb Mon Sep 17 00:00:00 2001 From: Jaremy Creechley Date: Fri, 25 Sep 2026 16:26:44 +0300 Subject: [PATCH 2/2] Require released FigDraw 0.43.0 for Atlas builds --- merenda.nimble | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) 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"