Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
46 commits
Select commit Hold shift + click to select a range
e575634
CI: also build the app for the iOS Simulator
claude Oct 18, 2026
c1902bc
Xcode project: use a synchronized folder for VisuStruct/
claude Oct 18, 2026
2112797
Move inspector and canvas text logic into VisuStructCore
claude Oct 18, 2026
04aa358
Move undo/redo and tab editing out of ContentView
claude Oct 18, 2026
efabe1b
Update DiagramSheetTabsModel doc comment
claude Oct 18, 2026
054d6bf
Move simulation state and controls into SimulationController
claude Oct 18, 2026
8c92504
Extract PaletteActionList and SimulationDetailView from ContentView
claude Oct 18, 2026
fc3ecc7
Move inspector hints and Fallauswahl path helpers out of ContentView
claude Oct 18, 2026
eb79ba5
Bundle PNG, file, PDF and print export in DiagramExporter
claude Oct 18, 2026
3ae4ee1
Split DiagramCanvasView into view, drop zones and renderer
claude Oct 18, 2026
0603fb4
Merge remote-tracking branch 'origin/main' into ci-app-build
claude Oct 19, 2026
5b89613
Merge branch 'ci-app-build' into xcode-synced-folders
claude Oct 19, 2026
1c61e23
Merge branch 'core-text-logic' into editor-store
claude Oct 19, 2026
5292a68
Merge branch 'xcode-synced-folders' into core-text-logic
claude Oct 19, 2026
e48aecb
Merge branch 'editor-store' into simulation-controller
claude Oct 19, 2026
0e77012
Adapt merged portrait simulation layout to SimulationController
claude Oct 19, 2026
cbf53ae
Merge branch 'palette-view' into inspector-helpers
claude Oct 19, 2026
d852cb9
Merge simulation-controller into palette-view
claude Oct 19, 2026
d2972b4
Merge branch 'inspector-helpers' into export-actions
claude Oct 19, 2026
55fd215
Merge branch 'export-actions' into canvas-split
claude Oct 19, 2026
c01409f
Merge remote-tracking branch 'origin/main' into ci-app-build
claude Oct 19, 2026
b171132
Merge branch 'ci-app-build' into xcode-synced-folders
claude Oct 19, 2026
dcc8adb
Merge branch 'core-text-logic' into editor-store
claude Oct 19, 2026
da8e470
Merge branch 'xcode-synced-folders' into core-text-logic
claude Oct 19, 2026
9febfde
Merge branch 'editor-store' into simulation-controller
claude Oct 19, 2026
a2a3759
Merge branch 'palette-view' into inspector-helpers
claude Oct 19, 2026
fab9f8f
Merge branch 'simulation-controller' into palette-view
claude Oct 19, 2026
820be7d
Merge branch 'inspector-helpers' into export-actions
claude Oct 19, 2026
3057857
Merge export-actions into canvas-split
claude Oct 19, 2026
d9e4351
Merge branch 'editor-store' into simulation-controller
claude Oct 19, 2026
753c2e4
Merge branch 'core-text-logic' into editor-store
claude Oct 19, 2026
ea3c70f
Merge branch 'xcode-synced-folders' into core-text-logic
claude Oct 19, 2026
164baaf
Merge branch 'ci-app-build' into xcode-synced-folders
claude Oct 19, 2026
ba6c820
Merge remote-tracking branch 'origin/main' into ci-app-build
claude Oct 19, 2026
a7b0a39
Merge branch 'palette-view' into inspector-helpers
claude Oct 19, 2026
9bf2f73
Merge branch 'simulation-controller' into palette-view
claude Oct 19, 2026
6ad16af
Merge branch 'export-actions' into canvas-split
claude Oct 19, 2026
2cf9ea9
Merge branch 'inspector-helpers' into export-actions
claude Oct 19, 2026
14d0997
Merge pull request #18 from code4teaching/canvas-split
code4teaching Oct 19, 2026
69d6259
Merge pull request #17 from code4teaching/export-actions
code4teaching Oct 19, 2026
74ec7ac
Merge pull request #16 from code4teaching/inspector-helpers
code4teaching Oct 19, 2026
251cb84
Merge pull request #15 from code4teaching/palette-view
code4teaching Oct 19, 2026
6c6a3be
Merge pull request #14 from code4teaching/simulation-controller
code4teaching Oct 19, 2026
7771e9c
Merge pull request #13 from code4teaching/editor-store
code4teaching Oct 19, 2026
bd9510d
Merge pull request #12 from code4teaching/core-text-logic
code4teaching Oct 19, 2026
1214587
Merge pull request #11 from code4teaching/xcode-synced-folders
code4teaching Oct 19, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion .github/workflows/core-tests.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
name: VisuStructCore tests
name: CI

