From 860eccd918217d2e2c13315585e60cf2c3e870b3 Mon Sep 17 00:00:00 2001 From: Fernando Date: Sun, 17 May 2026 16:46:20 -0400 Subject: [PATCH 1/2] =?UTF-8?q?feat(morph):=20A3b=20=E2=80=94=20Inspector?= =?UTF-8?q?=20add=20/=20rename=20/=20delete=20for=20morph=20targets?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to #578. Wires the C++ authoring API from A3 into the existing Morph Targets subgroup in qml/PropertiesPanel.qml: - **"+ Add…" button** next to "Reset all" — opens a built-in Qt Quick Controls Popup with a name field, calls `addMorphTargetFromCurrentEdit`. The Popup deliberately avoids custom dialog components: A5's QML changes hit cross-engine initialization issues we never fully isolated, so this slice sticks to primitives with no singleton dependencies beyond the already-imported MorphAnimationManager. - **Inline rename** — double-click a target name to edit in place, matching the per-animation rename UX a few sections above. Empty / whitespace-only / unchanged names are filtered client-side before the call. Server-side collision rejection still applies. - **Per-row "×" delete** — pushes DeleteMorphTargetCommand through UndoManager so Ctrl+Z restores the pose. The existing `Connections` block on the manager already refreshes the target list and weight readouts on `morphTargetsChanged` / `morphWeightChanged`, so add / rename / delete all flow back into the UI for free. After this slice the morph epic (#518) is end-to-end: import → list → set weight → save current edit as new target → rename → delete → export — all with undo. Closes the authoring acceptance criterion in the issue. Co-Authored-By: Claude Opus 4.7 (1M context) --- qml/PropertiesPanel.qml | 162 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 160 insertions(+), 2 deletions(-) diff --git a/qml/PropertiesPanel.qml b/qml/PropertiesPanel.qml index 9a8c594c..3023504b 100644 --- a/qml/PropertiesPanel.qml +++ b/qml/PropertiesPanel.qml @@ -4363,7 +4363,37 @@ Rectangle { font.bold: true anchors.verticalCenter: parent.verticalCenter } - Item { width: parent.width - 260; height: 1 } + Item { width: parent.width - 320; height: 1 } + // Add from current edit — captures the user's current + // edit-mode geometry minus the bind-pose baseline as + // a new morph target. Greyed out + tooltip when + // outside edit mode (since EditableSubMesh:: + // originalPositions is only populated then). + Rectangle { + id: addBtn + width: 56; height: 20; radius: 3 + color: addMa.containsMouse + ? Qt.lighter(PropertiesPanelController.headerColor, 1.3) + : PropertiesPanelController.controlBgColor + border.color: PropertiesPanelController.borderColor + anchors.verticalCenter: parent.verticalCenter + Text { + anchors.centerIn: parent + text: "+ Add…" + color: PropertiesPanelController.textColor + font.pixelSize: 9 + } + MouseArea { + id: addMa + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + addNameField.text = "" + addNamePopup.open() + } + } + } // Reset all: walks every target and sets weight to 0. Rectangle { width: 60; height: 20; radius: 3 @@ -4391,6 +4421,72 @@ Rectangle { } } + // Inline name-entry popup for "Add from edit…". Kept + // simple (no styled component) so a misbehaving custom + // dialog can't break the rest of the panel — Popup is + // a built-in Qt Quick Controls primitive with no + // singleton dependencies. + Popup { + id: addNamePopup + modal: true + focus: true + width: 240 + contentItem: Column { + spacing: 6 + Text { + text: "New morph target name:" + color: PropertiesPanelController.textColor + font.pixelSize: 11 + } + TextField { + id: addNameField + width: 220 + font.pixelSize: 11 + onAccepted: addConfirmMa.confirm() + Component.onCompleted: forceActiveFocus() + } + Row { + spacing: 6 + Rectangle { + width: 60; height: 20; radius: 3 + color: addConfirmMa.containsMouse + ? Qt.lighter(PropertiesPanelController.headerColor, 1.3) + : PropertiesPanelController.controlBgColor + border.color: PropertiesPanelController.borderColor + Text { anchors.centerIn: parent; text: "Save"; color: PropertiesPanelController.textColor; font.pixelSize: 10 } + MouseArea { + id: addConfirmMa + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + function confirm() { + var n = addNameField.text.trim() + if (n.length === 0) return + MorphAnimationManager.addMorphTargetFromCurrentEdit(n) + addNamePopup.close() + } + onClicked: confirm() + } + } + Rectangle { + width: 60; height: 20; radius: 3 + color: addCancelMa.containsMouse + ? Qt.lighter(PropertiesPanelController.headerColor, 1.3) + : PropertiesPanelController.controlBgColor + border.color: PropertiesPanelController.borderColor + Text { anchors.centerIn: parent; text: "Cancel"; color: PropertiesPanelController.textColor; font.pixelSize: 10 } + MouseArea { + id: addCancelMa + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: addNamePopup.close() + } + } + } + } + } + // Filter / search — characters often have 50+ blend // shapes, scanning a flat list is hopeless without // a typeahead box. @@ -4413,18 +4509,56 @@ Rectangle { || modelData.toLowerCase().indexOf(morphCol.filter.toLowerCase()) >= 0 height: visible ? 22 : 0 + // Name — double-click to rename in place, + // matching the per-animation rename UX above. Text { + id: morphNameText + visible: !morphNameEdit.visible text: modelData color: PropertiesPanelController.textColor font.pixelSize: 10 width: 120 elide: Text.ElideRight anchors.verticalCenter: parent.verticalCenter + MouseArea { + anchors.fill: parent + onDoubleClicked: { + morphNameEdit.text = modelData + morphNameEdit.visible = true + morphNameEdit.forceActiveFocus() + morphNameEdit.selectAll() + } + } + } + TextInput { + id: morphNameEdit + visible: false + width: 120 + color: PropertiesPanelController.textColor + font.pixelSize: 10 + anchors.verticalCenter: parent.verticalCenter + selectByMouse: true + Rectangle { + anchors.fill: parent + anchors.margins: -2 + z: -1 + color: PropertiesPanelController.inputColor + border.color: PropertiesPanelController.highlightColor + border.width: 1 + radius: 2 + } + onEditingFinished: { + var trimmed = text.trim() + if (trimmed.length > 0 && trimmed !== modelData) + MorphAnimationManager.renameMorphTarget(modelData, trimmed) + visible = false + } + Keys.onEscapePressed: visible = false } Slider { id: weightSlider from: 0; to: 1; stepSize: 0.01 - width: parent.width - 200 + width: parent.width - 222 // Bind to `weightTick` so changes that // bypass user drag (Reset all, MCP, future // dope-sheet scrubs) refresh the readout. @@ -4440,6 +4574,30 @@ Rectangle { width: 36 anchors.verticalCenter: parent.verticalCenter } + // Delete (×) — drops the pose + animation + // through DeleteMorphTargetCommand so Ctrl+Z + // restores it. + Rectangle { + width: 18; height: 18; radius: 3 + anchors.verticalCenter: parent.verticalCenter + color: morphDelMa.containsMouse + ? Qt.lighter(PropertiesPanelController.headerColor, 1.3) + : "transparent" + Text { + anchors.centerIn: parent + text: "×" + color: PropertiesPanelController.textColor + font.pixelSize: 12 + font.bold: true + } + MouseArea { + id: morphDelMa + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: MorphAnimationManager.deleteMorphTarget(modelData) + } + } } } } From 7b4405a6120da3a6e7e59aa65fd52b1c71aa9a9b Mon Sep 17 00:00:00 2001 From: Fernando Date: Sun, 17 May 2026 17:09:03 -0400 Subject: [PATCH 2/2] =?UTF-8?q?review(morph):=20A3b=20=E2=80=94=20Inspecto?= =?UTF-8?q?r=20UX=20fixes=20from=20PR=20#579=20reviews?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses three findings from Codex + CodeRabbit on PR #579: ## Codex P2 — keep Add popup open on failure `MorphAnimationManager.addMorphTargetFromCurrentEdit` returns false for several legitimate failure cases (duplicate name, not in edit mode, no vertex moved vs bind baseline). The popup was closing unconditionally, so users lost their typed name with no feedback about why nothing was created. Fix: branch on the return value. On true → close. On false → leave the popup open and surface an inline red error message ("Couldn't save: name already in use, or no vertex was edited."). Empty-name and edit-mode-required failures get their own explicit messages. ## Codex P2 — Escape must not commit rename Pressing Escape on the inline morph-rename input previously fired `Keys.onEscapePressed: visible = false`, but the hide path causes focus loss which then re-fires `onEditingFinished` — committing the rename the user tried to cancel. Fix: add a `cancelled` boolean property set by Escape and checked at the top of `onEditingFinished`; the cancel path resets the flag and returns without calling renameMorphTarget. ## CodeRabbit minor — visibly disable Add outside edit mode The "+ Add…" button stayed enabled even when not in edit mode, where `addMorphTargetFromCurrentEdit` always returns false. Fix: bind a `canAddFromEdit: EditModeController.editModeActive` property on the button; control opacity, hover highlight, MouseArea enabled state, cursor shape (PointingHand vs Forbidden), and add a tooltip that says "Enter Edit Mode (Tab) to add morph targets from current edit." The Save handler in the popup also re-checks `editModeActive` defensively — protects against the edge case where the user opens the popup mid-edit, exits edit mode, then clicks Save. Co-Authored-By: Claude Opus 4.7 (1M context) --- qml/PropertiesPanel.qml | 61 +++++++++++++++++++++++++++++++++++------ 1 file changed, 52 insertions(+), 9 deletions(-) diff --git a/qml/PropertiesPanel.qml b/qml/PropertiesPanel.qml index 3023504b..9ba04ccc 100644 --- a/qml/PropertiesPanel.qml +++ b/qml/PropertiesPanel.qml @@ -4366,13 +4366,17 @@ Rectangle { Item { width: parent.width - 320; height: 1 } // Add from current edit — captures the user's current // edit-mode geometry minus the bind-pose baseline as - // a new morph target. Greyed out + tooltip when - // outside edit mode (since EditableSubMesh:: - // originalPositions is only populated then). + // a new morph target. Disabled (greyed out, forbidden + // cursor) when outside edit mode because + // EditableSubMesh::originalPositions is only + // populated by EditModeController and the C++ method + // would return false anyway. Rectangle { id: addBtn + property bool canAddFromEdit: EditModeController.editModeActive width: 56; height: 20; radius: 3 - color: addMa.containsMouse + opacity: canAddFromEdit ? 1.0 : 0.45 + color: addMa.containsMouse && canAddFromEdit ? Qt.lighter(PropertiesPanelController.headerColor, 1.3) : PropertiesPanelController.controlBgColor border.color: PropertiesPanelController.borderColor @@ -4387,11 +4391,15 @@ Rectangle { id: addMa anchors.fill: parent hoverEnabled: true - cursorShape: Qt.PointingHandCursor + enabled: addBtn.canAddFromEdit + cursorShape: enabled ? Qt.PointingHandCursor : Qt.ForbiddenCursor onClicked: { addNameField.text = "" + addError.text = "" addNamePopup.open() } + ToolTip.visible: containsMouse && !enabled + ToolTip.text: "Enter Edit Mode (Tab) to add morph targets from current edit." } } // Reset all: walks every target and sets weight to 0. @@ -4443,8 +4451,23 @@ Rectangle { width: 220 font.pixelSize: 11 onAccepted: addConfirmMa.confirm() + onTextChanged: addError.text = "" Component.onCompleted: forceActiveFocus() } + // Inline error: shown when the C++ side rejects + // the request (duplicate name, no vertex moved, + // not in edit mode, …). We deliberately keep the + // popup open so the user can fix the input + // without retyping. + Text { + id: addError + text: "" + visible: text.length > 0 + color: "#d65d5d" + font.pixelSize: 10 + width: 220 + wrapMode: Text.Wrap + } Row { spacing: 6 Rectangle { @@ -4461,9 +4484,22 @@ Rectangle { cursorShape: Qt.PointingHandCursor function confirm() { var n = addNameField.text.trim() - if (n.length === 0) return - MorphAnimationManager.addMorphTargetFromCurrentEdit(n) - addNamePopup.close() + if (n.length === 0) { + addError.text = "Name cannot be empty." + return + } + if (!EditModeController.editModeActive) { + addError.text = "Enter Edit Mode (Tab) before saving." + return + } + var ok = MorphAnimationManager.addMorphTargetFromCurrentEdit(n) + if (ok) { + addNamePopup.close() + } else { + // C++ rejected — likely name collision or + // no vertex moved vs the bind baseline. + addError.text = "Couldn't save: name already in use, or no vertex was edited." + } } onClicked: confirm() } @@ -4538,6 +4574,12 @@ Rectangle { font.pixelSize: 10 anchors.verticalCenter: parent.verticalCenter selectByMouse: true + // Set by `Keys.onEscapePressed`; checked in + // `onEditingFinished` so that hiding the + // input on Escape (which causes focus loss + // and fires `editingFinished`) doesn't + // accidentally commit the rename. + property bool cancelled: false Rectangle { anchors.fill: parent anchors.margins: -2 @@ -4548,12 +4590,13 @@ Rectangle { radius: 2 } onEditingFinished: { + if (cancelled) { cancelled = false; visible = false; return } var trimmed = text.trim() if (trimmed.length > 0 && trimmed !== modelData) MorphAnimationManager.renameMorphTarget(modelData, trimmed) visible = false } - Keys.onEscapePressed: visible = false + Keys.onEscapePressed: { cancelled = true; visible = false } } Slider { id: weightSlider