From c1902bc394f4180131a18c0d85ed88d098593bd9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 22:12:12 +0000 Subject: [PATCH 01/10] 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 02/10] 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 03/10] 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 04/10] 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 05/10] 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 06/10] 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 07/10] 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 08/10] 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 09/10] 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 10/10] 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)