From 62a94d5124a59555c5702b720cba6dda945a12f5 Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Sun, 19 Jul 2026 23:01:35 -0400 Subject: [PATCH 1/3] Add "Copy as SysML" context menu to every diagram tab Every rendered diagram tab (General, Interconnection, State Transition, Action Flow, Sequence, Grid predefined views, and custom-view-builder preview tabs) now exposes a single shared right-click context menu item, "Copy as SysML", that copies the whole diagram's SysML `view { ... }` definition (view kind, expose targets, and filter expression) to the OS clipboard. All six diagram kinds and the custom preview funnel through the one shared DiagramDocumentView/DiagramDocumentViewModel pair, so the context menu, its enable/disable logic, and the click handler are added in exactly one place rather than duplicated per diagram kind. Predefined-view tabs previously had no concrete, reusable view definition to export - only display metadata (ViewDescriptor). ViewCatalogPresenter gains BuildViewDefinition(workspace, viewId), which reconstructs a real ViewDefinitionModel (kind, every expose member with its own recursion kind and optional bracket filter, filter expression, display name) from the loaded workspace's SysmlViewNode, using ResolvedExposeMembers rather than the raw ExposeMembers so qualified names are fully resolved against the workspace rather than left as raw as-written text. It returns null for an unknown view id, an unsupported render-target kind, or a view with zero expose members (a valid but unscoped "expose everything" view with no finite expose list to serialize). ViewDefinitionModel gains a matching AddExposeTarget(ExposeTargetSelection) overload so an arbitrary real expose member can be reconstructed in one call, deduplicated by the exact (qualified name, recursion kind) pair, rather than needing the multi-call add-then-retarget-then-filter sequence the existing string overload would otherwise require. WorkbenchTab now carries an optional SourceDefinition (the concrete ViewDefinitionModel a tab was rendered from, populated by SelectPredefinedView via BuildViewDefinition or carried through from PreviewCustomView). MainWindowShell.CanExportTabAsSysml/ExportTabAsSysmlSnippet expose whether a tab is exportable and generate its snippet text via the existing SysmlSnippetGenerator - the same generator the custom view builder's own export button already used - so there remains exactly one snippet-generation code path. The no-derivable-definition edge case (unknown tab, or a predefined view with no expose members) is handled gracefully: the context menu item is disabled and the reason is logged at Info level via the existing RollingFileLogger, never thrown. The actual clipboard write goes through a new IClipboardService seam (AvaloniaClipboardService wraps TopLevel.GetTopLevel(anchor)?.Clipboard, resolved lazily per call rather than cached at construction), mirroring the existing IUiDispatcher seam pattern, so DiagramDocumentViewModel.CopyAsSysmlAsync can be unit tested with a fake clipboard instead of a live UI. DiagramDocumentView's code-behind constructs the real clipboard service once it has a view model and assigns it to the view model's ClipboardService property. Companion updates: reqstream requirements and test-name traceability for MainWindowShell, ViewCatalogPresenter, ViewDefinitionModel, and the OTS Avalonia unit; design docs for the same units plus the "Diagram Document Tabs" section of docs/design/ots/dock.md, which documents the new context menu, its enable/disable behavior, and the SourceDefinition data flow; and matching verification-doc scenario narratives for every new test. New tests: ViewDefinitionModelTests (AddExposeTarget selection overload, add and null-argument cases), ViewCatalogPresenterTests (BuildViewDefinition kind/ expose/filter resolution, unknown view id, zero-expose-members cases), MainWindowShellTests (CanExportTabAsSysml/ExportTabAsSysmlSnippet across predefined, custom-preview, unknown, and unscoped-view cases), DiagramDocumentViewModelTests (CopyAsSysmlAsync with a fake clipboard service across exportable, non-exportable, and no-clipboard-assigned cases), and one new OTS headless Avalonia end-to-end test, DiagramContextMenu_CopyAsSysml_CopiesSnippetToClipboard, which drives the real context menu and menu item through the actual visual tree and reads the result back from the headless platform's real TopLevel.Clipboard. Full dotnet build (0 warnings/errors), dotnet test (153 + 19 passing), dotnet sysml2tools lint, dotnet reqstream --lint, and a local reproduction of CI's dotnet reqstream --enforce (clean aside from the three pre-existing, unrelated per-OS platform requirements that are only satisfiable on their respective CI runner) all pass. fix.ps1 and lint.ps1 both run clean. --- docs/design/ots/avalonia.md | 8 + docs/design/ots/dock.md | 45 +++++ .../app-shell-subsystem/main-window-shell.md | 26 +++ .../view-definition-model.md | 18 ++ .../view-catalog-presenter.md | 24 +++ docs/reqstream/ots/avalonia.yaml | 7 + .../main-window-shell.yaml | 11 ++ .../view-definition-model.yaml | 8 + .../view-catalog-presenter.yaml | 9 + docs/verification/ots/avalonia.md | 2 + .../app-shell-subsystem/main-window-shell.md | 20 +++ .../view-definition-model.md | 12 ++ .../view-catalog-presenter.md | 11 ++ .../AvaloniaClipboardService.cs | 53 ++++++ .../DiagramDocumentView.axaml | 10 ++ .../DiagramDocumentView.axaml.cs | 12 ++ .../DiagramDocumentViewModel.cs | 32 ++++ .../AppShellSubsystem/IClipboardService.cs | 19 +++ .../AppShellSubsystem/MainWindowShell.cs | 66 +++++++- .../ViewDefinitionModel.cs | 32 ++++ .../ViewCatalogPresenter.cs | 64 +++++++ .../DiagramDocumentViewModelTests.cs | 157 ++++++++++++++++++ .../AppShellSubsystem/MainWindowShellTests.cs | 125 ++++++++++++++ .../ViewDefinitionModelTests.cs | 45 +++++ .../ViewCatalogPresenterTests.cs | 104 ++++++++++++ test/OtsSoftwareTests/AvaloniaTests.cs | 58 +++++++ 26 files changed, 974 insertions(+), 4 deletions(-) create mode 100644 src/DemaConsulting.SysML2Workbench/AppShellSubsystem/AvaloniaClipboardService.cs create mode 100644 src/DemaConsulting.SysML2Workbench/AppShellSubsystem/IClipboardService.cs create mode 100644 test/DemaConsulting.SysML2Workbench.Tests/AppShellSubsystem/DiagramDocumentViewModelTests.cs diff --git a/docs/design/ots/avalonia.md b/docs/design/ots/avalonia.md index b476cce..1d82c3c 100644 --- a/docs/design/ots/avalonia.md +++ b/docs/design/ots/avalonia.md @@ -21,6 +21,14 @@ platform-specific UI stacks. UI. - **Pointer and keyboard input** — supports diagram pan and zoom plus workspace and view interactions. +- **`ContextMenu`/`MenuItem`** — every diagram tab's right-click "Copy as + SysML" action (the app's first `ContextMenu` usage; see + `docs/design/ots/dock.md`'s "Diagram Tab 'Copy as SysML' Context Menu" + section). +- **`TopLevel.Clipboard`** — writes the generated SysML snippet to the OS + clipboard, resolved from the diagram view's own control via + `TopLevel.GetTopLevel(control)`; the same API the custom view builder's own + "Copy as SysML" button already used. ### Integration Pattern diff --git a/docs/design/ots/dock.md b/docs/design/ots/dock.md index db58f95..8b3b7d9 100644 --- a/docs/design/ots/dock.md +++ b/docs/design/ots/dock.md @@ -134,3 +134,48 @@ with no restore path - closing a diagram tab is always a safe operation (zero tabs open is a supported, first-class state), and reopening one is one click away via the catalog or the "+ New Diagram Tab" button, so no restore mechanism is needed. + +### Diagram Tab "Copy as SysML" Context Menu + +Every `DiagramDocumentView` - one per open `WorkbenchTab`, and therefore +covering all six predefined view kinds (General, Interconnection, State +Transition, Action Flow, Sequence, Grid) plus a custom-view-builder preview +tab, since all of them are rendered through this one shared view/view-model +pair - hosts a single shared `ContextMenu` on its diagram `Border`, with one +`MenuItem` ("Copy as SysML"). This is the app's first `ContextMenu` usage. + +Clicking the item copies that tab's whole-diagram SysML `view { ... }` text +(view kind, every `expose` clause, and any `filter` expression) to the OS +clipboard, reusing the existing `SysmlSnippetGenerator` - the same unit the +custom view builder's own "Copy as SysML" button already used - so there is +exactly one snippet-generation code path regardless of which tab kind +triggered it. `MainWindowShell.WorkbenchTab.SourceDefinition` (a +`ViewDefinitionModel?`) carries the concrete definition each tab's diagram +was rendered from: for a custom-view-preview tab it is the definition passed +to `PreviewCustomView`; for a predefined-view tab it is derived by +`ViewCatalogPresenter.BuildViewDefinition`, since a predefined view's +`SysmlViewNode` in the loaded workspace carries the same kind/expose/filter +shape but had previously only been surfaced as a `ViewDescriptor` (display +metadata, not a definition). + +The menu item's `IsEnabled` is bound to `DiagramDocumentViewModel.CanCopyAsSysml`, +which mirrors `MainWindowShell.CanExportTabAsSysml(TabId)` - `false` (and so +the item is disabled, never crashes) when a tab has no derivable definition, +which happens in two disclosed cases: a brand-new custom-preview tab that +has not rendered anything yet, and an unscoped predefined view (zero +`expose` members - a real, valid "expose everything" SysML v2 view with no +finite expose list for `SysmlSnippetGenerator` to serialize). Both cases are +logged at `Info` level via the existing `RollingFileLogger` rather than +thrown, consistent with every other expected-but-unavailable shell state. + +Clicking the item invokes `DiagramDocumentViewModel.CopyAsSysmlAsync()`, +which asks `MainWindowShell.ExportTabAsSysmlSnippet` for the snippet text and +writes it via an injected `IClipboardService` - a small seam (mirroring the +existing `IUiDispatcher` seam pattern) so the generate-and-copy +orchestration can be unit tested with a fake clipboard rather than needing a +live UI. `DiagramDocumentView`'s code-behind constructs the real +`AvaloniaClipboardService` (wrapping `TopLevel.GetTopLevel(this)?.Clipboard`) +once it has a view model, and assigns it to the view model's +`ClipboardService` property; production code never touches +`TopLevel.Clipboard` directly from the view model, keeping the view model +UI-framework-agnostic aside from that one injected seam. diff --git a/docs/design/sysml2-workbench/app-shell-subsystem/main-window-shell.md b/docs/design/sysml2-workbench/app-shell-subsystem/main-window-shell.md index 3bc5d12..49fbc12 100644 --- a/docs/design/sysml2-workbench/app-shell-subsystem/main-window-shell.md +++ b/docs/design/sysml2-workbench/app-shell-subsystem/main-window-shell.md @@ -37,6 +37,12 @@ own focus-change signal via `NotifyActiveDiagramTab`. WorkspacePanel to build its tree without owning its own `WorkspaceSourceSet` instance. +Each `WorkbenchTab` also carries a `SourceDefinition: ViewDefinitionModel?` — +the definition that produced the tab's currently rendered diagram, or `null` +when none could be derived (an unscoped predefined view with zero expose +members, or a brand-new custom-preview tab that has not rendered anything +yet). This backs the tab's "Copy as SysML" context-menu action. + #### Key Methods **AddFileSource**: Adds a single file as a new workspace source. @@ -129,6 +135,26 @@ UI focus. when Dock reports a focus change onto a diagram document - never by `MainWindowShell` itself, which stays Dock-agnostic. +**CanExportTabAsSysml**: Reports whether an open diagram tab has a derivable +source definition and can export its diagram as a SysML snippet. + +- *Parameters*: `string tabId` — identifier of an open tab. +- *Returns*: `bool` — `true` when the tab exists and its `SourceDefinition` is + ready to export. +- *Postconditions*: None (read-only). Backs the enabled/disabled state of + every diagram tab's "Copy as SysML" context-menu item. + +**ExportTabAsSysmlSnippet**: Generates copy-pasteable SysML `view` text for +the diagram currently rendered in an open tab. + +- *Parameters*: `string tabId` — identifier of an open tab. +- *Returns*: `string?` — the SysML snippet, or `null` when + `CanExportTabAsSysml` would report `false` for that tab. +- *Postconditions*: A `null` result is logged at `Info` level with the reason + (an expected, valid outcome, not a failure) rather than thrown; a + successful export is also logged at `Info` level, mirroring + `ExportCustomViewSnippet`'s existing style. + #### Error Handling MainWindowShell handles user-cancelled operations, empty workspace selections, diff --git a/docs/design/sysml2-workbench/view-builder-subsystem/view-definition-model.md b/docs/design/sysml2-workbench/view-builder-subsystem/view-definition-model.md index 2d5da4f..1b5469e 100644 --- a/docs/design/sysml2-workbench/view-builder-subsystem/view-definition-model.md +++ b/docs/design/sysml2-workbench/view-builder-subsystem/view-definition-model.md @@ -55,6 +55,23 @@ a named snippet. only four possible recursion kinds, at most four selections can ever exist for one qualified name; once all four are taken, this remains a safe no-op. +**AddExposeTarget (selection overload)**: Adds a fully-specified +`ExposeTargetSelection` - qualified name, recursion kind, and optional +bracket-filter expression - directly, in a single call. + +- *Parameters*: `ExposeTargetSelection selection` — fully-specified target + selection. +- *Returns*: `void` — state is updated in place. +- *Preconditions*: `selection` is not null. +- *Postconditions*: Deduplicates by the exact (`QualifiedName`, + `RecursionKind`) pair, mirroring the string overload's own dedupe contract: + a matching existing selection is preserved (including its own bracket + filter) rather than overwritten. Lets a caller (for example + `ViewCatalogPresenter.BuildViewDefinition`) reconstruct an arbitrary real + `ExposeMember` - any of the four recursion kinds plus an optional bracket + filter - without the multi-call add-then-retarget-then-filter sequence the + string overload would otherwise require. + **RemoveExposeTarget**: Removes a qualified name/recursion kind pair from the `expose` target set. @@ -131,3 +148,4 @@ the two recursive expose forms. - **MainWindowShell** - **SysmlSnippetGenerator** - **LayoutInvoker** +- **ViewCatalogPresenter** diff --git a/docs/design/sysml2-workbench/view-catalog-subsystem/view-catalog-presenter.md b/docs/design/sysml2-workbench/view-catalog-subsystem/view-catalog-presenter.md index 478ccd2..d0a5846 100644 --- a/docs/design/sysml2-workbench/view-catalog-subsystem/view-catalog-presenter.md +++ b/docs/design/sysml2-workbench/view-catalog-subsystem/view-catalog-presenter.md @@ -49,6 +49,27 @@ underlying workspace snapshot has changed and the catalog must be rebuilt. - *Postconditions*: The returned descriptor matches the current catalog revision. +**BuildViewDefinition**: Derives a `ViewDefinitionModel` that faithfully +reconstructs a predefined view's real `view` declaration from the loaded +workspace. + +- *Parameters*: `SemanticWorkspace workspace` — loaded model content; + `string viewId` — qualified name of the predefined view, as published in + `AvailableViews`. +- *Returns*: `ViewDefinitionModel?` — a populated definition (view kind, every + expose member with its own recursion kind and optional bracket-filter + expression, filter expression, display name), or `null` when `viewId` is + not in the current catalog, does not resolve to a view node, its render + target does not map to a supported `ViewKind`, or it declares zero expose + members. +- *Preconditions*: `RefreshCatalog` has been called against `workspace`. +- *Postconditions*: The zero-expose-members case ("expose everything, no + scoping" - valid SysML v2) intentionally yields `null` rather than an + empty-but-technically-valid definition, since `SysmlSnippetGenerator` has + no finite expose list to serialize for it. Used by `MainWindowShell` to + populate a predefined-view diagram tab's `SourceDefinition` for its + "Copy as SysML" context-menu action. + #### Error Handling ViewCatalogPresenter handles invalid or disappearing selections locally by @@ -63,6 +84,9 @@ resolved before exposing the catalog. - **WorkspaceModel** — provides the semantic workspace snapshot used for discovery. - **LayoutInvoker** — consumes the selected descriptor to generate a layout. +- **ViewDefinitionModel** — populated by `BuildViewDefinition` for consumers + that need a concrete, reusable view definition rather than just a + `ViewDescriptor`. - **SysML2Tools** — supplies the view usage concepts and semantic model types. - **AppShellSubsystem** — hosts the UI surface that binds the catalog output. diff --git a/docs/reqstream/ots/avalonia.yaml b/docs/reqstream/ots/avalonia.yaml index c05b90d..f334eab 100644 --- a/docs/reqstream/ots/avalonia.yaml +++ b/docs/reqstream/ots/avalonia.yaml @@ -17,3 +17,10 @@ sections: The workbench depends on Avalonia to compose the major UI regions around the rendered SVG viewer. tests: - 'MainWindow_HostsDiagramAndDiagnosticsPanels' + + - id: 'Avalonia-CopyDiagramAsSysmlViaContextMenu' + title: 'The SysML2Workbench shall use Avalonia''s ContextMenu and TopLevel.Clipboard APIs to let a user right-click any open diagram tab and copy that diagram''s SysML view definition to the OS clipboard.' + justification: | + Every rendered diagram tab needs a discoverable, in-place way to copy its underlying SysML view text without navigating away to the custom view builder panel, and Avalonia's real ContextMenu/Clipboard integration is the genuine end-to-end behavior being qualified rather than a mocked UI harness. + tests: + - 'DiagramContextMenu_CopyAsSysml_CopiesSnippetToClipboard' diff --git a/docs/reqstream/sysml2-workbench/app-shell-subsystem/main-window-shell.yaml b/docs/reqstream/sysml2-workbench/app-shell-subsystem/main-window-shell.yaml index e7e17e2..df8a783 100644 --- a/docs/reqstream/sysml2-workbench/app-shell-subsystem/main-window-shell.yaml +++ b/docs/reqstream/sysml2-workbench/app-shell-subsystem/main-window-shell.yaml @@ -50,3 +50,14 @@ sections: - 'Construction_EstablishesValidEmptySnapshot' - 'RemoveSourceAsync_DownToZeroSources_ProducesEmptySnapshotAndUnwatchesEverything' - 'SelectPredefinedView_NoWorkspaceOpened_ThrowsInvalidOperationException' + + - id: 'SysML2Workbench-AppShellSubsystem-MainWindowShell-ExportTabAsSysml' + title: 'The MainWindowShell shall derive and export a SysML view snippet for any open diagram tab whose originating view definition can be determined, and shall report that a tab has no derivable definition rather than throwing.' + justification: | + Every diagram tab - predefined view or custom-view-builder preview - supports a "Copy as SysML" context-menu action that reuses the existing SysmlSnippetGenerator, and a tab whose definition cannot be derived (for example an unscoped predefined view with zero expose members, or an unrendered custom-preview tab) must disable that action gracefully instead of crashing. + tests: + - 'ExportTabAsSysmlSnippet_PredefinedViewTab_ReturnsSnippet' + - 'ExportTabAsSysmlSnippet_CustomPreviewTab_ReturnsSnippet' + - 'ExportTabAsSysmlSnippet_UnknownTabId_ReturnsNull' + - 'ExportTabAsSysmlSnippet_PredefinedViewWithNoExposeMembers_ReturnsNull' + - 'CanExportTabAsSysml_MirrorsExportability' diff --git a/docs/reqstream/sysml2-workbench/view-builder-subsystem/view-definition-model.yaml b/docs/reqstream/sysml2-workbench/view-builder-subsystem/view-definition-model.yaml index 54d3919..d951ea9 100644 --- a/docs/reqstream/sysml2-workbench/view-builder-subsystem/view-definition-model.yaml +++ b/docs/reqstream/sysml2-workbench/view-builder-subsystem/view-definition-model.yaml @@ -47,3 +47,11 @@ sections: The shell and snippet generator need to know when a user's current selections form a usable custom view definition. tests: - 'DefinitionState_ReportsRenderAndExportReadiness' + + - id: 'SysML2Workbench-ViewBuilderSubsystem-ViewDefinitionModel-AddExposeTargetSelectionDirectly' + title: 'The ViewDefinitionModel shall accept a fully-specified ExposeTargetSelection (qualified name, recursion kind, and optional bracket-filter expression) in a single call, deduplicated by the exact (qualified name, recursion kind) pair.' + justification: | + Reconstructing an arbitrary real expose member - any of the four recursion kinds plus an optional bracket filter - from a workspace's predefined view requires a single-call add that does not risk colliding with an already-present sibling selection, unlike the string overload's fixed default recursion kind plus a separate retarget/filter call sequence. + tests: + - 'AddExposeTarget_SelectionOverload_AppendsAndDedupesByQualifiedNameAndRecursionKind' + - 'AddExposeTarget_SelectionOverload_NullSelection_ThrowsArgumentNullException' diff --git a/docs/reqstream/sysml2-workbench/view-catalog-subsystem/view-catalog-presenter.yaml b/docs/reqstream/sysml2-workbench/view-catalog-subsystem/view-catalog-presenter.yaml index 6e52088..b981cab 100644 --- a/docs/reqstream/sysml2-workbench/view-catalog-subsystem/view-catalog-presenter.yaml +++ b/docs/reqstream/sysml2-workbench/view-catalog-subsystem/view-catalog-presenter.yaml @@ -26,3 +26,12 @@ sections: Rendering begins from a single active catalog selection, so the presenter must expose that selection change. tests: - 'SelectView_PublishesCurrentSelection' + + - id: 'SysML2Workbench-ViewCatalogSubsystem-ViewCatalogPresenter-BuildViewDefinitionFromWorkspaceView' + title: 'The ViewCatalogPresenter shall derive a ViewDefinitionModel that faithfully reconstructs a predefined view''s view kind, every expose member (recursion kind and bracket filter included), filter expression, and display name from the loaded workspace, and shall return null when no finite expose list can be determined.' + justification: | + Predefined-view diagram tabs need a concrete, reusable view definition - not just a display descriptor - so the shared "Copy as SysML" export path can generate a snippet for them the same way it does for custom-view-builder previews. + tests: + - 'BuildViewDefinition_ResolvesKindExposeAndFilter' + - 'BuildViewDefinition_UnknownViewId_ReturnsNull' + - 'BuildViewDefinition_NoExposeMembers_ReturnsNull' diff --git a/docs/verification/ots/avalonia.md b/docs/verification/ots/avalonia.md index 52a6321..b313b7b 100644 --- a/docs/verification/ots/avalonia.md +++ b/docs/verification/ots/avalonia.md @@ -9,3 +9,5 @@ OTS integration tests in `test/OtsSoftwareTests/AvaloniaTests.cs` verify the rea **Startup_HostsDesktopShellControls**: A real `MainWindowView` is constructed over a fully composed `MainWindowShell` and shown under the headless platform. The dependency proves it hosts the menu, predefined-views list, and custom-view builder controls (ComboBox, multi-select ListBox, buttons) as real, discoverable Avalonia controls attached to the window's visual tree. **MainWindow_HostsDiagramAndDiagnosticsPanels**: The same window's diagram `Image` control and diagnostics `ListBox` are confirmed present before any workspace is opened; after opening a workspace and selecting a predefined view through the shell, the shared canvas state backing the diagram control reports loaded content, proving Avalonia genuinely hosts the interactive diagram surface alongside the diagnostics panel. + +**DiagramContextMenu_CopyAsSysml_CopiesSnippetToClipboard**: After opening a workspace and rendering a predefined view, the real diagram tab's `ContextMenu` is opened on its `Border`, and the "Copy as SysML" `MenuItem` is clicked, both discovered as genuine Avalonia controls in the visual tree rather than invoked programmatically. The headless platform's own `TopLevel.Clipboard` is then read back via `TryGetTextAsync`, and its text is asserted to match the snippet `MainWindowShell.ExportTabAsSysmlSnippet` independently generates for the same tab - proving the whole click-to-clipboard path (context menu, view-model command, `IClipboardService` seam, and the real Avalonia clipboard) works end-to-end through the actual framework, not a mock. diff --git a/docs/verification/sysml2-workbench/app-shell-subsystem/main-window-shell.md b/docs/verification/sysml2-workbench/app-shell-subsystem/main-window-shell.md index f6047e2..0c3cbcc 100644 --- a/docs/verification/sysml2-workbench/app-shell-subsystem/main-window-shell.md +++ b/docs/verification/sysml2-workbench/app-shell-subsystem/main-window-shell.md @@ -85,3 +85,23 @@ source's file watcher. Verified by **SelectPredefinedView_NoWorkspaceOpened_ThrowsInvalidOperationException**: Selecting a predefined view while zero workspace sources are open throws `InvalidOperationException` rather than rendering against an empty workspace. Verified by `MainWindowShellTests.SelectPredefinedView_NoWorkspaceOpened_ThrowsInvalidOperationException`. + +**ExportTabAsSysmlSnippet_PredefinedViewTab_ReturnsSnippet**: A rendered predefined-view diagram tab exports a +SysML snippet reflecting its derived view definition, and `CanExportTabAsSysml` reports it as exportable. Verified +by `MainWindowShellTests.ExportTabAsSysmlSnippet_PredefinedViewTab_ReturnsSnippet`. + +**ExportTabAsSysmlSnippet_CustomPreviewTab_ReturnsSnippet**: A rendered custom-view-preview diagram tab exports a +SysML snippet reflecting the definition it was previewed from. Verified by +`MainWindowShellTests.ExportTabAsSysmlSnippet_CustomPreviewTab_ReturnsSnippet`. + +**ExportTabAsSysmlSnippet_UnknownTabId_ReturnsNull**: An unknown tab id is reported as not exportable and returns +`null` rather than throwing. Verified by `MainWindowShellTests.ExportTabAsSysmlSnippet_UnknownTabId_ReturnsNull`. + +**ExportTabAsSysmlSnippet_PredefinedViewWithNoExposeMembers_ReturnsNull**: A predefined view with zero expose +members (a valid, unscoped "expose everything" view) cannot be exported, since there is no finite expose list to +serialize - it is reported gracefully rather than throwing. Verified by +`MainWindowShellTests.ExportTabAsSysmlSnippet_PredefinedViewWithNoExposeMembers_ReturnsNull`. + +**CanExportTabAsSysml_MirrorsExportability**: `CanExportTabAsSysml` mirrors the same true/false outcomes as +`ExportTabAsSysmlSnippet` across the exportable and unknown-tab cases. Verified by +`MainWindowShellTests.CanExportTabAsSysml_MirrorsExportability`. diff --git a/docs/verification/sysml2-workbench/view-builder-subsystem/view-definition-model.md b/docs/verification/sysml2-workbench/view-builder-subsystem/view-definition-model.md index 2a3d121..52488c6 100644 --- a/docs/verification/sysml2-workbench/view-builder-subsystem/view-definition-model.md +++ b/docs/verification/sysml2-workbench/view-builder-subsystem/view-definition-model.md @@ -85,3 +85,15 @@ is reported as a diagnostic. Verified by **DefinitionState_ReportsRenderAndExportReadiness**: The definition reports whether it has enough information to render a preview or export a snippet. Verified by `ViewDefinitionModelTests.DefinitionState_ReportsRenderAndExportReadiness`. + +**AddExposeTarget_SelectionOverload_AppendsAndDedupesByQualifiedNameAndRecursionKind**: The `ExposeTargetSelection` +overload appends a fully-specified selection (qualified name, recursion kind, and bracket-filter expression) in a +single call, is a no-op when the exact (qualified name, recursion kind) pair is already present (preserving the +originally-added selection's bracket filter), and allows the same qualified name to be added again under a +different recursion kind. Verified by +`ViewDefinitionModelTests.AddExposeTarget_SelectionOverload_AppendsAndDedupesByQualifiedNameAndRecursionKind`. + +**AddExposeTarget_SelectionOverload_NullSelection_ThrowsArgumentNullException**: Passing a `null` selection to the +`ExposeTargetSelection` overload throws `ArgumentNullException` rather than silently ignoring it or throwing a +`NullReferenceException`. Verified by +`ViewDefinitionModelTests.AddExposeTarget_SelectionOverload_NullSelection_ThrowsArgumentNullException`. diff --git a/docs/verification/sysml2-workbench/view-catalog-subsystem/view-catalog-presenter.md b/docs/verification/sysml2-workbench/view-catalog-subsystem/view-catalog-presenter.md index 639ddd2..072e67a 100644 --- a/docs/verification/sysml2-workbench/view-catalog-subsystem/view-catalog-presenter.md +++ b/docs/verification/sysml2-workbench/view-catalog-subsystem/view-catalog-presenter.md @@ -33,3 +33,14 @@ Verified by `ViewCatalogPresenterTests.LoadedModel_ListsSupportedViewDefinitions **SelectView_PublishesCurrentSelection**: Selecting a predefined view publishes it as the current selection. Verified by `ViewCatalogPresenterTests.SelectView_PublishesCurrentSelection`. + +**BuildViewDefinition_ResolvesKindExposeAndFilter**: `BuildViewDefinition` faithfully reconstructs a predefined +view's kind, every expose member (recursion kind and bracket filter included), filter expression, and display +name. Verified by `ViewCatalogPresenterTests.BuildViewDefinition_ResolvesKindExposeAndFilter`. + +**BuildViewDefinition_UnknownViewId_ReturnsNull**: An unknown view identifier produces `null` rather than +throwing. Verified by `ViewCatalogPresenterTests.BuildViewDefinition_UnknownViewId_ReturnsNull`. + +**BuildViewDefinition_NoExposeMembers_ReturnsNull**: A predefined view with zero expose members (a valid, +unscoped "expose everything" view) produces `null`, since there is no finite expose list to reconstruct. Verified +by `ViewCatalogPresenterTests.BuildViewDefinition_NoExposeMembers_ReturnsNull`. diff --git a/src/DemaConsulting.SysML2Workbench/AppShellSubsystem/AvaloniaClipboardService.cs b/src/DemaConsulting.SysML2Workbench/AppShellSubsystem/AvaloniaClipboardService.cs new file mode 100644 index 0000000..fa4a788 --- /dev/null +++ b/src/DemaConsulting.SysML2Workbench/AppShellSubsystem/AvaloniaClipboardService.cs @@ -0,0 +1,53 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Input.Platform; +using DemaConsulting.SysML2Workbench.LoggingSubsystem; + +namespace DemaConsulting.SysML2Workbench.AppShellSubsystem; + +/// +/// implementation that writes to the live Avalonia clipboard, resolved via +/// from a control anchored in the visual tree. +/// +/// +/// The owning is intentionally resolved fresh on every call +/// rather than cached at construction, since the anchor control may not yet be attached to a window at the +/// point this service is constructed (for example, if a diagram document view model is bound before its view +/// is attached to the Dock layout) - by the time a user actually triggers a clipboard copy, the control is +/// expected to be attached, but resolving lazily is a safe, zero-cost precaution either way. +/// +public sealed class AvaloniaClipboardService : IClipboardService +{ + private readonly Visual _anchor; + private readonly RollingFileLogger? _logger; + + /// + /// Creates a clipboard service anchored to a control in the visual tree. + /// + /// Control used to resolve the owning at write time. + /// + /// Optional logger used to record the case where no /clipboard is available (for + /// example under the XAML designer). When omitted, that case is silently ignored rather than throwing. + /// + /// Thrown when is null. + public AvaloniaClipboardService(Visual anchor, RollingFileLogger? logger = null) + { + _anchor = anchor ?? throw new ArgumentNullException(nameof(anchor)); + _logger = logger; + } + + /// + public async Task SetTextAsync(string text) + { + ArgumentNullException.ThrowIfNull(text); + + var clipboard = TopLevel.GetTopLevel(_anchor)?.Clipboard; + if (clipboard is null) + { + _logger?.Log(LogLevel.Error, "Unable to copy to the clipboard: no Avalonia TopLevel/clipboard is available."); + return; + } + + await clipboard.SetTextAsync(text); + } +} diff --git a/src/DemaConsulting.SysML2Workbench/AppShellSubsystem/DiagramDocumentView.axaml b/src/DemaConsulting.SysML2Workbench/AppShellSubsystem/DiagramDocumentView.axaml index 8553f26..64edc2b 100644 --- a/src/DemaConsulting.SysML2Workbench/AppShellSubsystem/DiagramDocumentView.axaml +++ b/src/DemaConsulting.SysML2Workbench/AppShellSubsystem/DiagramDocumentView.axaml @@ -1,10 +1,20 @@ + + + + + + + + + diff --git a/src/DemaConsulting.SysML2Workbench/AppShellSubsystem/DiagramDocumentView.axaml.cs b/src/DemaConsulting.SysML2Workbench/AppShellSubsystem/DiagramDocumentView.axaml.cs index 5f1f83a..2fc2f0c 100644 --- a/src/DemaConsulting.SysML2Workbench/AppShellSubsystem/DiagramDocumentView.axaml.cs +++ b/src/DemaConsulting.SysML2Workbench/AppShellSubsystem/DiagramDocumentView.axaml.cs @@ -1,6 +1,7 @@ using Avalonia; using Avalonia.Controls; using Avalonia.Input; +using Avalonia.Interactivity; using Avalonia.Media; using Avalonia.Svg.Skia; @@ -53,10 +54,21 @@ private void OnDataContextChanged(object? sender, EventArgs e) if (_viewModel is not null) { _viewModel.DiagramChanged += OnDiagramChanged; + _viewModel.ClipboardService ??= new AvaloniaClipboardService(this); LoadCurrentDiagram(); } } + private async void OnCopyAsSysmlMenuItemClick(object? sender, RoutedEventArgs e) + { + if (_viewModel is null) + { + return; + } + + await _viewModel.CopyAsSysmlAsync(); + } + private void OnDiagramChanged(object? sender, EventArgs e) { LoadCurrentDiagram(); diff --git a/src/DemaConsulting.SysML2Workbench/AppShellSubsystem/DiagramDocumentViewModel.cs b/src/DemaConsulting.SysML2Workbench/AppShellSubsystem/DiagramDocumentViewModel.cs index 30f983e..18b50f3 100644 --- a/src/DemaConsulting.SysML2Workbench/AppShellSubsystem/DiagramDocumentViewModel.cs +++ b/src/DemaConsulting.SysML2Workbench/AppShellSubsystem/DiagramDocumentViewModel.cs @@ -56,11 +56,43 @@ public DiagramDocumentViewModel(MainWindowShell shell, string tabId) /// public event EventHandler? DiagramChanged; + /// + /// Clipboard write seam used by . Left unset () + /// until the owning attaches to the visual tree and assigns a real + /// ; unit tests instead assign a fake test double directly so the + /// generate-and-copy orchestration can be verified without any live UI/OS clipboard. + /// + public IClipboardService? ClipboardService { get; set; } + + /// + /// Reports whether this tab currently has enough information to export its diagram as a SysML snippet, + /// backing the enabled/disabled state of the tab's "Copy as SysML" context-menu item. + /// + public bool CanCopyAsSysml => Shell.CanExportTabAsSysml(TabId); + + /// + /// Generates this tab's SysML view snippet and copies it to the clipboard via + /// . A safe no-op - not an exception - when no snippet can be derived for + /// this tab ( already logs the reason) or when no + /// is available yet. + /// + public async Task CopyAsSysmlAsync() + { + var snippet = Shell.ExportTabAsSysmlSnippet(TabId); + if (snippet is null || ClipboardService is null) + { + return; + } + + await ClipboardService.SetTextAsync(snippet); + } + /// /// Notifies the diagram view that it should reload this tab's current diagram. /// public void RaiseDiagramChanged() { DiagramChanged?.Invoke(this, EventArgs.Empty); + OnPropertyChanged(nameof(CanCopyAsSysml)); } } diff --git a/src/DemaConsulting.SysML2Workbench/AppShellSubsystem/IClipboardService.cs b/src/DemaConsulting.SysML2Workbench/AppShellSubsystem/IClipboardService.cs new file mode 100644 index 0000000..dddf0dd --- /dev/null +++ b/src/DemaConsulting.SysML2Workbench/AppShellSubsystem/IClipboardService.cs @@ -0,0 +1,19 @@ +namespace DemaConsulting.SysML2Workbench.AppShellSubsystem; + +/// +/// Seam for writing text to the OS clipboard, so view models that need to copy generated text (for example a +/// diagram tab's "Copy as SysML" action) can be exercised in unit tests with a fake implementation rather than +/// requiring a live Avalonia /TopLevel. Mirrors the +/// existing seam pattern: a small +/// interface owned alongside its real implementation, with production wiring supplying the live implementation +/// and tests supplying a lightweight double. +/// +public interface IClipboardService +{ + /// + /// Writes to the OS clipboard. + /// + /// Text to place on the clipboard. + /// A task that completes once the clipboard write has finished. + Task SetTextAsync(string text); +} diff --git a/src/DemaConsulting.SysML2Workbench/AppShellSubsystem/MainWindowShell.cs b/src/DemaConsulting.SysML2Workbench/AppShellSubsystem/MainWindowShell.cs index bb6455b..2a7c481 100644 --- a/src/DemaConsulting.SysML2Workbench/AppShellSubsystem/MainWindowShell.cs +++ b/src/DemaConsulting.SysML2Workbench/AppShellSubsystem/MainWindowShell.cs @@ -28,13 +28,20 @@ public enum WorkbenchTabKind /// User-facing tab label. /// Content category shown by the tab. /// This tab's own diagram surface state. +/// +/// The that produced this tab's currently rendered diagram, or +/// when no concrete definition could be derived for it (an unscoped predefined view +/// with zero expose members, or a brand-new custom-preview tab that has not rendered anything yet). Used by +/// / to +/// back the diagram tab's "Copy as SysML" context-menu action. +/// /// /// Because is a mutable reference type, two instances are /// no longer meaningfully value-equal to each other once its state diverges. Nothing in /// compares instances by equality; all lookups are /// by . /// -public sealed record WorkbenchTab(string Id, string Title, WorkbenchTabKind Kind, SvgCanvasHost Canvas); +public sealed record WorkbenchTab(string Id, string Title, WorkbenchTabKind Kind, SvgCanvasHost Canvas, ViewDefinitionModel? SourceDefinition = null); /// /// MainWindowShell is the desktop composition root that coordinates workspace lifecycle, view selection, @@ -444,7 +451,14 @@ public string SelectPredefinedView(string viewId) try { var svg = _layoutInvoker.RenderPredefinedView(CurrentWorkspace.Workspace, descriptor); + var definition = _viewCatalogPresenter.BuildViewDefinition(CurrentWorkspace.Workspace, descriptor.QualifiedName); var tab = EnsureTabOpen(descriptor.QualifiedName, descriptor.DisplayName, WorkbenchTabKind.PredefinedView); + if (tab.SourceDefinition != definition) + { + tab = tab with { SourceDefinition = definition }; + _openTabs[_openTabs.FindIndex(t => t.Id == tab.Id)] = tab; + } + tab.Canvas.LoadSvg(svg); ActivePredefinedView = descriptor; ActiveCustomView = null; @@ -496,13 +510,13 @@ public string PreviewCustomView(ViewDefinitionModel definition) WorkbenchTab tab; if (ActiveTab is { Kind: WorkbenchTabKind.CustomViewPreview } activeTab) { - // Re-render in place: same tab identity and canvas, refreshed title. - tab = activeTab with { Title = title }; + // Re-render in place: same tab identity and canvas, refreshed title and source definition. + tab = activeTab with { Title = title, SourceDefinition = definition }; _openTabs[_openTabs.FindIndex(t => t.Id == activeTab.Id)] = tab; } else { - tab = CreateTab(NextCustomPreviewTabId(), title, WorkbenchTabKind.CustomViewPreview); + tab = CreateTab(NextCustomPreviewTabId(), title, WorkbenchTabKind.CustomViewPreview) with { SourceDefinition = definition }; _openTabs.Add(tab); } @@ -603,6 +617,50 @@ public string ExportCustomViewSnippet(ViewDefinitionModel definition) return snippet; } + /// + /// Reports whether the given open diagram tab has a derivable source definition and can therefore export + /// its diagram as a SysML view snippet via . Backs the + /// enabled/disabled state of every diagram tab's "Copy as SysML" context-menu item. + /// + /// Identifier of an open tab. + /// + /// when the tab is open and its is + /// ready to export; when the tab is unknown, has no source definition (for + /// example an empty custom-preview tab, or an unscoped predefined view with zero expose members), or that + /// definition is not yet ready to export. + /// + public bool CanExportTabAsSysml(string tabId) + { + var tab = _openTabs.FirstOrDefault(t => t.Id == tabId); + return tab?.SourceDefinition?.IsReadyToExport == true; + } + + /// + /// Generates copy-pasteable SysML view text for the diagram currently rendered in the given open + /// tab, reusing whichever produced that diagram - whether it came from a + /// predefined view or a custom-view-builder preview - so every diagram tab funnels through the same + /// snippet-generation path rather than duplicating it per tab kind. + /// + /// Identifier of an open tab. + /// + /// Complete SysML view snippet, or when + /// would report for - + /// this is an expected, valid outcome (not a failure), so it is reported rather than thrown. + /// + public string? ExportTabAsSysmlSnippet(string tabId) + { + var tab = _openTabs.FirstOrDefault(t => t.Id == tabId); + if (tab?.SourceDefinition is not { IsReadyToExport: true } definition) + { + _logger.Log(LogLevel.Info, $"Tab '{tabId}' has no derivable SysML view definition to copy."); + return null; + } + + var snippet = _snippetGenerator.GenerateSnippet(definition); + _logger.Log(LogLevel.Info, $"Diagram tab '{tabId}' copied as a SysML snippet."); + return snippet; + } + /// /// Applies a freshly loaded or reloaded workspace snapshot to all workspace-derived shell state. /// diff --git a/src/DemaConsulting.SysML2Workbench/ViewBuilderSubsystem/ViewDefinitionModel.cs b/src/DemaConsulting.SysML2Workbench/ViewBuilderSubsystem/ViewDefinitionModel.cs index 8ef12fa..26ad3c1 100644 --- a/src/DemaConsulting.SysML2Workbench/ViewBuilderSubsystem/ViewDefinitionModel.cs +++ b/src/DemaConsulting.SysML2Workbench/ViewBuilderSubsystem/ViewDefinitionModel.cs @@ -144,6 +144,38 @@ public void AddExposeTarget(string qualifiedName) _exposeTargets.Add(new ExposeTargetSelection(qualifiedName)); } + /// + /// Adds a fully-specified expose target selection - qualified name, recursion kind, and optional + /// bracket-filter expression - directly, without the string overload's fixed + /// default. + /// + /// + /// Deduplicates by the exact (, + /// ) pair, mirroring 's + /// own dedupe contract: if a selection already exists for that exact pair, this is a no-op and the existing + /// selection (including its own bracket filter) is preserved rather than overwritten by + /// 's. This lets a caller (for example + /// ViewCatalogPresenter.BuildViewDefinition) reconstruct an arbitrary real + /// - any of the four recursion kinds + /// plus an optional bracket filter - in a single call, without the multi-call + /// add-then-retarget-then-filter sequence the string overload plus / + /// would otherwise require (which risks colliding with an + /// already-present sibling selection under the target recursion kind). + /// + /// Fully-specified target selection to add. + /// Thrown when is null. + public void AddExposeTarget(ExposeTargetSelection selection) + { + ArgumentNullException.ThrowIfNull(selection); + + if (_exposeTargets.Any(t => t.QualifiedName == selection.QualifiedName && t.RecursionKind == selection.RecursionKind)) + { + return; + } + + _exposeTargets.Add(selection); + } + /// /// Removes a qualified name/recursion kind pair from the expose target set. /// diff --git a/src/DemaConsulting.SysML2Workbench/ViewCatalogSubsystem/ViewCatalogPresenter.cs b/src/DemaConsulting.SysML2Workbench/ViewCatalogSubsystem/ViewCatalogPresenter.cs index ed1b800..5bf5ef5 100644 --- a/src/DemaConsulting.SysML2Workbench/ViewCatalogSubsystem/ViewCatalogPresenter.cs +++ b/src/DemaConsulting.SysML2Workbench/ViewCatalogSubsystem/ViewCatalogPresenter.cs @@ -2,6 +2,7 @@ using DemaConsulting.SysML2Tools.Semantic; using DemaConsulting.SysML2Tools.Semantic.Model; using DemaConsulting.SysML2Workbench.LayoutRenderingSubsystem; +using DemaConsulting.SysML2Workbench.ViewBuilderSubsystem; namespace DemaConsulting.SysML2Workbench.ViewCatalogSubsystem; @@ -140,4 +141,67 @@ public ViewDescriptor SelectView(string viewId) { return SelectedViewId is null ? null : _availableViews.FirstOrDefault(d => d.QualifiedName == SelectedViewId); } + + /// + /// Derives a that faithfully reconstructs a predefined view's real + /// view declaration - view kind, every expose member (with its own recursion kind and + /// optional bracket-filter expression), filter expression, and display name - from the loaded workspace. + /// + /// + /// This is the shared derivation path used both by the view catalog (should it ever need a definition + /// rather than just a ) and by "Copy as SysML" for a predefined-view diagram + /// tab, so a single unit owns "convert a workspace's predefined view usage into UI-ready data" rather than + /// splitting that responsibility across the catalog and the shell. + /// + /// Loaded model content. + /// Qualified name of the predefined view, as published in . + /// + /// A populated , or when + /// does not resolve to a in , its render target + /// does not map to a supported , or it has zero ExposeMembers (the "expose + /// everything, no scoping" case - valid SysML v2, but with no finite expose list for + /// SysmlSnippetGenerator to serialize). + /// + /// Thrown when is null. + /// Thrown when is null or whitespace. + public ViewDefinitionModel? BuildViewDefinition(SysmlWorkspace workspace, string viewId) + { + ArgumentNullException.ThrowIfNull(workspace); + ArgumentException.ThrowIfNullOrWhiteSpace(viewId); + + var descriptor = _availableViews.FirstOrDefault(d => d.QualifiedName == viewId); + if (descriptor is null) + { + return null; + } + + if (!workspace.Declarations.TryGetValue(viewId, out var node) || node is not SysmlViewNode viewNode) + { + return null; + } + + var kind = ViewKindExtensions.FromRenderTargetName(viewNode.RenderTargetName); + if (kind is null) + { + return null; + } + + if (viewNode.ExposeMembers.Count == 0) + { + return null; + } + + var definition = new ViewDefinitionModel(); + definition.SetViewKind(kind.Value); + + foreach (var (member, resolvedQualifiedName) in viewNode.ResolvedExposeMembers) + { + definition.AddExposeTarget(new ExposeTargetSelection(resolvedQualifiedName, member.RecursionKind, member.BracketFilterExpressionText)); + } + + definition.SetFilterExpression(viewNode.FilterExpressionText); + definition.SetDisplayName(descriptor.DisplayName); + + return definition; + } } diff --git a/test/DemaConsulting.SysML2Workbench.Tests/AppShellSubsystem/DiagramDocumentViewModelTests.cs b/test/DemaConsulting.SysML2Workbench.Tests/AppShellSubsystem/DiagramDocumentViewModelTests.cs new file mode 100644 index 0000000..4368525 --- /dev/null +++ b/test/DemaConsulting.SysML2Workbench.Tests/AppShellSubsystem/DiagramDocumentViewModelTests.cs @@ -0,0 +1,157 @@ +using DemaConsulting.SysML2Workbench.AppShellSubsystem; +using DemaConsulting.SysML2Workbench.DiagnosticsPanelSubsystem; +using DemaConsulting.SysML2Workbench.LayoutRenderingSubsystem; +using DemaConsulting.SysML2Workbench.LoggingSubsystem; +using DemaConsulting.SysML2Workbench.ViewBuilderSubsystem; +using DemaConsulting.SysML2Workbench.ViewCatalogSubsystem; +using DemaConsulting.SysML2Workbench.WorkspaceSubsystem; + +namespace DemaConsulting.SysML2Workbench.Tests.AppShellSubsystem; + +/// +/// Unit tests for . +/// +public sealed class DiagramDocumentViewModelTests : IDisposable +{ + /// + /// Temporary workspace root folder created fresh for each test and removed on disposal. + /// + private readonly string _tempRoot = Directory.CreateTempSubdirectory("sysml2workbench-tests-").FullName; + + /// + /// Temporary log directory created fresh for each test and removed on disposal. + /// + private readonly string _tempLogRoot = Directory.CreateTempSubdirectory("sysml2workbench-tests-logs-").FullName; + + /// + public void Dispose() + { + if (Directory.Exists(_tempRoot)) + { + Directory.Delete(_tempRoot, recursive: true); + } + + if (Directory.Exists(_tempLogRoot)) + { + Directory.Delete(_tempLogRoot, recursive: true); + } + } + + /// + /// Fake test double that captures the last copied text instead of + /// touching any real OS clipboard. + /// + private sealed class FakeClipboardService : IClipboardService + { + public string? LastCopiedText { get; private set; } + + public Task SetTextAsync(string text) + { + LastCopiedText = text; + return Task.CompletedTask; + } + } + + /// + /// Builds a shell wired with real (non-mocked) subsystem units, with a small workspace containing one + /// predefined view already loaded. + /// + private async Task CreateShellWithSampleWorkspaceAsync() + { + await File.WriteAllTextAsync( + Path.Combine(_tempRoot, "Sample.sysml"), + "package Sample {\n" + + " part def Engine;\n" + + " view PredefinedView {\n" + + " expose Engine;\n" + + " render asGeneralDiagram;\n" + + " }\n" + + "}\n"); + + var shell = new MainWindowShell( + new WorkspaceModel(), + new FileWatcher(TimeSpan.FromMilliseconds(1)), + new DiagnosticsAggregator(), + new ViewCatalogPresenter(), + new LayoutInvoker(), + new DiagnosticsListView(), + new SysmlSnippetGenerator(), + new RollingFileLogger(_tempLogRoot)); + + await shell.AddFolderSourceAsync(_tempRoot); + return shell; + } + + /// + /// Validates that a diagram document view model whose tab has a derivable definition reports + /// as and copies the + /// generated snippet to the injected clipboard service. + /// + [Fact] + public async Task CopyAsSysmlAsync_ExportableTab_CopiesSnippetToClipboard() + { + // Arrange + using var shell = await CreateShellWithSampleWorkspaceAsync(); + var view = shell.ViewCatalog.AvailableViews[0]; + shell.SelectPredefinedView(view.QualifiedName); + var tabId = shell.ActiveTabId!; + + var viewModel = new DiagramDocumentViewModel(shell, tabId); + var clipboard = new FakeClipboardService(); + viewModel.ClipboardService = clipboard; + + // Act + var canCopy = viewModel.CanCopyAsSysml; + await viewModel.CopyAsSysmlAsync(); + + // Assert + Assert.True(canCopy); + Assert.NotNull(clipboard.LastCopiedText); + Assert.Contains("view PredefinedView {", clipboard.LastCopiedText); + Assert.Contains("expose Sample::Engine;", clipboard.LastCopiedText); + } + + /// + /// Validates that a diagram document view model for a tab with no derivable definition (a brand-new, + /// unrendered custom-preview tab) reports as + /// and leaves the clipboard untouched. + /// + [Fact] + public async Task CopyAsSysmlAsync_NonExportableTab_LeavesClipboardUntouched() + { + // Arrange + using var shell = await CreateShellWithSampleWorkspaceAsync(); + var tab = shell.OpenNewCustomPreviewTab(); + + var viewModel = new DiagramDocumentViewModel(shell, tab.Id); + var clipboard = new FakeClipboardService(); + viewModel.ClipboardService = clipboard; + + // Act + var canCopy = viewModel.CanCopyAsSysml; + await viewModel.CopyAsSysmlAsync(); + + // Assert + Assert.False(canCopy); + Assert.Null(clipboard.LastCopiedText); + } + + /// + /// Validates that copying as SysML is a safe no-op - not an exception - when no + /// has been assigned yet. + /// + [Fact] + public async Task CopyAsSysmlAsync_NoClipboardServiceAssigned_DoesNotThrow() + { + // Arrange + using var shell = await CreateShellWithSampleWorkspaceAsync(); + var view = shell.ViewCatalog.AvailableViews[0]; + shell.SelectPredefinedView(view.QualifiedName); + var tabId = shell.ActiveTabId!; + + var viewModel = new DiagramDocumentViewModel(shell, tabId); + + // Act / Assert + await viewModel.CopyAsSysmlAsync(); + } +} diff --git a/test/DemaConsulting.SysML2Workbench.Tests/AppShellSubsystem/MainWindowShellTests.cs b/test/DemaConsulting.SysML2Workbench.Tests/AppShellSubsystem/MainWindowShellTests.cs index 1411d28..658536e 100644 --- a/test/DemaConsulting.SysML2Workbench.Tests/AppShellSubsystem/MainWindowShellTests.cs +++ b/test/DemaConsulting.SysML2Workbench.Tests/AppShellSubsystem/MainWindowShellTests.cs @@ -190,6 +190,131 @@ public async Task CustomViewWorkflow_PreviewsAndExportsFromShell() Assert.True(shell.Canvas.IsContentLoaded); } + /// + /// Validates that a rendered predefined-view diagram tab can export its diagram as a SysML snippet, and + /// that reports readiness consistently. + /// + [Fact] + public async Task ExportTabAsSysmlSnippet_PredefinedViewTab_ReturnsSnippet() + { + // Arrange + await WriteSampleWorkspaceAsync(); + using var shell = CreateShell(); + await shell.AddFolderSourceAsync(_tempRoot); + var view = shell.ViewCatalog.AvailableViews[0]; + shell.SelectPredefinedView(view.QualifiedName); + var tabId = shell.ActiveTabId!; + + // Act + var canExport = shell.CanExportTabAsSysml(tabId); + var snippet = shell.ExportTabAsSysmlSnippet(tabId); + + // Assert + Assert.True(canExport); + Assert.NotNull(snippet); + Assert.Contains("view PredefinedView {", snippet); + Assert.Contains("expose Sample::Engine;", snippet); + } + + /// + /// Validates that a custom-view-preview diagram tab can export its diagram as a SysML snippet. + /// + [Fact] + public async Task ExportTabAsSysmlSnippet_CustomPreviewTab_ReturnsSnippet() + { + // Arrange + await WriteSampleWorkspaceAsync(); + using var shell = CreateShell(); + await shell.AddFolderSourceAsync(_tempRoot); + + var definition = new ViewDefinitionModel(); + definition.SetViewKind(ViewKind.General); + definition.AddExposeTarget("Sample::Engine"); + definition.SetDisplayName("EngineOnly"); + shell.PreviewCustomView(definition); + var tabId = shell.ActiveTabId!; + + // Act + var canExport = shell.CanExportTabAsSysml(tabId); + var snippet = shell.ExportTabAsSysmlSnippet(tabId); + + // Assert + Assert.True(canExport); + Assert.NotNull(snippet); + Assert.Contains("view EngineOnly {", snippet); + } + + /// + /// Validates that an unknown tab id reports no export readiness and returns rather + /// than throwing. + /// + [Fact] + public async Task ExportTabAsSysmlSnippet_UnknownTabId_ReturnsNull() + { + // Arrange + await WriteSampleWorkspaceAsync(); + using var shell = CreateShell(); + await shell.AddFolderSourceAsync(_tempRoot); + + // Act / Assert + Assert.False(shell.CanExportTabAsSysml("does-not-exist")); + Assert.Null(shell.ExportTabAsSysmlSnippet("does-not-exist")); + } + + /// + /// Validates that a predefined view with zero expose members (a valid, unscoped "expose everything" view) + /// cannot be exported, since there is no finite expose list to serialize - it is reported gracefully + /// rather than throwing. + /// + [Fact] + public async Task ExportTabAsSysmlSnippet_PredefinedViewWithNoExposeMembers_ReturnsNull() + { + // Arrange: a workspace whose predefined view declares no expose members at all + await File.WriteAllTextAsync( + Path.Combine(_tempRoot, "Sample.sysml"), + "package Sample {\n" + + " part def Engine;\n" + + " view UnscopedView {\n" + + " render asGeneralDiagram;\n" + + " }\n" + + "}\n", + TestContext.Current.CancellationToken); + using var shell = CreateShell(); + await shell.AddFolderSourceAsync(_tempRoot); + var view = shell.ViewCatalog.AvailableViews[0]; + shell.SelectPredefinedView(view.QualifiedName); + var tabId = shell.ActiveTabId!; + + // Act / Assert + Assert.False(shell.CanExportTabAsSysml(tabId)); + Assert.Null(shell.ExportTabAsSysmlSnippet(tabId)); + } + + /// + /// Validates that mirrors the same true/false outcomes + /// as across the exportable, unknown-tab, and + /// no-expose-members cases. + /// + [Fact] + public async Task CanExportTabAsSysml_MirrorsExportability() + { + // Arrange: an exportable predefined-view tab + await WriteSampleWorkspaceAsync(); + using var shell = CreateShell(); + await shell.AddFolderSourceAsync(_tempRoot); + var view = shell.ViewCatalog.AvailableViews[0]; + shell.SelectPredefinedView(view.QualifiedName); + var exportableTabId = shell.ActiveTabId!; + + // Assert: exportable tab + Assert.True(shell.CanExportTabAsSysml(exportableTabId)); + Assert.NotNull(shell.ExportTabAsSysmlSnippet(exportableTabId)); + + // Assert: unknown tab + Assert.False(shell.CanExportTabAsSysml("unknown-tab")); + Assert.Null(shell.ExportTabAsSysmlSnippet("unknown-tab")); + } + /// /// Validates that selecting a predefined view is rejected while the workspace has zero sources. /// diff --git a/test/DemaConsulting.SysML2Workbench.Tests/ViewBuilderSubsystem/ViewDefinitionModelTests.cs b/test/DemaConsulting.SysML2Workbench.Tests/ViewBuilderSubsystem/ViewDefinitionModelTests.cs index ae5c245..3f25073 100644 --- a/test/DemaConsulting.SysML2Workbench.Tests/ViewBuilderSubsystem/ViewDefinitionModelTests.cs +++ b/test/DemaConsulting.SysML2Workbench.Tests/ViewBuilderSubsystem/ViewDefinitionModelTests.cs @@ -335,6 +335,51 @@ public void DefinitionState_ReportsRenderAndExportReadiness() Assert.True(definition.IsReadyToExport); } + /// + /// Validates that the overload appends a fully-specified selection + /// and is a no-op when the exact (qualified name, recursion kind) pair is already present. + /// + [Fact] + public void AddExposeTarget_SelectionOverload_AppendsAndDedupesByQualifiedNameAndRecursionKind() + { + // Arrange: a definition with one selection added via the selection overload + var definition = new ViewDefinitionModel(); + var selection = new ExposeTargetSelection("Sample::Engine", ExposeRecursionKind.NamespaceRecursive, "@Safety"); + definition.AddExposeTarget(selection); + + // Assert: the selection was appended verbatim + var stored = Assert.Single(definition.ExposeTargets); + Assert.Equal("Sample::Engine", stored.QualifiedName); + Assert.Equal(ExposeRecursionKind.NamespaceRecursive, stored.RecursionKind); + Assert.Equal("@Safety", stored.BracketFilterExpression); + + // Act: re-add the exact same (qualified name, recursion kind) pair but with a different bracket filter + definition.AddExposeTarget(new ExposeTargetSelection("Sample::Engine", ExposeRecursionKind.NamespaceRecursive, "@Different")); + + // Assert: the re-add was a no-op, preserving the originally-added selection's bracket filter + var afterReAdd = Assert.Single(definition.ExposeTargets); + Assert.Equal("@Safety", afterReAdd.BracketFilterExpression); + + // Act: add the same qualified name under a different recursion kind + definition.AddExposeTarget(new ExposeTargetSelection("Sample::Engine", ExposeRecursionKind.MembershipExact)); + + // Assert: both selections are now present, one per recursion kind + Assert.Equal(2, definition.ExposeTargets.Count); + Assert.Contains(definition.ExposeTargets, t => t.RecursionKind == ExposeRecursionKind.NamespaceRecursive); + Assert.Contains(definition.ExposeTargets, t => t.RecursionKind == ExposeRecursionKind.MembershipExact); + } + + /// + /// Validates that the overload rejects a null selection. + /// + [Fact] + public void AddExposeTarget_SelectionOverload_NullSelection_ThrowsArgumentNullException() + { + var definition = new ViewDefinitionModel(); + + Assert.Throws(() => definition.AddExposeTarget((ExposeTargetSelection)null!)); + } + /// /// Validates that a definition targeting real workspace elements validates without diagnostics. /// diff --git a/test/DemaConsulting.SysML2Workbench.Tests/ViewCatalogSubsystem/ViewCatalogPresenterTests.cs b/test/DemaConsulting.SysML2Workbench.Tests/ViewCatalogSubsystem/ViewCatalogPresenterTests.cs index 53dd08e..7c7d1fa 100644 --- a/test/DemaConsulting.SysML2Workbench.Tests/ViewCatalogSubsystem/ViewCatalogPresenterTests.cs +++ b/test/DemaConsulting.SysML2Workbench.Tests/ViewCatalogSubsystem/ViewCatalogPresenterTests.cs @@ -1,3 +1,4 @@ +using DemaConsulting.SysML2Tools.Semantic.Model; using DemaConsulting.SysML2Workbench.LayoutRenderingSubsystem; using DemaConsulting.SysML2Workbench.ViewCatalogSubsystem; using DemaConsulting.SysML2Workbench.WorkspaceSubsystem; @@ -150,4 +151,107 @@ await File.WriteAllTextAsync( Assert.Null(presenter.SelectedViewId); Assert.Null(presenter.GetSelectedView()); } + + /// + /// Loads a small workspace containing a view with multiple expose members (differing recursion kinds and + /// a bracket filter on the recursive one) and a filter expression, so + /// has real fidelity to check. + /// + private async Task LoadRichViewWorkspaceAsync() + { + var filePath = Path.Combine(_tempRoot, "Sample.sysml"); + await File.WriteAllTextAsync( + filePath, + "package Sample {\n" + + " part def Engine;\n" + + " part def Wheel;\n" + + " view MyView {\n" + + " expose Engine;\n" + + " expose Wheel::**[@Safety];\n" + + " render asGeneralDiagram;\n" + + " filter @Critical;\n" + + " }\n" + + "}\n", + TestContext.Current.CancellationToken); + + var model = new WorkspaceModel(); + var sourceSet = new WorkspaceSourceSet(); + sourceSet.AddFolder(_tempRoot); + return await model.LoadWorkspaceAsync(sourceSet.Sources, sourceSet.Resolve()); + } + + /// + /// Validates that faithfully reconstructs a + /// predefined view's kind, every expose member (recursion kind and bracket filter included), filter + /// expression, and display name. + /// + [Fact] + public async Task BuildViewDefinition_ResolvesKindExposeAndFilter() + { + // Arrange + var snapshot = await LoadRichViewWorkspaceAsync(); + var presenter = new ViewCatalogPresenter(); + presenter.RefreshCatalog(snapshot.Workspace, snapshot.RevisionId); + + // Act + var definition = presenter.BuildViewDefinition(snapshot.Workspace, "Sample::MyView"); + + // Assert + Assert.NotNull(definition); + Assert.Equal(ViewKind.General, definition!.ViewKind); + Assert.Equal("MyView", definition.DisplayName); + Assert.Equal("@Critical", definition.FilterExpression); + Assert.Equal(2, definition.ExposeTargets.Count); + Assert.Contains(definition.ExposeTargets, t => t.QualifiedName == "Sample::Engine" && t.RecursionKind == ExposeRecursionKind.MembershipExact); + Assert.Contains(definition.ExposeTargets, t => + t.QualifiedName == "Sample::Wheel" + && t.RecursionKind == ExposeRecursionKind.NamespaceRecursive + && t.BracketFilterExpression == "@Safety"); + } + + /// + /// Validates that an unknown view identifier produces rather than throwing. + /// + [Fact] + public async Task BuildViewDefinition_UnknownViewId_ReturnsNull() + { + var snapshot = await LoadSingleViewWorkspaceAsync(); + var presenter = new ViewCatalogPresenter(); + presenter.RefreshCatalog(snapshot.Workspace, snapshot.RevisionId); + + var definition = presenter.BuildViewDefinition(snapshot.Workspace, "Sample::DoesNotExist"); + + Assert.Null(definition); + } + + /// + /// Validates that a predefined view with zero expose members - a valid, unscoped "expose everything" view + /// - produces since there is no finite expose list to reconstruct. + /// + [Fact] + public async Task BuildViewDefinition_NoExposeMembers_ReturnsNull() + { + var filePath = Path.Combine(_tempRoot, "Sample.sysml"); + await File.WriteAllTextAsync( + filePath, + "package Sample {\n" + + " part def Engine;\n" + + " view MyView {\n" + + " render asGeneralDiagram;\n" + + " }\n" + + "}\n", + TestContext.Current.CancellationToken); + + var model = new WorkspaceModel(); + var sourceSet = new WorkspaceSourceSet(); + sourceSet.AddFolder(_tempRoot); + var snapshot = await model.LoadWorkspaceAsync(sourceSet.Sources, sourceSet.Resolve()); + + var presenter = new ViewCatalogPresenter(); + presenter.RefreshCatalog(snapshot.Workspace, snapshot.RevisionId); + + var definition = presenter.BuildViewDefinition(snapshot.Workspace, "Sample::MyView"); + + Assert.Null(definition); + } } diff --git a/test/OtsSoftwareTests/AvaloniaTests.cs b/test/OtsSoftwareTests/AvaloniaTests.cs index e7d17b9..29963f3 100644 --- a/test/OtsSoftwareTests/AvaloniaTests.cs +++ b/test/OtsSoftwareTests/AvaloniaTests.cs @@ -1,6 +1,8 @@ using Avalonia; using Avalonia.Controls; using Avalonia.Headless.XUnit; +using Avalonia.Input.Platform; +using Avalonia.Interactivity; using Avalonia.Threading; using Avalonia.VisualTree; using DemaConsulting.SysML2Workbench.AppShellSubsystem; @@ -128,6 +130,62 @@ await File.WriteAllTextAsync( window.Close(); } + /// + /// Validates the real end-to-end "Copy as SysML" diagram-tab context-menu integration: opening a + /// workspace, rendering a predefined view into a real hosted by + /// , invoking its "Copy as SysML" MenuItem, and confirming the + /// headless platform's real TopLevel.Clipboard now holds the exact SysML snippet + /// produces for that view's definition. + /// + [AvaloniaFact] + public async Task DiagramContextMenu_CopyAsSysml_CopiesSnippetToClipboard() + { + // Arrange + await File.WriteAllTextAsync( + Path.Combine(_tempRoot, "Sample.sysml"), + "package Sample {\n" + + " part def Engine;\n" + + " view PredefinedView {\n" + + " expose Engine;\n" + + " render asGeneralDiagram;\n" + + " }\n" + + "}\n"); + using var shell = CreateShell(); + var window = new MainWindowView(shell); + window.Show(); + Dispatcher.UIThread.RunJobs(); + + await shell.AddFolderSourceAsync(_tempRoot); + var view = shell.ViewCatalog.AvailableViews[0]; + shell.SelectPredefinedView(view.QualifiedName); + Dispatcher.UIThread.RunJobs(); + + var expectedSnippet = shell.ExportTabAsSysmlSnippet(shell.ActiveTabId!); + Assert.NotNull(expectedSnippet); + + // Act: find the real diagram border, open its context menu, and click the "Copy as SysML" menu item + var diagramBorder = FindByName(window, "DiagramBorder"); + Assert.NotNull(diagramBorder); + var contextMenu = diagramBorder!.ContextMenu; + Assert.NotNull(contextMenu); + contextMenu!.Open(diagramBorder); + Dispatcher.UIThread.RunJobs(); + + var copyMenuItem = FindByName(window, "CopyAsSysmlMenuItem"); + Assert.NotNull(copyMenuItem); + Assert.True(copyMenuItem!.IsEnabled); + copyMenuItem.RaiseEvent(new RoutedEventArgs(MenuItem.ClickEvent)); + Dispatcher.UIThread.RunJobs(); + + // Assert: the headless platform's real clipboard now holds the expected SysML snippet + var clipboard = TopLevel.GetTopLevel(window)?.Clipboard; + Assert.NotNull(clipboard); + var clipboardText = await clipboard!.TryGetTextAsync(); + Assert.Equal(expectedSnippet, clipboardText); + + window.Close(); + } + /// /// Finds a named control anywhere in 's visual tree, regardless of which /// Dock-hosted UserControl's own compiled NameScope it belongs to (unlike a control's own From c88b518fd2cf5c54763a5c795f331c39cafac24e Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Sun, 19 Jul 2026 23:12:21 -0400 Subject: [PATCH 2/3] docs: document per-tab Copy as SysML context menu in user guide The right-click 'Copy as SysML' context menu (added in 62a94d5) applies to every open diagram tab - all predefined view kinds and custom-view previews - via the shared DiagramDocumentView, not just the Custom View Builder panel's own button. The user guide only documented the panel button, leaving the broader context-menu capability undiscoverable. Add a paragraph at the end of 'Browsing Predefined Views' describing the per-tab context menu and its disabled state when no concrete view definition can be derived, and a one-line cross-reference at the end of step 7 in 'Building a Custom View' pointing readers to it. --- docs/user_guide/getting_started.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docs/user_guide/getting_started.md b/docs/user_guide/getting_started.md index 28495ee..5b92ba7 100644 --- a/docs/user_guide/getting_started.md +++ b/docs/user_guide/getting_started.md @@ -79,6 +79,13 @@ The rendered diagram appears as an interactive SVG in its tab's canvas: Each diagram tab has its own independent pan/zoom state, so switching between tabs never disturbs another tab's view of its diagram. +Every open diagram tab - whether it renders a predefined view or a custom +view preview - supports right-click > **Copy as SysML** to copy that tab's +`view { ... }` SysML v2 text to the clipboard. The option is disabled when +no concrete view definition can be derived for the tab, for example an +unscoped predefined view or a custom-view preview tab that has not yet been +rendered. + ## Building a Custom View The **Custom View Builder** panel (right side, dockable) lets you construct an @@ -113,6 +120,9 @@ ad-hoc view without writing SysML syntax: regardless of whether a custom view name was supplied. Paste this into a `.sysml` file to promote the ephemeral preview into a permanent, version-controlled view definition. + The same right-click **Copy as SysML** context menu described in + "Browsing Predefined Views" is also available on this preview's diagram + tab, not just the panel's own button. Custom views built in the GUI are **session-only** - they are not saved to disk by SysML2Workbench itself. The "Copy as SysML" snippet is the only way From 7efb78804b2c4183a6ff7936a3b3203179df942c Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Sun, 19 Jul 2026 23:28:30 -0400 Subject: [PATCH 3/3] fix: prevent right-click pan lock caused by Copy as SysML context menu Border.ContextMenu consumes the right-button pointer release before the diagram's own PointerReleased handler runs, so panning (started for any button) was never stopped, leaving the diagram permanently dragging on subsequent mouse moves. Pan gestures now only start on the left button; right-click is reserved exclusively for the context menu. A PointerCaptureLost handler also resets the panning flag defensively. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/design/ots/dock.md | 12 +++++++++++ .../DiagramDocumentView.axaml.cs | 20 ++++++++++++++++++- 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/docs/design/ots/dock.md b/docs/design/ots/dock.md index 8b3b7d9..e3c8e4d 100644 --- a/docs/design/ots/dock.md +++ b/docs/design/ots/dock.md @@ -179,3 +179,15 @@ once it has a view model, and assigns it to the view model's `ClipboardService` property; production code never touches `TopLevel.Clipboard` directly from the view model, keeping the view model UI-framework-agnostic aside from that one injected seam. + +Adding this `ContextMenu` exposed a pre-existing pan-gesture bug: the same +`Border`'s pointer handlers previously started a pan on *any* pointer button, +including the right button. Since opening a `ContextMenu` on right-button +release consumes that release before it reaches the `Border`'s own +`PointerReleased` handler, panning was left permanently "stuck on" after +every right-click - the very next pointer move (even a plain mouse move with +no button held) dragged the diagram. `DiagramDocumentView` now only starts a +pan on `PointerPressed` when `PointerPoint.Properties.IsLeftButtonPressed` is +true, so the right button is reserved exclusively for the context menu, and +a `PointerCaptureLost` handler resets the panning flag defensively in case +capture is ever stolen mid-drag for some other reason. diff --git a/src/DemaConsulting.SysML2Workbench/AppShellSubsystem/DiagramDocumentView.axaml.cs b/src/DemaConsulting.SysML2Workbench/AppShellSubsystem/DiagramDocumentView.axaml.cs index 2fc2f0c..8246e47 100644 --- a/src/DemaConsulting.SysML2Workbench/AppShellSubsystem/DiagramDocumentView.axaml.cs +++ b/src/DemaConsulting.SysML2Workbench/AppShellSubsystem/DiagramDocumentView.axaml.cs @@ -33,6 +33,7 @@ public DiagramDocumentView() DiagramBorder.PointerPressed += OnDiagramPointerPressed; DiagramBorder.PointerMoved += OnDiagramPointerMoved; DiagramBorder.PointerReleased += OnDiagramPointerReleased; + DiagramBorder.PointerCaptureLost += OnDiagramPointerCaptureLost; DataContextChanged += OnDataContextChanged; @@ -87,9 +88,16 @@ private void OnDiagramPointerWheelChanged(object? sender, PointerWheelEventArgs e.Handled = true; } + /// + /// Begins a left-button pan gesture. The right button is deliberately excluded: it opens the diagram's + /// context menu (the "Copy as SysML" item) instead, and a right-button press was previously starting a + /// pan here too - since opening the context menu on release consumes that pointer release before + /// could observe it, panning was left stuck on until the next + /// unrelated pointer press, making the diagram appear to drag itself after every right-click. + /// private void OnDiagramPointerPressed(object? sender, PointerPressedEventArgs e) { - if (_viewModel is null || !_viewModel.Canvas.IsContentLoaded) + if (_viewModel is null || !_viewModel.Canvas.IsContentLoaded || !e.GetCurrentPoint(DiagramBorder).Properties.IsLeftButtonPressed) { return; } @@ -118,6 +126,16 @@ private void OnDiagramPointerReleased(object? sender, PointerReleasedEventArgs e _isPanning = false; } + /// + /// Defensive backstop: if pointer capture is lost mid-pan for any reason other than a normal release + /// (for example another control or popup stealing capture), panning must still stop rather than staying + /// stuck on until the next pointer press. + /// + private void OnDiagramPointerCaptureLost(object? sender, PointerCaptureLostEventArgs e) + { + _isPanning = false; + } + /// /// Loads this tab's currently active diagram SVG into the on-screen image control. ///