on:
push:
Expand All @@ -17,3 +17,17 @@ jobs:
run: swift --version
- name: Build and test
run: swift test

app-build:
runs-on: macos-15
steps:
- uses: actions/checkout@v4
- name: Xcode version
run: xcodebuild -version
- name: Build app for iOS Simulator
run: |
xcodebuild build \
-project VisuStruct-iOS/VisuStructXcodeOpen/VisuStruct.xcodeproj \
-scheme VisuStruct \
-destination 'generic/platform=iOS Simulator' \
CODE_SIGNING_ALLOWED=NO
Original file line number Diff line number Diff line change
@@ -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]
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import Foundation

extension VisuStructDocumentEditing {
/// Auswahl nach Undo/Redo oder Löschen: leeres Dokument → keine Auswahl; fehlende oder ungültige Auswahl → erstes Element.
public static func sanitizedSelection(_ path: [Int]?, in doc: VisuStructDocument) -> [Int]? {
if doc.elements.isEmpty { return nil }
guard let path else { return [0] }
if (try? element(at: path, in: doc)) == nil { return [0] }
return path
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import Foundation

/// Undo-/Redo-Verlauf eines Diagramms: Schnappschüsse des Dokuments vor jeder Änderung.
public struct VisuStructEditHistory: Equatable, Sendable {
public static let defaultMaxSteps = 80

public private(set) var undoStack: [VisuStructDocument] = []
public private(set) var redoStack: [VisuStructDocument] = []
public let maxSteps: Int

public init(maxSteps: Int = VisuStructEditHistory.defaultMaxSteps) {
self.maxSteps = maxSteps
}

public var canUndo: Bool { !undoStack.isEmpty }
public var canRedo: Bool { !redoStack.isEmpty }

/// Vor einer Änderung: bisherigen Zustand ablegen, Redo verwerfen.
public mutating func record(_ current: VisuStructDocument) {
pushUndo(current)
redoStack.removeAll()
}

/// Liefert den vorherigen Zustand und legt `current` für Redo ab; `nil`, wenn nichts rückgängig zu machen ist.
public mutating func undo(from current: VisuStructDocument) -> VisuStructDocument? {
guard let previous = undoStack.popLast() else { return nil }
redoStack.append(current)
return previous
}

/// Liefert den wiederhergestellten Zustand und legt `current` für Undo ab; `nil`, wenn nichts wiederherzustellen ist.
public mutating func redo(from current: VisuStructDocument) -> VisuStructDocument? {
guard let next = redoStack.popLast() else { return nil }
pushUndo(current)
return next
}

public mutating func reset() {
undoStack.removeAll()
redoStack.removeAll()
}

private mutating func pushUndo(_ document: VisuStructDocument) {
undoStack.append(document)
while undoStack.count > maxSteps {
undoStack.removeFirst()
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import Foundation

/// Darstellung von Anweisung vs. Ein-/Ausgabe im Inspector (Modus-Picker).
public enum VisuStructStatementIOMode: String, CaseIterable, Hashable, Sendable {
case statement
case input
case output
}

/// Textumformungen für den Zeileneditor im Inspector (ohne UI, daher testbar).
public enum VisuStructLineEditorText {
public static func ioMode(of text: String) -> VisuStructStatementIOMode {
let t = text.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
if t.hasPrefix("input:") { return .input }
if t.hasPrefix("output:") { return .output }
if t.hasPrefix("print:") { return .output }
return .statement
}

/// Entfernt `input:` / `output:` / `print:` und umgebende Leerzeichen; ohne Präfix bleibt der Text unverändert.
public static func strippingIOPrefix(from s: String) -> String {
let trimmed = s.trimmingCharacters(in: .whitespacesAndNewlines)
let lower = trimmed.lowercased()
if lower.hasPrefix("input:") {
return String(trimmed.dropFirst(6)).trimmingCharacters(in: .whitespacesAndNewlines)
}
if lower.hasPrefix("output:") {
return String(trimmed.dropFirst(7)).trimmingCharacters(in: .whitespacesAndNewlines)
}
if lower.hasPrefix("print:") {
return String(trimmed.dropFirst(6)).trimmingCharacters(in: .whitespacesAndNewlines)
}
return s
}

/// Text nach Wechsel im Modus-Picker: Präfix tauschen, Inhalt behalten.
public static func applyingIOMode(_ mode: VisuStructStatementIOMode, to text: String) -> String {
let stripped = strippingIOPrefix(from: text)
switch mode {
case .statement:
return stripped
case .input:
return prefixing("input:", to: stripped)
case .output:
return prefixing("output:", to: stripped)
}
}

/// Wie `strippingIOPrefix` bzw. Schlüsselwort-Entfernung, aber Leerzeichen beim Tippen bleiben erhalten.
public static func strippingPrefixForEditing(_ p: String, from s: String) -> String {
if p == "input:" || p == "output:" {
return strippingIOPrefixPreservingEditingWhitespace(from: s)
}
let stripped = strippingKeywordPrefixPreservingEditingWhitespace(p, from: s)
if p == "while", stripped == s {
let legacy = strippingKeywordPrefixPreservingEditingWhitespace("until", from: s)
if legacy != s { return legacy }
}
return stripped
}

public static func prefixingForEditing(_ p: String, to content: String) -> String {
if content.isEmpty { return "\(p) " }
return "\(p) \(content)"
}

/// iOS/macOS ersetzen beim Tippen teils typografische Anführungszeichen; für Code immer gerade Quotes.
public static func normalizingQuotes(_ s: String) -> String {
s
.replacingOccurrences(of: "“", with: "\"")
.replacingOccurrences(of: "”", with: "\"")
.replacingOccurrences(of: "„", with: "\"")
.replacingOccurrences(of: "«", with: "\"")
.replacingOccurrences(of: "»", with: "\"")
.replacingOccurrences(of: "’", with: "'")
.replacingOccurrences(of: "‘", with: "'")
.replacingOccurrences(of: "‚", with: "'")
.replacingOccurrences(of: "´", with: "'")
}

/// Genau drei Felder (Start, Bedingung, Schritt) der Zählschleife.
public static func paddedForLoopParts(_ lines: [String]) -> [String] {
var a = lines.isEmpty ? [""] : lines
while a.count < 3 { a.append("") }
if a.count > 3 { return Array(a.prefix(3)) }
return a
}

/// Ein Feld der Zählschleife: gerade Quotes, ohne abschließende `;`.
public static func normalizedForLoopPart(_ s: String) -> String {
var x = normalizingQuotes(s)
while true {
let trimmedEnd = x.trimmingCharacters(in: .whitespacesAndNewlines)
guard trimmedEnd.hasSuffix(";") else { return x }
x = String(trimmedEnd.dropLast()).trimmingCharacters(in: .whitespacesAndNewlines)
}
}

private static func prefixing(_ p: String, to content: String) -> String {
let c = content.trimmingCharacters(in: .whitespacesAndNewlines)
if c.isEmpty { return "\(p) " }
return "\(p) \(c)"
}

private static func strippingIOPrefixPreservingEditingWhitespace(from s: String) -> String {
let leadingTrimmed = String(s.drop(while: { $0.isWhitespace }))
let lower = leadingTrimmed.lowercased()
if lower.hasPrefix("input:") {
return droppingOneLeadingWhitespace(String(leadingTrimmed.dropFirst(6)))
}
if lower.hasPrefix("output:") {
return droppingOneLeadingWhitespace(String(leadingTrimmed.dropFirst(7)))
}
if lower.hasPrefix("print:") {
return droppingOneLeadingWhitespace(String(leadingTrimmed.dropFirst(6)))
}
return s
}

private static func strippingKeywordPrefixPreservingEditingWhitespace(_ keyword: String, from s: String) -> String {
let leadingTrimmed = String(s.drop(while: { $0.isWhitespace }))
let lower = leadingTrimmed.lowercased()
let key = keyword.lowercased()
guard lower.hasPrefix(key) else { return s }

let afterKeyword = leadingTrimmed.dropFirst(keyword.count)
guard afterKeyword.isEmpty || afterKeyword.first?.isWhitespace == true || afterKeyword.first == "(" else {
return s
}

let content = droppingOneLeadingWhitespace(String(afterKeyword))
if content.hasPrefix("("), content.hasSuffix(")"), content.count >= 2 {
return String(content.dropFirst().dropLast())
}
return content
}

private static func droppingOneLeadingWhitespace(_ s: String) -> String {
guard let first = s.first, first.isWhitespace else { return s }
return String(s.dropFirst())
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import Foundation

/// Texte, wie sie im Diagramm angezeigt werden (Schlüsselwörter ergänzt, Zählschleife einzeilig).
public enum VisuStructDiagramText {
/// Anzeigezeilen eines Elements; `nil`, wenn das Element keinen Text hat (dann zeigt der Canvas einen Platzhalter).
public static func displayLines(for element: VisuStructElement) -> [String]? {
let raw = element.textLines
let isEffectivelyEmpty = raw.allSatisfy { $0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
guard !isEffectivelyEmpty else { return nil }

switch element.type {
case .forSchleife:
if raw.count <= 1 { return raw }
return [VisuStructForLoopDiagramDisplay.singleLine(from: raw)]
case .verzweigung:
return withLeadingKeyword("if", raw)
case .whileSchleife:
return withLeadingKeyword("while", raw)
case .doUntilSchleife:
return withLeadingKeyword("while", normalizedDoUntilConditionLines(raw))
case .fallauswahl:
return withLeadingKeyword("switch", raw)
default:
return raw
}
}

/// Entfernt ein führendes `until` aus gespeicherten Diagrammen, damit die Anzeige einheitlich `while …` nutzt (wie Java `do {} while`).
public static func normalizedDoUntilConditionLines(_ raw: [String]) -> [String] {
guard let first = raw.first else { return raw }
let t = first.trimmingCharacters(in: .whitespacesAndNewlines)
let lower = t.lowercased()
if lower == "until" {
var o = raw
o[0] = ""
return o
}
if lower.hasPrefix("until ") {
var o = raw
o[0] = String(t.dropFirst(6)).trimmingCharacters(in: .whitespacesAndNewlines)
return o
}
if lower.hasPrefix("until(") {
var o = raw
o[0] = String(t.dropFirst(5))
return o
}
return raw
}

/// Teilt eine Anzeigezeile in fett gedrucktes Schlüsselwort und Rest (`while i < 3` → `while`, `i < 3`).
public static func keywordSplit(_ line: String) -> (keyword: String, rest: String)? {
let keywords: Set<String> = ["if", "for", "while", "until", "switch"]
let colonKeywords: [String] = ["input:", "output:", "print:"]
let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines)

let lower = trimmed.lowercased()
for prefix in colonKeywords {
if lower == prefix {
return (trimmed, "")
}
if lower.hasPrefix(prefix + " ") {
let rest = String(trimmed.dropFirst(prefix.count)).trimmingCharacters(in: .whitespacesAndNewlines)
let kw = String(trimmed.prefix(prefix.count))
return (kw, rest)
}
}

guard let firstSpace = trimmed.firstIndex(of: " ") else { return nil }
let head = String(trimmed[..<firstSpace])
guard keywords.contains(head.lowercased()) else { return nil }
let rest = String(trimmed[trimmed.index(after: firstSpace)...]).trimmingCharacters(in: .whitespacesAndNewlines)
return (head, rest)
}

private static func withLeadingKeyword(_ keyword: String, _ lines: [String]) -> [String] {
guard let first = lines.first else { return lines }
let trimmed = first.trimmingCharacters(in: .whitespacesAndNewlines)
let lower = trimmed.lowercased()
let k = keyword.lowercased()
if lower == k || lower.hasPrefix(k + " ") { return lines }
var out = lines
out[0] = keyword + " " + trimmed
return out
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import XCTest
import VisuStructCore

final class DiagramTextTests: XCTestCase {
func testKeywordsAreAddedForControlStructures() {
XCTAssertEqual(VisuStructDiagramText.displayLines(for: VisuStructElement(type: .verzweigung, textLines: ["a > 3"])), ["if a > 3"])
XCTAssertEqual(VisuStructDiagramText.displayLines(for: VisuStructElement(type: .verzweigung, textLines: ["if a > 3"])), ["if a > 3"])
XCTAssertEqual(VisuStructDiagramText.displayLines(for: VisuStructElement(type: .whileSchleife, textLines: ["i < 3"])), ["while i < 3"])
XCTAssertEqual(VisuStructDiagramText.displayLines(for: VisuStructElement(type: .doUntilSchleife, textLines: ["until x == 1"])), ["while x == 1"])
XCTAssertEqual(VisuStructDiagramText.displayLines(for: VisuStructElement(type: .fallauswahl, textLines: ["day"])), ["switch day"])
XCTAssertEqual(VisuStructDiagramText.displayLines(for: VisuStructElement(type: .anweisung, textLines: ["x = 1"])), ["x = 1"])
}

func testEmptyElementsHaveNoDisplayLines() {
XCTAssertNil(VisuStructDiagramText.displayLines(for: VisuStructElement(type: .anweisung, textLines: [" "])))
XCTAssertNil(VisuStructDiagramText.displayLines(for: VisuStructElement(type: .forSchleife, textLines: ["", "", ""])))
}

func testForLoopIsShownOnOneLine() {
let lines = VisuStructDiagramText.displayLines(for: VisuStructElement(type: .forSchleife, textLines: ["int i = 0", "i < 3", "i++"]))
XCTAssertEqual(lines?.count, 1)
}

func testKeywordSplit() {
XCTAssertEqual(VisuStructDiagramText.keywordSplit("while i < 3")?.keyword, "while")
XCTAssertEqual(VisuStructDiagramText.keywordSplit("while i < 3")?.rest, "i < 3")
XCTAssertEqual(VisuStructDiagramText.keywordSplit("print: x")?.keyword, "print:")
XCTAssertEqual(VisuStructDiagramText.keywordSplit("print: x")?.rest, "x")
XCTAssertNil(VisuStructDiagramText.keywordSplit("x = 1"))
}
}
Loading
Loading