From e5756348c1b23be269ee4033628fdf8d4373b15f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 22:10:51 +0000 Subject: [PATCH 01/11] CI: also build the app for the iOS Simulator Catches changes that break the Xcode project (missing file references, compile errors in the app target), which swift test on VisuStructCore cannot see. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Cp4EQgFQyGbM8MVLiWbJKQ --- .github/workflows/core-tests.yml | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/.github/workflows/core-tests.yml b/.github/workflows/core-tests.yml index b9263bb7..01bc2764 100644 --- a/.github/workflows/core-tests.yml +++ b/.github/workflows/core-tests.yml @@ -1,4 +1,4 @@ -name: VisuStructCore tests +name: CI on: push: @@ -17,3 +17,17 @@ jobs: run: swift --version - name: Build and test run: swift test + + app-build: + runs-on: macos-15 + steps: + - uses: actions/checkout@v4 + - name: Xcode version + run: xcodebuild -version + - name: Build app for iOS Simulator + run: | + xcodebuild build \ + -project VisuStruct-iOS/VisuStructXcodeOpen/VisuStruct.xcodeproj \ + -scheme VisuStruct \ + -destination 'generic/platform=iOS Simulator' \ + CODE_SIGNING_ALLOWED=NO From c1902bc394f4180131a18c0d85ed88d098593bd9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 22:12:12 +0000 Subject: [PATCH 02/11] Xcode project: use a synchronized folder for VisuStruct/ New, moved or deleted files under VisuStruct/ are picked up by Xcode automatically, so later refactoring steps need no project.pbxproj edits. Info.plist and the unused java.lproj/Localizable.strings stay out of the target, as before (Java.strings is used instead). Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Cp4EQgFQyGbM8MVLiWbJKQ --- .../VisuStruct.xcodeproj/project.pbxproj | 248 ++---------------- 1 file changed, 28 insertions(+), 220 deletions(-) diff --git a/VisuStruct-iOS/VisuStructXcodeOpen/VisuStruct.xcodeproj/project.pbxproj b/VisuStruct-iOS/VisuStructXcodeOpen/VisuStruct.xcodeproj/project.pbxproj index ed6f99c9..94e8be22 100644 --- a/VisuStruct-iOS/VisuStructXcodeOpen/VisuStruct.xcodeproj/project.pbxproj +++ b/VisuStruct-iOS/VisuStructXcodeOpen/VisuStruct.xcodeproj/project.pbxproj @@ -3,118 +3,39 @@ archiveVersion = 1; classes = { }; - objectVersion = 60; + objectVersion = 77; objects = { /* Begin PBXBuildFile section */ - AA0000000000000000000001 /* VisuStructApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA1000000000000000000001 /* VisuStructApp.swift */; }; - AA0000000000000000000002 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA1000000000000000000002 /* ContentView.swift */; }; - AA0000000000000000000003 /* DiagramCanvasView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA1000000000000000000003 /* DiagramCanvasView.swift */; }; - AA0000000000000000000004 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = AA1000000000000000000004 /* Assets.xcassets */; }; AA0000000000000000000005 /* VisuStructCore in Frameworks */ = {isa = PBXBuildFile; productRef = AA2000000000000000000001 /* VisuStructCore */; }; - AA0000000000000000000101 /* AppLanguage.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA1000000000000000000005 /* AppLanguage.swift */; }; - AA0000000000000000000102 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = AA1000000000000000000006 /* Localizable.strings */; }; - AA0000000000000000000103 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = AA1000000000000000000007 /* Localizable.strings */; }; - AA0000000000000000000104 /* Settings.bundle in Resources */ = {isa = PBXBuildFile; fileRef = AA1000000000000000000008 /* Settings.bundle */; }; - AA0000000000000000000105 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = AA1000000000000000000009 /* Localizable.strings */; }; - AA0000000000000000000109 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = AA100000000000000000000D /* Localizable.strings */; }; - AA0000000000000000000110 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = AA100000000000000000000E /* Localizable.strings */; }; - AA0000000000000000000111 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = AA100000000000000000000F /* Localizable.strings */; }; - AA0000000000000000000112 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = AA1000000000000000000010 /* Localizable.strings */; }; - AA0000000000000000000113 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = AA1000000000000000000011 /* Localizable.strings */; }; - AA0000000000000000000114 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = AA1000000000000000000012 /* Localizable.strings */; }; - AA0000000000000000000115 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = AA1000000000000000000013 /* Localizable.strings */; }; - AA0000000000000000000116 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = AA1000000000000000000014 /* Localizable.strings */; }; - AA0000000000000000000117 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = AA1000000000000000000015 /* Localizable.strings */; }; - AA0000000000000000000118 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = AA1000000000000000000016 /* Localizable.strings */; }; - AA0000000000000000000119 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = AA1000000000000000000017 /* Localizable.strings */; }; - AA0000000000000000000120 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = AA1000000000000000000018 /* Localizable.strings */; }; - AA0000000000000000000121 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = AA1000000000000000000019 /* Localizable.strings */; }; - AA0000000000000000000122 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = AA100000000000000000001A /* Localizable.strings */; }; - AA0000000000000000000123 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = AA100000000000000000001B /* Localizable.strings */; }; - AA0000000000000000000124 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = AA100000000000000000001C /* Localizable.strings */; }; - AA0000000000000000000125 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = AA100000000000000000001D /* Localizable.strings */; }; - AA0000000000000000000126 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = AA100000000000000000001E /* Localizable.strings */; }; - AA0000000000000000000107 /* DiagramDragPayload.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA100000000000000000000B /* DiagramDragPayload.swift */; }; - AA0000000000000000000108 /* Java.strings in Resources */ = {isa = PBXBuildFile; fileRef = AA100000000000000000000C /* Java.strings */; }; - AA0000000000000000000127 /* VisuStructUIScale.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA100000000000000000001F /* VisuStructUIScale.swift */; }; - AA0000000000000000000128 /* RecentFilesStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA1000000000000000000020 /* RecentFilesStore.swift */; }; - AA0000000000000000000129 /* CodeLineEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA1000000000000000000021 /* CodeLineEditor.swift */; }; - AA0000000000000000000130 /* VisuStructFileDocuments.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA1000000000000000000022 /* VisuStructFileDocuments.swift */; }; - AA0000000000000000000146 /* DiagramEditorDocumentServices.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA1000000000000000000038 /* DiagramEditorDocumentServices.swift */; }; - AA0000000000000000000131 /* ElementPalette.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA1000000000000000000023 /* ElementPalette.swift */; }; - AA0000000000000000000132 /* DiagramSheetTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA1000000000000000000024 /* DiagramSheetTab.swift */; }; - AA0000000000000000000133 /* AppStorageEnums.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA1000000000000000000025 /* AppStorageEnums.swift */; }; - AA0000000000000000000134 /* ViewPanelChrome.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA1000000000000000000026 /* ViewPanelChrome.swift */; }; - AA0000000000000000000135 /* SimulationPanels.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA1000000000000000000027 /* SimulationPanels.swift */; }; - AA0000000000000000000136 /* CodeGeneratorPanels.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA1000000000000000000028 /* CodeGeneratorPanels.swift */; }; - AA0000000000000000000137 /* JavaScriptRunner.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA1000000000000000000029 /* JavaScriptRunner.swift */; }; - AA0000000000000000000138 /* AboutVisuStructView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA1000000000000000000030 /* AboutVisuStructView.swift */; }; - AA0000000000000000000139 /* DiagramEditorFocusTarget.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA1000000000000000000031 /* DiagramEditorFocusTarget.swift */; }; - AA0000000000000000000140 /* StatementIOMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA1000000000000000000032 /* StatementIOMode.swift */; }; - AA0000000000000000000141 /* InspectorElementTypeLocalization.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA1000000000000000000033 /* InspectorElementTypeLocalization.swift */; }; - AA0000000000000000000142 /* InspectorPanelViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA1000000000000000000034 /* InspectorPanelViews.swift */; }; - AA0000000000000000000143 /* DiagramMainEditorToolbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA1000000000000000000035 /* DiagramMainEditorToolbar.swift */; }; - AA0000000000000000000144 /* DiagramTabStrip.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA1000000000000000000036 /* DiagramTabStrip.swift */; }; - AA0000000000000000000145 /* DiagramSheetTabsModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA1000000000000000000037 /* DiagramSheetTabsModel.swift */; }; - AA0000000000000000000147 /* DiagramSnapshotAndExport.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA1000000000000000000039 /* DiagramSnapshotAndExport.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ AA1000000000000000000000 /* VisuStruct.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = VisuStruct.app; sourceTree = BUILT_PRODUCTS_DIR; }; - AA1000000000000000000001 /* VisuStructApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VisuStructApp.swift; sourceTree = ""; }; - AA1000000000000000000002 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; - AA1000000000000000000003 /* DiagramCanvasView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiagramCanvasView.swift; sourceTree = ""; }; - AA1000000000000000000004 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; - AA1000000000000000000005 /* AppLanguage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLanguage.swift; sourceTree = ""; }; - AA1000000000000000000006 /* Localizable.strings */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = Localizable.strings; path = de.lproj/Localizable.strings; sourceTree = ""; }; - AA1000000000000000000007 /* Localizable.strings */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = Localizable.strings; path = en.lproj/Localizable.strings; sourceTree = ""; }; - AA1000000000000000000008 /* Settings.bundle */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = Settings.bundle; sourceTree = ""; }; - AA1000000000000000000009 /* Localizable.strings */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = Localizable.strings; path = "pt-PT.lproj/Localizable.strings"; sourceTree = ""; }; - AA100000000000000000000D /* Localizable.strings */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = Localizable.strings; path = es.lproj/Localizable.strings; sourceTree = ""; }; - AA100000000000000000000E /* Localizable.strings */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = Localizable.strings; path = fr.lproj/Localizable.strings; sourceTree = ""; }; - AA100000000000000000000F /* Localizable.strings */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = Localizable.strings; path = it.lproj/Localizable.strings; sourceTree = ""; }; - AA1000000000000000000010 /* Localizable.strings */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = Localizable.strings; path = pl.lproj/Localizable.strings; sourceTree = ""; }; - AA1000000000000000000011 /* Localizable.strings */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = Localizable.strings; path = tr.lproj/Localizable.strings; sourceTree = ""; }; - AA1000000000000000000012 /* Localizable.strings */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = Localizable.strings; path = "zh-Hans.lproj/Localizable.strings"; sourceTree = ""; }; - AA1000000000000000000013 /* Localizable.strings */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = Localizable.strings; path = "zh-Hant.lproj/Localizable.strings"; sourceTree = ""; }; - AA1000000000000000000014 /* Localizable.strings */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = Localizable.strings; path = ko.lproj/Localizable.strings; sourceTree = ""; }; - AA1000000000000000000015 /* Localizable.strings */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = Localizable.strings; path = ja.lproj/Localizable.strings; sourceTree = ""; }; - AA1000000000000000000016 /* Localizable.strings */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = Localizable.strings; path = el.lproj/Localizable.strings; sourceTree = ""; }; - AA1000000000000000000017 /* Localizable.strings */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = Localizable.strings; path = uk.lproj/Localizable.strings; sourceTree = ""; }; - AA1000000000000000000018 /* Localizable.strings */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = Localizable.strings; path = ar.lproj/Localizable.strings; sourceTree = ""; }; - AA1000000000000000000019 /* Localizable.strings */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = Localizable.strings; path = ru.lproj/Localizable.strings; sourceTree = ""; }; - AA100000000000000000001A /* Localizable.strings */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = Localizable.strings; path = he.lproj/Localizable.strings; sourceTree = ""; }; - AA100000000000000000001B /* Localizable.strings */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = Localizable.strings; path = nl.lproj/Localizable.strings; sourceTree = ""; }; - AA100000000000000000001C /* Localizable.strings */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = Localizable.strings; path = sv.lproj/Localizable.strings; sourceTree = ""; }; - AA100000000000000000001D /* Localizable.strings */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = Localizable.strings; path = da.lproj/Localizable.strings; sourceTree = ""; }; - AA100000000000000000001E /* Localizable.strings */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = Localizable.strings; path = nb.lproj/Localizable.strings; sourceTree = ""; }; - AA100000000000000000000B /* DiagramDragPayload.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiagramDragPayload.swift; sourceTree = ""; }; - AA100000000000000000000C /* Java.strings */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; path = Java.strings; sourceTree = ""; }; - AA100000000000000000001F /* VisuStructUIScale.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VisuStructUIScale.swift; sourceTree = ""; }; - AA1000000000000000000020 /* RecentFilesStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecentFilesStore.swift; sourceTree = ""; }; - AA1000000000000000000021 /* CodeLineEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Editing/CodeLineEditor.swift; sourceTree = ""; }; - AA1000000000000000000022 /* VisuStructFileDocuments.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Documents/VisuStructFileDocuments.swift; sourceTree = ""; }; - AA1000000000000000000038 /* DiagramEditorDocumentServices.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Documents/DiagramEditorDocumentServices.swift; sourceTree = ""; }; - AA1000000000000000000023 /* ElementPalette.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Palette/ElementPalette.swift; sourceTree = ""; }; - AA1000000000000000000024 /* DiagramSheetTab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tabs/DiagramSheetTab.swift; sourceTree = ""; }; - AA1000000000000000000025 /* AppStorageEnums.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Preferences/AppStorageEnums.swift; sourceTree = ""; }; - AA1000000000000000000026 /* ViewPanelChrome.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Supporting/ViewPanelChrome.swift; sourceTree = ""; }; - AA1000000000000000000027 /* SimulationPanels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Simulation/SimulationPanels.swift; sourceTree = ""; }; - AA1000000000000000000028 /* CodeGeneratorPanels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodeGeneration/CodeGeneratorPanels.swift; sourceTree = ""; }; - AA1000000000000000000029 /* JavaScriptRunner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Runner/JavaScriptRunner.swift; sourceTree = ""; }; - AA1000000000000000000030 /* AboutVisuStructView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = About/AboutVisuStructView.swift; sourceTree = ""; }; - AA1000000000000000000031 /* DiagramEditorFocusTarget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Supporting/DiagramEditorFocusTarget.swift; sourceTree = ""; }; - AA1000000000000000000032 /* StatementIOMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Inspector/StatementIOMode.swift; sourceTree = ""; }; - AA1000000000000000000033 /* InspectorElementTypeLocalization.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Inspector/InspectorElementTypeLocalization.swift; sourceTree = ""; }; - AA1000000000000000000034 /* InspectorPanelViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Inspector/InspectorPanelViews.swift; sourceTree = ""; }; - AA1000000000000000000035 /* DiagramMainEditorToolbar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Toolbar/DiagramMainEditorToolbar.swift; sourceTree = ""; }; - AA1000000000000000000036 /* DiagramTabStrip.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tabs/DiagramTabStrip.swift; sourceTree = ""; }; - AA1000000000000000000037 /* DiagramSheetTabsModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tabs/DiagramSheetTabsModel.swift; sourceTree = ""; }; - AA1000000000000000000039 /* DiagramSnapshotAndExport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Export/DiagramSnapshotAndExport.swift; sourceTree = ""; }; - AA100000000000000000003A /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; /* End PBXFileReference section */ +/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ + AA8000000000000000000002 /* Exceptions for "VisuStruct" folder in "VisuStruct" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + java.lproj/Localizable.strings, + ); + target = AA5000000000000000000001 /* VisuStruct */; + }; +/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + AA8000000000000000000001 /* VisuStruct */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + AA8000000000000000000002 /* Exceptions for "VisuStruct" folder in "VisuStruct" target */, + ); + path = VisuStruct; + sourceTree = ""; + }; +/* End PBXFileSystemSynchronizedRootGroup section */ + /* Begin PBXFrameworksBuildPhase section */ AA3000000000000000000001 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; @@ -130,77 +51,11 @@ AA4000000000000000000001 = { isa = PBXGroup; children = ( - AA4000000000000000000002 /* VisuStruct */, + AA8000000000000000000001 /* VisuStruct */, AA4000000000000000000003 /* Products */, ); sourceTree = ""; }; - AA4000000000000000000002 /* VisuStruct */ = { - isa = PBXGroup; - children = ( - AA1000000000000000000001 /* VisuStructApp.swift */, - AA100000000000000000003A /* Info.plist */, - AA1000000000000000000005 /* AppLanguage.swift */, - AA1000000000000000000002 /* ContentView.swift */, - AA1000000000000000000039 /* DiagramSnapshotAndExport.swift */, - AA1000000000000000000020 /* RecentFilesStore.swift */, - AA1000000000000000000021 /* CodeLineEditor.swift */, - AA1000000000000000000022 /* VisuStructFileDocuments.swift */, - AA1000000000000000000038 /* DiagramEditorDocumentServices.swift */, - AA1000000000000000000023 /* ElementPalette.swift */, - AA1000000000000000000024 /* DiagramSheetTab.swift */, - AA1000000000000000000036 /* DiagramTabStrip.swift */, - AA1000000000000000000037 /* DiagramSheetTabsModel.swift */, - AA1000000000000000000025 /* AppStorageEnums.swift */, - AA1000000000000000000026 /* ViewPanelChrome.swift */, - AA1000000000000000000027 /* SimulationPanels.swift */, - AA1000000000000000000028 /* CodeGeneratorPanels.swift */, - AA1000000000000000000029 /* JavaScriptRunner.swift */, - AA1000000000000000000030 /* AboutVisuStructView.swift */, - AA1000000000000000000031 /* DiagramEditorFocusTarget.swift */, - AA1000000000000000000032 /* StatementIOMode.swift */, - AA1000000000000000000033 /* InspectorElementTypeLocalization.swift */, - AA1000000000000000000034 /* InspectorPanelViews.swift */, - AA1000000000000000000035 /* DiagramMainEditorToolbar.swift */, - AA4000000000000000000004 /* Canvas */, - AA100000000000000000001F /* VisuStructUIScale.swift */, - AA1000000000000000000004 /* Assets.xcassets */, - AA1000000000000000000006 /* Localizable.strings */, - AA1000000000000000000007 /* Localizable.strings */, - AA100000000000000000000D /* Localizable.strings */, - AA100000000000000000000E /* Localizable.strings */, - AA100000000000000000000F /* Localizable.strings */, - AA1000000000000000000010 /* Localizable.strings */, - AA1000000000000000000011 /* Localizable.strings */, - AA1000000000000000000012 /* Localizable.strings */, - AA1000000000000000000013 /* Localizable.strings */, - AA1000000000000000000014 /* Localizable.strings */, - AA1000000000000000000015 /* Localizable.strings */, - AA1000000000000000000016 /* Localizable.strings */, - AA1000000000000000000017 /* Localizable.strings */, - AA1000000000000000000018 /* Localizable.strings */, - AA1000000000000000000019 /* Localizable.strings */, - AA100000000000000000001A /* Localizable.strings */, - AA100000000000000000001B /* Localizable.strings */, - AA100000000000000000001C /* Localizable.strings */, - AA100000000000000000001D /* Localizable.strings */, - AA100000000000000000001E /* Localizable.strings */, - AA1000000000000000000009 /* Localizable.strings */, - AA100000000000000000000C /* Java.strings */, - AA1000000000000000000008 /* Settings.bundle */, - ); - path = VisuStruct; - sourceTree = ""; - }; - AA4000000000000000000004 /* Canvas */ = { - isa = PBXGroup; - children = ( - AA1000000000000000000003 /* DiagramCanvasView.swift */, - AA100000000000000000000B /* DiagramDragPayload.swift */, - ); - path = Canvas; - sourceTree = ""; - }; AA4000000000000000000003 /* Products */ = { isa = PBXGroup; children = ( @@ -224,6 +79,9 @@ ); dependencies = ( ); + fileSystemSynchronizedGroups = ( + AA8000000000000000000001 /* VisuStruct */, + ); name = VisuStruct; packageProductDependencies = ( AA2000000000000000000001 /* VisuStructCore */, @@ -248,7 +106,6 @@ }; }; buildConfigurationList = AA6000000000000000000001 /* Build configuration list for PBXProject "VisuStruct" */; - compatibilityVersion = "Xcode 15.0"; developmentRegion = de; hasScannedForEncodings = 0; knownRegions = ( @@ -276,6 +133,7 @@ Base, ); mainGroup = AA4000000000000000000001; + preferredProjectObjectVersion = 77; packageReferences = ( AA2000000000000000000002 /* XCLocalSwiftPackageReference "../VisuStructCore" */, ); @@ -293,30 +151,6 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( - AA0000000000000000000004 /* Assets.xcassets in Resources */, - AA0000000000000000000102 /* Localizable.strings in Resources */, - AA0000000000000000000103 /* Localizable.strings in Resources */, - AA0000000000000000000109 /* Localizable.strings in Resources */, - AA0000000000000000000110 /* Localizable.strings in Resources */, - AA0000000000000000000111 /* Localizable.strings in Resources */, - AA0000000000000000000112 /* Localizable.strings in Resources */, - AA0000000000000000000113 /* Localizable.strings in Resources */, - AA0000000000000000000114 /* Localizable.strings in Resources */, - AA0000000000000000000115 /* Localizable.strings in Resources */, - AA0000000000000000000116 /* Localizable.strings in Resources */, - AA0000000000000000000117 /* Localizable.strings in Resources */, - AA0000000000000000000118 /* Localizable.strings in Resources */, - AA0000000000000000000119 /* Localizable.strings in Resources */, - AA0000000000000000000120 /* Localizable.strings in Resources */, - AA0000000000000000000121 /* Localizable.strings in Resources */, - AA0000000000000000000122 /* Localizable.strings in Resources */, - AA0000000000000000000123 /* Localizable.strings in Resources */, - AA0000000000000000000124 /* Localizable.strings in Resources */, - AA0000000000000000000125 /* Localizable.strings in Resources */, - AA0000000000000000000126 /* Localizable.strings in Resources */, - AA0000000000000000000105 /* Localizable.strings in Resources */, - AA0000000000000000000108 /* Java.strings in Resources */, - AA0000000000000000000104 /* Settings.bundle in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -327,32 +161,6 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - AA0000000000000000000001 /* VisuStructApp.swift in Sources */, - AA0000000000000000000101 /* AppLanguage.swift in Sources */, - AA0000000000000000000002 /* ContentView.swift in Sources */, - AA0000000000000000000147 /* DiagramSnapshotAndExport.swift in Sources */, - AA0000000000000000000128 /* RecentFilesStore.swift in Sources */, - AA0000000000000000000129 /* CodeLineEditor.swift in Sources */, - AA0000000000000000000130 /* VisuStructFileDocuments.swift in Sources */, - AA0000000000000000000146 /* DiagramEditorDocumentServices.swift in Sources */, - AA0000000000000000000131 /* ElementPalette.swift in Sources */, - AA0000000000000000000132 /* DiagramSheetTab.swift in Sources */, - AA0000000000000000000144 /* DiagramTabStrip.swift in Sources */, - AA0000000000000000000145 /* DiagramSheetTabsModel.swift in Sources */, - AA0000000000000000000133 /* AppStorageEnums.swift in Sources */, - AA0000000000000000000134 /* ViewPanelChrome.swift in Sources */, - AA0000000000000000000135 /* SimulationPanels.swift in Sources */, - AA0000000000000000000136 /* CodeGeneratorPanels.swift in Sources */, - AA0000000000000000000137 /* JavaScriptRunner.swift in Sources */, - AA0000000000000000000138 /* AboutVisuStructView.swift in Sources */, - AA0000000000000000000139 /* DiagramEditorFocusTarget.swift in Sources */, - AA0000000000000000000140 /* StatementIOMode.swift in Sources */, - AA0000000000000000000141 /* InspectorElementTypeLocalization.swift in Sources */, - AA0000000000000000000142 /* InspectorPanelViews.swift in Sources */, - AA0000000000000000000143 /* DiagramMainEditorToolbar.swift in Sources */, - AA0000000000000000000003 /* DiagramCanvasView.swift in Sources */, - AA0000000000000000000107 /* DiagramDragPayload.swift in Sources */, - AA0000000000000000000127 /* VisuStructUIScale.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; From 21127979afb68ea97ee2c9c60e1cea70eda28cbf Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 22:14:53 +0000 Subject: [PATCH 03/11] Move inspector and canvas text logic into VisuStructCore IO-prefix handling, keyword stripping for the line editor, quote normalization and for-loop field handling move from ContentView into VisuStructLineEditorText; the canvas display text (added keywords, until -> while, keyword split) moves into VisuStructDiagramText. Both are now covered by swift test. Removes two unused helpers (stripLineEditorPrefix, stripKeywordPrefix). No behavior change. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Cp4EQgFQyGbM8MVLiWbJKQ --- .../Editing/VisuStructLineEditorText.swift | 142 ++++++++++++++ .../Layout/VisuStructDiagramText.swift | 86 +++++++++ .../DiagramTextTests.swift | 31 +++ .../LineEditorTextTests.swift | 41 ++++ .../VisuStruct/Canvas/DiagramCanvasView.swift | 87 +-------- .../VisuStruct/ContentView.swift | 181 ++---------------- .../Inspector/StatementIOMode.swift | 10 +- 7 files changed, 321 insertions(+), 257 deletions(-) create mode 100644 VisuStruct-iOS/VisuStructCore/Sources/VisuStructCore/Editing/VisuStructLineEditorText.swift create mode 100644 VisuStruct-iOS/VisuStructCore/Sources/VisuStructCore/Layout/VisuStructDiagramText.swift create mode 100644 VisuStruct-iOS/VisuStructCore/Tests/VisuStructCoreTests/DiagramTextTests.swift create mode 100644 VisuStruct-iOS/VisuStructCore/Tests/VisuStructCoreTests/LineEditorTextTests.swift diff --git a/VisuStruct-iOS/VisuStructCore/Sources/VisuStructCore/Editing/VisuStructLineEditorText.swift b/VisuStruct-iOS/VisuStructCore/Sources/VisuStructCore/Editing/VisuStructLineEditorText.swift new file mode 100644 index 00000000..6b16d850 --- /dev/null +++ b/VisuStruct-iOS/VisuStructCore/Sources/VisuStructCore/Editing/VisuStructLineEditorText.swift @@ -0,0 +1,142 @@ +import Foundation + +/// Darstellung von Anweisung vs. Ein-/Ausgabe im Inspector (Modus-Picker). +public enum VisuStructStatementIOMode: String, CaseIterable, Hashable, Sendable { + case statement + case input + case output +} + +/// Textumformungen für den Zeileneditor im Inspector (ohne UI, daher testbar). +public enum VisuStructLineEditorText { + public static func ioMode(of text: String) -> VisuStructStatementIOMode { + let t = text.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + if t.hasPrefix("input:") { return .input } + if t.hasPrefix("output:") { return .output } + if t.hasPrefix("print:") { return .output } + return .statement + } + + /// Entfernt `input:` / `output:` / `print:` und umgebende Leerzeichen; ohne Präfix bleibt der Text unverändert. + public static func strippingIOPrefix(from s: String) -> String { + let trimmed = s.trimmingCharacters(in: .whitespacesAndNewlines) + let lower = trimmed.lowercased() + if lower.hasPrefix("input:") { + return String(trimmed.dropFirst(6)).trimmingCharacters(in: .whitespacesAndNewlines) + } + if lower.hasPrefix("output:") { + return String(trimmed.dropFirst(7)).trimmingCharacters(in: .whitespacesAndNewlines) + } + if lower.hasPrefix("print:") { + return String(trimmed.dropFirst(6)).trimmingCharacters(in: .whitespacesAndNewlines) + } + return s + } + + /// Text nach Wechsel im Modus-Picker: Präfix tauschen, Inhalt behalten. + public static func applyingIOMode(_ mode: VisuStructStatementIOMode, to text: String) -> String { + let stripped = strippingIOPrefix(from: text) + switch mode { + case .statement: + return stripped + case .input: + return prefixing("input:", to: stripped) + case .output: + return prefixing("output:", to: stripped) + } + } + + /// Wie `strippingIOPrefix` bzw. Schlüsselwort-Entfernung, aber Leerzeichen beim Tippen bleiben erhalten. + public static func strippingPrefixForEditing(_ p: String, from s: String) -> String { + if p == "input:" || p == "output:" { + return strippingIOPrefixPreservingEditingWhitespace(from: s) + } + let stripped = strippingKeywordPrefixPreservingEditingWhitespace(p, from: s) + if p == "while", stripped == s { + let legacy = strippingKeywordPrefixPreservingEditingWhitespace("until", from: s) + if legacy != s { return legacy } + } + return stripped + } + + public static func prefixingForEditing(_ p: String, to content: String) -> String { + if content.isEmpty { return "\(p) " } + return "\(p) \(content)" + } + + /// iOS/macOS ersetzen beim Tippen teils typografische Anführungszeichen; für Code immer gerade Quotes. + public static func normalizingQuotes(_ s: String) -> String { + s + .replacingOccurrences(of: "“", with: "\"") + .replacingOccurrences(of: "”", with: "\"") + .replacingOccurrences(of: "„", with: "\"") + .replacingOccurrences(of: "«", with: "\"") + .replacingOccurrences(of: "»", with: "\"") + .replacingOccurrences(of: "’", with: "'") + .replacingOccurrences(of: "‘", with: "'") + .replacingOccurrences(of: "‚", with: "'") + .replacingOccurrences(of: "´", with: "'") + } + + /// Genau drei Felder (Start, Bedingung, Schritt) der Zählschleife. + public static func paddedForLoopParts(_ lines: [String]) -> [String] { + var a = lines.isEmpty ? [""] : lines + while a.count < 3 { a.append("") } + if a.count > 3 { return Array(a.prefix(3)) } + return a + } + + /// Ein Feld der Zählschleife: gerade Quotes, ohne abschließende `;`. + public static func normalizedForLoopPart(_ s: String) -> String { + var x = normalizingQuotes(s) + while true { + let trimmedEnd = x.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmedEnd.hasSuffix(";") else { return x } + x = String(trimmedEnd.dropLast()).trimmingCharacters(in: .whitespacesAndNewlines) + } + } + + private static func prefixing(_ p: String, to content: String) -> String { + let c = content.trimmingCharacters(in: .whitespacesAndNewlines) + if c.isEmpty { return "\(p) " } + return "\(p) \(c)" + } + + private static func strippingIOPrefixPreservingEditingWhitespace(from s: String) -> String { + let leadingTrimmed = String(s.drop(while: { $0.isWhitespace })) + let lower = leadingTrimmed.lowercased() + if lower.hasPrefix("input:") { + return droppingOneLeadingWhitespace(String(leadingTrimmed.dropFirst(6))) + } + if lower.hasPrefix("output:") { + return droppingOneLeadingWhitespace(String(leadingTrimmed.dropFirst(7))) + } + if lower.hasPrefix("print:") { + return droppingOneLeadingWhitespace(String(leadingTrimmed.dropFirst(6))) + } + return s + } + + private static func strippingKeywordPrefixPreservingEditingWhitespace(_ keyword: String, from s: String) -> String { + let leadingTrimmed = String(s.drop(while: { $0.isWhitespace })) + let lower = leadingTrimmed.lowercased() + let key = keyword.lowercased() + guard lower.hasPrefix(key) else { return s } + + let afterKeyword = leadingTrimmed.dropFirst(keyword.count) + guard afterKeyword.isEmpty || afterKeyword.first?.isWhitespace == true || afterKeyword.first == "(" else { + return s + } + + let content = droppingOneLeadingWhitespace(String(afterKeyword)) + if content.hasPrefix("("), content.hasSuffix(")"), content.count >= 2 { + return String(content.dropFirst().dropLast()) + } + return content + } + + private static func droppingOneLeadingWhitespace(_ s: String) -> String { + guard let first = s.first, first.isWhitespace else { return s } + return String(s.dropFirst()) + } +} diff --git a/VisuStruct-iOS/VisuStructCore/Sources/VisuStructCore/Layout/VisuStructDiagramText.swift b/VisuStruct-iOS/VisuStructCore/Sources/VisuStructCore/Layout/VisuStructDiagramText.swift new file mode 100644 index 00000000..fe35eb94 --- /dev/null +++ b/VisuStruct-iOS/VisuStructCore/Sources/VisuStructCore/Layout/VisuStructDiagramText.swift @@ -0,0 +1,86 @@ +import Foundation + +/// Texte, wie sie im Diagramm angezeigt werden (Schlüsselwörter ergänzt, Zählschleife einzeilig). +public enum VisuStructDiagramText { + /// Anzeigezeilen eines Elements; `nil`, wenn das Element keinen Text hat (dann zeigt der Canvas einen Platzhalter). + public static func displayLines(for element: VisuStructElement) -> [String]? { + let raw = element.textLines + let isEffectivelyEmpty = raw.allSatisfy { $0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } + guard !isEffectivelyEmpty else { return nil } + + switch element.type { + case .forSchleife: + if raw.count <= 1 { return raw } + return [VisuStructForLoopDiagramDisplay.singleLine(from: raw)] + case .verzweigung: + return withLeadingKeyword("if", raw) + case .whileSchleife: + return withLeadingKeyword("while", raw) + case .doUntilSchleife: + return withLeadingKeyword("while", normalizedDoUntilConditionLines(raw)) + case .fallauswahl: + return withLeadingKeyword("switch", raw) + default: + return raw + } + } + + /// Entfernt ein führendes `until` aus gespeicherten Diagrammen, damit die Anzeige einheitlich `while …` nutzt (wie Java `do {} while`). + public static func normalizedDoUntilConditionLines(_ raw: [String]) -> [String] { + guard let first = raw.first else { return raw } + let t = first.trimmingCharacters(in: .whitespacesAndNewlines) + let lower = t.lowercased() + if lower == "until" { + var o = raw + o[0] = "" + return o + } + if lower.hasPrefix("until ") { + var o = raw + o[0] = String(t.dropFirst(6)).trimmingCharacters(in: .whitespacesAndNewlines) + return o + } + if lower.hasPrefix("until(") { + var o = raw + o[0] = String(t.dropFirst(5)) + return o + } + return raw + } + + /// Teilt eine Anzeigezeile in fett gedrucktes Schlüsselwort und Rest (`while i < 3` → `while`, `i < 3`). + public static func keywordSplit(_ line: String) -> (keyword: String, rest: String)? { + let keywords: Set = ["if", "for", "while", "until", "switch"] + let colonKeywords: [String] = ["input:", "output:", "print:"] + let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) + + let lower = trimmed.lowercased() + for prefix in colonKeywords { + if lower == prefix { + return (trimmed, "") + } + if lower.hasPrefix(prefix + " ") { + let rest = String(trimmed.dropFirst(prefix.count)).trimmingCharacters(in: .whitespacesAndNewlines) + let kw = String(trimmed.prefix(prefix.count)) + return (kw, rest) + } + } + + guard let firstSpace = trimmed.firstIndex(of: " ") else { return nil } + let head = String(trimmed[.. [String] { + guard let first = lines.first else { return lines } + let trimmed = first.trimmingCharacters(in: .whitespacesAndNewlines) + let lower = trimmed.lowercased() + let k = keyword.lowercased() + if lower == k || lower.hasPrefix(k + " ") { return lines } + var out = lines + out[0] = keyword + " " + trimmed + return out + } +} diff --git a/VisuStruct-iOS/VisuStructCore/Tests/VisuStructCoreTests/DiagramTextTests.swift b/VisuStruct-iOS/VisuStructCore/Tests/VisuStructCoreTests/DiagramTextTests.swift new file mode 100644 index 00000000..89533578 --- /dev/null +++ b/VisuStruct-iOS/VisuStructCore/Tests/VisuStructCoreTests/DiagramTextTests.swift @@ -0,0 +1,31 @@ +import XCTest +import VisuStructCore + +final class DiagramTextTests: XCTestCase { + func testKeywordsAreAddedForControlStructures() { + XCTAssertEqual(VisuStructDiagramText.displayLines(for: VisuStructElement(type: .verzweigung, textLines: ["a > 3"])), ["if a > 3"]) + XCTAssertEqual(VisuStructDiagramText.displayLines(for: VisuStructElement(type: .verzweigung, textLines: ["if a > 3"])), ["if a > 3"]) + XCTAssertEqual(VisuStructDiagramText.displayLines(for: VisuStructElement(type: .whileSchleife, textLines: ["i < 3"])), ["while i < 3"]) + XCTAssertEqual(VisuStructDiagramText.displayLines(for: VisuStructElement(type: .doUntilSchleife, textLines: ["until x == 1"])), ["while x == 1"]) + XCTAssertEqual(VisuStructDiagramText.displayLines(for: VisuStructElement(type: .fallauswahl, textLines: ["day"])), ["switch day"]) + XCTAssertEqual(VisuStructDiagramText.displayLines(for: VisuStructElement(type: .anweisung, textLines: ["x = 1"])), ["x = 1"]) + } + + func testEmptyElementsHaveNoDisplayLines() { + XCTAssertNil(VisuStructDiagramText.displayLines(for: VisuStructElement(type: .anweisung, textLines: [" "]))) + XCTAssertNil(VisuStructDiagramText.displayLines(for: VisuStructElement(type: .forSchleife, textLines: ["", "", ""]))) + } + + func testForLoopIsShownOnOneLine() { + let lines = VisuStructDiagramText.displayLines(for: VisuStructElement(type: .forSchleife, textLines: ["int i = 0", "i < 3", "i++"])) + XCTAssertEqual(lines?.count, 1) + } + + func testKeywordSplit() { + XCTAssertEqual(VisuStructDiagramText.keywordSplit("while i < 3")?.keyword, "while") + XCTAssertEqual(VisuStructDiagramText.keywordSplit("while i < 3")?.rest, "i < 3") + XCTAssertEqual(VisuStructDiagramText.keywordSplit("print: x")?.keyword, "print:") + XCTAssertEqual(VisuStructDiagramText.keywordSplit("print: x")?.rest, "x") + XCTAssertNil(VisuStructDiagramText.keywordSplit("x = 1")) + } +} diff --git a/VisuStruct-iOS/VisuStructCore/Tests/VisuStructCoreTests/LineEditorTextTests.swift b/VisuStruct-iOS/VisuStructCore/Tests/VisuStructCoreTests/LineEditorTextTests.swift new file mode 100644 index 00000000..ea7d7958 --- /dev/null +++ b/VisuStruct-iOS/VisuStructCore/Tests/VisuStructCoreTests/LineEditorTextTests.swift @@ -0,0 +1,41 @@ +import XCTest +import VisuStructCore + +final class LineEditorTextTests: XCTestCase { + func testIOMode() { + XCTAssertEqual(VisuStructLineEditorText.ioMode(of: "input: int age"), .input) + XCTAssertEqual(VisuStructLineEditorText.ioMode(of: " OUTPUT: x"), .output) + XCTAssertEqual(VisuStructLineEditorText.ioMode(of: "print: x"), .output) + XCTAssertEqual(VisuStructLineEditorText.ioMode(of: "x = 1"), .statement) + } + + func testApplyingIOModeSwapsPrefixAndKeepsContent() { + XCTAssertEqual(VisuStructLineEditorText.applyingIOMode(.input, to: "print: age"), "input: age") + XCTAssertEqual(VisuStructLineEditorText.applyingIOMode(.output, to: "age"), "output: age") + XCTAssertEqual(VisuStructLineEditorText.applyingIOMode(.statement, to: "input: age "), "age") + XCTAssertEqual(VisuStructLineEditorText.applyingIOMode(.input, to: ""), "input: ") + } + + func testStrippingPrefixForEditingKeepsTypedWhitespace() { + XCTAssertEqual(VisuStructLineEditorText.strippingPrefixForEditing("input:", from: "input: age "), "age ") + XCTAssertEqual(VisuStructLineEditorText.strippingPrefixForEditing("if", from: "if (a > 3)"), "a > 3") + XCTAssertEqual(VisuStructLineEditorText.strippingPrefixForEditing("if", from: "if a > 3 "), "a > 3 ") + XCTAssertEqual(VisuStructLineEditorText.strippingPrefixForEditing("while", from: "until x == 1"), "x == 1") + XCTAssertEqual(VisuStructLineEditorText.strippingPrefixForEditing("if", from: "iffy"), "iffy") + } + + func testPrefixingForEditing() { + XCTAssertEqual(VisuStructLineEditorText.prefixingForEditing("while", to: ""), "while ") + XCTAssertEqual(VisuStructLineEditorText.prefixingForEditing("while", to: "i < 3"), "while i < 3") + } + + func testNormalizingQuotes() { + XCTAssertEqual(VisuStructLineEditorText.normalizingQuotes("print: „Hallo“ ‚x’"), "print: \"Hallo\" 'x'") + } + + func testForLoopParts() { + XCTAssertEqual(VisuStructLineEditorText.paddedForLoopParts([]), ["", "", ""]) + XCTAssertEqual(VisuStructLineEditorText.paddedForLoopParts(["a", "b", "c", "d"]), ["a", "b", "c"]) + XCTAssertEqual(VisuStructLineEditorText.normalizedForLoopPart("int i = 0;; "), "int i = 0") + } +} diff --git a/VisuStruct-iOS/VisuStructXcodeOpen/VisuStruct/Canvas/DiagramCanvasView.swift b/VisuStruct-iOS/VisuStructXcodeOpen/VisuStruct/Canvas/DiagramCanvasView.swift index ec37350b..d74793fa 100644 --- a/VisuStruct-iOS/VisuStructXcodeOpen/VisuStruct/Canvas/DiagramCanvasView.swift +++ b/VisuStruct-iOS/VisuStructXcodeOpen/VisuStruct/Canvas/DiagramCanvasView.swift @@ -148,38 +148,7 @@ struct DiagramCanvasView: View { } private func displayTextLines(for element: VisuStructElement) -> [String] { - let raw = element.textLines - let isEffectivelyEmpty = raw.allSatisfy { $0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } - if element.type == .forSchleife, !isEffectivelyEmpty { - if raw.count <= 1 { return raw } - return [VisuStructForLoopDiagramDisplay.singleLine(from: raw)] - } - if !isEffectivelyEmpty { - func withLeadingKeyword(_ keyword: String, _ lines: [String]) -> [String] { - guard let first = lines.first else { return lines } - let trimmed = first.trimmingCharacters(in: .whitespacesAndNewlines) - let lower = trimmed.lowercased() - let k = keyword.lowercased() - if lower == k || lower.hasPrefix(k + " ") { return lines } - var out = lines - out[0] = keyword + " " + trimmed - return out - } - - switch element.type { - case .verzweigung: - return withLeadingKeyword("if", raw) - case .whileSchleife: - return withLeadingKeyword("while", raw) - case .doUntilSchleife: - return withLeadingKeyword("while", normalizedDoUntilConditionLines(raw)) - case .fallauswahl: - return withLeadingKeyword("switch", raw) - default: - break - } - } - guard isEffectivelyEmpty else { return raw } + if let lines = VisuStructDiagramText.displayLines(for: element) { return lines } switch element.type { case .anweisung: @@ -205,29 +174,6 @@ struct DiagramCanvasView: View { } } - /// Entfernt ein führendes `until` aus gespeicherten Diagrammen, damit die Anzeige einheitlich `while …` nutzt (wie Java `do {} while`). - private func normalizedDoUntilConditionLines(_ raw: [String]) -> [String] { - guard let first = raw.first else { return raw } - let t = first.trimmingCharacters(in: .whitespacesAndNewlines) - let lower = t.lowercased() - if lower == "until" { - var o = raw - o[0] = "" - return o - } - if lower.hasPrefix("until ") { - var o = raw - o[0] = String(t.dropFirst(6)).trimmingCharacters(in: .whitespacesAndNewlines) - return o - } - if lower.hasPrefix("until(") { - var o = raw - o[0] = String(t.dropFirst(5)) - return o - } - return raw - } - /// Gemeinsame Stroke-Farbe für Kacheln und Hilfslinien (eine Allokation pro Frame statt dutzende). private var diagramLineColor: Color { Color.black } private var diagramCanvasFill: Color { Color(white: 0.98) } @@ -1010,7 +956,7 @@ struct DiagramCanvasView: View { for (i, line) in lines.enumerated() { let y = firstY + CGFloat(i) * lineHeight let tw: CGFloat = { - if let parts = keywordSplit(for: node.element.type, line: line) { + if let parts = VisuStructDiagramText.keywordSplit(line) { let kwW = measureText(parts.keyword, size: headerFontSize, weight: .bold) let restW = parts.rest.isEmpty ? 0 : measureText(" " + parts.rest, size: headerFontSize, weight: .medium) return kwW + restW @@ -1057,7 +1003,7 @@ struct DiagramCanvasView: View { var y = r.midY - totalTextHeight / 2 for line in lines { let tw: CGFloat = { - if let parts = keywordSplit(for: node.element.type, line: line) { + if let parts = VisuStructDiagramText.keywordSplit(line) { let kwW = measureText(parts.keyword, size: headerFontSize, weight: .bold) let restW = parts.rest.isEmpty ? 0 : measureText(" " + parts.rest, size: headerFontSize, weight: .medium) return kwW + restW @@ -1258,33 +1204,8 @@ struct DiagramCanvasView: View { #endif } - private func keywordSplit(for elementType: VisuStructElementType, line: String) -> (keyword: String, rest: String)? { - // Keywords nur bei Kontrollstrukturen/Anzeigezeilen. - let keywords: Set = ["if", "for", "while", "until", "switch"] - let colonKeywords: [String] = ["input:", "output:", "print:"] - let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) - - let lower = trimmed.lowercased() - for prefix in colonKeywords { - if lower == prefix { - return (trimmed, "") - } - if lower.hasPrefix(prefix + " ") { - let rest = String(trimmed.dropFirst(prefix.count)).trimmingCharacters(in: .whitespacesAndNewlines) - let kw = String(trimmed.prefix(prefix.count)) - return (kw, rest) - } - } - - guard let firstSpace = trimmed.firstIndex(of: " ") else { return nil } - let head = String(trimmed[.. StatementIOMode { - let t = text.trimmingCharacters(in: .whitespacesAndNewlines) - if t.lowercased().hasPrefix("input:") { return .input } - if t.lowercased().hasPrefix("output:") { return .output } - if t.lowercased().hasPrefix("print:") { return .output } - return .statement - } - - private func stripIOPrefixes(from s: String) -> String { - let trimmed = s.trimmingCharacters(in: .whitespacesAndNewlines) - let lower = trimmed.lowercased() - if lower.hasPrefix("input:") { - return String(trimmed.dropFirst(6)).trimmingCharacters(in: .whitespacesAndNewlines) - } - if lower.hasPrefix("output:") { - return String(trimmed.dropFirst(7)).trimmingCharacters(in: .whitespacesAndNewlines) - } - if lower.hasPrefix("print:") { - return String(trimmed.dropFirst(6)).trimmingCharacters(in: .whitespacesAndNewlines) - } - return s - } - - private func stripLineEditorPrefix(_ p: String, from s: String) -> String { - if p == "input:" || p == "output:" { - return stripIOPrefixes(from: s) - } - return stripKeywordPrefix(p, from: s) - } - - private func stripLineEditorPrefixForEditing(_ p: String, from s: String) -> String { - if p == "input:" || p == "output:" { - return stripIOPrefixPreservingEditingWhitespace(from: s) - } - let stripped = stripKeywordPrefixPreservingEditingWhitespace(p, from: s) - if p == "while", stripped == s { - let legacy = stripKeywordPrefixPreservingEditingWhitespace("until", from: s) - if legacy != s { return legacy } - } - return stripped - } - - private func stripIOPrefixPreservingEditingWhitespace(from s: String) -> String { - let leadingTrimmed = String(s.drop(while: { $0.isWhitespace })) - let lower = leadingTrimmed.lowercased() - if lower.hasPrefix("input:") { - return dropOneLeadingWhitespace(String(leadingTrimmed.dropFirst(6))) - } - if lower.hasPrefix("output:") { - return dropOneLeadingWhitespace(String(leadingTrimmed.dropFirst(7))) - } - if lower.hasPrefix("print:") { - return dropOneLeadingWhitespace(String(leadingTrimmed.dropFirst(6))) - } - return s - } - - private func stripKeywordPrefix(_ keyword: String, from s: String) -> String { - let trimmed = s.trimmingCharacters(in: .whitespacesAndNewlines) - let lower = trimmed.lowercased() - let key = keyword.lowercased() - guard lower.hasPrefix(key) else { return s } - - let afterKeyword = trimmed.dropFirst(keyword.count) - guard afterKeyword.isEmpty || afterKeyword.first?.isWhitespace == true || afterKeyword.first == "(" else { - return s - } - - var content = String(afterKeyword).trimmingCharacters(in: .whitespacesAndNewlines) - if content.hasPrefix("("), content.hasSuffix(")"), content.count >= 2 { - content.removeFirst() - content.removeLast() - content = content.trimmingCharacters(in: .whitespacesAndNewlines) - } - return content - } - - private func stripKeywordPrefixPreservingEditingWhitespace(_ keyword: String, from s: String) -> String { - let leadingTrimmed = String(s.drop(while: { $0.isWhitespace })) - let lower = leadingTrimmed.lowercased() - let key = keyword.lowercased() - guard lower.hasPrefix(key) else { return s } - - let afterKeyword = leadingTrimmed.dropFirst(keyword.count) - guard afterKeyword.isEmpty || afterKeyword.first?.isWhitespace == true || afterKeyword.first == "(" else { - return s - } - - let content = dropOneLeadingWhitespace(String(afterKeyword)) - if content.hasPrefix("("), content.hasSuffix(")"), content.count >= 2 { - return String(content.dropFirst().dropLast()) - } - return content - } - - private func dropOneLeadingWhitespace(_ s: String) -> String { - guard let first = s.first, first.isWhitespace else { return s } - return String(s.dropFirst()) - } - - private func prefix(_ p: String, to content: String) -> String { - let c = content.trimmingCharacters(in: .whitespacesAndNewlines) - if c.isEmpty { return "\(p) " } - return "\(p) \(c)" - } - - private func prefixPreservingEditingWhitespace(_ p: String, to content: String) -> String { - if content.isEmpty { return "\(p) " } - return "\(p) \(content)" + return VisuStructLineEditorText.ioMode(of: tabsModel.tabs[tabsModel.currentTabIndex].lineEditorText) } /// Placeholder for the line editor, shown only when empty (grau im Feld). Code-Beispiele als Hilfe: `inspectorConditionEditorHintLine()` darüber. @@ -1423,7 +1301,7 @@ struct ContentView: View { switch el.type { case .anweisung: - switch statementIOMode(for: tabsModel.tabs[tabsModel.currentTabIndex].lineEditorText) { + switch VisuStructLineEditorText.ioMode(of: tabsModel.tabs[tabsModel.currentTabIndex].lineEditorText) { case .statement: return lang.tr("inspector_placeholder_statement") case .input: @@ -1601,22 +1479,6 @@ struct ContentView: View { return InspectorElementTypeLocalization.title(for: el.type, lang: lang) } - private func padForLoopTextLines(_ lines: [String]) -> [String] { - var a = lines.isEmpty ? [""] : lines - while a.count < 3 { a.append("") } - if a.count > 3 { return Array(a.prefix(3)) } - return a - } - - private func normalizeForLoopPart(_ s: String) -> String { - var x = normalizeQuotesForCode(s) - while true { - let trimmedEnd = x.trimmingCharacters(in: .whitespacesAndNewlines) - guard trimmedEnd.hasSuffix(";") else { return x } - x = String(trimmedEnd.dropLast()).trimmingCharacters(in: .whitespacesAndNewlines) - } - } - private var inspectorShowsForLoopThreeFields: Bool { guard let path = selectedPath, let el = try? VisuStructDocumentEditing.element(at: path, in: document) else { return false } @@ -1629,15 +1491,15 @@ struct ContentView: View { guard (0 ..< 3).contains(index), let path = selectedPath, let el = try? VisuStructDocumentEditing.element(at: path, in: document) else { return "" } - let padded = padForLoopTextLines(el.textLines) + let padded = VisuStructLineEditorText.paddedForLoopParts(el.textLines) return padded[index] }, set: { newVal in guard (0 ..< 3).contains(index), let path = selectedPath else { return } guard let el = try? VisuStructDocumentEditing.element(at: path, in: document), el.type == .forSchleife else { return } - var lines = padForLoopTextLines(el.textLines) - lines[index] = normalizeForLoopPart(newVal) + var lines = VisuStructLineEditorText.paddedForLoopParts(el.textLines) + lines[index] = VisuStructLineEditorText.normalizedForLoopPart(newVal) do { commitDocument(try VisuStructDocumentEditing.setTextLines(document, path: path, lines: lines)) // `lineEditorText` wird bei den drei For-Feldern nicht mitgetippt; sonst würde z. B. @@ -2013,21 +1875,6 @@ struct ContentView: View { } } - private func normalizeQuotesForCode(_ s: String) -> String { - // iOS/macOS Eingaben ersetzen typografische Anführungszeichen teils automatisch. - // Für Code wollen wir immer gerade Quotes. - return s - .replacingOccurrences(of: "“", with: "\"") - .replacingOccurrences(of: "”", with: "\"") - .replacingOccurrences(of: "„", with: "\"") - .replacingOccurrences(of: "«", with: "\"") - .replacingOccurrences(of: "»", with: "\"") - .replacingOccurrences(of: "’", with: "'") - .replacingOccurrences(of: "‘", with: "'") - .replacingOccurrences(of: "‚", with: "'") - .replacingOccurrences(of: "´", with: "'") - } - #if canImport(UIKit) /// Verlässt die aktive Eingabe im Key-Window, damit `UITextView` (Zeileneditor) sicher /// `textViewDidEndEditing` liefert und `lineEditorText` dem sichtbaren Inhalt entspricht. @@ -2063,12 +1910,12 @@ struct ContentView: View { let editorText = tabsModel.tabs[tabsModel.currentTabIndex].lineEditorText let storedText: String if let labelPrefix = lineEditorFixedPrefixLabel { - storedText = prefixPreservingEditingWhitespace(labelPrefix, to: stripLineEditorPrefixForEditing(labelPrefix, from: editorText)) + storedText = VisuStructLineEditorText.prefixingForEditing(labelPrefix, to: VisuStructLineEditorText.strippingPrefixForEditing(labelPrefix, from: editorText)) } else { storedText = editorText } let raw = storedText.components(separatedBy: "\n") - let lines = (raw.isEmpty ? [""] : raw).map(normalizeQuotesForCode) + let lines = (raw.isEmpty ? [""] : raw).map(VisuStructLineEditorText.normalizingQuotes) do { commitDocument(try VisuStructDocumentEditing.setTextLines(document, path: path, lines: lines)) } catch { diff --git a/VisuStruct-iOS/VisuStructXcodeOpen/VisuStruct/Inspector/StatementIOMode.swift b/VisuStruct-iOS/VisuStructXcodeOpen/VisuStruct/Inspector/StatementIOMode.swift index 11f3b3f6..583ea24a 100644 --- a/VisuStruct-iOS/VisuStructXcodeOpen/VisuStruct/Inspector/StatementIOMode.swift +++ b/VisuStruct-iOS/VisuStructXcodeOpen/VisuStruct/Inspector/StatementIOMode.swift @@ -1,8 +1,4 @@ -import Foundation +import VisuStructCore -/// Darstellung von Anweisung vs. Ein-/Ausgabe im Inspector (Modus-Picker). -enum StatementIOMode: String, CaseIterable, Hashable { - case statement - case input - case output -} +/// Darstellung von Anweisung vs. Ein-/Ausgabe im Inspector (Modus-Picker); Logik in `VisuStructLineEditorText`. +typealias StatementIOMode = VisuStructStatementIOMode From 04aa358cfb10a2b2aa94e4e9cb82329849a2c145 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 22:20:02 +0000 Subject: [PATCH 04/11] Move undo/redo and tab editing out of ContentView - VisuStructCore: VisuStructEditHistory (undo/redo stacks, 80-step limit) and VisuStructDocumentEditing.sanitizedSelection, with tests. - DiagramSheetTab keeps a VisuStructEditHistory instead of two arrays. - DiagramSheetTabsModel+Editing: currentTab, updateCurrentTab, commitDocument, resetHistoryAndDocument, undo, redo, selectAndClearError, setErrorOnCurrentTab. - ContentView uses these instead of copying tabs by hand at each site. No behavior change intended. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Cp4EQgFQyGbM8MVLiWbJKQ --- .../VisuStructDocumentEditing+Selection.swift | 11 + .../Editing/VisuStructEditHistory.swift | 49 ++++ .../EditHistoryTests.swift | 70 +++++ .../VisuStruct/ContentView.swift | 275 +++++------------- .../VisuStruct/Tabs/DiagramSheetTab.swift | 6 +- .../Tabs/DiagramSheetTabsModel+Editing.swift | 78 +++++ 6 files changed, 282 insertions(+), 207 deletions(-) create mode 100644 VisuStruct-iOS/VisuStructCore/Sources/VisuStructCore/Editing/VisuStructDocumentEditing+Selection.swift create mode 100644 VisuStruct-iOS/VisuStructCore/Sources/VisuStructCore/Editing/VisuStructEditHistory.swift create mode 100644 VisuStruct-iOS/VisuStructCore/Tests/VisuStructCoreTests/EditHistoryTests.swift create mode 100644 VisuStruct-iOS/VisuStructXcodeOpen/VisuStruct/Tabs/DiagramSheetTabsModel+Editing.swift diff --git a/VisuStruct-iOS/VisuStructCore/Sources/VisuStructCore/Editing/VisuStructDocumentEditing+Selection.swift b/VisuStruct-iOS/VisuStructCore/Sources/VisuStructCore/Editing/VisuStructDocumentEditing+Selection.swift new file mode 100644 index 00000000..e4230c79 --- /dev/null +++ b/VisuStruct-iOS/VisuStructCore/Sources/VisuStructCore/Editing/VisuStructDocumentEditing+Selection.swift @@ -0,0 +1,11 @@ +import Foundation + +extension VisuStructDocumentEditing { + /// Auswahl nach Undo/Redo oder Löschen: leeres Dokument → keine Auswahl; fehlende oder ungültige Auswahl → erstes Element. + public static func sanitizedSelection(_ path: [Int]?, in doc: VisuStructDocument) -> [Int]? { + if doc.elements.isEmpty { return nil } + guard let path else { return [0] } + if (try? element(at: path, in: doc)) == nil { return [0] } + return path + } +} diff --git a/VisuStruct-iOS/VisuStructCore/Sources/VisuStructCore/Editing/VisuStructEditHistory.swift b/VisuStruct-iOS/VisuStructCore/Sources/VisuStructCore/Editing/VisuStructEditHistory.swift new file mode 100644 index 00000000..bd543590 --- /dev/null +++ b/VisuStruct-iOS/VisuStructCore/Sources/VisuStructCore/Editing/VisuStructEditHistory.swift @@ -0,0 +1,49 @@ +import Foundation + +/// Undo-/Redo-Verlauf eines Diagramms: Schnappschüsse des Dokuments vor jeder Änderung. +public struct VisuStructEditHistory: Equatable, Sendable { + public static let defaultMaxSteps = 80 + + public private(set) var undoStack: [VisuStructDocument] = [] + public private(set) var redoStack: [VisuStructDocument] = [] + public let maxSteps: Int + + public init(maxSteps: Int = VisuStructEditHistory.defaultMaxSteps) { + self.maxSteps = maxSteps + } + + public var canUndo: Bool { !undoStack.isEmpty } + public var canRedo: Bool { !redoStack.isEmpty } + + /// Vor einer Änderung: bisherigen Zustand ablegen, Redo verwerfen. + public mutating func record(_ current: VisuStructDocument) { + pushUndo(current) + redoStack.removeAll() + } + + /// Liefert den vorherigen Zustand und legt `current` für Redo ab; `nil`, wenn nichts rückgängig zu machen ist. + public mutating func undo(from current: VisuStructDocument) -> VisuStructDocument? { + guard let previous = undoStack.popLast() else { return nil } + redoStack.append(current) + return previous + } + + /// Liefert den wiederhergestellten Zustand und legt `current` für Undo ab; `nil`, wenn nichts wiederherzustellen ist. + public mutating func redo(from current: VisuStructDocument) -> VisuStructDocument? { + guard let next = redoStack.popLast() else { return nil } + pushUndo(current) + return next + } + + public mutating func reset() { + undoStack.removeAll() + redoStack.removeAll() + } + + private mutating func pushUndo(_ document: VisuStructDocument) { + undoStack.append(document) + while undoStack.count > maxSteps { + undoStack.removeFirst() + } + } +} diff --git a/VisuStruct-iOS/VisuStructCore/Tests/VisuStructCoreTests/EditHistoryTests.swift b/VisuStruct-iOS/VisuStructCore/Tests/VisuStructCoreTests/EditHistoryTests.swift new file mode 100644 index 00000000..2f350d4c --- /dev/null +++ b/VisuStruct-iOS/VisuStructCore/Tests/VisuStructCoreTests/EditHistoryTests.swift @@ -0,0 +1,70 @@ +import XCTest +import VisuStructCore + +final class EditHistoryTests: XCTestCase { + private func document(_ texts: String...) -> VisuStructDocument { + var doc = VisuStructDocumentEditing.newEmpty() + doc.elements = texts.map { VisuStructElement(type: .anweisung, textLines: [$0]) } + return doc + } + + func testUndoAndRedoRestoreSnapshots() { + let a = document("a"), b = document("b"), c = document("c") + var history = VisuStructEditHistory() + XCTAssertFalse(history.canUndo) + history.record(a) + history.record(b) + XCTAssertEqual(history.undo(from: c), b) + XCTAssertEqual(history.undo(from: b), a) + XCTAssertNil(history.undo(from: a)) + XCTAssertEqual(history.redo(from: a), b) + XCTAssertEqual(history.redo(from: b), c) + XCTAssertNil(history.redo(from: c)) + } + + func testRecordClearsRedo() { + var history = VisuStructEditHistory() + history.record(document("a")) + _ = history.undo(from: document("b")) + XCTAssertTrue(history.canRedo) + history.record(document("a")) + XCTAssertFalse(history.canRedo) + } + + func testUndoStackIsLimited() { + var history = VisuStructEditHistory(maxSteps: 3) + for i in 0..<5 { history.record(document("\(i)")) } + XCTAssertEqual(history.undoStack, [document("2"), document("3"), document("4")]) + XCTAssertEqual(VisuStructEditHistory().maxSteps, 80) + } + + func testRedoAlsoRespectsLimit() { + var history = VisuStructEditHistory(maxSteps: 2) + history.record(document("0")) + history.record(document("1")) + _ = history.undo(from: document("2")) + history.record(document("x")) + history.record(document("y")) + XCTAssertEqual(history.undoStack.count, 2) + } + + func testReset() { + var history = VisuStructEditHistory() + history.record(document("a")) + _ = history.undo(from: document("b")) + history.record(document("c")) + history.reset() + XCTAssertFalse(history.canUndo) + XCTAssertFalse(history.canRedo) + } + + func testSanitizedSelection() { + let doc = document("a", "b") + XCTAssertEqual(VisuStructDocumentEditing.sanitizedSelection([1], in: doc), [1]) + XCTAssertEqual(VisuStructDocumentEditing.sanitizedSelection([5], in: doc), [0]) + XCTAssertEqual(VisuStructDocumentEditing.sanitizedSelection(nil, in: doc), [0]) + var empty = doc + empty.elements = [] + XCTAssertNil(VisuStructDocumentEditing.sanitizedSelection([0], in: empty)) + } +} diff --git a/VisuStruct-iOS/VisuStructXcodeOpen/VisuStruct/ContentView.swift b/VisuStruct-iOS/VisuStructXcodeOpen/VisuStruct/ContentView.swift index f6d78796..91f81d99 100644 --- a/VisuStruct-iOS/VisuStructXcodeOpen/VisuStruct/ContentView.swift +++ b/VisuStruct-iOS/VisuStructXcodeOpen/VisuStruct/ContentView.swift @@ -135,7 +135,7 @@ struct ContentView: View { if isSimulationMode { endSimulationMode() } - let tab = tabsModel.tabs[tabsModel.currentTabIndex] + let tab = tabsModel.currentTab if tabsModel.tabHasUnsavedChanges(tab) || tab.linkedFileBookmarkData != nil { addDiagramTab() } @@ -143,18 +143,15 @@ struct ContentView: View { } private var document: VisuStructDocument { - tabsModel.tabs[tabsModel.currentTabIndex].document + tabsModel.currentTab.document } private var selectedPath: [Int]? { - tabsModel.tabs[tabsModel.currentTabIndex].selectedPath + tabsModel.currentTab.selectedPath } private func replaceSelectedPathOnCurrentTab(_ path: [Int]?) { - var tabs = tabsModel.tabs - let i = tabsModel.currentTabIndex - tabs[i].selectedPath = path - tabsModel.tabs = tabs + tabsModel.updateCurrentTab { $0.selectedPath = path } } private var unsavedCloseAlertBinding: Binding { @@ -377,8 +374,8 @@ struct ContentView: View { let canCut = canCopy && (selectedPath.map { VisuStructDocumentEditing.canDeleteElement(at: $0, in: document) } ?? false) let canDelete = !isTyping && (selectedPath.map { VisuStructDocumentEditing.canDeleteElement(at: $0, in: document) } ?? false) return DiagramEditActions( - canUndo: !isTyping && !tabsModel.tabs[tabsModel.currentTabIndex].undoStack.isEmpty, - canRedo: !isTyping && !tabsModel.tabs[tabsModel.currentTabIndex].redoStack.isEmpty, + canUndo: !isTyping && tabsModel.currentTab.history.canUndo, + canRedo: !isTyping && tabsModel.currentTab.history.canRedo, canCopy: canCopy, canCut: canCut, canPaste: canPaste, @@ -439,20 +436,14 @@ struct ContentView: View { if let path = selectedPath { if VisuStructDocumentEditing.isPlaceholderSlot(at: path, in: doc) { commitDocument(try VisuStructDocumentEditing.replaceAtPath(doc, path: path, with: clip)) - var tabs = tabsModel.tabs - tabs[tabsModel.currentTabIndex].selectedPath = path - tabs[tabsModel.currentTabIndex].errorText = nil - tabsModel.tabs = tabs + tabsModel.selectAndClearError(path) refreshLineEditor() return } if (try? VisuStructDocumentEditing.element(at: path, in: doc)) != nil { commitDocument(try VisuStructDocumentEditing.insertAfterSibling(doc, clip, after: path)) let newPath = Array(path.dropLast()) + [path.last! + 1] - var tabs = tabsModel.tabs - tabs[tabsModel.currentTabIndex].selectedPath = newPath - tabs[tabsModel.currentTabIndex].errorText = nil - tabsModel.tabs = tabs + tabsModel.selectAndClearError(newPath) refreshLineEditor() return } @@ -667,7 +658,7 @@ struct ContentView: View { onRequestCloseTab: { requestCloseDiagramTab(id: $0) } ) Group { - if let err = tabsModel.tabs[tabsModel.currentTabIndex].errorText { + if let err = tabsModel.currentTab.errorText { VStack(spacing: 20) { ContentUnavailableView( lang.tr("error_title"), @@ -687,7 +678,7 @@ struct ContentView: View { } // Explizit `navigationTitle` setzen, nicht nur `ToolbarItem(placement: .principal)`: sonst reserviert SwiftUI // in der Navigationsleiste oft keinen sichtbaren Mittelbereich (Titel wirkend „invisible“ / Breite 0, bes. iPhone). - .navigationTitle(tabsModel.diagramTabDisplayTitle(tabsModel.tabs[tabsModel.currentTabIndex], language: lang)) + .navigationTitle(tabsModel.diagramTabDisplayTitle(tabsModel.currentTab, language: lang)) .navigationBarTitleDisplayMode(.inline) .toolbarBackground(.visible, for: .navigationBar) .toolbarBackground(.regularMaterial, for: .navigationBar) @@ -700,7 +691,7 @@ struct ContentView: View { } private func mainEditorToolbarItems() -> DiagramMainEditorToolbar { - let tab = tabsModel.tabs[tabsModel.currentTabIndex] + let tab = tabsModel.currentTab let displayTitle = tabsModel.diagramTabDisplayTitle(tab, language: lang) let unsaved = tabsModel.tabHasUnsavedChanges(tab) let isTyping: Bool = { @@ -718,8 +709,8 @@ struct ContentView: View { principalTitle: displayTitle, principalShowsUnsaved: unsaved, principalAccessibilityLabel: unsaved ? "\(displayTitle), \(lang.tr("a11y_unsaved_changes"))" : displayTitle, - canUndo: !isTyping && !tab.undoStack.isEmpty, - canRedo: !isTyping && !tab.redoStack.isEmpty, + canUndo: !isTyping && tab.history.canUndo, + canRedo: !isTyping && tab.history.canRedo, onUndo: performUndo, onRedo: performRedo, isSimulationMode: isSimulationMode, @@ -954,7 +945,7 @@ struct ContentView: View { withAnimation(.easeInOut(duration: 0.18)) { isSimulationMode = false } - if tabsModel.tabs[tabsModel.currentTabIndex].selectedPath == nil, !document.elements.isEmpty { + if tabsModel.currentTab.selectedPath == nil, !document.elements.isEmpty { replaceSelectedPathOnCurrentTab([0]) } } @@ -1045,11 +1036,9 @@ struct ContentView: View { private var selectedPathBinding: Binding<[Int]?> { Binding( - get: { tabsModel.tabs[tabsModel.currentTabIndex].selectedPath }, + get: { tabsModel.currentTab.selectedPath }, set: { newVal in - var tabs = tabsModel.tabs - tabs[tabsModel.currentTabIndex].selectedPath = newVal - tabsModel.tabs = tabs + tabsModel.updateCurrentTab { $0.selectedPath = newVal } refreshLineEditor() } ) @@ -1057,12 +1046,10 @@ struct ContentView: View { private var lineEditorTextBinding: Binding { Binding( - get: { tabsModel.tabs[tabsModel.currentTabIndex].lineEditorText }, + get: { tabsModel.currentTab.lineEditorText }, set: { newVal in let normalized = VisuStructLineEditorText.normalizingQuotes(newVal) - var tabs = tabsModel.tabs - tabs[tabsModel.currentTabIndex].lineEditorText = normalized - tabsModel.tabs = tabs + tabsModel.updateCurrentTab { $0.lineEditorText = normalized } } ) } @@ -1070,7 +1057,7 @@ struct ContentView: View { private var lineEditorVisibleTextBinding: Binding { Binding( get: { - let text = tabsModel.tabs[tabsModel.currentTabIndex].lineEditorText + let text = tabsModel.currentTab.lineEditorText guard let labelPrefix = lineEditorFixedPrefixLabel else { return text } return VisuStructLineEditorText.strippingPrefixForEditing(labelPrefix, from: text) }, @@ -1082,9 +1069,7 @@ struct ContentView: View { } else { text = normalized } - var tabs = tabsModel.tabs - tabs[tabsModel.currentTabIndex].lineEditorText = text - tabsModel.tabs = tabs + tabsModel.updateCurrentTab { $0.lineEditorText = text } } ) } @@ -1095,7 +1080,7 @@ struct ContentView: View { switch el.type { case .anweisung: - switch VisuStructLineEditorText.ioMode(of: tabsModel.tabs[tabsModel.currentTabIndex].lineEditorText) { + switch VisuStructLineEditorText.ioMode(of: tabsModel.currentTab.lineEditorText) { case .input: return "input:" case .output: @@ -1120,32 +1105,26 @@ struct ContentView: View { private var errorTextBinding: Binding { Binding( - get: { tabsModel.tabs[tabsModel.currentTabIndex].errorText }, + get: { tabsModel.currentTab.errorText }, set: { newVal in - var tabs = tabsModel.tabs - tabs[tabsModel.currentTabIndex].errorText = newVal - tabsModel.tabs = tabs + tabsModel.setErrorOnCurrentTab(newVal) } ) } private func setErrorOnCurrentTab(_ message: String) { - var tabs = tabsModel.tabs - tabs[tabsModel.currentTabIndex].errorText = message - tabsModel.tabs = tabs + tabsModel.setErrorOnCurrentTab(message) } private func clearErrorOnCurrentTab() { - var tabs = tabsModel.tabs - tabs[tabsModel.currentTabIndex].errorText = nil - tabsModel.tabs = tabs + tabsModel.setErrorOnCurrentTab(nil) } // MARK: - Export-Dateinamen & Slugs private func exportFilenameForCurrentTab() -> String { DiagramExportFilename.visustructFilename( - for: tabsModel.tabs[tabsModel.currentTabIndex], + for: tabsModel.currentTab, style: exportFilenameStyle ) } @@ -1274,13 +1253,12 @@ struct ContentView: View { return Binding( get: { - VisuStructLineEditorText.ioMode(of: tabsModel.tabs[tabsModel.currentTabIndex].lineEditorText) + VisuStructLineEditorText.ioMode(of: tabsModel.currentTab.lineEditorText) }, set: { newMode in - var tabs = tabsModel.tabs - let i = tabsModel.currentTabIndex - tabs[i].lineEditorText = VisuStructLineEditorText.applyingIOMode(newMode, to: tabs[i].lineEditorText) - tabsModel.tabs = tabs + tabsModel.updateCurrentTab { tab in + tab.lineEditorText = VisuStructLineEditorText.applyingIOMode(newMode, to: tab.lineEditorText) + } applyLineEditorRespectingTextFocus() } ) @@ -1291,7 +1269,7 @@ struct ContentView: View { let el = try? VisuStructDocumentEditing.element(at: path, in: document), el.type == .anweisung else { return nil } - return VisuStructLineEditorText.ioMode(of: tabsModel.tabs[tabsModel.currentTabIndex].lineEditorText) + return VisuStructLineEditorText.ioMode(of: tabsModel.currentTab.lineEditorText) } /// Placeholder for the line editor, shown only when empty (grau im Feld). Code-Beispiele als Hilfe: `inspectorConditionEditorHintLine()` darüber. @@ -1301,7 +1279,7 @@ struct ContentView: View { switch el.type { case .anweisung: - switch VisuStructLineEditorText.ioMode(of: tabsModel.tabs[tabsModel.currentTabIndex].lineEditorText) { + switch VisuStructLineEditorText.ioMode(of: tabsModel.currentTab.lineEditorText) { case .statement: return lang.tr("inspector_placeholder_statement") case .input: @@ -1402,11 +1380,7 @@ struct ContentView: View { do { let (updated, newPath) = try VisuStructDocumentEditing.appendCaseToFallauswahl(document, fallauswahlPath: fallPath) commitDocument(updated) - var tabs = tabsModel.tabs - let i = tabsModel.currentTabIndex - tabs[i].selectedPath = newPath - tabs[i].errorText = nil - tabsModel.tabs = tabs + tabsModel.selectAndClearError(newPath) refreshLineEditor() } catch { setErrorOnCurrentTab(error.localizedDescription) @@ -1425,11 +1399,7 @@ struct ContentView: View { document, fallauswahlPath: fallPath, caseIndex: caseIndex ) commitDocument(updated) - var tabs = tabsModel.tabs - let i = tabsModel.currentTabIndex - tabs[i].selectedPath = newPath - tabs[i].errorText = nil - tabsModel.tabs = tabs + tabsModel.selectAndClearError(newPath) refreshLineEditor() } catch { setErrorOnCurrentTab(error.localizedDescription) @@ -1504,10 +1474,7 @@ struct ContentView: View { commitDocument(try VisuStructDocumentEditing.setTextLines(document, path: path, lines: lines)) // `lineEditorText` wird bei den drei For-Feldern nicht mitgetippt; sonst würde z. B. // „Code erzeugen“ veralteten Puffer per `applyLineEditor` ins Element schreiben. - var tabs = tabsModel.tabs - let i = tabsModel.currentTabIndex - tabs[i].lineEditorText = lines.joined(separator: "\n") - tabsModel.tabs = tabs + tabsModel.updateCurrentTab { $0.lineEditorText = lines.joined(separator: "\n") } } catch { setErrorOnCurrentTab(error.localizedDescription) } @@ -1542,109 +1509,54 @@ struct ContentView: View { } } - private static let maxUndoSteps = 80 - // MARK: - Dokument: Commit, Undo & Redo /// Ersetzt das Dokument und legt einen Undo-Snapshot des bisherigen Zustands ab. private func commitDocument(_ new: VisuStructDocument) { - let i = tabsModel.currentTabIndex - var tabs = tabsModel.tabs - var tab = tabs[i] - guard new != tab.document else { return } - tab.undoStack.append(tab.document) - while tab.undoStack.count > Self.maxUndoSteps { - tab.undoStack.removeFirst() - } - tab.redoStack.removeAll() - tab.document = new - tabs[i] = tab - tabsModel.tabs = tabs + tabsModel.commitDocument(new) } /// Neu / Import: Historie verwerfen, Zustand setzen. private func resetHistoryAndDocument(_ new: VisuStructDocument) { - let i = tabsModel.currentTabIndex - var tabs = tabsModel.tabs - tabs[i].undoStack.removeAll() - tabs[i].redoStack.removeAll() - tabs[i].document = new - tabs[i].savedDocumentBaseline = VisuStructXMLCodec.encode(new) - tabsModel.tabs = tabs + tabsModel.resetHistoryAndDocument(new) } private func performUndo() { - let i = tabsModel.currentTabIndex - var tabs = tabsModel.tabs - var tab = tabs[i] - guard let previous = tab.undoStack.popLast() else { return } - tab.redoStack.append(tab.document) - tab.document = previous - tab.errorText = nil - tabs[i] = tab - tabsModel.tabs = tabs - tabsModel.syncTabTitleWithDocumentCaption(tabIndex: i, language: lang) - sanitizeSelectionAfterHistoryJump() + guard tabsModel.undo() else { return } + tabsModel.syncTabTitleWithDocumentCaption(tabIndex: tabsModel.currentTabIndex, language: lang) refreshLineEditor() } private func performRedo() { - let i = tabsModel.currentTabIndex - var tabs = tabsModel.tabs - var tab = tabs[i] - guard let next = tab.redoStack.popLast() else { return } - tab.undoStack.append(tab.document) - while tab.undoStack.count > Self.maxUndoSteps { - tab.undoStack.removeFirst() - } - tab.document = next - tab.errorText = nil - tabs[i] = tab - tabsModel.tabs = tabs - tabsModel.syncTabTitleWithDocumentCaption(tabIndex: i, language: lang) - sanitizeSelectionAfterHistoryJump() + guard tabsModel.redo() else { return } + tabsModel.syncTabTitleWithDocumentCaption(tabIndex: tabsModel.currentTabIndex, language: lang) refreshLineEditor() } - private func sanitizeSelectionAfterHistoryJump() { - let i = tabsModel.currentTabIndex - var tabs = tabsModel.tabs - var tab = tabs[i] - let doc = tab.document - if doc.elements.isEmpty { - tab.selectedPath = nil - } else if tab.selectedPath == nil { - tab.selectedPath = [0] - } else if let path = tab.selectedPath, - (try? VisuStructDocumentEditing.element(at: path, in: doc)) == nil { - tab.selectedPath = [0] - } - tabs[i] = tab - tabsModel.tabs = tabs - } - // MARK: - Neue Datei & Tab zurücksetzen private func newDocument() { resetHistoryAndDocument(VisuStructDocumentEditing.newEmpty()) - var tabs = tabsModel.tabs - let i = tabsModel.currentTabIndex - let hadLinkedFile = tabs[i].linkedFileDisplayName != nil || tabs[i].linkedFileBookmarkData != nil - tabs[i].selectedPath = [0] - tabs[i].errorText = nil - tabs[i].linkedFileBookmarkData = nil - tabs[i].linkedFileDisplayName = nil + let current = tabsModel.currentTab + let hadLinkedFile = current.linkedFileDisplayName != nil || current.linkedFileBookmarkData != nil // Wenn vorher eine Datei geöffnet/gespeichert war, soll „Neu“ wieder einen unbenannten Titel bekommen. + var untitledTitle: String? if hadLinkedFile { let serial = tabsModel.nextTabSerial tabsModel.nextTabSerial += 1 - tabs[i].title = String( + untitledTitle = String( format: lang.tr("tab_title_format"), locale: Locale(identifier: lang.localeIdentifierForFormatting), arguments: [serial as CVarArg] ) } - tabsModel.tabs = tabs + tabsModel.updateCurrentTab { tab in + tab.selectedPath = [0] + tab.errorText = nil + tab.linkedFileBookmarkData = nil + tab.linkedFileDisplayName = nil + if let untitledTitle { tab.title = untitledTitle } + } refreshLineEditor() } @@ -1665,20 +1577,14 @@ struct ContentView: View { if VisuStructDocumentEditing.isSingleRootPlaceholder(in: doc) { do { commitDocument(try VisuStructDocumentEditing.replaceAtPath(doc, path: [0], with: element)) - var tabs = tabsModel.tabs - tabs[tabsModel.currentTabIndex].selectedPath = [0] - tabs[tabsModel.currentTabIndex].errorText = nil - tabsModel.tabs = tabs + tabsModel.selectAndClearError([0]) refreshLineEditor() } catch { setErrorOnCurrentTab(error.localizedDescription) } } else { commitDocument(VisuStructDocumentEditing.appendRoot(doc, element)) - var tabs = tabsModel.tabs - tabs[tabsModel.currentTabIndex].selectedPath = [tabs[tabsModel.currentTabIndex].document.elements.count - 1] - tabs[tabsModel.currentTabIndex].errorText = nil - tabsModel.tabs = tabs + tabsModel.selectAndClearError([tabsModel.currentTab.document.elements.count - 1]) refreshLineEditor() } } @@ -1694,51 +1600,30 @@ struct ContentView: View { switch t { case .replace(let path): commitDocument(try VisuStructDocumentEditing.replaceAtPath(doc, path: path, with: el)) - var tabs = tabsModel.tabs - tabs[tabsModel.currentTabIndex].selectedPath = path - tabs[tabsModel.currentTabIndex].errorText = nil - tabsModel.tabs = tabs + tabsModel.selectAndClearError(path) case .before(let path): commitDocument(try VisuStructDocumentEditing.insertBeforeSibling(doc, el, before: path)) - var tabs = tabsModel.tabs - tabs[tabsModel.currentTabIndex].selectedPath = path - tabs[tabsModel.currentTabIndex].errorText = nil - tabsModel.tabs = tabs + tabsModel.selectAndClearError(path) case .after(let path): commitDocument(try VisuStructDocumentEditing.insertAfterSibling(doc, el, after: path)) let newPath = Array(path.dropLast()) + [path.last! + 1] - var tabs = tabsModel.tabs - tabs[tabsModel.currentTabIndex].selectedPath = newPath - tabs[tabsModel.currentTabIndex].errorText = nil - tabsModel.tabs = tabs + tabsModel.selectAndClearError(newPath) case .rootInsertAt0: if VisuStructDocumentEditing.isSingleRootPlaceholder(in: doc) { commitDocument(try VisuStructDocumentEditing.replaceAtPath(doc, path: [0], with: el)) - var tabs = tabsModel.tabs - tabs[tabsModel.currentTabIndex].selectedPath = [0] - tabs[tabsModel.currentTabIndex].errorText = nil - tabsModel.tabs = tabs + tabsModel.selectAndClearError([0]) } else { commitDocument(try VisuStructDocumentEditing.insertAt(doc, parentPath: [], index: 0, element: el)) - var tabs = tabsModel.tabs - tabs[tabsModel.currentTabIndex].selectedPath = [0] - tabs[tabsModel.currentTabIndex].errorText = nil - tabsModel.tabs = tabs + tabsModel.selectAndClearError([0]) } case .rootAppend: if VisuStructDocumentEditing.isSingleRootPlaceholder(in: doc) { commitDocument(try VisuStructDocumentEditing.replaceAtPath(doc, path: [0], with: el)) - var tabs = tabsModel.tabs - tabs[tabsModel.currentTabIndex].selectedPath = [0] - tabs[tabsModel.currentTabIndex].errorText = nil - tabsModel.tabs = tabs + tabsModel.selectAndClearError([0]) } else { let idx = doc.elements.count commitDocument(try VisuStructDocumentEditing.insertAt(doc, parentPath: [], index: idx, element: el)) - var tabs = tabsModel.tabs - tabs[tabsModel.currentTabIndex].selectedPath = [idx] - tabs[tabsModel.currentTabIndex].errorText = nil - tabsModel.tabs = tabs + tabsModel.selectAndClearError([idx]) } } insertTarget = nil @@ -1752,14 +1637,11 @@ struct ContentView: View { } // iPad/Mac (ohne Tap-Einfügemarke): Ist ein Platzhalter (ø) ausgewählt, ersetzen — wie Drop auf die Mitte. // Sonst landet z. B. eine Verzweigung fälschlich als neues Wurzelelement unter der Schleife. - if let path = tabsModel.tabs[tabsModel.currentTabIndex].selectedPath, + if let path = tabsModel.currentTab.selectedPath, VisuStructDocumentEditing.isPlaceholderSlot(at: path, in: doc) { do { commitDocument(try VisuStructDocumentEditing.replaceAtPath(doc, path: path, with: el)) - var tabs = tabsModel.tabs - tabs[tabsModel.currentTabIndex].selectedPath = path - tabs[tabsModel.currentTabIndex].errorText = nil - tabsModel.tabs = tabs + tabsModel.selectAndClearError(path) refreshLineEditor() } catch { setErrorOnCurrentTab(error.localizedDescription) @@ -1768,16 +1650,13 @@ struct ContentView: View { } // Ausgewählt ist ein „echtes“ Element: neues Palette-Element als Geschwister **direkt darunter** // (z. B. in der while-Schleife: nach dem if noch i++ einfügen). - if let path = tabsModel.tabs[tabsModel.currentTabIndex].selectedPath, + if let path = tabsModel.currentTab.selectedPath, (try? VisuStructDocumentEditing.element(at: path, in: doc)) != nil, !VisuStructDocumentEditing.isPlaceholderSlot(at: path, in: doc) { do { commitDocument(try VisuStructDocumentEditing.insertAfterSibling(doc, el, after: path)) let newPath = Array(path.dropLast()) + [path.last! + 1] - var tabs = tabsModel.tabs - tabs[tabsModel.currentTabIndex].selectedPath = newPath - tabs[tabsModel.currentTabIndex].errorText = nil - tabsModel.tabs = tabs + tabsModel.selectAndClearError(newPath) refreshLineEditor() } catch { setErrorOnCurrentTab(error.localizedDescription) @@ -1795,16 +1674,10 @@ struct ContentView: View { guard VisuStructDocumentEditing.canDeleteElement(at: path, in: doc) else { return } do { commitDocument(try VisuStructDocumentEditing.deleteAtPath(doc, path: path)) - var tabs = tabsModel.tabs - let i = tabsModel.currentTabIndex - let newDoc = tabs[i].document - if newDoc.elements.isEmpty { - tabs[i].selectedPath = nil - } else if (try? VisuStructDocumentEditing.element(at: path, in: newDoc)) == nil { - tabs[i].selectedPath = [0] + tabsModel.updateCurrentTab { tab in + tab.selectedPath = VisuStructDocumentEditing.sanitizedSelection(path, in: tab.document) + tab.errorText = nil } - tabs[i].errorText = nil - tabsModel.tabs = tabs refreshLineEditor() } catch { if let editErr = error as? VisuStructEditError, editErr == .cannotDeleteLastRootElement { @@ -1857,17 +1730,13 @@ struct ContentView: View { // MARK: - Zeileneditor (Synchronisation Element ↔ Textfeld) private func refreshLineEditor() { - let i = tabsModel.currentTabIndex - var tabs = tabsModel.tabs - let tab = tabs[i] + let tab = tabsModel.currentTab guard let path = tab.selectedPath, let el = try? VisuStructDocumentEditing.element(at: path, in: tab.document) else { - tabs[i].lineEditorText = "" - tabsModel.tabs = tabs + tabsModel.updateCurrentTab { $0.lineEditorText = "" } return } - tabs[i].lineEditorText = el.textLines.joined(separator: "\n") - tabsModel.tabs = tabs + tabsModel.updateCurrentTab { $0.lineEditorText = el.textLines.joined(separator: "\n") } if !isPhone, focusedField == nil, el.type != .forSchleife { focusedField = .textEditor @@ -1907,7 +1776,7 @@ struct ContentView: View { private func applyLineEditor() { guard let path = selectedPath else { return } if let el = try? VisuStructDocumentEditing.element(at: path, in: document), el.type == .forSchleife { return } - let editorText = tabsModel.tabs[tabsModel.currentTabIndex].lineEditorText + let editorText = tabsModel.currentTab.lineEditorText let storedText: String if let labelPrefix = lineEditorFixedPrefixLabel { storedText = VisuStructLineEditorText.prefixingForEditing(labelPrefix, to: VisuStructLineEditorText.strippingPrefixForEditing(labelPrefix, from: editorText)) diff --git a/VisuStruct-iOS/VisuStructXcodeOpen/VisuStruct/Tabs/DiagramSheetTab.swift b/VisuStruct-iOS/VisuStructXcodeOpen/VisuStruct/Tabs/DiagramSheetTab.swift index 7fb5cc63..5d5972bd 100644 --- a/VisuStruct-iOS/VisuStructXcodeOpen/VisuStruct/Tabs/DiagramSheetTab.swift +++ b/VisuStruct-iOS/VisuStructXcodeOpen/VisuStruct/Tabs/DiagramSheetTab.swift @@ -7,8 +7,7 @@ struct DiagramSheetTab: Identifiable { let id: UUID var title: String var document: VisuStructDocument - var undoStack: [VisuStructDocument] = [] - var redoStack: [VisuStructDocument] = [] + var history = VisuStructEditHistory() var selectedPath: [Int]? = [0] var lineEditorText: String = "" var errorText: String? = nil @@ -30,8 +29,7 @@ struct DiagramSheetTab: Identifiable { id: id, title: title, document: doc, - undoStack: [], - redoStack: [], + history: VisuStructEditHistory(), selectedPath: [0], lineEditorText: "", errorText: nil, diff --git a/VisuStruct-iOS/VisuStructXcodeOpen/VisuStruct/Tabs/DiagramSheetTabsModel+Editing.swift b/VisuStruct-iOS/VisuStructXcodeOpen/VisuStruct/Tabs/DiagramSheetTabsModel+Editing.swift new file mode 100644 index 00000000..10970b0f --- /dev/null +++ b/VisuStruct-iOS/VisuStructXcodeOpen/VisuStruct/Tabs/DiagramSheetTabsModel+Editing.swift @@ -0,0 +1,78 @@ +import Foundation +import VisuStructCore + +// MARK: - Bearbeiten des aktuellen Reiters (Dokument, Verlauf, Auswahl, Fehler) + +extension DiagramSheetTabsModel { + var currentTab: DiagramSheetTab { + tabs[currentTabIndex] + } + + /// Ändert den aktuellen Reiter und weist `tabs` genau einmal neu zu. + func updateCurrentTab(_ change: (inout DiagramSheetTab) -> Void) { + let i = currentTabIndex + var copy = tabs + change(©[i]) + tabs = copy + } + + /// Ersetzt das Dokument und legt einen Undo-Snapshot des bisherigen Zustands ab. + func commitDocument(_ new: VisuStructDocument) { + updateCurrentTab { tab in + guard new != tab.document else { return } + tab.history.record(tab.document) + tab.document = new + } + } + + /// Neu / Import: Historie verwerfen, Zustand setzen. + func resetHistoryAndDocument(_ new: VisuStructDocument) { + updateCurrentTab { tab in + tab.history.reset() + tab.document = new + tab.savedDocumentBaseline = VisuStructXMLCodec.encode(new) + } + } + + /// `true`, wenn ein Schritt rückgängig gemacht wurde. + @discardableResult + func undo() -> Bool { + var changed = false + updateCurrentTab { tab in + guard let previous = tab.history.undo(from: tab.document) else { return } + Self.applyHistoryJump(previous, to: &tab) + changed = true + } + return changed + } + + /// `true`, wenn ein Schritt wiederhergestellt wurde. + @discardableResult + func redo() -> Bool { + var changed = false + updateCurrentTab { tab in + guard let next = tab.history.redo(from: tab.document) else { return } + Self.applyHistoryJump(next, to: &tab) + changed = true + } + return changed + } + + private static func applyHistoryJump(_ document: VisuStructDocument, to tab: inout DiagramSheetTab) { + tab.document = document + tab.errorText = nil + tab.selectedPath = VisuStructDocumentEditing.sanitizedSelection(tab.selectedPath, in: document) + } + + /// Neue Auswahl nach einer Einfügung o. Ä.; ein angezeigter Fehler verschwindet. + func selectAndClearError(_ path: [Int]?) { + updateCurrentTab { tab in + tab.selectedPath = path + tab.errorText = nil + } + } + + func setErrorOnCurrentTab(_ message: String?) { + updateCurrentTab { $0.errorText = message } + } +} From efabe1b6f403ebe612a8ebe9012ec5cc3dd424d7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 22:20:23 +0000 Subject: [PATCH 05/11] Update DiagramSheetTabsModel doc comment Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Cp4EQgFQyGbM8MVLiWbJKQ --- .../VisuStruct/Tabs/DiagramSheetTabsModel.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/VisuStruct-iOS/VisuStructXcodeOpen/VisuStruct/Tabs/DiagramSheetTabsModel.swift b/VisuStruct-iOS/VisuStructXcodeOpen/VisuStruct/Tabs/DiagramSheetTabsModel.swift index ce750cfc..5cb65c33 100644 --- a/VisuStruct-iOS/VisuStructXcodeOpen/VisuStruct/Tabs/DiagramSheetTabsModel.swift +++ b/VisuStruct-iOS/VisuStructXcodeOpen/VisuStruct/Tabs/DiagramSheetTabsModel.swift @@ -3,7 +3,8 @@ import Observation import VisuStructCore /// Mehrere Diagramm-Reiter: Tabs, Auswahl, Serial, Schließen inkl. „ungespeichert“-Dialog. -/// Dokument-/Editor-Logik und UI-Nebenwirkungen (z. B. `refreshLineEditor`) bleiben in `ContentView`. +/// Bearbeiten des aktuellen Reiters (Dokument, Undo/Redo, Auswahl): `DiagramSheetTabsModel+Editing.swift`. +/// UI-Nebenwirkungen (z. B. `refreshLineEditor`, Fokus) bleiben in `ContentView`. @MainActor @Observable final class DiagramSheetTabsModel { From 054d6bf5849d4d4decf93bfa5a54d47b860c6ff9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 22:22:25 +0000 Subject: [PATCH 06/11] Move simulation state and controls into SimulationController The engine, play/pause task, input text and the simulation mode flag move from ContentView @State into an @Observable SimulationController. The step delay and highlight mode are read from UserDefaults at each step, as the @AppStorage values were before. No behavior change intended. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Cp4EQgFQyGbM8MVLiWbJKQ --- .../VisuStruct/ContentView.swift | 149 +++--------------- .../Simulation/SimulationController.swift | 142 +++++++++++++++++ 2 files changed, 164 insertions(+), 127 deletions(-) create mode 100644 VisuStruct-iOS/VisuStructXcodeOpen/VisuStruct/Simulation/SimulationController.swift diff --git a/VisuStruct-iOS/VisuStructXcodeOpen/VisuStruct/ContentView.swift b/VisuStruct-iOS/VisuStructXcodeOpen/VisuStruct/ContentView.swift index 91f81d99..a6415773 100644 --- a/VisuStruct-iOS/VisuStructXcodeOpen/VisuStruct/ContentView.swift +++ b/VisuStruct-iOS/VisuStructXcodeOpen/VisuStruct/ContentView.swift @@ -39,12 +39,7 @@ struct ContentView: View { @State private var showInfo = false @State private var showCodeGeneratorPaywall = false @State private var showCodeGeneratorPlaceholder = false - @State private var isSimulationMode = false - @State private var simulationEngine: VisuStructSimulationEngine - @State private var simulationPlayTask: Task? = nil - @State private var isSimulationPlaying = false - @State private var simulationWasPlayingBeforeInput = false - @State private var simulationInputText = "" + @State private var simulation: SimulationController @ObservedObject private var recentFilesStore: RecentFilesStore /// Erhöhen bei jedem Tipp auf „Code erzeugen“, damit die Anzeige neu aufgebaut wird (auch wenn der Dialog schon offen ist). @State private var codeGeneratorRegenerationToken: Int = 0 @@ -74,7 +69,6 @@ struct ContentView: View { @AppStorage(VisuStructUIScaleStep.appStorageKeyCanvas) private var canvasScaleRaw: String = VisuStructUIScaleStep.small.rawValue @AppStorage(VisuStructUIScaleStep.appStorageKeyInspector) private var inspectorScaleRaw: String = VisuStructUIScaleStep.small.rawValue @State private var editorFocusToken = UUID() - @AppStorage(SimulationStepDelayOption.appStorageKey) private var simulationStepDelayRaw: String = SimulationStepDelayOption.defaultRawValue @AppStorage(SimulationHighlightMode.appStorageKey) private var simulationHighlightModeRaw: String = SimulationHighlightMode.defaultRawValue private var canvasScaleStep: VisuStructUIScaleStep { @@ -89,14 +83,6 @@ struct ContentView: View { ExportFilenameStyle.fromStored(exportFilenameStyleRaw) } - private var simulationStepDelay: SimulationStepDelayOption { - SimulationStepDelayOption.fromStored(simulationStepDelayRaw) - } - - private var simulationHighlightMode: SimulationHighlightMode { - SimulationHighlightMode.fromStored(simulationHighlightModeRaw) - } - init() { let startupLang = AppLanguage.currentFromDefaults() let model = DiagramSheetTabsModel.makeInitial(language: startupLang) @@ -104,7 +90,7 @@ struct ContentView: View { _tabsModel = State(initialValue: model) _recentFilesStore = ObservedObject(wrappedValue: recentStore) _documentServices = State(initialValue: DiagramEditorDocumentServices(tabsModel: model, recentFilesStore: recentStore)) - _simulationEngine = State(initialValue: VisuStructSimulationEngine(document: model.tabs[0].document)) + _simulation = State(initialValue: SimulationController(tabsModel: model)) } // MARK: - Tab-Titel & Dokument öffnen @@ -770,15 +756,15 @@ struct ContentView: View { simulationCanvasBlock .layoutPriority(1) SimulationStatePanel( - engine: simulationEngine, - isPlaying: isSimulationPlaying, - inputText: $simulationInputText, + engine: simulation.engine, + isPlaying: simulation.isPlaying, + inputText: $simulation.inputText, highlightModeRaw: $simulationHighlightModeRaw, - onPlay: playSimulation, - onPause: pauseSimulation, - onStep: stepSimulation, - onSubmitInput: submitSimulationInput, - onStop: resetSimulation + onPlay: { simulation.play() }, + onPause: { simulation.pause() }, + onStep: { simulation.step() }, + onSubmitInput: { simulation.submitInput() }, + onStop: { simulation.reset() } ) .frame(width: panelWidth) .frame(maxHeight: .infinity, alignment: .top) @@ -789,15 +775,15 @@ struct ContentView: View { simulationCanvasBlock .frame(height: max(340, geo.size.height * 0.55)) SimulationStatePanel( - engine: simulationEngine, - isPlaying: isSimulationPlaying, - inputText: $simulationInputText, + engine: simulation.engine, + isPlaying: simulation.isPlaying, + inputText: $simulation.inputText, highlightModeRaw: $simulationHighlightModeRaw, - onPlay: playSimulation, - onPause: pauseSimulation, - onStep: stepSimulation, - onSubmitInput: submitSimulationInput, - onStop: resetSimulation + onPlay: { simulation.play() }, + onPause: { simulation.pause() }, + onStep: { simulation.step() }, + onSubmitInput: { simulation.submitInput() }, + onStop: { simulation.reset() } ) } .frame(maxWidth: .infinity, alignment: .leading) @@ -926,104 +912,13 @@ struct ContentView: View { ) } - // MARK: - Simulation (Modus & Steuerung) - - private func beginSimulationMode() { - pauseSimulation() - var engine = VisuStructSimulationEngine(document: document) - engine.reset() - simulationEngine = engine - simulationInputText = "" - replaceSelectedPathOnCurrentTab(engine.currentStepPath) - withAnimation(.easeInOut(duration: 0.18)) { - isSimulationMode = true - } - } - - private func endSimulationMode() { - pauseSimulation() - withAnimation(.easeInOut(duration: 0.18)) { - isSimulationMode = false - } - if tabsModel.currentTab.selectedPath == nil, !document.elements.isEmpty { - replaceSelectedPathOnCurrentTab([0]) - } - } - - private func stepSimulation() { - guard simulationEngine.canStep else { - pauseSimulation() - return - } - let wasPlaying = isSimulationPlaying - let lastExecutedPath = simulationEngine.currentStepPath - var engine = simulationEngine - engine.step() - simulationEngine = engine - if simulationHighlightMode == .nextToExecute, engine.canStep { - replaceSelectedPathOnCurrentTab(engine.currentStepPath) - } else { - // lastExecuted (oder Ende/Input): letztes ausgeführtes Element bleibt markiert. - replaceSelectedPathOnCurrentTab(lastExecutedPath ?? engine.currentStepPath) - } - // Wenn ein Input als Ergebnis des Steps angefordert wurde, Play pausieren, - // aber merken, dass wir nach OK automatisch weiterlaufen sollen. - if engine.state.inputRequest != nil, wasPlaying { - simulationWasPlayingBeforeInput = true - } - if !engine.canStep { - pauseSimulation() - } - } - - private func resetSimulation() { - pauseSimulation() - var engine = VisuStructSimulationEngine(document: document) - engine.reset() - simulationEngine = engine - simulationInputText = "" - replaceSelectedPathOnCurrentTab(engine.currentStepPath) - } + // MARK: - Simulation (Modus & Steuerung) → `SimulationController` - private func submitSimulationInput() { - let shouldResume = isSimulationPlaying || simulationWasPlayingBeforeInput - pauseSimulation() - var engine = simulationEngine - engine.provideInput(simulationInputText) - simulationEngine = engine - if engine.state.inputRequest == nil { - simulationInputText = "" - } - replaceSelectedPathOnCurrentTab(engine.currentStepPath) + private var isSimulationMode: Bool { simulation.isActive } - if shouldResume, isSimulationMode, engine.canStep { - simulationWasPlayingBeforeInput = false - playSimulation() - } - } + private func beginSimulationMode() { simulation.begin() } - private func playSimulation() { - guard !isSimulationPlaying, simulationEngine.canStep else { return } - isSimulationPlaying = true - simulationPlayTask?.cancel() - simulationPlayTask = Task { @MainActor in - while !Task.isCancelled && isSimulationMode && simulationEngine.canStep { - stepSimulation() - guard !Task.isCancelled, isSimulationMode, simulationEngine.canStep else { break } - try? await Task.sleep(nanoseconds: simulationStepDelay.nanoseconds) - } - if !Task.isCancelled { - isSimulationPlaying = false - simulationPlayTask = nil - } - } - } - - private func pauseSimulation() { - simulationPlayTask?.cancel() - simulationPlayTask = nil - isSimulationPlaying = false - } + private func endSimulationMode() { simulation.end() } // MARK: - Tab-Titel (Platzhalter) diff --git a/VisuStruct-iOS/VisuStructXcodeOpen/VisuStruct/Simulation/SimulationController.swift b/VisuStruct-iOS/VisuStructXcodeOpen/VisuStruct/Simulation/SimulationController.swift new file mode 100644 index 00000000..b13fa5c4 --- /dev/null +++ b/VisuStruct-iOS/VisuStructXcodeOpen/VisuStruct/Simulation/SimulationController.swift @@ -0,0 +1,142 @@ +import SwiftUI +import Observation +import VisuStructCore + +/// Simulationsmodus: Engine, Abspielen/Pause, Eingabe. Die Markierung des aktuellen Schritts ist die Auswahl im aktuellen Reiter. +@MainActor +@Observable +final class SimulationController { + private(set) var isActive = false + private(set) var engine: VisuStructSimulationEngine + private(set) var isPlaying = false + var inputText = "" + + @ObservationIgnored private var playTask: Task? = nil + @ObservationIgnored private var wasPlayingBeforeInput = false + @ObservationIgnored private let tabsModel: DiagramSheetTabsModel + + init(tabsModel: DiagramSheetTabsModel) { + self.tabsModel = tabsModel + self.engine = VisuStructSimulationEngine(document: tabsModel.currentTab.document) + } + + /// Wie `@AppStorage` in den Einstellungen; bei jedem Schritt neu gelesen, damit Änderungen während des Abspielens greifen. + private var stepDelay: SimulationStepDelayOption { + SimulationStepDelayOption.fromStored( + UserDefaults.standard.string(forKey: SimulationStepDelayOption.appStorageKey) ?? SimulationStepDelayOption.defaultRawValue + ) + } + + private var highlightMode: SimulationHighlightMode { + SimulationHighlightMode.fromStored( + UserDefaults.standard.string(forKey: SimulationHighlightMode.appStorageKey) ?? SimulationHighlightMode.defaultRawValue + ) + } + + private func select(_ path: [Int]?) { + tabsModel.updateCurrentTab { $0.selectedPath = path } + } + + // MARK: - Modus + + func begin() { + pause() + restartEngine() + withAnimation(.easeInOut(duration: 0.18)) { + isActive = true + } + } + + func end() { + pause() + withAnimation(.easeInOut(duration: 0.18)) { + isActive = false + } + let tab = tabsModel.currentTab + if tab.selectedPath == nil, !tab.document.elements.isEmpty { + select([0]) + } + } + + // MARK: - Steuerung + + func step() { + guard engine.canStep else { + pause() + return + } + let wasPlaying = isPlaying + let lastExecutedPath = engine.currentStepPath + var next = engine + next.step() + engine = next + if highlightMode == .nextToExecute, next.canStep { + select(next.currentStepPath) + } else { + // lastExecuted (oder Ende/Input): letztes ausgeführtes Element bleibt markiert. + select(lastExecutedPath ?? next.currentStepPath) + } + // Wenn ein Input als Ergebnis des Steps angefordert wurde, Play pausieren, + // aber merken, dass wir nach OK automatisch weiterlaufen sollen. + if next.state.inputRequest != nil, wasPlaying { + wasPlayingBeforeInput = true + } + if !next.canStep { + pause() + } + } + + func reset() { + pause() + restartEngine() + } + + func submitInput() { + let shouldResume = isPlaying || wasPlayingBeforeInput + pause() + var next = engine + next.provideInput(inputText) + engine = next + if next.state.inputRequest == nil { + inputText = "" + } + select(next.currentStepPath) + + if shouldResume, isActive, next.canStep { + wasPlayingBeforeInput = false + play() + } + } + + func play() { + guard !isPlaying, engine.canStep else { return } + isPlaying = true + playTask?.cancel() + playTask = Task { @MainActor in + while !Task.isCancelled && isActive && engine.canStep { + step() + guard !Task.isCancelled, isActive, engine.canStep else { break } + try? await Task.sleep(nanoseconds: stepDelay.nanoseconds) + } + if !Task.isCancelled { + isPlaying = false + playTask = nil + } + } + } + + func pause() { + playTask?.cancel() + playTask = nil + isPlaying = false + } + + /// Neue Engine für das aktuelle Dokument, Markierung auf den ersten Schritt. + private func restartEngine() { + var fresh = VisuStructSimulationEngine(document: tabsModel.currentTab.document) + fresh.reset() + engine = fresh + inputText = "" + select(fresh.currentStepPath) + } +} From 8c92504893e65d7418b649ff3b73bde4d4bca8b7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 22:24:06 +0000 Subject: [PATCH 07/11] Extract PaletteActionList and SimulationDetailView from ContentView - Palette/PaletteActionList.swift: palette rows, drag preview and the action buttons below; actions come in as closures. - Simulation/SimulationDetailView.swift: canvas plus state panel layout for simulation mode, driven by SimulationController. - ContentView keeps the action logic (toggleSimulationMode, openCodeGenerator) and the simulation canvas. No behavior change intended. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Cp4EQgFQyGbM8MVLiWbJKQ --- .../VisuStruct/ContentView.swift | 292 +++--------------- .../Palette/PaletteActionList.swift | 213 +++++++++++++ .../Simulation/SimulationDetailView.swift | 55 ++++ 3 files changed, 309 insertions(+), 251 deletions(-) create mode 100644 VisuStruct-iOS/VisuStructXcodeOpen/VisuStruct/Palette/PaletteActionList.swift create mode 100644 VisuStruct-iOS/VisuStructXcodeOpen/VisuStruct/Simulation/SimulationDetailView.swift diff --git a/VisuStruct-iOS/VisuStructXcodeOpen/VisuStruct/ContentView.swift b/VisuStruct-iOS/VisuStructXcodeOpen/VisuStruct/ContentView.swift index a6415773..74df2a69 100644 --- a/VisuStruct-iOS/VisuStructXcodeOpen/VisuStruct/ContentView.swift +++ b/VisuStruct-iOS/VisuStructXcodeOpen/VisuStruct/ContentView.swift @@ -469,160 +469,47 @@ struct ContentView: View { } /// Gemeinsamer List-Inhalt für Palette + Aktionen. - @ViewBuilder private var paletteListStackBody: some View { - let isCompact = horizontalSizeClass != .regular - List { - Section { - ForEach(ElementPalette.PaletteItem.paletteOrder) { item in - paletteStructureRow(item, isCompact: isCompact) - } + PaletteActionList( + isSimulationMode: isSimulationMode, + canDeleteSelection: selectedPath.map { path in + VisuStructDocumentEditing.canDeleteElement(at: path, in: document) + } ?? false, + codeGeneratorAccessGranted: codeGeneratorAccessGranted, + onInsertItem: insertPaletteItem, + onDeleteSelection: deleteSelection, + onToggleSimulation: toggleSimulationMode, + onGenerateCode: openCodeGenerator, + onSharePNG: exportPNG, + onShareDocument: shareDocumentAsFile, + onExportPDF: shareDiagramPDF, + onShowAbout: { showInfo = true } + ) + } + + private func toggleSimulationMode() { + if !isSimulationMode { + applyLineEditorRespectingTextFocus() + DispatchQueue.main.async { + beginSimulationMode() } - Section { - Button { - deleteSelection() - } label: { - Label(lang.tr("action_delete_selection"), systemImage: "trash") - } - .buttonStyle(PaletteListElementButtonStyle(emphasis: .destructive)) - .disabledAndDimmed(isSimulationMode) - .listRowInsets( - isCompact - ? EdgeInsets(top: 4, leading: 10, bottom: 3, trailing: 10) - : EdgeInsets(top: 8, leading: 12, bottom: 5, trailing: 12) - ) - .listRowSeparator(.hidden) - .listRowBackground(Color.clear) - .disabled( - isSimulationMode || (selectedPath.map { path in - !VisuStructDocumentEditing.canDeleteElement(at: path, in: document) - } ?? true) - ) - Button { - if !isSimulationMode { - applyLineEditorRespectingTextFocus() - DispatchQueue.main.async { - beginSimulationMode() - } - } else { - endSimulationMode() - } - } label: { - Label( - isSimulationMode ? lang.tr("action_diagram") : lang.tr("action_simulation"), - systemImage: isSimulationMode ? "rectangle.split.3x1" : "play.circle" - ) - } - .buttonStyle(PaletteListElementButtonStyle()) - .listRowInsets( - isCompact - ? EdgeInsets(top: 3, leading: 10, bottom: 3, trailing: 10) - : EdgeInsets(top: 5, leading: 12, bottom: 5, trailing: 12) - ) - .listRowSeparator(.hidden) - .listRowBackground(Color.clear) - .accessibilityLabel(isSimulationMode ? lang.tr("action_diagram") : lang.tr("action_simulation")) - Button { - if codeGeneratorAccessGranted { - // Wichtig: Text im Inspector ist nicht immer sofort ins Dokument geschrieben (Button „Text übernehmen“). - // Für Code-Generierung wollen wir immer den aktuell sichtbaren Stand verwenden. - applyLineEditorRespectingTextFocus() - DispatchQueue.main.async { - codeGeneratorRegenerationToken &+= 1 - showCodeGeneratorPlaceholder = true - } - } else { - showCodeGeneratorPaywall = true - } - } label: { - HStack(spacing: 10) { - Label(lang.tr("action_generate_code"), systemImage: "chevron.left.forwardslash.chevron.right") - .foregroundStyle(isSimulationMode ? .tertiary : .primary) - Spacer(minLength: 8) - if !codeGeneratorAccessGranted { - Image(systemName: "lock.fill") - .font(.body) - .foregroundStyle(.secondary) - } - } - } - .buttonStyle(PaletteListElementButtonStyle()) - .disabledAndDimmed(isSimulationMode) - .listRowInsets( - isCompact - ? EdgeInsets(top: 3, leading: 10, bottom: 3, trailing: 10) - : EdgeInsets(top: 5, leading: 12, bottom: 5, trailing: 12) - ) - .listRowSeparator(.hidden) - .listRowBackground(Color.clear) - .accessibilityLabel( - codeGeneratorAccessGranted ? lang.tr("a11y_generate_code") : lang.tr("a11y_generate_code_locked") - ) - Button { - exportPNG() - } label: { - Label(lang.tr("action_share_png"), systemImage: "square.and.arrow.up") - .foregroundStyle(isSimulationMode ? .tertiary : .primary) - } - .buttonStyle(PaletteListElementButtonStyle()) - .disabledAndDimmed(isSimulationMode) - .listRowInsets( - isCompact - ? EdgeInsets(top: 3, leading: 10, bottom: 3, trailing: 10) - : EdgeInsets(top: 5, leading: 12, bottom: 5, trailing: 12) - ) - .listRowSeparator(.hidden) - .listRowBackground(Color.clear) - Button { - shareDocumentAsFile() - } label: { - Label(lang.tr("action_share_document"), systemImage: "square.and.arrow.up.on.square") - .foregroundStyle(isSimulationMode ? .tertiary : .primary) - } - .buttonStyle(PaletteListElementButtonStyle()) - .disabledAndDimmed(isSimulationMode) - .listRowInsets( - isCompact - ? EdgeInsets(top: 3, leading: 10, bottom: 3, trailing: 10) - : EdgeInsets(top: 5, leading: 12, bottom: 5, trailing: 12) - ) - .listRowSeparator(.hidden) - .listRowBackground(Color.clear) - Button { - shareDiagramPDF() - } label: { - Label(lang.tr("action_export_pdf"), systemImage: "doc.text.magnifyingglass") - .foregroundStyle(isSimulationMode ? .tertiary : .primary) - } - .buttonStyle(PaletteListElementButtonStyle()) - .disabledAndDimmed(isSimulationMode) - .listRowInsets( - isCompact - ? EdgeInsets(top: 3, leading: 10, bottom: 3, trailing: 10) - : EdgeInsets(top: 5, leading: 12, bottom: 5, trailing: 12) - ) - .listRowSeparator(.hidden) - .listRowBackground(Color.clear) + } else { + endSimulationMode() + } + } - Button { - showInfo = true - } label: { - Label(lang.tr("action_about"), systemImage: "info.circle") - } - .buttonStyle(PaletteListElementButtonStyle()) - .listRowInsets( - isCompact - ? EdgeInsets(top: 3, leading: 10, bottom: 4, trailing: 10) - : EdgeInsets(top: 5, leading: 12, bottom: 8, trailing: 12) - ) - .listRowSeparator(.hidden) - .listRowBackground(Color.clear) + private func openCodeGenerator() { + if codeGeneratorAccessGranted { + // Wichtig: Text im Inspector ist nicht immer sofort ins Dokument geschrieben (Button „Text übernehmen“). + // Für Code-Generierung wollen wir immer den aktuell sichtbaren Stand verwenden. + applyLineEditorRespectingTextFocus() + DispatchQueue.main.async { + codeGeneratorRegenerationToken &+= 1 + showCodeGeneratorPlaceholder = true } + } else { + showCodeGeneratorPaywall = true } - .listStyle(.insetGrouped) - .listSectionSpacing(isCompact ? 8 : 18) - .environment(\.defaultMinListRowHeight, isCompact ? 34 : 48) - .scrollContentBackground(.hidden) } // MARK: - Haupteditor (NavigationStack, Tabs, Fehler) @@ -745,50 +632,12 @@ struct ContentView: View { // MARK: - Simulations-Ansicht - @ViewBuilder private var simulationDetailView: some View { - GeometryReader { geo in - let useColumns = geo.size.width >= 760 - let panelWidth = min(max(geo.size.width * 0.32, 280), 360) - - if useColumns { - HStack(alignment: .top, spacing: 16) { - simulationCanvasBlock - .layoutPriority(1) - SimulationStatePanel( - engine: simulation.engine, - isPlaying: simulation.isPlaying, - inputText: $simulation.inputText, - highlightModeRaw: $simulationHighlightModeRaw, - onPlay: { simulation.play() }, - onPause: { simulation.pause() }, - onStep: { simulation.step() }, - onSubmitInput: { simulation.submitInput() }, - onStop: { simulation.reset() } - ) - .frame(width: panelWidth) - .frame(maxHeight: .infinity, alignment: .top) - } - } else { - ScrollView { - VStack(alignment: .leading, spacing: 16) { - simulationCanvasBlock - .frame(height: max(340, geo.size.height * 0.55)) - SimulationStatePanel( - engine: simulation.engine, - isPlaying: simulation.isPlaying, - inputText: $simulation.inputText, - highlightModeRaw: $simulationHighlightModeRaw, - onPlay: { simulation.play() }, - onPause: { simulation.pause() }, - onStep: { simulation.step() }, - onSubmitInput: { simulation.submitInput() }, - onStop: { simulation.reset() } - ) - } - .frame(maxWidth: .infinity, alignment: .leading) - } - } + SimulationDetailView( + simulation: simulation, + highlightModeRaw: $simulationHighlightModeRaw + ) { + simulationCanvasBlock } } @@ -1582,46 +1431,6 @@ struct ContentView: View { } } - @ViewBuilder - private func paletteStructureRow(_ item: ElementPalette.PaletteItem, isCompact: Bool) -> some View { - let rowInsets = isCompact - ? EdgeInsets(top: 1, leading: 10, bottom: 1, trailing: 10) - : EdgeInsets(top: 5, leading: 12, bottom: 5, trailing: 12) - - if isSimulationMode { - Button { - insertPaletteItem(item) - } label: { - Label(item.localizedTitle(in: lang), systemImage: item.symbolName) - .foregroundStyle(.tertiary) - .multilineTextAlignment(.leading) - } - .buttonStyle(PaletteListElementButtonStyle()) - .accessibilityAddTraits(.isButton) - .disabled(true) - .opacity(0.55) - .listRowInsets(rowInsets) - .listRowSeparator(.hidden) - .listRowBackground(Color.clear) - } else { - Button { - insertPaletteItem(item) - } label: { - Label(item.localizedTitle(in: lang), systemImage: item.symbolName) - .foregroundStyle(.primary) - .multilineTextAlignment(.leading) - } - .buttonStyle(PaletteListElementButtonStyle()) - .draggable(DiagramDragPayload(raw: ElementPalette.paletteDragIdentifier(item))) { - paletteDragPreview(for: item) - } - .accessibilityAddTraits(.isButton) - .listRowInsets(rowInsets) - .listRowSeparator(.hidden) - .listRowBackground(Color.clear) - } - } - // MARK: - Zeileneditor (Synchronisation Element ↔ Textfeld) private func refreshLineEditor() { @@ -1768,23 +1577,4 @@ struct ContentView: View { controller.present(animated: true) } #endif - - // MARK: - Palette Drag-Vorschau - - @ViewBuilder - private func paletteDragPreview(for item: ElementPalette.PaletteItem) -> some View { - HStack(spacing: 8) { - Image(systemName: item.symbolName) - Text(item.localizedTitle(in: lang)) - .font(.callout.weight(.medium)) - } - .padding(.horizontal, 12) - .padding(.vertical, 10) - .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 8)) - .overlay( - RoundedRectangle(cornerRadius: 8) - .strokeBorder(Color.blue.opacity(0.9), lineWidth: 2) - ) - .shadow(color: .black.opacity(0.22), radius: 8, x: 0, y: 4) - } } diff --git a/VisuStruct-iOS/VisuStructXcodeOpen/VisuStruct/Palette/PaletteActionList.swift b/VisuStruct-iOS/VisuStructXcodeOpen/VisuStruct/Palette/PaletteActionList.swift new file mode 100644 index 00000000..6d2fe790 --- /dev/null +++ b/VisuStruct-iOS/VisuStructXcodeOpen/VisuStruct/Palette/PaletteActionList.swift @@ -0,0 +1,213 @@ +import SwiftUI +import VisuStructCore + +/// Palette (Strukturelemente zum Einfügen/Ziehen) und Aktionen darunter: Löschen, Simulation, Code, Teilen, Export, Info. +struct PaletteActionList: View { + @Environment(\.horizontalSizeClass) private var horizontalSizeClass + @Environment(\.appLanguage) private var lang + + let isSimulationMode: Bool + let canDeleteSelection: Bool + let codeGeneratorAccessGranted: Bool + let onInsertItem: (ElementPalette.PaletteItem) -> Void + let onDeleteSelection: () -> Void + let onToggleSimulation: () -> Void + let onGenerateCode: () -> Void + let onSharePNG: () -> Void + let onShareDocument: () -> Void + let onExportPDF: () -> Void + let onShowAbout: () -> Void + + var body: some View { + let isCompact = horizontalSizeClass != .regular + List { + Section { + ForEach(ElementPalette.PaletteItem.paletteOrder) { item in + paletteStructureRow(item, isCompact: isCompact) + } + } + Section { + Button { + onDeleteSelection() + } label: { + Label(lang.tr("action_delete_selection"), systemImage: "trash") + } + .buttonStyle(PaletteListElementButtonStyle(emphasis: .destructive)) + .disabledAndDimmed(isSimulationMode) + .listRowInsets( + isCompact + ? EdgeInsets(top: 4, leading: 10, bottom: 3, trailing: 10) + : EdgeInsets(top: 8, leading: 12, bottom: 5, trailing: 12) + ) + .listRowSeparator(.hidden) + .listRowBackground(Color.clear) + .disabled(isSimulationMode || !canDeleteSelection) + Button { + onToggleSimulation() + } label: { + Label( + isSimulationMode ? lang.tr("action_diagram") : lang.tr("action_simulation"), + systemImage: isSimulationMode ? "rectangle.split.3x1" : "play.circle" + ) + } + .buttonStyle(PaletteListElementButtonStyle()) + .listRowInsets( + isCompact + ? EdgeInsets(top: 3, leading: 10, bottom: 3, trailing: 10) + : EdgeInsets(top: 5, leading: 12, bottom: 5, trailing: 12) + ) + .listRowSeparator(.hidden) + .listRowBackground(Color.clear) + .accessibilityLabel(isSimulationMode ? lang.tr("action_diagram") : lang.tr("action_simulation")) + Button { + onGenerateCode() + } label: { + HStack(spacing: 10) { + Label(lang.tr("action_generate_code"), systemImage: "chevron.left.forwardslash.chevron.right") + .foregroundStyle(isSimulationMode ? .tertiary : .primary) + Spacer(minLength: 8) + if !codeGeneratorAccessGranted { + Image(systemName: "lock.fill") + .font(.body) + .foregroundStyle(.secondary) + } + } + } + .buttonStyle(PaletteListElementButtonStyle()) + .disabledAndDimmed(isSimulationMode) + .listRowInsets( + isCompact + ? EdgeInsets(top: 3, leading: 10, bottom: 3, trailing: 10) + : EdgeInsets(top: 5, leading: 12, bottom: 5, trailing: 12) + ) + .listRowSeparator(.hidden) + .listRowBackground(Color.clear) + .accessibilityLabel( + codeGeneratorAccessGranted ? lang.tr("a11y_generate_code") : lang.tr("a11y_generate_code_locked") + ) + Button { + onSharePNG() + } label: { + Label(lang.tr("action_share_png"), systemImage: "square.and.arrow.up") + .foregroundStyle(isSimulationMode ? .tertiary : .primary) + } + .buttonStyle(PaletteListElementButtonStyle()) + .disabledAndDimmed(isSimulationMode) + .listRowInsets( + isCompact + ? EdgeInsets(top: 3, leading: 10, bottom: 3, trailing: 10) + : EdgeInsets(top: 5, leading: 12, bottom: 5, trailing: 12) + ) + .listRowSeparator(.hidden) + .listRowBackground(Color.clear) + Button { + onShareDocument() + } label: { + Label(lang.tr("action_share_document"), systemImage: "square.and.arrow.up.on.square") + .foregroundStyle(isSimulationMode ? .tertiary : .primary) + } + .buttonStyle(PaletteListElementButtonStyle()) + .disabledAndDimmed(isSimulationMode) + .listRowInsets( + isCompact + ? EdgeInsets(top: 3, leading: 10, bottom: 3, trailing: 10) + : EdgeInsets(top: 5, leading: 12, bottom: 5, trailing: 12) + ) + .listRowSeparator(.hidden) + .listRowBackground(Color.clear) + Button { + onExportPDF() + } label: { + Label(lang.tr("action_export_pdf"), systemImage: "doc.text.magnifyingglass") + .foregroundStyle(isSimulationMode ? .tertiary : .primary) + } + .buttonStyle(PaletteListElementButtonStyle()) + .disabledAndDimmed(isSimulationMode) + .listRowInsets( + isCompact + ? EdgeInsets(top: 3, leading: 10, bottom: 3, trailing: 10) + : EdgeInsets(top: 5, leading: 12, bottom: 5, trailing: 12) + ) + .listRowSeparator(.hidden) + .listRowBackground(Color.clear) + + Button { + onShowAbout() + } label: { + Label(lang.tr("action_about"), systemImage: "info.circle") + } + .buttonStyle(PaletteListElementButtonStyle()) + .listRowInsets( + isCompact + ? EdgeInsets(top: 3, leading: 10, bottom: 4, trailing: 10) + : EdgeInsets(top: 5, leading: 12, bottom: 8, trailing: 12) + ) + .listRowSeparator(.hidden) + .listRowBackground(Color.clear) + } + } + .listStyle(.insetGrouped) + .listSectionSpacing(isCompact ? 8 : 18) + .environment(\.defaultMinListRowHeight, isCompact ? 34 : 48) + .scrollContentBackground(.hidden) + } + + @ViewBuilder + private func paletteStructureRow(_ item: ElementPalette.PaletteItem, isCompact: Bool) -> some View { + let rowInsets = isCompact + ? EdgeInsets(top: 1, leading: 10, bottom: 1, trailing: 10) + : EdgeInsets(top: 5, leading: 12, bottom: 5, trailing: 12) + + if isSimulationMode { + Button { + onInsertItem(item) + } label: { + Label(item.localizedTitle(in: lang), systemImage: item.symbolName) + .foregroundStyle(.tertiary) + .multilineTextAlignment(.leading) + } + .buttonStyle(PaletteListElementButtonStyle()) + .accessibilityAddTraits(.isButton) + .disabled(true) + .opacity(0.55) + .listRowInsets(rowInsets) + .listRowSeparator(.hidden) + .listRowBackground(Color.clear) + } else { + Button { + onInsertItem(item) + } label: { + Label(item.localizedTitle(in: lang), systemImage: item.symbolName) + .foregroundStyle(.primary) + .multilineTextAlignment(.leading) + } + .buttonStyle(PaletteListElementButtonStyle()) + .draggable(DiagramDragPayload(raw: ElementPalette.paletteDragIdentifier(item))) { + paletteDragPreview(for: item) + } + .accessibilityAddTraits(.isButton) + .listRowInsets(rowInsets) + .listRowSeparator(.hidden) + .listRowBackground(Color.clear) + } + } + + // MARK: - Palette Drag-Vorschau + + @ViewBuilder + private func paletteDragPreview(for item: ElementPalette.PaletteItem) -> some View { + HStack(spacing: 8) { + Image(systemName: item.symbolName) + Text(item.localizedTitle(in: lang)) + .font(.callout.weight(.medium)) + } + .padding(.horizontal, 12) + .padding(.vertical, 10) + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 8)) + .overlay( + RoundedRectangle(cornerRadius: 8) + .strokeBorder(Color.blue.opacity(0.9), lineWidth: 2) + ) + .shadow(color: .black.opacity(0.22), radius: 8, x: 0, y: 4) + } +} diff --git a/VisuStruct-iOS/VisuStructXcodeOpen/VisuStruct/Simulation/SimulationDetailView.swift b/VisuStruct-iOS/VisuStructXcodeOpen/VisuStruct/Simulation/SimulationDetailView.swift new file mode 100644 index 00000000..ea30ec23 --- /dev/null +++ b/VisuStruct-iOS/VisuStructXcodeOpen/VisuStruct/Simulation/SimulationDetailView.swift @@ -0,0 +1,55 @@ +import SwiftUI +import VisuStructCore + +/// Simulationsmodus: Diagramm (nur Anzeige) und Zustands-Panel, breit nebeneinander, schmal untereinander. +struct SimulationDetailView: View { + @Bindable var simulation: SimulationController + @Binding var highlightModeRaw: String + @ViewBuilder let canvas: Canvas + + var body: some View { + GeometryReader { geo in + let useColumns = geo.size.width >= 760 + let panelWidth = min(max(geo.size.width * 0.32, 280), 360) + + if useColumns { + HStack(alignment: .top, spacing: 16) { + canvas + .layoutPriority(1) + SimulationStatePanel( + engine: simulation.engine, + isPlaying: simulation.isPlaying, + inputText: $simulation.inputText, + highlightModeRaw: $highlightModeRaw, + onPlay: { simulation.play() }, + onPause: { simulation.pause() }, + onStep: { simulation.step() }, + onSubmitInput: { simulation.submitInput() }, + onStop: { simulation.reset() } + ) + .frame(width: panelWidth) + .frame(maxHeight: .infinity, alignment: .top) + } + } else { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + canvas + .frame(height: max(340, geo.size.height * 0.55)) + SimulationStatePanel( + engine: simulation.engine, + isPlaying: simulation.isPlaying, + inputText: $simulation.inputText, + highlightModeRaw: $highlightModeRaw, + onPlay: { simulation.play() }, + onPause: { simulation.pause() }, + onStep: { simulation.step() }, + onSubmitInput: { simulation.submitInput() }, + onStop: { simulation.reset() } + ) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + } + } + } +} From fc3ecc798fa8022374775456a5af85e8f206b427 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 22:25:38 +0000 Subject: [PATCH 08/11] Move inspector hints and Fallauswahl path helpers out of ContentView - VisuStructCore: VisuStructDocumentEditing.multiwayAncestorPath and caseIndex(of:inMultiwayAt:), with tests. - Inspector/InspectorElementHints.swift: line editor placeholder and condition hint line per element type. - Remove the unused statementIOModeIfApplicable(). No behavior change intended. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Cp4EQgFQyGbM8MVLiWbJKQ --- .../VisuStructDocumentEditing+Multiway.swift | 22 +++++ .../MultiwayPathTests.swift | 44 +++++++++ .../VisuStruct/ContentView.swift | 98 +++---------------- .../Inspector/InspectorElementHints.swift | 48 +++++++++ 4 files changed, 130 insertions(+), 82 deletions(-) create mode 100644 VisuStruct-iOS/VisuStructCore/Sources/VisuStructCore/Editing/VisuStructDocumentEditing+Multiway.swift create mode 100644 VisuStruct-iOS/VisuStructCore/Tests/VisuStructCoreTests/MultiwayPathTests.swift create mode 100644 VisuStruct-iOS/VisuStructXcodeOpen/VisuStruct/Inspector/InspectorElementHints.swift diff --git a/VisuStruct-iOS/VisuStructCore/Sources/VisuStructCore/Editing/VisuStructDocumentEditing+Multiway.swift b/VisuStruct-iOS/VisuStructCore/Sources/VisuStructCore/Editing/VisuStructDocumentEditing+Multiway.swift new file mode 100644 index 00000000..14cf5f91 --- /dev/null +++ b/VisuStruct-iOS/VisuStructCore/Sources/VisuStructCore/Editing/VisuStructDocumentEditing+Multiway.swift @@ -0,0 +1,22 @@ +import Foundation + +extension VisuStructDocumentEditing { + /// Innerste **Fallauswahl** (`multiway`), die `path` enthält (oder selbst ist) — für „Fall hinzufügen/entfernen“. + public static func multiwayAncestorPath(for path: [Int]?, in doc: VisuStructDocument) -> [Int]? { + guard let path, !path.isEmpty else { return nil } + for len in stride(from: path.count, through: 1, by: -1) { + let prefix = Array(path.prefix(len)) + if let el = try? element(at: prefix, in: doc), + el.type == .fallauswahl { + return prefix + } + } + return nil + } + + /// Spaltenindex der Fallauswahl, wenn die Auswahl **in** einer Spalte liegt (`[…, fall, spalte, …]`). + public static func caseIndex(of selectedPath: [Int]?, inMultiwayAt multiwayPath: [Int]) -> Int? { + guard let sp = selectedPath, sp.count > multiwayPath.count else { return nil } + return sp[multiwayPath.count] + } +} diff --git a/VisuStruct-iOS/VisuStructCore/Tests/VisuStructCoreTests/MultiwayPathTests.swift b/VisuStruct-iOS/VisuStructCore/Tests/VisuStructCoreTests/MultiwayPathTests.swift new file mode 100644 index 00000000..50fc3f6c --- /dev/null +++ b/VisuStruct-iOS/VisuStructCore/Tests/VisuStructCoreTests/MultiwayPathTests.swift @@ -0,0 +1,44 @@ +import XCTest +import VisuStructCore + +final class MultiwayPathTests: XCTestCase { + /// `[0]` Anweisung, `[1]` Fallauswahl mit zwei Fällen; im zweiten Fall eine innere Fallauswahl `[1, 1, 0]`. + private func document() -> VisuStructDocument { + let statement = VisuStructElement(type: .anweisung, textLines: ["x = 1"]) + let inner = VisuStructElement( + type: .fallauswahl, + textLines: ["y"], + cases: [ + VisuStructCase(name: "1", elements: [statement]), + VisuStructCase(name: "default", elements: [statement]), + ] + ) + let outer = VisuStructElement( + type: .fallauswahl, + textLines: ["x"], + cases: [ + VisuStructCase(name: "1", elements: [statement]), + VisuStructCase(name: "default", elements: [inner]), + ] + ) + var doc = VisuStructDocumentEditing.newEmpty() + doc.elements = [statement, outer] + return doc + } + + func testAncestorIsInnermostFallauswahl() { + let doc = document() + XCTAssertNil(VisuStructDocumentEditing.multiwayAncestorPath(for: nil, in: doc)) + XCTAssertNil(VisuStructDocumentEditing.multiwayAncestorPath(for: [0], in: doc)) + XCTAssertEqual(VisuStructDocumentEditing.multiwayAncestorPath(for: [1], in: doc), [1]) + XCTAssertEqual(VisuStructDocumentEditing.multiwayAncestorPath(for: [1, 0, 0], in: doc), [1]) + XCTAssertEqual(VisuStructDocumentEditing.multiwayAncestorPath(for: [1, 1, 0], in: doc), [1, 1, 0]) + XCTAssertEqual(VisuStructDocumentEditing.multiwayAncestorPath(for: [1, 1, 0, 1, 0], in: doc), [1, 1, 0]) + } + + func testCaseIndex() { + XCTAssertNil(VisuStructDocumentEditing.caseIndex(of: [1], inMultiwayAt: [1])) + XCTAssertNil(VisuStructDocumentEditing.caseIndex(of: nil, inMultiwayAt: [1])) + XCTAssertEqual(VisuStructDocumentEditing.caseIndex(of: [1, 1, 0], inMultiwayAt: [1]), 1) + } +} diff --git a/VisuStruct-iOS/VisuStructXcodeOpen/VisuStruct/ContentView.swift b/VisuStruct-iOS/VisuStructXcodeOpen/VisuStruct/ContentView.swift index 74df2a69..d5812309 100644 --- a/VisuStruct-iOS/VisuStructXcodeOpen/VisuStruct/ContentView.swift +++ b/VisuStruct-iOS/VisuStructXcodeOpen/VisuStruct/ContentView.swift @@ -929,11 +929,16 @@ struct ContentView: View { // MARK: - Inspector (unter dem Canvas) + private var selectedElement: VisuStructElement? { + guard let path = selectedPath else { return nil } + return try? VisuStructDocumentEditing.element(at: path, in: document) + } + private var inspectorFallauswahlToolbar: InspectorFallauswahlToolbarContext? { - guard let faPath = multiwayAncestorPath(for: selectedPath) else { return nil } + guard let faPath = VisuStructDocumentEditing.multiwayAncestorPath(for: selectedPath, in: document) else { return nil } let faCaseCount = (try? VisuStructDocumentEditing.element(at: faPath, in: document))?.cases?.count ?? 0 - let removeCaseIdx = selectedCaseIndex(multiwayPath: faPath, selectedPath: selectedPath) + let removeCaseIdx = VisuStructDocumentEditing.caseIndex(of: selectedPath, inMultiwayAt: faPath) return InspectorFallauswahlToolbarContext( caseCount: faCaseCount, removeCaseIndex: removeCaseIdx, @@ -981,8 +986,14 @@ struct ContentView: View { lineEditorVisibleTextBinding: lineEditorVisibleTextBinding, editorFocusToken: editorFocusToken, lineEditorFixedPrefixLabel: lineEditorFixedPrefixLabel, - inspectorConditionEditorHintLine: inspectorConditionEditorHintLine(), - inspectorLineEditorPlaceholder: inspectorLineEditorPlaceholder(), + inspectorConditionEditorHintLine: selectedElement.flatMap { InspectorElementHints.conditionHintLine(for: $0.type) }, + inspectorLineEditorPlaceholder: selectedElement.flatMap { + InspectorElementHints.lineEditorPlaceholder( + for: $0.type, + ioMode: VisuStructLineEditorText.ioMode(of: tabsModel.currentTab.lineEditorText), + lang: lang + ) + }, fallauswahl: inspectorFallauswahlToolbar, onApplyLineEditor: applyLineEditor, onApplyRespectingFocus: applyLineEditorRespectingTextFocus, @@ -1008,63 +1019,6 @@ struct ContentView: View { ) } - private func statementIOModeIfApplicable() -> StatementIOMode? { - guard let path = selectedPath, - let el = try? VisuStructDocumentEditing.element(at: path, in: document), - el.type == .anweisung else { return nil } - - return VisuStructLineEditorText.ioMode(of: tabsModel.currentTab.lineEditorText) - } - - /// Placeholder for the line editor, shown only when empty (grau im Feld). Code-Beispiele als Hilfe: `inspectorConditionEditorHintLine()` darüber. - private func inspectorLineEditorPlaceholder() -> String? { - guard let path = selectedPath, - let el = try? VisuStructDocumentEditing.element(at: path, in: document) else { return nil } - - switch el.type { - case .anweisung: - switch VisuStructLineEditorText.ioMode(of: tabsModel.currentTab.lineEditorText) { - case .statement: - return lang.tr("inspector_placeholder_statement") - case .input: - return "int age \"Enter age\"" - case .output: - return "\"Your age: \" + age" - } - case .forSchleife: - return nil - case .verzweigung, .whileSchleife, .doUntilSchleife, .endlosschleife: - return lang.tr("inspector_placeholder_condition") - case .fallauswahl: - return lang.tr("inspector_placeholder_selector") - case .aufruf: - return lang.tr("inspector_placeholder_call") - case .aussprung: - return lang.tr("inspector_placeholder_break") - case .leerElement: - return nil - } - } - - /// Sichtbare Hilfszeile über dem Bedingungs-Editor (wie Statement-Hinweis unter dem Modus-Picker) — Englisch, code-like. - private func inspectorConditionEditorHintLine() -> String? { - guard let path = selectedPath, - let el = try? VisuStructDocumentEditing.element(at: path, in: document) else { return nil } - switch el.type { - case .verzweigung: - return "age >= 18" - case .whileSchleife: - return "i < 10" - case .doUntilSchleife: - return "again == 1" - case .fallauswahl: - // Ausdruck in Klammern: switch (day) { … } — gleicher Stil wie die anderen Hinweise (englisch, code-nah). - return "day" - default: - return nil - } - } - // MARK: - Inspector (iPhone: ein-/ausblendbar) private var inspectorPanelPhoneAware: some View { @@ -1106,20 +1060,6 @@ struct ContentView: View { // MARK: - Fallauswahl (Multiway) - /// Innerste **Fallauswahl** (`multiway`), die die aktuelle Auswahl enthält (für „Fall hinzufügen“). - private func multiwayAncestorPath(for path: [Int]?) -> [Int]? { - guard let path, !path.isEmpty else { return nil } - let doc = document - for len in stride(from: path.count, through: 1, by: -1) { - let prefix = Array(path.prefix(len)) - if let el = try? VisuStructDocumentEditing.element(at: prefix, in: doc), - el.type == .fallauswahl { - return prefix - } - } - return nil - } - private func addCaseToFallauswahl(at fallPath: [Int]) { do { let (updated, newPath) = try VisuStructDocumentEditing.appendCaseToFallauswahl(document, fallauswahlPath: fallPath) @@ -1131,12 +1071,6 @@ struct ContentView: View { } } - /// Spaltenindex der Fallauswahl, wenn die Auswahl **in** einer Spalte liegt (`[…, fall, spalte, …]`). - private func selectedCaseIndex(multiwayPath: [Int], selectedPath: [Int]?) -> Int? { - guard let sp = selectedPath, sp.count > multiwayPath.count else { return nil } - return sp[multiwayPath.count] - } - private func removeCaseFromFallauswahl(at fallPath: [Int], caseIndex: Int) { do { let (updated, newPath) = try VisuStructDocumentEditing.removeCaseFromFallauswahl( @@ -1152,7 +1086,7 @@ struct ContentView: View { /// Bindung für den **Fallnamen**, wenn die Auswahl in einer Spalte einer Fallauswahl liegt (nicht beim bloßen Kopf `[n]` ohne Folgeindex). private func multiwayCaseLabelBinding() -> Binding? { - guard let fa = multiwayAncestorPath(for: selectedPath), + guard let fa = VisuStructDocumentEditing.multiwayAncestorPath(for: selectedPath, in: document), let sp = selectedPath, sp.count > fa.count else { return nil } let caseIdx = sp[fa.count] diff --git a/VisuStruct-iOS/VisuStructXcodeOpen/VisuStruct/Inspector/InspectorElementHints.swift b/VisuStruct-iOS/VisuStructXcodeOpen/VisuStruct/Inspector/InspectorElementHints.swift new file mode 100644 index 00000000..dfa90e05 --- /dev/null +++ b/VisuStruct-iOS/VisuStructXcodeOpen/VisuStruct/Inspector/InspectorElementHints.swift @@ -0,0 +1,48 @@ +import VisuStructCore + +/// Hilfetexte im Inspector je nach Elementtyp. +enum InspectorElementHints { + /// Placeholder for the line editor, shown only when empty (grau im Feld). Code-Beispiele als Hilfe: `conditionHintLine(for:)` darüber. + static func lineEditorPlaceholder(for type: VisuStructElementType, ioMode: StatementIOMode, lang: AppLanguage) -> String? { + switch type { + case .anweisung: + switch ioMode { + case .statement: + return lang.tr("inspector_placeholder_statement") + case .input: + return "int age \"Enter age\"" + case .output: + return "\"Your age: \" + age" + } + case .forSchleife: + return nil + case .verzweigung, .whileSchleife, .doUntilSchleife, .endlosschleife: + return lang.tr("inspector_placeholder_condition") + case .fallauswahl: + return lang.tr("inspector_placeholder_selector") + case .aufruf: + return lang.tr("inspector_placeholder_call") + case .aussprung: + return lang.tr("inspector_placeholder_break") + case .leerElement: + return nil + } + } + + /// Sichtbare Hilfszeile über dem Bedingungs-Editor (wie Statement-Hinweis unter dem Modus-Picker) — Englisch, code-like. + static func conditionHintLine(for type: VisuStructElementType) -> String? { + switch type { + case .verzweigung: + return "age >= 18" + case .whileSchleife: + return "i < 10" + case .doUntilSchleife: + return "again == 1" + case .fallauswahl: + // Ausdruck in Klammern: switch (day) { … } — gleicher Stil wie die anderen Hinweise (englisch, code-nah). + return "day" + default: + return nil + } + } +} From eb79ba583a826796550d3ab23d5a87048db04e45 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 22:26:49 +0000 Subject: [PATCH 09/11] Bundle PNG, file, PDF and print export in DiagramExporter The four export actions in ContentView each rendered the diagram and mapped errors themselves. Export/DiagramExporter.swift now does that; ContentView only stores the share payloads it returns. No behavior change intended. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Cp4EQgFQyGbM8MVLiWbJKQ --- .../VisuStruct/ContentView.swift | 80 +++++-------------- .../VisuStruct/Export/DiagramExporter.swift | 77 ++++++++++++++++++ 2 files changed, 95 insertions(+), 62 deletions(-) create mode 100644 VisuStruct-iOS/VisuStructXcodeOpen/VisuStruct/Export/DiagramExporter.swift diff --git a/VisuStruct-iOS/VisuStructXcodeOpen/VisuStruct/ContentView.swift b/VisuStruct-iOS/VisuStructXcodeOpen/VisuStruct/ContentView.swift index d5812309..561d4751 100644 --- a/VisuStruct-iOS/VisuStructXcodeOpen/VisuStruct/ContentView.swift +++ b/VisuStruct-iOS/VisuStructXcodeOpen/VisuStruct/ContentView.swift @@ -1430,85 +1430,41 @@ struct ContentView: View { } } - // MARK: - Export & Teilen (PNG, PDF, Druck, Datei) + // MARK: - Export & Teilen (PNG, PDF, Druck, Datei) → `DiagramExporter` - private func exportPNG() { - #if canImport(UIKit) - guard let img = DiagramSnapshotExport.renderedDiagramUIImage( + #if canImport(UIKit) + private var exporter: DiagramExporter { + DiagramExporter( document: document, horizontalSizeClass: horizontalSizeClass, canvasScaleStep: canvasScaleStep, lang: lang, - displayScale: displayScale - ) else { - setErrorOnCurrentTab(lang.tr("err_diagram_too_small_export")) - return - } + displayScale: displayScale, + setError: setErrorOnCurrentTab + ) + } + #endif + + private func exportPNG() { + #if canImport(UIKit) + guard let img = exporter.pngImage() else { return } pngSharePayload = PNGSharePayload(image: img) #endif } #if canImport(UIKit) private func shareDocumentAsFile() { - let data = VisuStructXMLCodec.encode(document) - let fileName = exportFilenameForCurrentTab() - do { - let url = try DiagramSnapshotExport.writeTemporaryVisustructFile(data: data, fileName: fileName) - fileURLSharePayload = FileURLSharePayload(url: url) - } catch { - setErrorOnCurrentTab(String(format: lang.tr("err_share_prepare_failed_fmt"), error.localizedDescription)) - } + guard let url = exporter.temporaryDocumentFile(named: exportFilenameForCurrentTab()) else { return } + fileURLSharePayload = FileURLSharePayload(url: url) } private func shareDiagramPDF() { - guard let img = DiagramSnapshotExport.renderedDiagramUIImage( - document: document, - horizontalSizeClass: horizontalSizeClass, - canvasScaleStep: canvasScaleStep, - lang: lang, - displayScale: displayScale - ) else { - setErrorOnCurrentTab(lang.tr("err_diagram_too_small_export")) - return - } - let fileName = pdfFilenameForCurrentTab() - let metaTitle = DiagramSnapshotExport.exportVisibleDiagramTitle(document: document, lang: lang) - do { - let url = try DiagramSnapshotExport.writeTemporaryDiagramPDF( - image: img, - fileName: fileName, - pdfMetadataTitle: metaTitle - ) - pdfQuickLookURL = url - } catch { - setErrorOnCurrentTab(String(format: lang.tr("err_share_prepare_failed_fmt"), error.localizedDescription)) - } + guard let url = exporter.temporaryPDF(named: pdfFilenameForCurrentTab()) else { return } + pdfQuickLookURL = url } - #endif - #if canImport(UIKit) private func printDiagram() { - guard let img = DiagramSnapshotExport.renderedDiagramUIImage( - document: document, - horizontalSizeClass: horizontalSizeClass, - canvasScaleStep: canvasScaleStep, - lang: lang, - displayScale: displayScale - ) else { - setErrorOnCurrentTab(lang.tr("err_diagram_too_small_print")) - return - } - guard UIPrintInteractionController.isPrintingAvailable else { - setErrorOnCurrentTab(lang.tr("err_print_unavailable")) - return - } - let info = UIPrintInfo(dictionary: nil) - info.outputType = .photo - info.jobName = DiagramSnapshotExport.exportVisibleDiagramTitle(document: document, lang: lang) - let controller = UIPrintInteractionController.shared - controller.printInfo = info - controller.printingItem = img - controller.present(animated: true) + exporter.printDiagram() } #endif } diff --git a/VisuStruct-iOS/VisuStructXcodeOpen/VisuStruct/Export/DiagramExporter.swift b/VisuStruct-iOS/VisuStructXcodeOpen/VisuStruct/Export/DiagramExporter.swift new file mode 100644 index 00000000..5493d772 --- /dev/null +++ b/VisuStruct-iOS/VisuStructXcodeOpen/VisuStruct/Export/DiagramExporter.swift @@ -0,0 +1,77 @@ +#if canImport(UIKit) +import SwiftUI +import UIKit +import VisuStructCore + +/// PNG, Datei, PDF und Druck für das aktuelle Diagramm. Fehler gehen als lokalisierter Text an `setError`. +@MainActor +struct DiagramExporter { + let document: VisuStructDocument + let horizontalSizeClass: UserInterfaceSizeClass? + let canvasScaleStep: VisuStructUIScaleStep + let lang: AppLanguage + let displayScale: CGFloat + let setError: (String) -> Void + + /// Bild für „PNG teilen“; `nil` mit Fehlermeldung, wenn das Diagramm zu klein ist. + func pngImage() -> UIImage? { + renderedImage(tooSmallErrorKey: "err_diagram_too_small_export") + } + + /// Temporäre `.visustruct`-Datei zum Teilen. + func temporaryDocumentFile(named fileName: String) -> URL? { + let data = VisuStructXMLCodec.encode(document) + do { + return try DiagramSnapshotExport.writeTemporaryVisustructFile(data: data, fileName: fileName) + } catch { + setError(String(format: lang.tr("err_share_prepare_failed_fmt"), error.localizedDescription)) + return nil + } + } + + /// Temporäre PDF-Datei für die Vorschau. + func temporaryPDF(named fileName: String) -> URL? { + guard let img = renderedImage(tooSmallErrorKey: "err_diagram_too_small_export") else { return nil } + let metaTitle = DiagramSnapshotExport.exportVisibleDiagramTitle(document: document, lang: lang) + do { + return try DiagramSnapshotExport.writeTemporaryDiagramPDF( + image: img, + fileName: fileName, + pdfMetadataTitle: metaTitle + ) + } catch { + setError(String(format: lang.tr("err_share_prepare_failed_fmt"), error.localizedDescription)) + return nil + } + } + + func printDiagram() { + guard let img = renderedImage(tooSmallErrorKey: "err_diagram_too_small_print") else { return } + guard UIPrintInteractionController.isPrintingAvailable else { + setError(lang.tr("err_print_unavailable")) + return + } + let info = UIPrintInfo(dictionary: nil) + info.outputType = .photo + info.jobName = DiagramSnapshotExport.exportVisibleDiagramTitle(document: document, lang: lang) + let controller = UIPrintInteractionController.shared + controller.printInfo = info + controller.printingItem = img + controller.present(animated: true) + } + + private func renderedImage(tooSmallErrorKey: String) -> UIImage? { + guard let img = DiagramSnapshotExport.renderedDiagramUIImage( + document: document, + horizontalSizeClass: horizontalSizeClass, + canvasScaleStep: canvasScaleStep, + lang: lang, + displayScale: displayScale + ) else { + setError(lang.tr(tooSmallErrorKey)) + return nil + } + return img + } +} +#endif From 3ae4ee1c6b8a70d6ad3bee833d28aa4a1119a576 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 22:28:25 +0000 Subject: [PATCH 10/11] Split DiagramCanvasView into view, drop zones and renderer - DiagramCanvasView.swift: inputs, state, layout and body, clipboard. - DiagramCanvasDropZones.swift: drop bands, tap insert targets and applying drops. - DiagramCanvasRenderer.swift: Canvas drawing of cells, lines and text. The two new files are extensions of DiagramCanvasView, so members they share lose their `private`. Code is moved unchanged otherwise. No behavior change intended. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Cp4EQgFQyGbM8MVLiWbJKQ --- .../Canvas/DiagramCanvasDropZones.swift | 453 ++++++++ .../Canvas/DiagramCanvasRenderer.swift | 485 +++++++++ .../VisuStruct/Canvas/DiagramCanvasView.swift | 975 +----------------- 3 files changed, 966 insertions(+), 947 deletions(-) create mode 100644 VisuStruct-iOS/VisuStructXcodeOpen/VisuStruct/Canvas/DiagramCanvasDropZones.swift create mode 100644 VisuStruct-iOS/VisuStructXcodeOpen/VisuStruct/Canvas/DiagramCanvasRenderer.swift diff --git a/VisuStruct-iOS/VisuStructXcodeOpen/VisuStruct/Canvas/DiagramCanvasDropZones.swift b/VisuStruct-iOS/VisuStructXcodeOpen/VisuStruct/Canvas/DiagramCanvasDropZones.swift new file mode 100644 index 00000000..86f6488d --- /dev/null +++ b/VisuStruct-iOS/VisuStructXcodeOpen/VisuStruct/Canvas/DiagramCanvasDropZones.swift @@ -0,0 +1,453 @@ +import SwiftUI +import VisuStructCore +#if canImport(UIKit) +import UIKit +#endif + +// MARK: - Drop-Zonen (Drag & Drop, Tap-Einfügen) + +extension DiagramCanvasView { + /// Sichtbare, oberste Drop-Fläche, wenn es noch keine Strukturknoten gibt (sonst gewinnt kein Knoten-Drop das Hit-Testing). + @ViewBuilder + func emptyDiagramDropPlate(cw: CGFloat, ch: CGFloat) -> some View { + ZStack { + Color(white: 0.96) + .frame(width: cw, height: ch) + VStack(spacing: 18) { + Image(systemName: "square.dashed") + .font(.system(size: 36)) + .foregroundStyle(.tertiary) + Text(lang.tr("canvas_empty_title")) + .font(.headline) + Text(lang.tr("canvas_empty_hint")) + .font(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal, 28) + } + .allowsHitTesting(false) + } + .contentShape(Rectangle()) + .frame(width: cw, height: ch) + .overlay { + if dropHighlightKey == rootDropHighlightKey { + RoundedRectangle(cornerRadius: 10) + .strokeBorder(Color.blue.opacity(0.95), lineWidth: 4) + .padding(10) + .allowsHitTesting(false) + } + } + .onTapGesture { + // iPhone: leeres Dokument → Einfügen ans Root (ersetzt Platzhalter). + if isPhone { + insertTarget = .rootAppend + } + } + .dropDestination(for: DiagramDragPayload.self) { items, _ in + applyRootDrop(items) + } isTargeted: { targeted in + updateDropHighlight(rootDropHighlightKey, targeted: targeted) + } + } + + @ViewBuilder + func nodeDropBands(node: VisuStructLayout.Node, isPlaceholderNode: Bool) -> some View { + let w = CGFloat(node.frame.width) + let h = CGFloat(node.frame.height) + + Group { + if isPlaceholderNode { + /// Niedrige Kacheln (z. B. ø / leer in einem Zweig): „davor/danach“-Streifen wären je 10 pt, die Mitte nur 1–2 pt — Drops landeten fälschlich auf „danach“ und haben eingefügt statt ersetzt. + placeholderReplaceDropSurface(node: node, width: w, height: h) + } else { + segmentedSiblingDropBands(node: node, width: w, height: h) + } + } + .frame(width: w, height: h) + .contentShape(Rectangle()) + .offset(x: CGFloat(node.frame.x), y: CGFloat(node.frame.y)) + .onTapGesture { + if isPhone, let target = insertTarget { + // Falls bereits ein Ziel gesetzt ist: Tap auf Kachel selektiert normal (zum Text bearbeiten). + // Einfügemarke bleibt. + selectedPath = node.idPath + _ = target + } else if isPhone { + // iPhone: Tap markiert Einfügeziel. Für Platzhalter = replace, sonst "on" ist nicht zulässig. + if isPlaceholderNode { + insertTarget = .replace(node.idPath) + } else { + // Default: Einfügen NACH dem Element (am häufigsten sinnvoll beim Tippen). + insertTarget = .after(node.idPath) + } + selectedPath = node.idPath + } else { + selectedPath = node.idPath + } + } + .contextMenu { + Button(lang.tr("ctx_copy")) { + copyElement(at: node.idPath) + } + Button(lang.tr("ctx_cut")) { + cutElement(at: node.idPath) + } + .disabled(!VisuStructDocumentEditing.canDeleteElement(at: node.idPath, in: document)) + Button(lang.tr("ctx_paste")) { + pasteClipboard(relativeTo: node.idPath) + } + .disabled(elementClipboard == nil) + } + .draggable(DiagramDragPayload(raw: ElementPalette.moveDragIdentifier(path: node.idPath))) { + nodeDragPreview(for: node) + } + .accessibilityHint( + isPlaceholderNode + ? lang.tr("a11y_placeholder_drop") + : lang.tr("a11y_node_drop") + ) + } + + /// Gesamte Kachel = ein Ziel nur für „ersetzen“ (`.on`-Semantik). + @ViewBuilder + func placeholderReplaceDropSurface(node: VisuStructLayout.Node, width: CGFloat, height: CGFloat) -> some View { + let key = bandHighlightKey(path: node.idPath, band: .on) + let showHL = dropHighlightKey == key || (insertTarget.map(highlightKey(for:)) == key) + let isSelected = selectedPath == node.idPath + Color.clear + .frame(width: width, height: height) + .contentShape(Rectangle()) + // Im Normalzustand keine farbigen Drop-Flächen rendern (wirkt sonst wie ein Verlauf). + .background(showHL ? Color.blue.opacity(0.2) : (isSelected ? selectionFillColor : Color.clear)) + .overlay { + Group { + if showHL { + RoundedRectangle(cornerRadius: 4) + .strokeBorder(Color.blue.opacity(0.95), lineWidth: 3) + } else if isSelected { + // Platzhalterflächen haben immer eine gestrichelte Rahmen-Overlay. + // Wenn selektiert, soll die blaue Auswahl sichtbar sein (sonst überdeckt der gestrichelte Rahmen die Canvas-Auswahl). + RoundedRectangle(cornerRadius: 4) + .strokeBorder(selectionStrokeColor, lineWidth: ink(DiagramCanvasInk.selectionLineWidth)) + } else { + RoundedRectangle(cornerRadius: 4) + .strokeBorder(Color.blue.opacity(0.35), style: StrokeStyle(lineWidth: 1.5, dash: [5, 4])) + } + } + .padding(2) + } + // Kein eigener Tap hier: auf iPad/Mac wäre der Body leer, SwiftUI würde den Tap trotzdem schlucken — + // dann feuert der äußere Tap in `nodeDropBands` nicht mehr (keine Auswahl / kein Ersatz-Ziel). + .dropDestination(for: DiagramDragPayload.self) { items, _ in + applyNodeDrop(items, node: node, band: .on) + } isTargeted: { targeted in + updateDropHighlight(key, targeted: targeted) + } + } + + @ViewBuilder + func segmentedSiblingDropBands(node: VisuStructLayout.Node, width: CGFloat, height: CGFloat) -> some View { + let strip = siblingDropStripHeight(totalHeight: height) + let midH = max(height - 2 * strip, 1) + VStack(spacing: 0) { + dropBandView(node: node, band: .before, height: strip, width: width) + dropZoneSeparator(width: width) + dropBandView(node: node, band: .on, height: midH, width: width) + dropZoneSeparator(width: width) + dropBandView(node: node, band: .after, height: strip, width: width) + } + } + + /// Nur Layout-Hilfe zwischen den Drop-Streifen — **keine** sichtbare Linie (sonst volle Knotenbreite, wirkt wie kaputtes Raster / schneidet Verzweigung & Fallauswahl). + func dropZoneSeparator(width: CGFloat) -> some View { + Color.clear.frame(width: width, height: 1) + } + + /// Obere/untere Drop-Streifen: groß genug für den Finger, Mitte bleibt für Tap / Ziel „Mitte“ nutzbar. + func siblingDropStripHeight(totalHeight h: CGFloat) -> CGFloat { + guard h > 2 else { return 1 } + let minMid: CGFloat = 8 + var strip = min(max(h * 0.22, 11), 34) + if 2 * strip + minMid > h { + strip = max((h - minMid) / 2, 4) + } + return max(1, strip) + } + + @ViewBuilder + func rootInsertBeforeFirstStrip(firstRootFrame: VisuStructRect, canvasWidth: CGFloat, canvasHeight: CGFloat) -> some View { + let sh = ink(DiagramCanvasInk.rootInsertStripHeight) + let y = max(0, CGFloat(firstRootFrame.y) - sh - 2) + if y + sh <= canvasHeight && canvasWidth > 4 { + let key = rootInsertBeforeFirstKey + let showHL = dropHighlightKey == key || (insertTarget.map(highlightKey(for:)) == key) + let stripCore = Color.clear + .frame(width: canvasWidth, height: sh) + .offset(x: 0, y: y) + .contentShape(Rectangle()) + .background(showHL ? Color.blue.opacity(0.18) : Color.clear) + .overlay(alignment: .center) { + if showHL { + Capsule() + .fill(Color.blue.opacity(0.55)) + .frame(width: min(canvasWidth - 24, 200), height: 3) + } + } + .dropDestination(for: DiagramDragPayload.self) { items, _ in + applyRootInsertBeforeFirst(items) + } isTargeted: { targeted in + updateDropHighlight(key, targeted: targeted) + } + if isPhone { + stripCore.onTapGesture { + insertTarget = .rootInsertAt0 + } + } else { + stripCore + } + } + } + + func bandHighlightKey(path: [Int], band: DropBand) -> String { + path.map(String.init).joined(separator: ",") + ":" + band.rawValue + } + + func updateDropHighlight(_ key: String, targeted: Bool) { + if targeted { + dropHighlightKey = key + } else if dropHighlightKey == key { + dropHighlightKey = nil + } + } + + func notifyDragDropCommitted() { + dropHighlightKey = nil + #if canImport(UIKit) + let g = UIImpactFeedbackGenerator(style: .light) + g.prepare() + g.impactOccurred() + #endif + } + + @ViewBuilder + func dropBandView(node: VisuStructLayout.Node, band: DropBand, height: CGFloat, width: CGFloat) -> some View { + let key = bandHighlightKey(path: node.idPath, band: band) + let showHL = dropHighlightKey == key || (insertTarget.map(highlightKey(for:)) == key) + let bandCore = Color.clear + .frame(width: width, height: height) + .contentShape(Rectangle()) + // Drop-Zonen nur beim tatsächlichen Drag-Target visuell zeigen. + .background(showHL ? Color.blue.opacity(band == .on ? 0.22 : 0.16) : Color.clear) + .overlay { + if showHL { + RoundedRectangle(cornerRadius: 2) + .strokeBorder(Color.blue.opacity(0.95), lineWidth: band == .on ? 3.5 : 2.5) + .padding(EdgeInsets(top: band == .before ? 1 : 0, leading: 1, bottom: band == .after ? 1 : 0, trailing: 1)) + } + } + .dropDestination(for: DiagramDragPayload.self) { items, _ in + applyNodeDrop(items, node: node, band: band) + } isTargeted: { targeted in + updateDropHighlight(key, targeted: targeted) + } + // Tap nur auf dem iPhone anhängen: sonst schluckt `onTapGesture` den Klick auf iPad/Mac, + // obwohl der Body sofort returnt — der äußere Tap in `nodeDropBands` kommt nicht an. + if isPhone { + bandCore.onTapGesture { + switch band { + case .before: + insertTarget = .before(node.idPath) + case .after: + insertTarget = .after(node.idPath) + case .on: + if VisuStructDocumentEditing.isPlaceholderSlot(at: node.idPath, in: document) { + insertTarget = .replace(node.idPath) + } + } + selectedPath = node.idPath + } + } else { + bandCore + } + } + + func applyNodeDrop(_ items: [DiagramDragPayload], node: VisuStructLayout.Node, band: DropBand) -> Bool { + let path = node.idPath + guard let s = items.first?.raw else { return false } + if s.hasPrefix("pal:") { + let raw = String(s.dropFirst(4)) + guard let kind = ElementPalette.PaletteItem(rawValue: raw) else { return false } + let el = ElementPalette.element(for: kind, lang: lang) + do { + switch band { + case .before, .after: + if band == .before { + document = try VisuStructDocumentEditing.insertBeforeSibling(document, el, before: path) + selectedPath = path + } else { + document = try VisuStructDocumentEditing.insertAfterSibling(document, el, after: path) + selectedPath = pathOfInsertedSibling(after: path) + } + errorText = nil + case .on: + guard VisuStructDocumentEditing.isPlaceholderSlot(at: path, in: document) else { return false } + document = try VisuStructDocumentEditing.replaceAtPath(document, path: path, with: el) + errorText = nil + selectedPath = path + } + notifyDragDropCommitted() + return true + } catch { + errorText = error.localizedDescription + return false + } + } + if s.hasPrefix("mov:") { + let from = parsePath(String(s.dropFirst(4))) + guard from.count >= 1 else { return false } + do { + switch band { + case .before, .after: + if band == .before { + guard from != path else { return false } + document = try VisuStructDocumentEditing.moveBeforeSibling(document, from: from, before: path) + errorText = nil + selectedPath = VisuStructDocumentEditing.adjustPathAfterRemoving(path: path, removedPath: from) + } else { + guard from != path else { return false } + document = try VisuStructDocumentEditing.moveAfterSibling(document, from: from, after: path) + errorText = nil + selectedPath = pathOfInsertedSibling(after: VisuStructDocumentEditing.adjustPathAfterRemoving(path: path, removedPath: from)) + } + case .on: + guard VisuStructDocumentEditing.isPlaceholderSlot(at: path, in: document) else { return false } + guard from != path else { return false } + let (updated, newPath) = try VisuStructDocumentEditing.moveReplacingPlaceholder(document, from: from, placeholderPath: path) + document = updated + errorText = nil + selectedPath = newPath + } + notifyDragDropCommitted() + return true + } catch { + errorText = error.localizedDescription + return false + } + } + return false + } + + func nodeDragPreview(for node: VisuStructLayout.Node) -> some View { + let w = min(CGFloat(node.frame.width), 260) + let h = min(CGFloat(node.frame.height), 180) + return RoundedRectangle(cornerRadius: 6) + .strokeBorder(Color.blue.opacity(0.95), lineWidth: 2.5) + .background(RoundedRectangle(cornerRadius: 6).fill(Color.white.opacity(0.97))) + .frame(width: w, height: h) + .shadow(color: .black.opacity(0.28), radius: 10, x: 0, y: 5) + } + + /// Oberer Wurzel-Streifen: an Index 0 einfügen / dorthin verschieben. + func applyRootInsertBeforeFirst(_ items: [DiagramDragPayload]) -> Bool { + guard document.elements.indices.contains(0) else { return false } + guard let s = items.first?.raw else { return false } + if s.hasPrefix("pal:") { + let raw = String(s.dropFirst(4)) + guard let kind = ElementPalette.PaletteItem(rawValue: raw) else { return false } + let el = ElementPalette.element(for: kind, lang: lang) + if VisuStructDocumentEditing.isSingleRootPlaceholder(in: document) { + do { + document = try VisuStructDocumentEditing.replaceAtPath(document, path: [0], with: el) + errorText = nil + selectedPath = [0] + notifyDragDropCommitted() + return true + } catch { + errorText = error.localizedDescription + return false + } + } + do { + document = try VisuStructDocumentEditing.insertAt(document, parentPath: [], index: 0, element: el) + errorText = nil + selectedPath = [0] + notifyDragDropCommitted() + return true + } catch { + errorText = error.localizedDescription + return false + } + } + if s.hasPrefix("mov:") { + let from = parsePath(String(s.dropFirst(4))) + guard from.count >= 1 else { return false } + guard from != [0] else { return false } + do { + document = try VisuStructDocumentEditing.moveBeforeSibling(document, from: from, before: [0]) + errorText = nil + selectedPath = [0] + notifyDragDropCommitted() + return true + } catch { + errorText = error.localizedDescription + return false + } + } + return false + } + + func applyRootDrop(_ items: [DiagramDragPayload]) -> Bool { + guard let s = items.first?.raw else { return false } + if s.hasPrefix("pal:") { + let raw = String(s.dropFirst(4)) + guard let kind = ElementPalette.PaletteItem(rawValue: raw) else { return false } + let el = ElementPalette.element(for: kind, lang: lang) + if VisuStructDocumentEditing.isSingleRootPlaceholder(in: document) { + do { + document = try VisuStructDocumentEditing.replaceAtPath(document, path: [0], with: el) + errorText = nil + selectedPath = [0] + notifyDragDropCommitted() + return true + } catch { + errorText = error.localizedDescription + return false + } + } + do { + document = try VisuStructDocumentEditing.insertAt(document, parentPath: [], index: document.elements.count, element: el) + errorText = nil + selectedPath = [document.elements.count - 1] + notifyDragDropCommitted() + return true + } catch { + errorText = error.localizedDescription + return false + } + } + if s.hasPrefix("mov:") { + let from = parsePath(String(s.dropFirst(4))) + guard from.count >= 1 else { return false } + guard let lastIdx = document.elements.indices.last else { return false } + do { + document = try VisuStructDocumentEditing.moveAfterSibling(document, from: from, after: [lastIdx]) + errorText = nil + selectedPath = pathOfInsertedSibling(after: [lastIdx]) + notifyDragDropCommitted() + return true + } catch { + errorText = error.localizedDescription + return false + } + } + return false + } + + func pathOfInsertedSibling(after siblingPath: [Int]) -> [Int] { + Array(siblingPath.dropLast()) + [siblingPath.last! + 1] + } + + func parsePath(_ s: String) -> [Int] { + s.split(separator: ",").compactMap { Int(String($0).trimmingCharacters(in: .whitespaces)) } + } +} diff --git a/VisuStruct-iOS/VisuStructXcodeOpen/VisuStruct/Canvas/DiagramCanvasRenderer.swift b/VisuStruct-iOS/VisuStructXcodeOpen/VisuStruct/Canvas/DiagramCanvasRenderer.swift new file mode 100644 index 00000000..b8b8bd31 --- /dev/null +++ b/VisuStruct-iOS/VisuStructXcodeOpen/VisuStruct/Canvas/DiagramCanvasRenderer.swift @@ -0,0 +1,485 @@ +import SwiftUI +import VisuStructCore +#if canImport(UIKit) +import UIKit +#endif + +// MARK: - Zeichnen (Canvas: Kacheln, Linien, Text) + +extension DiagramCanvasView { + func canvasView(layout: VisuStructLayout, cw: CGFloat, ch: CGFloat) -> some View { + Canvas { context, size in + context.fill(Path(CGRect(origin: .zero, size: size)), with: .color(diagramCanvasFill)) + + let marginBase = exportCanvasOuterMarginPt ?? DiagramCanvasInk.canvasOuterMargin + let m = ink(marginBase) + let innerTrim: CGFloat = 1 + let frameRect = CGRect( + x: m, + y: m, + width: CGFloat(layout.canvasSize.width) - 2 * m - innerTrim, + height: CGFloat(layout.canvasSize.height) - 2 * m - innerTrim + ) + let outerFramePath = Path(frameRect) + context.stroke(outerFramePath, with: .color(Color.black), lineWidth: ink(DiagramCanvasInk.outerFrameLineWidth)) + + for n in layout.elements { + drawNode(n, selectedPath: selectedPath, in: &context, parentType: nil) + } + } + .frame(width: cw, height: ch) + } + + /// Dachspitze + Textgrenze für Verzweigung/Fallauswahl; **zwei** Zeichenphasen müssen dieselbe Spitze nutzen (sonst „springen“ senkrechte Linien). + struct MultiwayRoofMetrics { + let apex: CGPoint + let maxTextBottom: CGFloat + let roofBottom: CGFloat + } + + func multiwayRoofMetrics(node: VisuStructLayout.Node, rect r: CGRect, headerH: CGFloat, dividerX: CGFloat) -> MultiwayRoofMetrics { + let roofBottom = r.minY + headerH + let yInset = CGFloat(node.dividerYInset ?? 0) + let lines = node.element.textLines + let apexPadding: CGFloat = 12 + let blockH = CGFloat(max(lines.count, 1)) * lineHeight + let topStart = r.minY + ink(DiagramCanvasInk.multiHeaderFirstLineY) + + if node.element.type == .fallauswahl { + let caseBand = max(ink(DiagramCanvasInk.fallauswahlCaseLabelBand), lineHeight + ink(10)) + let titleBottomLimit = roofBottom - caseBand + let maxTextBottom = titleBottomLimit - apexPadding + let idealApexY = roofBottom + yInset + let apexMaxY = titleBottomLimit - 8 + let apexY = min(idealApexY, apexMaxY) + let apexClampedY = max(apexY, topStart + blockH + 6) + return MultiwayRoofMetrics( + apex: CGPoint(x: dividerX, y: apexClampedY), + maxTextBottom: maxTextBottom, + roofBottom: roofBottom + ) + } + let apex = CGPoint(x: dividerX, y: roofBottom + yInset) + return MultiwayRoofMetrics( + apex: apex, + maxTextBottom: apex.y - apexPadding, + roofBottom: roofBottom + ) + } + + func strokeDiagramCellBorder( + _ r: CGRect, + omitting omission: CellBorderOmission, + in context: inout GraphicsContext, + linePaint: Color, + lineWidth: CGFloat + ) { + if omission.isEmpty { + context.stroke(Path(r), with: .color(linePaint), lineWidth: lineWidth) + return + } + if !omission.contains(.top) { + context.stroke( + Path { p in + p.move(to: CGPoint(x: r.minX, y: r.minY)) + p.addLine(to: CGPoint(x: r.maxX, y: r.minY)) + }, + with: .color(linePaint), + lineWidth: lineWidth + ) + } + if !omission.contains(.right) { + context.stroke( + Path { p in + p.move(to: CGPoint(x: r.maxX, y: r.minY)) + p.addLine(to: CGPoint(x: r.maxX, y: r.maxY)) + }, + with: .color(linePaint), + lineWidth: lineWidth + ) + } + if !omission.contains(.bottom) { + context.stroke( + Path { p in + p.move(to: CGPoint(x: r.maxX, y: r.maxY)) + p.addLine(to: CGPoint(x: r.minX, y: r.maxY)) + }, + with: .color(linePaint), + lineWidth: lineWidth + ) + } + if !omission.contains(.left) { + context.stroke( + Path { p in + p.move(to: CGPoint(x: r.minX, y: r.maxY)) + p.addLine(to: CGPoint(x: r.minX, y: r.minY)) + }, + with: .color(linePaint), + lineWidth: lineWidth + ) + } + } + + func drawNode( + _ node: VisuStructLayout.Node, + selectedPath: [Int]?, + in context: inout GraphicsContext, + cellBorderOmission: CellBorderOmission = [], + parentType: VisuStructElementType? = nil + ) { + let r = CGRect(x: node.frame.x, y: node.frame.y, width: node.frame.width, height: node.frame.height) + let cellPath = Path(r) + let isMulti = node.element.type == .verzweigung || node.element.type == .fallauswahl + /// Exakt die Höhe, mit der das Layout die Spaltenkinder nach unten verschiebt (verhindert Überlappung Kopf/Zweig). + let headerH: CGFloat = { + guard isMulti else { return 0 } + let h = node.headerBandHeight > 0 ? node.headerBandHeight : headerHeightApprox(node) + return CGFloat(h) + }() + let isSelected = node.idPath == selectedPath + let linePaint = diagramLineColor + + context.fill(cellPath, with: .color(isSelected ? (usesSimulationSelectionHighlight ? selectionFillColor : diagramCellFill) : diagramCellFill)) + strokeDiagramCellBorder( + r, + omitting: cellBorderOmission, + in: &context, + linePaint: linePaint, + lineWidth: ink(DiagramCanvasInk.diagramLineWidth) + ) + // Kein zweites Rechteck für Verzweigung/Fallauswahl: dessen Oberkante läge auf Höhe der Dreiecksspitze + // und würde eine Sehne durch das V zeichnen sowie linke/rechte Kanten doppelt stroken („X“ / Doppellinien). + + switch node.element.type { + case .aufruf: + let bar = ink(DiagramCanvasInk.callSideBarInset) + context.stroke(Path { p in + p.move(to: CGPoint(x: r.minX + bar, y: r.minY)) + p.addLine(to: CGPoint(x: r.minX + bar, y: r.maxY)) + p.move(to: CGPoint(x: r.maxX - bar, y: r.minY)) + p.addLine(to: CGPoint(x: r.maxX - bar, y: r.maxY)) + }, with: .color(linePaint), lineWidth: ink(DiagramCanvasInk.diagramLineWidth)) + case .aussprung: + let bar = ink(DiagramCanvasInk.callSideBarInset) + context.stroke(Path { p in + p.move(to: CGPoint(x: r.minX + bar, y: r.minY)) + p.addLine(to: CGPoint(x: r.minX, y: r.midY)) + p.addLine(to: CGPoint(x: r.minX + bar, y: r.maxY)) + }, with: .color(linePaint), lineWidth: ink(DiagramCanvasInk.diagramLineWidth)) + default: + break + } + + if isMulti, let dividerXRaw = node.dividerX { + let dividerX = CGFloat(dividerXRaw) + let roof = multiwayRoofMetrics(node: node, rect: r, headerH: headerH, dividerX: dividerX) + let lines = displayTextLines(for: node.element) + let blockH = CGFloat(max(lines.count, 1)) * lineHeight + let topStart = r.minY + ink(DiagramCanvasInk.multiHeaderFirstLineY) + + context.stroke(Path { p in + let topLeft = CGPoint(x: r.minX, y: r.minY) + let topRight = CGPoint(x: r.maxX, y: r.minY) + p.move(to: topLeft) + p.addLine(to: roof.apex) + p.addLine(to: topRight) + }, with: .color(linePaint), lineWidth: ink(DiagramCanvasInk.diagramLineWidth)) + + if node.element.type == .fallauswahl { + context.stroke( + Path { p in + p.move(to: CGPoint(x: r.minX, y: roof.roofBottom)) + p.addLine(to: CGPoint(x: r.maxX, y: roof.roofBottom)) + }, + with: .color(linePaint), + lineWidth: ink(DiagramCanvasInk.diagramLineWidth) + ) + } + + // Kopftext: Fallauswahl zentriert zur Gesamtbreite; Verzweigung über der Dachspitze (Zweig-Grenze). Nicht zu nah an die Spitze. + let maxTextBottom = roof.maxTextBottom + let firstY: CGFloat + if topStart + blockH <= maxTextBottom { + firstY = topStart + } else { + let totalH = CGFloat(max(lines.count, 1) - 1) * lineHeight + firstY = max(r.minY + 2, maxTextBottom - totalH - lineHeight) + } + for (i, line) in lines.enumerated() { + let y = firstY + CGFloat(i) * lineHeight + let tw: CGFloat = { + if let parts = VisuStructDiagramText.keywordSplit(line) { + let kwW = measureText(parts.keyword, size: headerFontSize, weight: .bold) + let restW = parts.rest.isEmpty ? 0 : measureText(" " + parts.rest, size: headerFontSize, weight: .medium) + return kwW + restW + } + return measureText(line, size: headerFontSize, weight: .medium) + }() + /// Verzweigung: Kopf über der **Zweig-Grenze** (Dachspitze), nicht über `midX` — sonst wirkt die Mitte falsch, wenn ein Zweig viel breiter ist (z. B. Fallauswahl im false-Zweig). + let tx: CGFloat = { + if node.element.type == .verzweigung { + let ideal = dividerX - tw / 2 + let lo = r.minX + ink(DiagramCanvasInk.cellTextInsetX) + let hi = r.maxX - tw - ink(DiagramCanvasInk.cellTextInsetX) + return min(max(ideal, lo), hi) + } + return r.midX - tw / 2 + }() + drawKeywordLine(line, elementType: node.element.type, at: CGPoint(x: tx, y: y), in: &context) + } + } else { + let lines = displayTextLines(for: node.element) + let ix = ink(DiagramCanvasInk.cellTextInsetX) + let iy = ink(DiagramCanvasInk.cellTextInsetY) + if node.element.type == .doUntilSchleife { + /// „do“ wird nach den Rumpf-Kindern gezeichnet — sonst können weiße Kachelflächen den Text überdecken (siehe Block nach `drawNode` der Kinder). + } else if node.element.type == .anweisung || node.element.type == .aufruf || node.element.type == .aussprung { + // Anweisung, Aufruf, Aussprung: Text zentriert; bei Aufruf/Aussprung Platz für die seitlichen Striche. + let bar = ink(DiagramCanvasInk.callSideBarInset) + let contentLeft: CGFloat + let contentRight: CGFloat + switch node.element.type { + case .aufruf: + let callTextInset = ink(DiagramCanvasInk.callTextInsetFromSideBar) + contentLeft = r.minX + bar + callTextInset + contentRight = r.maxX - bar - callTextInset + case .aussprung: + contentLeft = r.minX + bar + ix + contentRight = r.maxX - ix + default: + contentLeft = r.minX + ix + contentRight = r.maxX - ix + } + let contentWidth = max(0, contentRight - contentLeft) + let totalTextHeight = CGFloat(max(1, lines.count)) * lineHeight + var y = r.midY - totalTextHeight / 2 + for line in lines { + let tw: CGFloat = { + if let parts = VisuStructDiagramText.keywordSplit(line) { + let kwW = measureText(parts.keyword, size: headerFontSize, weight: .bold) + let restW = parts.rest.isEmpty ? 0 : measureText(" " + parts.rest, size: headerFontSize, weight: .medium) + return kwW + restW + } + return measureText(line, size: headerFontSize, weight: .medium) + }() + let x = contentLeft + (contentWidth - tw) / 2 + drawKeywordLine(line, elementType: node.element.type, at: CGPoint(x: x, y: y), in: &context) + y += lineHeight + } + } else { + let totalTextHeight = CGFloat(max(1, lines.count)) * lineHeight + var y = parentType == .doUntilSchleife + ? (r.midY - totalTextHeight / 2) + : (r.minY + iy) + for line in lines { + drawKeywordLine(line, elementType: node.element.type, at: CGPoint(x: r.minX + ix, y: y), in: &context) + y += lineHeight + } + } + } + + for (idx, c) in node.children.enumerated() { + let childOmission: CellBorderOmission = (node.element.type == .doUntilSchleife && idx == 0) ? [.top, .left] : [] + drawNode(c, selectedPath: selectedPath, in: &context, cellBorderOmission: childOmission, parentType: node.element.type) + } + + if node.element.type == .doUntilSchleife { + /// Senkrechte / Rumpf: X = Layout-Kante = `minX` + `loopBodyLeadingInset` (aus **gemessener** Bold-Breite „do“ + Padding in `doUntilBodyLeadingInset()`). + let dividerX = node.children.first.map { CGFloat($0.frame.x) } + ?? (r.minX + CGFloat(node.loopBodyLeadingInset > 0 ? node.loopBodyLeadingInset : DiagramCanvasInk.loopBodyLeadingInset)) + let kwFont: Font = .system(size: headerFontSize, weight: .bold) + let xDo = r.minX + ink(4) + /// Oben im linken Streifen — nur wenige Punkte unter der Kante (`doUntilDoLabelTopInset`), nicht voller Zellen-Einzug. + let doTextTop = r.minY + CGFloat(layoutMetrics.doUntilDoLabelTopInset) + context.draw( + context.resolve(Text(verbatim: "do").font(kwFont).foregroundStyle(.black)), + at: CGPoint(x: xDo, y: doTextTop), + anchor: .topLeading + ) + + let footerH = CGFloat(node.headerBandHeight) + let ySep = r.maxY - footerH + /// Rumpf beginnt auf der Oberkante der Schleife — kein separates „do“-Band mehr (`topBandHeight` ≈ 0). + let bodyTopY = r.minY + let lw = ink(DiagramCanvasInk.diagramLineWidth) + context.stroke( + Path { p in + p.move(to: CGPoint(x: dividerX, y: ySep)) + p.addLine(to: CGPoint(x: r.maxX, y: ySep)) + }, + with: .color(linePaint), + lineWidth: lw + ) + context.stroke( + Path { p in + p.move(to: CGPoint(x: dividerX, y: bodyTopY)) + p.addLine(to: CGPoint(x: dividerX, y: ySep)) + }, + with: .color(linePaint), + lineWidth: lw + ) + let lines = displayTextLines(for: node.element) + let ix = ink(DiagramCanvasInk.cellTextInsetX) + var y = ySep + CGFloat(layoutMetrics.doUntilFooterTopInset) + for line in lines { + drawKeywordLine(line, elementType: node.element.type, at: CGPoint(x: r.minX + ix, y: y), in: &context) + y += lineHeight + } + } + + if isMulti, let dividerXRaw = node.dividerX, node.caseColumnFrames.count > 1 { + let dividerX = CGFloat(dividerXRaw) + let roof = multiwayRoofMetrics(node: node, rect: r, headerH: headerH, dividerX: dividerX) + let apex = roof.apex + for (idx, col) in node.caseColumnFrames.enumerated() where idx < node.caseColumnFrames.count - 1 { + let xSep = CGFloat(col.x + col.width) + let yTopRaw = yOnSwitchHeaderDiagonal(xSep: xSep, rect: r, apex: apex) + let yTop = yTopRaw.isFinite ? min(max(yTopRaw, r.minY), r.maxY) : r.minY + context.stroke(Path { p in + p.move(to: CGPoint(x: xSep, y: r.maxY)) + p.addLine(to: CGPoint(x: xSep, y: yTop)) + }, with: .color(linePaint), lineWidth: ink(DiagramCanvasInk.diagramLineWidth)) + } + + if let cases = node.element.cases, cases.count == node.caseColumnFrames.count { + /// Fallauswahl: etwas höher setzen, damit „1/2/default“ nicht in die Dachkanten rutschen; Verzweigung: knapp über dem Rumpf wie in Java. + let labelY: CGFloat = { + if node.element.type == .fallauswahl { + /// Am unteren Rand des Kopfteils (über Rumpf), passend zur waagerechten Linie bei `roofBottom`. + return roof.roofBottom - 6 + } + return r.minY + headerH - 8 + }() + let lx = ink(DiagramCanvasInk.branchLabelInsetX) + if node.element.type == .verzweigung && cases.count == 2 { + let left = "true" + let right = "false" + let kwFont: Font = .system(size: headerFontSize, weight: .bold) + let rw = measureText(right, size: headerFontSize, weight: .bold) + context.draw( + context.resolve(Text(verbatim: left).font(kwFont).foregroundStyle(.black)), + at: CGPoint(x: r.minX + lx, y: labelY), + anchor: .bottomLeading + ) + let rx = node.caseColumnFrames[1].x + node.caseColumnFrames[1].width - lx - rw + context.draw( + context.resolve(Text(verbatim: right).font(kwFont).foregroundStyle(.black)), + at: CGPoint(x: rx, y: labelY), + anchor: .bottomLeading + ) + } else { + for (idx, c) in cases.enumerated() { + let col = node.caseColumnFrames[idx] + let label = c.name + let isKeywordLabel = ["default", "true", "false"].contains(label.lowercased()) + let w: UIFont.Weight = isKeywordLabel ? .bold : .medium + let f: Font = .system(size: headerFontSize, weight: isKeywordLabel ? .bold : .medium) + let tw = measureText(label, size: headerFontSize, weight: w) + let cx = col.x + col.width / 2 - tw / 2 + context.draw( + context.resolve(Text(verbatim: label).font(f).foregroundStyle(.black)), + at: CGPoint(x: cx, y: labelY), + anchor: .bottomLeading + ) + } + } + } + } + + // Auswahlrahmen IMMER zuletzt zeichnen, damit Kinder/Innenlinien ihn nicht übermalen (v. a. bei verschachtelten Verzweigungen/Fallauswahl). + if isSelected { + context.stroke(cellPath, with: .color(selectionStrokeColor), lineWidth: ink(DiagramCanvasInk.selectionLineWidth)) + } + } + + func yOnSwitchHeaderDiagonal(xSep: CGFloat, rect: CGRect, apex: CGPoint) -> CGFloat { + let topY = rect.minY + let leftX = rect.minX + if xSep <= apex.x { + let run = apex.x - leftX + if abs(run) < 0.5 { return apex.y } + let slope = (apex.y - topY) / run + return topY + slope * (xSep - leftX) + } + let run = rect.maxX - apex.x + if abs(run) < 0.5 { return apex.y } + let slope = (topY - apex.y) / run + return apex.y + slope * (xSep - apex.x) + } + + func headerHeightApprox(_ node: VisuStructLayout.Node) -> Double { + let e = node.element + let lines = max(1, e.textLines.count) + if e.type == .doUntilSchleife { + return layoutMetrics.doUntilFooterTopInset + Double(lines) * layoutMetrics.lineHeight + layoutMetrics.doUntilFooterBottomInset + Double(e.zoomY) + } + let baseExtra: Double + switch e.type { + case .anweisung, .aussprung, .aufruf, .leerElement: + baseExtra = 14 + case .fallauswahl: + baseExtra = 40 + case .verzweigung: + baseExtra = 20 + case .doUntilSchleife: + preconditionFailure("Do-Until-Fuß nur über den dedizierten Zweig oben.") + case .forSchleife, .whileSchleife, .endlosschleife: + baseExtra = layoutMetrics.baseHeaderExtra + 4 + @unknown default: + baseExtra = layoutMetrics.baseHeaderExtra + } + return baseExtra + Double(lines) * layoutMetrics.lineHeight + Double(e.zoomY) + } + + /// NSString-Messung ist korrekt, aber teuer bei vielen Zeilen — Cache mit begrenzter Größe. + #if canImport(UIKit) + static let textWidthCache: NSCache = { + let c = NSCache() + c.countLimit = 400 + return c + }() + #endif + + func measureText(_ s: String, size: CGFloat, weight: UIFont.Weight = .medium) -> CGFloat { + #if canImport(UIKit) + let key = "\(Int((size * 1000).rounded()))|\(weight.rawValue)|\(s)" as NSString + if let cached = Self.textWidthCache.object(forKey: key) { + return CGFloat(truncating: cached) + } + let w = (s as NSString).size(withAttributes: [ + .font: UIFont.systemFont(ofSize: size, weight: weight) + ]).width + Self.textWidthCache.setObject(NSNumber(value: Double(w)), forKey: key) + return w + #else + return CGFloat(s.count) * size * 0.55 + #endif + } + + func drawKeywordLine(_ line: String, elementType: VisuStructElementType, at origin: CGPoint, in context: inout GraphicsContext) { + if let parts = VisuStructDiagramText.keywordSplit(line) { + let kwFont: Font = .system(size: headerFontSize, weight: .bold) + let kw = parts.keyword + let rest = parts.rest + let kwW = measureText(kw, size: headerFontSize, weight: .bold) + context.draw( + context.resolve(Text(verbatim: kw).font(kwFont).foregroundStyle(.black)), + at: origin, + anchor: .topLeading + ) + if !rest.isEmpty { + context.draw( + context.resolve(Text(verbatim: " " + rest).font(cellFont).foregroundStyle(.black)), + at: CGPoint(x: origin.x + kwW, y: origin.y), + anchor: .topLeading + ) + } + } else { + context.draw( + context.resolve(Text(verbatim: line).font(cellFont).foregroundStyle(.black)), + at: origin, + anchor: .topLeading + ) + } + } +} diff --git a/VisuStruct-iOS/VisuStructXcodeOpen/VisuStruct/Canvas/DiagramCanvasView.swift b/VisuStruct-iOS/VisuStructXcodeOpen/VisuStruct/Canvas/DiagramCanvasView.swift index d74793fa..6c45da60 100644 --- a/VisuStruct-iOS/VisuStructXcodeOpen/VisuStruct/Canvas/DiagramCanvasView.swift +++ b/VisuStruct-iOS/VisuStructXcodeOpen/VisuStruct/Canvas/DiagramCanvasView.swift @@ -13,18 +13,18 @@ enum DiagramInsertTarget: Equatable, Sendable { case rootAppend } -private extension [Int] { +extension [Int] { /// Stabiler Schlüssel für Sets / Drop-Zonen (z. B. Platzhalter-Lookup). var diagramPathKey: String { map(String.init).joined(separator: ",") } } -private extension VisuStructLayout.Node { +extension VisuStructLayout.Node { /// `ForEach`-Identität (stabiler String statt `[Int]`-Hash). var pathKey: String { idPath.diagramPathKey } } /// Etwas größere Typo (+2 pt) und kräftigere Kanten im Diagramm-`Canvas`. -private enum DiagramCanvasInk { +enum DiagramCanvasInk { static let extraFontPoints: Double = 2 static let extraLineHeightPoints: Double = 2 static let diagramLineWidth: CGFloat = 2 @@ -47,7 +47,7 @@ private enum DiagramCanvasInk { } /// Teilweise fehlende Rechteckkanten (z. B. Do-Until: erster Rumpf ohne obere/linke Linie — gemeinsam mit senkrechter Trennlinie). -private struct CellBorderOmission: OptionSet { +struct CellBorderOmission: OptionSet { let rawValue: Int static let top = CellBorderOmission(rawValue: 1 << 0) static let left = CellBorderOmission(rawValue: 1 << 1) @@ -77,9 +77,9 @@ struct DiagramCanvasView: View { var usesSimulationSelectionHighlight: Bool = false @Environment(\.horizontalSizeClass) private var horizontalSizeClass - @Environment(\.appLanguage) private var lang + @Environment(\.appLanguage) var lang - private var isPhone: Bool { + var isPhone: Bool { #if canImport(UIKit) return UIDevice.current.userInterfaceIdiom == .phone #else @@ -87,19 +87,19 @@ struct DiagramCanvasView: View { #endif } - @State private var dropHighlightKey: String? + @State var dropHighlightKey: String? - private enum DropBand: String { + enum DropBand: String { case before case on case after } - private var rootDropHighlightKey: String { "__root__" } - private var rootInsertBeforeFirstKey: String { "__root_before_first__" } - private var rootAppendKey: String { "__root_append__" } + var rootDropHighlightKey: String { "__root__" } + var rootInsertBeforeFirstKey: String { "__root_before_first__" } + var rootAppendKey: String { "__root_append__" } - private func highlightKey(for target: DiagramInsertTarget) -> String { + func highlightKey(for target: DiagramInsertTarget) -> String { switch target { case .before(let p): p.map(String.init).joined(separator: ",") + ":" + DropBand.before.rawValue case .after(let p): p.map(String.init).joined(separator: ",") + ":" + DropBand.after.rawValue @@ -109,22 +109,22 @@ struct DiagramCanvasView: View { } } - private var canvasScale: CGFloat { CGFloat(canvasScaleStep.multiplier) } + var canvasScale: CGFloat { CGFloat(canvasScaleStep.multiplier) } - private var layoutMetrics: VisuStructLayoutEngine.Metrics { + var layoutMetrics: VisuStructLayoutEngine.Metrics { let base = VisuStructLayoutEngine.diagramDisplayMetrics(isRegularWidth: horizontalSizeClass == .regular) return base.scaled(by: Double(canvasScale)) } - private var lineHeight: CGFloat { CGFloat(layoutMetrics.lineHeight) } - private var headerFontSize: CGFloat { CGFloat(layoutMetrics.fontSize) } + var lineHeight: CGFloat { CGFloat(layoutMetrics.lineHeight) } + var headerFontSize: CGFloat { CGFloat(layoutMetrics.fontSize) } /// Skaliert feste Abstände/Linien im Canvas mit der Diagramm-Stufe. - private func ink(_ v: CGFloat) -> CGFloat { v * canvasScale } + func ink(_ v: CGFloat) -> CGFloat { v * canvasScale } - private var cellFont: Font { .system(size: headerFontSize, weight: .medium) } + var cellFont: Font { .system(size: headerFontSize, weight: .medium) } - private func displayDocumentForLayout(_ doc: VisuStructDocument) -> VisuStructDocument { + func displayDocumentForLayout(_ doc: VisuStructDocument) -> VisuStructDocument { func mapElement(_ e: VisuStructElement) -> VisuStructElement { let mappedLoop = e.loopBody?.map(mapElement) let mappedCases = e.cases?.map { c in @@ -147,7 +147,7 @@ struct DiagramCanvasView: View { return out } - private func displayTextLines(for element: VisuStructElement) -> [String] { + func displayTextLines(for element: VisuStructElement) -> [String] { if let lines = VisuStructDiagramText.displayLines(for: element) { return lines } switch element.type { @@ -175,13 +175,13 @@ struct DiagramCanvasView: View { } /// Gemeinsame Stroke-Farbe für Kacheln und Hilfslinien (eine Allokation pro Frame statt dutzende). - private var diagramLineColor: Color { Color.black } - private var diagramCanvasFill: Color { Color(white: 0.98) } - private var diagramCellFill: Color { Color.white } - private var selectionFillColor: Color { + var diagramLineColor: Color { Color.black } + var diagramCanvasFill: Color { Color(white: 0.98) } + var diagramCellFill: Color { Color.white } + var selectionFillColor: Color { usesSimulationSelectionHighlight ? Color(red: 0.78, green: 0.90, blue: 1.0).opacity(0.55) : Color.clear } - private var selectionStrokeColor: Color { + var selectionStrokeColor: Color { usesSimulationSelectionHighlight ? Color(red: 0.26, green: 0.55, blue: 0.95).opacity(0.78) : Color.blue.opacity(0.9) } @@ -260,113 +260,12 @@ struct DiagramCanvasView: View { #endif } - /// Sichtbare, oberste Drop-Fläche, wenn es noch keine Strukturknoten gibt (sonst gewinnt kein Knoten-Drop das Hit-Testing). - @ViewBuilder - private func emptyDiagramDropPlate(cw: CGFloat, ch: CGFloat) -> some View { - ZStack { - Color(white: 0.96) - .frame(width: cw, height: ch) - VStack(spacing: 18) { - Image(systemName: "square.dashed") - .font(.system(size: 36)) - .foregroundStyle(.tertiary) - Text(lang.tr("canvas_empty_title")) - .font(.headline) - Text(lang.tr("canvas_empty_hint")) - .font(.subheadline) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - .padding(.horizontal, 28) - } - .allowsHitTesting(false) - } - .contentShape(Rectangle()) - .frame(width: cw, height: ch) - .overlay { - if dropHighlightKey == rootDropHighlightKey { - RoundedRectangle(cornerRadius: 10) - .strokeBorder(Color.blue.opacity(0.95), lineWidth: 4) - .padding(10) - .allowsHitTesting(false) - } - } - .onTapGesture { - // iPhone: leeres Dokument → Einfügen ans Root (ersetzt Platzhalter). - if isPhone { - insertTarget = .rootAppend - } - } - .dropDestination(for: DiagramDragPayload.self) { items, _ in - applyRootDrop(items) - } isTargeted: { targeted in - updateDropHighlight(rootDropHighlightKey, targeted: targeted) - } - } - - @ViewBuilder - private func nodeDropBands(node: VisuStructLayout.Node, isPlaceholderNode: Bool) -> some View { - let w = CGFloat(node.frame.width) - let h = CGFloat(node.frame.height) - - Group { - if isPlaceholderNode { - /// Niedrige Kacheln (z. B. ø / leer in einem Zweig): „davor/danach“-Streifen wären je 10 pt, die Mitte nur 1–2 pt — Drops landeten fälschlich auf „danach“ und haben eingefügt statt ersetzt. - placeholderReplaceDropSurface(node: node, width: w, height: h) - } else { - segmentedSiblingDropBands(node: node, width: w, height: h) - } - } - .frame(width: w, height: h) - .contentShape(Rectangle()) - .offset(x: CGFloat(node.frame.x), y: CGFloat(node.frame.y)) - .onTapGesture { - if isPhone, let target = insertTarget { - // Falls bereits ein Ziel gesetzt ist: Tap auf Kachel selektiert normal (zum Text bearbeiten). - // Einfügemarke bleibt. - selectedPath = node.idPath - _ = target - } else if isPhone { - // iPhone: Tap markiert Einfügeziel. Für Platzhalter = replace, sonst "on" ist nicht zulässig. - if isPlaceholderNode { - insertTarget = .replace(node.idPath) - } else { - // Default: Einfügen NACH dem Element (am häufigsten sinnvoll beim Tippen). - insertTarget = .after(node.idPath) - } - selectedPath = node.idPath - } else { - selectedPath = node.idPath - } - } - .contextMenu { - Button(lang.tr("ctx_copy")) { - copyElement(at: node.idPath) - } - Button(lang.tr("ctx_cut")) { - cutElement(at: node.idPath) - } - .disabled(!VisuStructDocumentEditing.canDeleteElement(at: node.idPath, in: document)) - Button(lang.tr("ctx_paste")) { - pasteClipboard(relativeTo: node.idPath) - } - .disabled(elementClipboard == nil) - } - .draggable(DiagramDragPayload(raw: ElementPalette.moveDragIdentifier(path: node.idPath))) { - nodeDragPreview(for: node) - } - .accessibilityHint( - isPlaceholderNode - ? lang.tr("a11y_placeholder_drop") - : lang.tr("a11y_node_drop") - ) - } - - private func copyElement(at path: [Int]) { + func copyElement(at path: [Int]) { guard let el = try? VisuStructDocumentEditing.element(at: path, in: document) else { return } elementClipboard = el } - private func cutElement(at path: [Int]) { + func cutElement(at path: [Int]) { guard let el = try? VisuStructDocumentEditing.element(at: path, in: document) else { return } elementClipboard = el do { @@ -383,7 +282,7 @@ struct DiagramCanvasView: View { } } - private func pasteClipboard(relativeTo path: [Int]?) { + func pasteClipboard(relativeTo path: [Int]?) { guard let clip = elementClipboard else { return } do { if let targetPath = path { @@ -412,824 +311,6 @@ struct DiagramCanvasView: View { } } - /// Gesamte Kachel = ein Ziel nur für „ersetzen“ (`.on`-Semantik). - @ViewBuilder - private func placeholderReplaceDropSurface(node: VisuStructLayout.Node, width: CGFloat, height: CGFloat) -> some View { - let key = bandHighlightKey(path: node.idPath, band: .on) - let showHL = dropHighlightKey == key || (insertTarget.map(highlightKey(for:)) == key) - let isSelected = selectedPath == node.idPath - Color.clear - .frame(width: width, height: height) - .contentShape(Rectangle()) - // Im Normalzustand keine farbigen Drop-Flächen rendern (wirkt sonst wie ein Verlauf). - .background(showHL ? Color.blue.opacity(0.2) : (isSelected ? selectionFillColor : Color.clear)) - .overlay { - Group { - if showHL { - RoundedRectangle(cornerRadius: 4) - .strokeBorder(Color.blue.opacity(0.95), lineWidth: 3) - } else if isSelected { - // Platzhalterflächen haben immer eine gestrichelte Rahmen-Overlay. - // Wenn selektiert, soll die blaue Auswahl sichtbar sein (sonst überdeckt der gestrichelte Rahmen die Canvas-Auswahl). - RoundedRectangle(cornerRadius: 4) - .strokeBorder(selectionStrokeColor, lineWidth: ink(DiagramCanvasInk.selectionLineWidth)) - } else { - RoundedRectangle(cornerRadius: 4) - .strokeBorder(Color.blue.opacity(0.35), style: StrokeStyle(lineWidth: 1.5, dash: [5, 4])) - } - } - .padding(2) - } - // Kein eigener Tap hier: auf iPad/Mac wäre der Body leer, SwiftUI würde den Tap trotzdem schlucken — - // dann feuert der äußere Tap in `nodeDropBands` nicht mehr (keine Auswahl / kein Ersatz-Ziel). - .dropDestination(for: DiagramDragPayload.self) { items, _ in - applyNodeDrop(items, node: node, band: .on) - } isTargeted: { targeted in - updateDropHighlight(key, targeted: targeted) - } - } - - @ViewBuilder - private func segmentedSiblingDropBands(node: VisuStructLayout.Node, width: CGFloat, height: CGFloat) -> some View { - let strip = siblingDropStripHeight(totalHeight: height) - let midH = max(height - 2 * strip, 1) - VStack(spacing: 0) { - dropBandView(node: node, band: .before, height: strip, width: width) - dropZoneSeparator(width: width) - dropBandView(node: node, band: .on, height: midH, width: width) - dropZoneSeparator(width: width) - dropBandView(node: node, band: .after, height: strip, width: width) - } - } - - /// Nur Layout-Hilfe zwischen den Drop-Streifen — **keine** sichtbare Linie (sonst volle Knotenbreite, wirkt wie kaputtes Raster / schneidet Verzweigung & Fallauswahl). - private func dropZoneSeparator(width: CGFloat) -> some View { - Color.clear.frame(width: width, height: 1) - } - - /// Obere/untere Drop-Streifen: groß genug für den Finger, Mitte bleibt für Tap / Ziel „Mitte“ nutzbar. - private func siblingDropStripHeight(totalHeight h: CGFloat) -> CGFloat { - guard h > 2 else { return 1 } - let minMid: CGFloat = 8 - var strip = min(max(h * 0.22, 11), 34) - if 2 * strip + minMid > h { - strip = max((h - minMid) / 2, 4) - } - return max(1, strip) - } - - @ViewBuilder - private func rootInsertBeforeFirstStrip(firstRootFrame: VisuStructRect, canvasWidth: CGFloat, canvasHeight: CGFloat) -> some View { - let sh = ink(DiagramCanvasInk.rootInsertStripHeight) - let y = max(0, CGFloat(firstRootFrame.y) - sh - 2) - if y + sh <= canvasHeight && canvasWidth > 4 { - let key = rootInsertBeforeFirstKey - let showHL = dropHighlightKey == key || (insertTarget.map(highlightKey(for:)) == key) - let stripCore = Color.clear - .frame(width: canvasWidth, height: sh) - .offset(x: 0, y: y) - .contentShape(Rectangle()) - .background(showHL ? Color.blue.opacity(0.18) : Color.clear) - .overlay(alignment: .center) { - if showHL { - Capsule() - .fill(Color.blue.opacity(0.55)) - .frame(width: min(canvasWidth - 24, 200), height: 3) - } - } - .dropDestination(for: DiagramDragPayload.self) { items, _ in - applyRootInsertBeforeFirst(items) - } isTargeted: { targeted in - updateDropHighlight(key, targeted: targeted) - } - if isPhone { - stripCore.onTapGesture { - insertTarget = .rootInsertAt0 - } - } else { - stripCore - } - } - } - - private func bandHighlightKey(path: [Int], band: DropBand) -> String { - path.map(String.init).joined(separator: ",") + ":" + band.rawValue - } - - private func updateDropHighlight(_ key: String, targeted: Bool) { - if targeted { - dropHighlightKey = key - } else if dropHighlightKey == key { - dropHighlightKey = nil - } - } - - private func notifyDragDropCommitted() { - dropHighlightKey = nil - #if canImport(UIKit) - let g = UIImpactFeedbackGenerator(style: .light) - g.prepare() - g.impactOccurred() - #endif - } - - @ViewBuilder - private func dropBandView(node: VisuStructLayout.Node, band: DropBand, height: CGFloat, width: CGFloat) -> some View { - let key = bandHighlightKey(path: node.idPath, band: band) - let showHL = dropHighlightKey == key || (insertTarget.map(highlightKey(for:)) == key) - let bandCore = Color.clear - .frame(width: width, height: height) - .contentShape(Rectangle()) - // Drop-Zonen nur beim tatsächlichen Drag-Target visuell zeigen. - .background(showHL ? Color.blue.opacity(band == .on ? 0.22 : 0.16) : Color.clear) - .overlay { - if showHL { - RoundedRectangle(cornerRadius: 2) - .strokeBorder(Color.blue.opacity(0.95), lineWidth: band == .on ? 3.5 : 2.5) - .padding(EdgeInsets(top: band == .before ? 1 : 0, leading: 1, bottom: band == .after ? 1 : 0, trailing: 1)) - } - } - .dropDestination(for: DiagramDragPayload.self) { items, _ in - applyNodeDrop(items, node: node, band: band) - } isTargeted: { targeted in - updateDropHighlight(key, targeted: targeted) - } - // Tap nur auf dem iPhone anhängen: sonst schluckt `onTapGesture` den Klick auf iPad/Mac, - // obwohl der Body sofort returnt — der äußere Tap in `nodeDropBands` kommt nicht an. - if isPhone { - bandCore.onTapGesture { - switch band { - case .before: - insertTarget = .before(node.idPath) - case .after: - insertTarget = .after(node.idPath) - case .on: - if VisuStructDocumentEditing.isPlaceholderSlot(at: node.idPath, in: document) { - insertTarget = .replace(node.idPath) - } - } - selectedPath = node.idPath - } - } else { - bandCore - } - } - - private func applyNodeDrop(_ items: [DiagramDragPayload], node: VisuStructLayout.Node, band: DropBand) -> Bool { - let path = node.idPath - guard let s = items.first?.raw else { return false } - if s.hasPrefix("pal:") { - let raw = String(s.dropFirst(4)) - guard let kind = ElementPalette.PaletteItem(rawValue: raw) else { return false } - let el = ElementPalette.element(for: kind, lang: lang) - do { - switch band { - case .before, .after: - if band == .before { - document = try VisuStructDocumentEditing.insertBeforeSibling(document, el, before: path) - selectedPath = path - } else { - document = try VisuStructDocumentEditing.insertAfterSibling(document, el, after: path) - selectedPath = pathOfInsertedSibling(after: path) - } - errorText = nil - case .on: - guard VisuStructDocumentEditing.isPlaceholderSlot(at: path, in: document) else { return false } - document = try VisuStructDocumentEditing.replaceAtPath(document, path: path, with: el) - errorText = nil - selectedPath = path - } - notifyDragDropCommitted() - return true - } catch { - errorText = error.localizedDescription - return false - } - } - if s.hasPrefix("mov:") { - let from = parsePath(String(s.dropFirst(4))) - guard from.count >= 1 else { return false } - do { - switch band { - case .before, .after: - if band == .before { - guard from != path else { return false } - document = try VisuStructDocumentEditing.moveBeforeSibling(document, from: from, before: path) - errorText = nil - selectedPath = VisuStructDocumentEditing.adjustPathAfterRemoving(path: path, removedPath: from) - } else { - guard from != path else { return false } - document = try VisuStructDocumentEditing.moveAfterSibling(document, from: from, after: path) - errorText = nil - selectedPath = pathOfInsertedSibling(after: VisuStructDocumentEditing.adjustPathAfterRemoving(path: path, removedPath: from)) - } - case .on: - guard VisuStructDocumentEditing.isPlaceholderSlot(at: path, in: document) else { return false } - guard from != path else { return false } - let (updated, newPath) = try VisuStructDocumentEditing.moveReplacingPlaceholder(document, from: from, placeholderPath: path) - document = updated - errorText = nil - selectedPath = newPath - } - notifyDragDropCommitted() - return true - } catch { - errorText = error.localizedDescription - return false - } - } - return false - } - - private func nodeDragPreview(for node: VisuStructLayout.Node) -> some View { - let w = min(CGFloat(node.frame.width), 260) - let h = min(CGFloat(node.frame.height), 180) - return RoundedRectangle(cornerRadius: 6) - .strokeBorder(Color.blue.opacity(0.95), lineWidth: 2.5) - .background(RoundedRectangle(cornerRadius: 6).fill(Color.white.opacity(0.97))) - .frame(width: w, height: h) - .shadow(color: .black.opacity(0.28), radius: 10, x: 0, y: 5) - } - - /// Oberer Wurzel-Streifen: an Index 0 einfügen / dorthin verschieben. - private func applyRootInsertBeforeFirst(_ items: [DiagramDragPayload]) -> Bool { - guard document.elements.indices.contains(0) else { return false } - guard let s = items.first?.raw else { return false } - if s.hasPrefix("pal:") { - let raw = String(s.dropFirst(4)) - guard let kind = ElementPalette.PaletteItem(rawValue: raw) else { return false } - let el = ElementPalette.element(for: kind, lang: lang) - if VisuStructDocumentEditing.isSingleRootPlaceholder(in: document) { - do { - document = try VisuStructDocumentEditing.replaceAtPath(document, path: [0], with: el) - errorText = nil - selectedPath = [0] - notifyDragDropCommitted() - return true - } catch { - errorText = error.localizedDescription - return false - } - } - do { - document = try VisuStructDocumentEditing.insertAt(document, parentPath: [], index: 0, element: el) - errorText = nil - selectedPath = [0] - notifyDragDropCommitted() - return true - } catch { - errorText = error.localizedDescription - return false - } - } - if s.hasPrefix("mov:") { - let from = parsePath(String(s.dropFirst(4))) - guard from.count >= 1 else { return false } - guard from != [0] else { return false } - do { - document = try VisuStructDocumentEditing.moveBeforeSibling(document, from: from, before: [0]) - errorText = nil - selectedPath = [0] - notifyDragDropCommitted() - return true - } catch { - errorText = error.localizedDescription - return false - } - } - return false - } - - private func applyRootDrop(_ items: [DiagramDragPayload]) -> Bool { - guard let s = items.first?.raw else { return false } - if s.hasPrefix("pal:") { - let raw = String(s.dropFirst(4)) - guard let kind = ElementPalette.PaletteItem(rawValue: raw) else { return false } - let el = ElementPalette.element(for: kind, lang: lang) - if VisuStructDocumentEditing.isSingleRootPlaceholder(in: document) { - do { - document = try VisuStructDocumentEditing.replaceAtPath(document, path: [0], with: el) - errorText = nil - selectedPath = [0] - notifyDragDropCommitted() - return true - } catch { - errorText = error.localizedDescription - return false - } - } - do { - document = try VisuStructDocumentEditing.insertAt(document, parentPath: [], index: document.elements.count, element: el) - errorText = nil - selectedPath = [document.elements.count - 1] - notifyDragDropCommitted() - return true - } catch { - errorText = error.localizedDescription - return false - } - } - if s.hasPrefix("mov:") { - let from = parsePath(String(s.dropFirst(4))) - guard from.count >= 1 else { return false } - guard let lastIdx = document.elements.indices.last else { return false } - do { - document = try VisuStructDocumentEditing.moveAfterSibling(document, from: from, after: [lastIdx]) - errorText = nil - selectedPath = pathOfInsertedSibling(after: [lastIdx]) - notifyDragDropCommitted() - return true - } catch { - errorText = error.localizedDescription - return false - } - } - return false - } - - private func pathOfInsertedSibling(after siblingPath: [Int]) -> [Int] { - Array(siblingPath.dropLast()) + [siblingPath.last! + 1] - } - - private func parsePath(_ s: String) -> [Int] { - s.split(separator: ",").compactMap { Int(String($0).trimmingCharacters(in: .whitespaces)) } - } - - private func canvasView(layout: VisuStructLayout, cw: CGFloat, ch: CGFloat) -> some View { - Canvas { context, size in - context.fill(Path(CGRect(origin: .zero, size: size)), with: .color(diagramCanvasFill)) - - let marginBase = exportCanvasOuterMarginPt ?? DiagramCanvasInk.canvasOuterMargin - let m = ink(marginBase) - let innerTrim: CGFloat = 1 - let frameRect = CGRect( - x: m, - y: m, - width: CGFloat(layout.canvasSize.width) - 2 * m - innerTrim, - height: CGFloat(layout.canvasSize.height) - 2 * m - innerTrim - ) - let outerFramePath = Path(frameRect) - context.stroke(outerFramePath, with: .color(Color.black), lineWidth: ink(DiagramCanvasInk.outerFrameLineWidth)) - - for n in layout.elements { - drawNode(n, selectedPath: selectedPath, in: &context, parentType: nil) - } - } - .frame(width: cw, height: ch) - } - - /// Dachspitze + Textgrenze für Verzweigung/Fallauswahl; **zwei** Zeichenphasen müssen dieselbe Spitze nutzen (sonst „springen“ senkrechte Linien). - private struct MultiwayRoofMetrics { - let apex: CGPoint - let maxTextBottom: CGFloat - let roofBottom: CGFloat - } - - private func multiwayRoofMetrics(node: VisuStructLayout.Node, rect r: CGRect, headerH: CGFloat, dividerX: CGFloat) -> MultiwayRoofMetrics { - let roofBottom = r.minY + headerH - let yInset = CGFloat(node.dividerYInset ?? 0) - let lines = node.element.textLines - let apexPadding: CGFloat = 12 - let blockH = CGFloat(max(lines.count, 1)) * lineHeight - let topStart = r.minY + ink(DiagramCanvasInk.multiHeaderFirstLineY) - - if node.element.type == .fallauswahl { - let caseBand = max(ink(DiagramCanvasInk.fallauswahlCaseLabelBand), lineHeight + ink(10)) - let titleBottomLimit = roofBottom - caseBand - let maxTextBottom = titleBottomLimit - apexPadding - let idealApexY = roofBottom + yInset - let apexMaxY = titleBottomLimit - 8 - let apexY = min(idealApexY, apexMaxY) - let apexClampedY = max(apexY, topStart + blockH + 6) - return MultiwayRoofMetrics( - apex: CGPoint(x: dividerX, y: apexClampedY), - maxTextBottom: maxTextBottom, - roofBottom: roofBottom - ) - } - let apex = CGPoint(x: dividerX, y: roofBottom + yInset) - return MultiwayRoofMetrics( - apex: apex, - maxTextBottom: apex.y - apexPadding, - roofBottom: roofBottom - ) - } - - private func strokeDiagramCellBorder( - _ r: CGRect, - omitting omission: CellBorderOmission, - in context: inout GraphicsContext, - linePaint: Color, - lineWidth: CGFloat - ) { - if omission.isEmpty { - context.stroke(Path(r), with: .color(linePaint), lineWidth: lineWidth) - return - } - if !omission.contains(.top) { - context.stroke( - Path { p in - p.move(to: CGPoint(x: r.minX, y: r.minY)) - p.addLine(to: CGPoint(x: r.maxX, y: r.minY)) - }, - with: .color(linePaint), - lineWidth: lineWidth - ) - } - if !omission.contains(.right) { - context.stroke( - Path { p in - p.move(to: CGPoint(x: r.maxX, y: r.minY)) - p.addLine(to: CGPoint(x: r.maxX, y: r.maxY)) - }, - with: .color(linePaint), - lineWidth: lineWidth - ) - } - if !omission.contains(.bottom) { - context.stroke( - Path { p in - p.move(to: CGPoint(x: r.maxX, y: r.maxY)) - p.addLine(to: CGPoint(x: r.minX, y: r.maxY)) - }, - with: .color(linePaint), - lineWidth: lineWidth - ) - } - if !omission.contains(.left) { - context.stroke( - Path { p in - p.move(to: CGPoint(x: r.minX, y: r.maxY)) - p.addLine(to: CGPoint(x: r.minX, y: r.minY)) - }, - with: .color(linePaint), - lineWidth: lineWidth - ) - } - } - - private func drawNode( - _ node: VisuStructLayout.Node, - selectedPath: [Int]?, - in context: inout GraphicsContext, - cellBorderOmission: CellBorderOmission = [], - parentType: VisuStructElementType? = nil - ) { - let r = CGRect(x: node.frame.x, y: node.frame.y, width: node.frame.width, height: node.frame.height) - let cellPath = Path(r) - let isMulti = node.element.type == .verzweigung || node.element.type == .fallauswahl - /// Exakt die Höhe, mit der das Layout die Spaltenkinder nach unten verschiebt (verhindert Überlappung Kopf/Zweig). - let headerH: CGFloat = { - guard isMulti else { return 0 } - let h = node.headerBandHeight > 0 ? node.headerBandHeight : headerHeightApprox(node) - return CGFloat(h) - }() - let isSelected = node.idPath == selectedPath - let linePaint = diagramLineColor - - context.fill(cellPath, with: .color(isSelected ? (usesSimulationSelectionHighlight ? selectionFillColor : diagramCellFill) : diagramCellFill)) - strokeDiagramCellBorder( - r, - omitting: cellBorderOmission, - in: &context, - linePaint: linePaint, - lineWidth: ink(DiagramCanvasInk.diagramLineWidth) - ) - // Kein zweites Rechteck für Verzweigung/Fallauswahl: dessen Oberkante läge auf Höhe der Dreiecksspitze - // und würde eine Sehne durch das V zeichnen sowie linke/rechte Kanten doppelt stroken („X“ / Doppellinien). - - switch node.element.type { - case .aufruf: - let bar = ink(DiagramCanvasInk.callSideBarInset) - context.stroke(Path { p in - p.move(to: CGPoint(x: r.minX + bar, y: r.minY)) - p.addLine(to: CGPoint(x: r.minX + bar, y: r.maxY)) - p.move(to: CGPoint(x: r.maxX - bar, y: r.minY)) - p.addLine(to: CGPoint(x: r.maxX - bar, y: r.maxY)) - }, with: .color(linePaint), lineWidth: ink(DiagramCanvasInk.diagramLineWidth)) - case .aussprung: - let bar = ink(DiagramCanvasInk.callSideBarInset) - context.stroke(Path { p in - p.move(to: CGPoint(x: r.minX + bar, y: r.minY)) - p.addLine(to: CGPoint(x: r.minX, y: r.midY)) - p.addLine(to: CGPoint(x: r.minX + bar, y: r.maxY)) - }, with: .color(linePaint), lineWidth: ink(DiagramCanvasInk.diagramLineWidth)) - default: - break - } - - if isMulti, let dividerXRaw = node.dividerX { - let dividerX = CGFloat(dividerXRaw) - let roof = multiwayRoofMetrics(node: node, rect: r, headerH: headerH, dividerX: dividerX) - let lines = displayTextLines(for: node.element) - let blockH = CGFloat(max(lines.count, 1)) * lineHeight - let topStart = r.minY + ink(DiagramCanvasInk.multiHeaderFirstLineY) - - context.stroke(Path { p in - let topLeft = CGPoint(x: r.minX, y: r.minY) - let topRight = CGPoint(x: r.maxX, y: r.minY) - p.move(to: topLeft) - p.addLine(to: roof.apex) - p.addLine(to: topRight) - }, with: .color(linePaint), lineWidth: ink(DiagramCanvasInk.diagramLineWidth)) - - if node.element.type == .fallauswahl { - context.stroke( - Path { p in - p.move(to: CGPoint(x: r.minX, y: roof.roofBottom)) - p.addLine(to: CGPoint(x: r.maxX, y: roof.roofBottom)) - }, - with: .color(linePaint), - lineWidth: ink(DiagramCanvasInk.diagramLineWidth) - ) - } - - // Kopftext: Fallauswahl zentriert zur Gesamtbreite; Verzweigung über der Dachspitze (Zweig-Grenze). Nicht zu nah an die Spitze. - let maxTextBottom = roof.maxTextBottom - let firstY: CGFloat - if topStart + blockH <= maxTextBottom { - firstY = topStart - } else { - let totalH = CGFloat(max(lines.count, 1) - 1) * lineHeight - firstY = max(r.minY + 2, maxTextBottom - totalH - lineHeight) - } - for (i, line) in lines.enumerated() { - let y = firstY + CGFloat(i) * lineHeight - let tw: CGFloat = { - if let parts = VisuStructDiagramText.keywordSplit(line) { - let kwW = measureText(parts.keyword, size: headerFontSize, weight: .bold) - let restW = parts.rest.isEmpty ? 0 : measureText(" " + parts.rest, size: headerFontSize, weight: .medium) - return kwW + restW - } - return measureText(line, size: headerFontSize, weight: .medium) - }() - /// Verzweigung: Kopf über der **Zweig-Grenze** (Dachspitze), nicht über `midX` — sonst wirkt die Mitte falsch, wenn ein Zweig viel breiter ist (z. B. Fallauswahl im false-Zweig). - let tx: CGFloat = { - if node.element.type == .verzweigung { - let ideal = dividerX - tw / 2 - let lo = r.minX + ink(DiagramCanvasInk.cellTextInsetX) - let hi = r.maxX - tw - ink(DiagramCanvasInk.cellTextInsetX) - return min(max(ideal, lo), hi) - } - return r.midX - tw / 2 - }() - drawKeywordLine(line, elementType: node.element.type, at: CGPoint(x: tx, y: y), in: &context) - } - } else { - let lines = displayTextLines(for: node.element) - let ix = ink(DiagramCanvasInk.cellTextInsetX) - let iy = ink(DiagramCanvasInk.cellTextInsetY) - if node.element.type == .doUntilSchleife { - /// „do“ wird nach den Rumpf-Kindern gezeichnet — sonst können weiße Kachelflächen den Text überdecken (siehe Block nach `drawNode` der Kinder). - } else if node.element.type == .anweisung || node.element.type == .aufruf || node.element.type == .aussprung { - // Anweisung, Aufruf, Aussprung: Text zentriert; bei Aufruf/Aussprung Platz für die seitlichen Striche. - let bar = ink(DiagramCanvasInk.callSideBarInset) - let contentLeft: CGFloat - let contentRight: CGFloat - switch node.element.type { - case .aufruf: - let callTextInset = ink(DiagramCanvasInk.callTextInsetFromSideBar) - contentLeft = r.minX + bar + callTextInset - contentRight = r.maxX - bar - callTextInset - case .aussprung: - contentLeft = r.minX + bar + ix - contentRight = r.maxX - ix - default: - contentLeft = r.minX + ix - contentRight = r.maxX - ix - } - let contentWidth = max(0, contentRight - contentLeft) - let totalTextHeight = CGFloat(max(1, lines.count)) * lineHeight - var y = r.midY - totalTextHeight / 2 - for line in lines { - let tw: CGFloat = { - if let parts = VisuStructDiagramText.keywordSplit(line) { - let kwW = measureText(parts.keyword, size: headerFontSize, weight: .bold) - let restW = parts.rest.isEmpty ? 0 : measureText(" " + parts.rest, size: headerFontSize, weight: .medium) - return kwW + restW - } - return measureText(line, size: headerFontSize, weight: .medium) - }() - let x = contentLeft + (contentWidth - tw) / 2 - drawKeywordLine(line, elementType: node.element.type, at: CGPoint(x: x, y: y), in: &context) - y += lineHeight - } - } else { - let totalTextHeight = CGFloat(max(1, lines.count)) * lineHeight - var y = parentType == .doUntilSchleife - ? (r.midY - totalTextHeight / 2) - : (r.minY + iy) - for line in lines { - drawKeywordLine(line, elementType: node.element.type, at: CGPoint(x: r.minX + ix, y: y), in: &context) - y += lineHeight - } - } - } - - for (idx, c) in node.children.enumerated() { - let childOmission: CellBorderOmission = (node.element.type == .doUntilSchleife && idx == 0) ? [.top, .left] : [] - drawNode(c, selectedPath: selectedPath, in: &context, cellBorderOmission: childOmission, parentType: node.element.type) - } - - if node.element.type == .doUntilSchleife { - /// Senkrechte / Rumpf: X = Layout-Kante = `minX` + `loopBodyLeadingInset` (aus **gemessener** Bold-Breite „do“ + Padding in `doUntilBodyLeadingInset()`). - let dividerX = node.children.first.map { CGFloat($0.frame.x) } - ?? (r.minX + CGFloat(node.loopBodyLeadingInset > 0 ? node.loopBodyLeadingInset : DiagramCanvasInk.loopBodyLeadingInset)) - let kwFont: Font = .system(size: headerFontSize, weight: .bold) - let xDo = r.minX + ink(4) - /// Oben im linken Streifen — nur wenige Punkte unter der Kante (`doUntilDoLabelTopInset`), nicht voller Zellen-Einzug. - let doTextTop = r.minY + CGFloat(layoutMetrics.doUntilDoLabelTopInset) - context.draw( - context.resolve(Text(verbatim: "do").font(kwFont).foregroundStyle(.black)), - at: CGPoint(x: xDo, y: doTextTop), - anchor: .topLeading - ) - - let footerH = CGFloat(node.headerBandHeight) - let ySep = r.maxY - footerH - /// Rumpf beginnt auf der Oberkante der Schleife — kein separates „do“-Band mehr (`topBandHeight` ≈ 0). - let bodyTopY = r.minY - let lw = ink(DiagramCanvasInk.diagramLineWidth) - context.stroke( - Path { p in - p.move(to: CGPoint(x: dividerX, y: ySep)) - p.addLine(to: CGPoint(x: r.maxX, y: ySep)) - }, - with: .color(linePaint), - lineWidth: lw - ) - context.stroke( - Path { p in - p.move(to: CGPoint(x: dividerX, y: bodyTopY)) - p.addLine(to: CGPoint(x: dividerX, y: ySep)) - }, - with: .color(linePaint), - lineWidth: lw - ) - let lines = displayTextLines(for: node.element) - let ix = ink(DiagramCanvasInk.cellTextInsetX) - var y = ySep + CGFloat(layoutMetrics.doUntilFooterTopInset) - for line in lines { - drawKeywordLine(line, elementType: node.element.type, at: CGPoint(x: r.minX + ix, y: y), in: &context) - y += lineHeight - } - } - - if isMulti, let dividerXRaw = node.dividerX, node.caseColumnFrames.count > 1 { - let dividerX = CGFloat(dividerXRaw) - let roof = multiwayRoofMetrics(node: node, rect: r, headerH: headerH, dividerX: dividerX) - let apex = roof.apex - for (idx, col) in node.caseColumnFrames.enumerated() where idx < node.caseColumnFrames.count - 1 { - let xSep = CGFloat(col.x + col.width) - let yTopRaw = yOnSwitchHeaderDiagonal(xSep: xSep, rect: r, apex: apex) - let yTop = yTopRaw.isFinite ? min(max(yTopRaw, r.minY), r.maxY) : r.minY - context.stroke(Path { p in - p.move(to: CGPoint(x: xSep, y: r.maxY)) - p.addLine(to: CGPoint(x: xSep, y: yTop)) - }, with: .color(linePaint), lineWidth: ink(DiagramCanvasInk.diagramLineWidth)) - } - - if let cases = node.element.cases, cases.count == node.caseColumnFrames.count { - /// Fallauswahl: etwas höher setzen, damit „1/2/default“ nicht in die Dachkanten rutschen; Verzweigung: knapp über dem Rumpf wie in Java. - let labelY: CGFloat = { - if node.element.type == .fallauswahl { - /// Am unteren Rand des Kopfteils (über Rumpf), passend zur waagerechten Linie bei `roofBottom`. - return roof.roofBottom - 6 - } - return r.minY + headerH - 8 - }() - let lx = ink(DiagramCanvasInk.branchLabelInsetX) - if node.element.type == .verzweigung && cases.count == 2 { - let left = "true" - let right = "false" - let kwFont: Font = .system(size: headerFontSize, weight: .bold) - let rw = measureText(right, size: headerFontSize, weight: .bold) - context.draw( - context.resolve(Text(verbatim: left).font(kwFont).foregroundStyle(.black)), - at: CGPoint(x: r.minX + lx, y: labelY), - anchor: .bottomLeading - ) - let rx = node.caseColumnFrames[1].x + node.caseColumnFrames[1].width - lx - rw - context.draw( - context.resolve(Text(verbatim: right).font(kwFont).foregroundStyle(.black)), - at: CGPoint(x: rx, y: labelY), - anchor: .bottomLeading - ) - } else { - for (idx, c) in cases.enumerated() { - let col = node.caseColumnFrames[idx] - let label = c.name - let isKeywordLabel = ["default", "true", "false"].contains(label.lowercased()) - let w: UIFont.Weight = isKeywordLabel ? .bold : .medium - let f: Font = .system(size: headerFontSize, weight: isKeywordLabel ? .bold : .medium) - let tw = measureText(label, size: headerFontSize, weight: w) - let cx = col.x + col.width / 2 - tw / 2 - context.draw( - context.resolve(Text(verbatim: label).font(f).foregroundStyle(.black)), - at: CGPoint(x: cx, y: labelY), - anchor: .bottomLeading - ) - } - } - } - } - - // Auswahlrahmen IMMER zuletzt zeichnen, damit Kinder/Innenlinien ihn nicht übermalen (v. a. bei verschachtelten Verzweigungen/Fallauswahl). - if isSelected { - context.stroke(cellPath, with: .color(selectionStrokeColor), lineWidth: ink(DiagramCanvasInk.selectionLineWidth)) - } - } - - private func yOnSwitchHeaderDiagonal(xSep: CGFloat, rect: CGRect, apex: CGPoint) -> CGFloat { - let topY = rect.minY - let leftX = rect.minX - if xSep <= apex.x { - let run = apex.x - leftX - if abs(run) < 0.5 { return apex.y } - let slope = (apex.y - topY) / run - return topY + slope * (xSep - leftX) - } - let run = rect.maxX - apex.x - if abs(run) < 0.5 { return apex.y } - let slope = (topY - apex.y) / run - return apex.y + slope * (xSep - apex.x) - } - - private func headerHeightApprox(_ node: VisuStructLayout.Node) -> Double { - let e = node.element - let lines = max(1, e.textLines.count) - if e.type == .doUntilSchleife { - return layoutMetrics.doUntilFooterTopInset + Double(lines) * layoutMetrics.lineHeight + layoutMetrics.doUntilFooterBottomInset + Double(e.zoomY) - } - let baseExtra: Double - switch e.type { - case .anweisung, .aussprung, .aufruf, .leerElement: - baseExtra = 14 - case .fallauswahl: - baseExtra = 40 - case .verzweigung: - baseExtra = 20 - case .doUntilSchleife: - preconditionFailure("Do-Until-Fuß nur über den dedizierten Zweig oben.") - case .forSchleife, .whileSchleife, .endlosschleife: - baseExtra = layoutMetrics.baseHeaderExtra + 4 - @unknown default: - baseExtra = layoutMetrics.baseHeaderExtra - } - return baseExtra + Double(lines) * layoutMetrics.lineHeight + Double(e.zoomY) - } - - /// NSString-Messung ist korrekt, aber teuer bei vielen Zeilen — Cache mit begrenzter Größe. - #if canImport(UIKit) - private static let textWidthCache: NSCache = { - let c = NSCache() - c.countLimit = 400 - return c - }() - #endif - - private func measureText(_ s: String, size: CGFloat, weight: UIFont.Weight = .medium) -> CGFloat { - #if canImport(UIKit) - let key = "\(Int((size * 1000).rounded()))|\(weight.rawValue)|\(s)" as NSString - if let cached = Self.textWidthCache.object(forKey: key) { - return CGFloat(truncating: cached) - } - let w = (s as NSString).size(withAttributes: [ - .font: UIFont.systemFont(ofSize: size, weight: weight) - ]).width - Self.textWidthCache.setObject(NSNumber(value: Double(w)), forKey: key) - return w - #else - return CGFloat(s.count) * size * 0.55 - #endif - } - - private func drawKeywordLine(_ line: String, elementType: VisuStructElementType, at origin: CGPoint, in context: inout GraphicsContext) { - if let parts = VisuStructDiagramText.keywordSplit(line) { - let kwFont: Font = .system(size: headerFontSize, weight: .bold) - let kw = parts.keyword - let rest = parts.rest - let kwW = measureText(kw, size: headerFontSize, weight: .bold) - context.draw( - context.resolve(Text(verbatim: kw).font(kwFont).foregroundStyle(.black)), - at: origin, - anchor: .topLeading - ) - if !rest.isEmpty { - context.draw( - context.resolve(Text(verbatim: " " + rest).font(cellFont).foregroundStyle(.black)), - at: CGPoint(x: origin.x + kwW, y: origin.y), - anchor: .topLeading - ) - } - } else { - context.draw( - context.resolve(Text(verbatim: line).font(cellFont).foregroundStyle(.black)), - at: origin, - anchor: .topLeading - ) - } - } } #if canImport(UIKit) From 0e77012a604dc1d08ea654bfe01c2fd42692ee9b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 06:52:21 +0000 Subject: [PATCH 11/11] Adapt merged portrait simulation layout to SimulationController Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Cp4EQgFQyGbM8MVLiWbJKQ --- .../VisuStruct/ContentView.swift | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/VisuStruct-iOS/VisuStructXcodeOpen/VisuStruct/ContentView.swift b/VisuStruct-iOS/VisuStructXcodeOpen/VisuStruct/ContentView.swift index cc498854..79963b72 100644 --- a/VisuStruct-iOS/VisuStructXcodeOpen/VisuStruct/ContentView.swift +++ b/VisuStruct-iOS/VisuStructXcodeOpen/VisuStruct/ContentView.swift @@ -777,15 +777,15 @@ struct ContentView: View { simulationCanvasBlock .frame(height: geo.size.height * 0.5) SimulationStatePanel( - engine: simulationEngine, - isPlaying: isSimulationPlaying, - inputText: $simulationInputText, + engine: simulation.engine, + isPlaying: simulation.isPlaying, + inputText: $simulation.inputText, highlightModeRaw: $simulationHighlightModeRaw, - onPlay: playSimulation, - onPause: pauseSimulation, - onStep: stepSimulation, - onSubmitInput: submitSimulationInput, - onStop: resetSimulation, + onPlay: { simulation.play() }, + onPause: { simulation.pause() }, + onStep: { simulation.step() }, + onSubmitInput: { simulation.submitInput() }, + onStop: { simulation.reset() }, usesWideLayout: true ) .frame(maxHeight: .infinity)