From 8c92504893e65d7418b649ff3b73bde4d4bca8b7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 22:24:06 +0000 Subject: [PATCH 1/4] 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 2/4] 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 3/4] 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 4/4] 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)