diff options
| author | Ruben Beltran del Rio <git@r.bdr.sh> | 2024-09-16 11:10:32 +0200 |
|---|---|---|
| committer | Ruben Beltran del Rio <git@r.bdr.sh> | 2024-09-16 11:10:32 +0200 |
| commit | e2c37ac1dd2ad562e3d619d39b72a174a2212b67 (patch) | |
| tree | d4da2c0a12db0473eb4030014a92045eebe38834 /Map/Presentation | |
| parent | fdb4633d3e9158e457d57e820df6e1efb4df39c2 (diff) | |
Map 3 first commit: files, groups and layout
Diffstat (limited to 'Map/Presentation')
23 files changed, 605 insertions, 355 deletions
diff --git a/Map/Presentation/Base Components/EvolutionPicker.swift b/Map/Presentation/Base Components/EvolutionPicker.swift index c68e90c..c30798a 100644 --- a/Map/Presentation/Base Components/EvolutionPicker.swift +++ b/Map/Presentation/Base Components/EvolutionPicker.swift @@ -2,40 +2,30 @@ import SwiftUI struct EvolutionPicker: View { - @EnvironmentObject private var store: AppStore - - private var selectedEvolution: Binding<StageType> { - Binding( - get: { store.state.selectedEvolution }, - set: { evolution in - store.send(.selectEvolution(evolution: evolution)) - } - ) - } + @Binding var selectedEvolution: StageType var body: some View { - Picker("Evolution", selection: selectedEvolution) { + Picker("Evolution", selection: $selectedEvolution) { ForEach(StageType.types) { stage in - Text(Stage.title(stage)).tag(stage).padding(4.0) + Text(Stage.title(stage)).font(.theme.body).tag(stage).padding(4.0) } Divider() ForEach(StageType.characteristics) { stage in - Text(Stage.title(stage)).tag(stage).padding(4.0) + Text(Stage.title(stage)).font(.theme.body).tag(stage).padding(4.0).font(.theme.body) } Divider() ForEach(StageType.properties) { stage in - Text(Stage.title(stage)).tag(stage).padding(4.0) + Text(Stage.title(stage)).font(.theme.body).tag(stage).padding(4.0) } Divider() ForEach(StageType.custom) { stage in - Text(Stage.title(stage)).tag(stage).padding(4.0) + Text(Stage.title(stage)).font(.theme.body).tag(stage).padding(4.0) } - }.padding(.horizontal, 8.0).padding(.vertical, 4.0) + }.font(.theme.body).padding(.horizontal, 8.0).padding(.vertical, 4.0) } } -struct EvolutionPicker_Previews: PreviewProvider { - static var previews: some View { - EvolutionPicker() - } +#Preview { + let selectedEvolution: StageType = .behavior + EvolutionPicker(selectedEvolution: .constant(selectedEvolution)) } diff --git a/Map/Presentation/Base Components/MapRender/MapAxes.swift b/Map/Presentation/Base Components/MapRender/MapAxes.swift index 6ba758f..1b9c226 100644 --- a/Map/Presentation/Base Components/MapRender/MapAxes.swift +++ b/Map/Presentation/Base Components/MapRender/MapAxes.swift @@ -22,10 +22,14 @@ struct MapAxes: View { }.stroke(Color.map.axisColor, lineWidth: lineWidth * 2) // Y Labels - Text("Visible").font(.theme.axisLabel).foregroundColor(.map.labelColor).rotationEffect(Angle(degrees: -90.0)) - .offset(CGSize(width: -35.0, height: 0.0)) - Text("Invisible").font(.theme.axisLabel).foregroundColor(.map.labelColor).rotationEffect(Angle(degrees: -90.0)) - .offset(CGSize(width: -40.0, height: mapSize.height - 20)) + Text("Visible").font(.theme.axisLabel).foregroundColor(.map.labelColor).rotationEffect( + Angle(degrees: -90.0) + ) + .offset(CGSize(width: -35.0, height: 0.0)) + Text("Invisible").font(.theme.axisLabel).foregroundColor(.map.labelColor).rotationEffect( + Angle(degrees: -90.0) + ) + .offset(CGSize(width: -40.0, height: mapSize.height - 20)) // X Labels @@ -71,11 +75,9 @@ struct MapAxes: View { } } -struct MapAxes_Previews: PreviewProvider { - static var previews: some View { - MapAxes( - mapSize: CGSize(width: 200.0, height: 200.0), lineWidth: CGFloat(1.0), - evolution: Stage.stages(.general), stages: [25.0, 50.0, 75.0] - ).padding(50.0) - } +#Preview { + MapAxes( + mapSize: CGSize(width: 200.0, height: 200.0), lineWidth: CGFloat(1.0), + evolution: Stage.stages(.general), stages: [25.0, 50.0, 75.0] + ).padding(50.0) } diff --git a/Map/Presentation/Base Components/MapRender/MapBlockers.swift b/Map/Presentation/Base Components/MapRender/MapBlockers.swift index d943ba8..efd84fa 100644 --- a/Map/Presentation/Base Components/MapRender/MapBlockers.swift +++ b/Map/Presentation/Base Components/MapRender/MapBlockers.swift @@ -31,13 +31,11 @@ struct MapBlockers: View { } } -struct MapBlockers_Previews: PreviewProvider { - static var previews: some View { - MapBlockers( - mapSize: CGSize(width: 400.0, height: 400.0), vertexSize: CGSize(width: 25.0, height: 25.0), - blockers: [ - Blocker(id: 0, position: CGPoint(x: 50.0, y: 50.0)), - Blocker(id: 1, position: CGPoint(x: 10.0, y: 20.0)), - ]) - } +#Preview { + MapBlockers( + mapSize: CGSize(width: 400.0, height: 400.0), vertexSize: CGSize(width: 25.0, height: 25.0), + blockers: [ + Blocker(id: 0, position: CGPoint(x: 50.0, y: 50.0)), + Blocker(id: 1, position: CGPoint(x: 10.0, y: 20.0)), + ]) } diff --git a/Map/Presentation/Base Components/MapRender/MapEdges.swift b/Map/Presentation/Base Components/MapRender/MapEdges.swift index 3814495..2af7089 100644 --- a/Map/Presentation/Base Components/MapRender/MapEdges.swift +++ b/Map/Presentation/Base Components/MapRender/MapEdges.swift @@ -65,15 +65,13 @@ struct MapEdges: View { } } -struct MapEdges_Previews: PreviewProvider { - static var previews: some View { - MapEdges( - mapSize: CGSize(width: 400.0, height: 400.0), lineWidth: 1.0, - vertexSize: CGSize(width: 25.0, height: 25.0), - edges: [ - MapEdge( - id: 1, origin: CGPoint(x: 2.0, y: 34.0), destination: CGPoint(x: 23.0, y: 76.2), - arrowhead: true) - ]) - } +#Preview { + MapEdges( + mapSize: CGSize(width: 400.0, height: 400.0), lineWidth: 1.0, + vertexSize: CGSize(width: 25.0, height: 25.0), + edges: [ + MapEdge( + id: 1, origin: CGPoint(x: 2.0, y: 34.0), destination: CGPoint(x: 23.0, y: 76.2), + arrowhead: true) + ]) } diff --git a/Map/Presentation/Base Components/MapRender/MapGroup.swift b/Map/Presentation/Base Components/MapRender/MapGroup.swift new file mode 100644 index 0000000..9df3338 --- /dev/null +++ b/Map/Presentation/Base Components/MapRender/MapGroup.swift @@ -0,0 +1,83 @@ +import ConcaveHull +import SwiftUI + +struct MapGroup: View { + + let mapSize: CGSize + let vertexSize: CGSize + let group: [Vertex] + let color: Color + + let cornerSize = CGSize(width: 2.0, height: 2.0) + var strokeSize: CGFloat { 1.75 * vertexSize.width } + + var hull: [CGPoint] { + let groupList = group.map({ vertex in + return [Double(vertex.position.x), Double(vertex.position.y)] + }) + let hull = Hull() + let hullPoints = hull.hull(groupList, nil) + return hullPoints.compactMap({ object in + if let point = object as? [Double] { + return CGPoint(x: point[0], y: point[1]) + } + return nil + }) + } + + var body: some View { + Path { path in + var initialMove: CGPoint? + + for point in hull { + let offsetPoint = CGPoint(x: w(point.x), y: h(point.y)) + + if initialMove == nil { + path.move(to: offsetPoint) + initialMove = offsetPoint + } else { + path.addLine(to: offsetPoint) + } + } + + if let initialMove = initialMove { + path.addLine(to: initialMove) + } + + } + .applying( + CGAffineTransform(translationX: vertexSize.width / 2.0, y: vertexSize.height / 2.0) + ) + .fill(color) + .stroke( + color, + style: StrokeStyle( + lineWidth: strokeSize, + lineCap: .round, + lineJoin: .round, + miterLimit: 0, + dash: [], + dashPhase: 0 + ) + ) + } + + func h(_ dimension: CGFloat) -> CGFloat { + max(0.0, min(mapSize.height, dimension * mapSize.height / 100.0)) + } + + func w(_ dimension: CGFloat) -> CGFloat { + max(0.0, min(mapSize.width, dimension * mapSize.width / 100.0)) + } +} + +#Preview { + MapGroup( + mapSize: CGSize(width: 400.0, height: 400.0), vertexSize: CGSize(width: 25.0, height: 25.0), + group: [ + Vertex(id: 0, label: "A Circle", position: CGPoint(x: 50.0, y: 50.0)), + Vertex(id: 1, label: "A Square", position: CGPoint(x: 10.0, y: 20.0), shape: .square), + Vertex(id: 2, label: "A triangle", position: CGPoint(x: 25, y: 32.0), shape: .triangle), + Vertex(id: 3, label: "An X", position: CGPoint(x: 70.0, y: 70.0), shape: .x), + ], color: .red) +} diff --git a/Map/Presentation/Base Components/MapRender/MapGroups.swift b/Map/Presentation/Base Components/MapRender/MapGroups.swift new file mode 100644 index 0000000..84f7fb2 --- /dev/null +++ b/Map/Presentation/Base Components/MapRender/MapGroups.swift @@ -0,0 +1,32 @@ +import ConcaveHull +import SwiftUI + +struct MapGroups: View { + + let mapSize: CGSize + let vertexSize: CGSize + let groups: [[Vertex]] + + var body: some View { + ForEach(Array(groups.enumerated()), id: \.element) { index, group in + MapGroup(mapSize: mapSize, vertexSize: vertexSize, group: group, color: color(index)) + } + } + + private func color(_ index: Int) -> Color { + return .map.groupColors[index % Color.map.groupColors.count] + } +} + +#Preview { + MapGroups( + mapSize: CGSize(width: 400.0, height: 400.0), vertexSize: CGSize(width: 25.0, height: 25.0), + groups: [ + [ + Vertex(id: 0, label: "A Circle", position: CGPoint(x: 50.0, y: 50.0)), + Vertex(id: 1, label: "A Square", position: CGPoint(x: 10.0, y: 20.0), shape: .square), + Vertex(id: 2, label: "A triangle", position: CGPoint(x: 25, y: 32.0), shape: .triangle), + Vertex(id: 3, label: "An X", position: CGPoint(x: 70.0, y: 70.0), shape: .x), + ] + ]) +} diff --git a/Map/Presentation/Base Components/MapRender/MapNotes.swift b/Map/Presentation/Base Components/MapRender/MapNotes.swift index f35b3fe..bbb1aba 100644 --- a/Map/Presentation/Base Components/MapRender/MapNotes.swift +++ b/Map/Presentation/Base Components/MapRender/MapNotes.swift @@ -5,13 +5,12 @@ struct MapNotes: View { let mapSize: CGSize let lineWidth: CGFloat let notes: [Note] - + let maxWidth = 400.0 - var body: some View { ForEach(notes, id: \.id) { note in - Text(note.text.replacingOccurrences(of: "\\n", with: "\n")).font(.theme.axisLabel) + Text(note.text.replacingOccurrences(of: "\\n", with: "\n")).font(.theme.note) .padding(2.0) .background(.white) .foregroundColor(.map.labelColor) @@ -35,12 +34,13 @@ struct MapNotes: View { } } -struct MapNotes_Previews: PreviewProvider { - static var previews: some View { - MapNotes( - mapSize: CGSize(width: 400.0, height: 400.0), lineWidth: 1.0, - notes: [ - Note(id: 0, position: CGPoint(x: 50.0, y: 50.0), text: "Notes can have a lot more text, so we need to make sure that they're resized correctly"), - ]) - } +#Preview { + MapNotes( + mapSize: CGSize(width: 400.0, height: 400.0), lineWidth: 1.0, + notes: [ + Note( + id: 0, position: CGPoint(x: 50.0, y: 50.0), + text: + "Notes can have a lot more text, so we need to make sure that they're resized correctly") + ]) } diff --git a/Map/Presentation/Base Components/MapRender/MapOpportunities.swift b/Map/Presentation/Base Components/MapRender/MapOpportunities.swift index 7fcadff..b3051ee 100644 --- a/Map/Presentation/Base Components/MapRender/MapOpportunities.swift +++ b/Map/Presentation/Base Components/MapRender/MapOpportunities.swift @@ -46,7 +46,8 @@ struct MapOpportunities: View { path.closeSubpath() }.applying( CGAffineTransform(translationX: vertexSize.width / 2.0, y: vertexSize.height / 2.0) - ).strokedPath(StrokeStyle(lineWidth: lineWidth / 4, dash: [10.0])).stroke(Color.map.opportunityColor) + ).strokedPath(StrokeStyle(lineWidth: lineWidth / 4, dash: [10.0])).stroke( + Color.map.opportunityColor) } } @@ -59,13 +60,11 @@ struct MapOpportunities: View { } } -struct MapOpportunities_Previews: PreviewProvider { - static var previews: some View { - MapOpportunities( - mapSize: CGSize(width: 400.0, height: 400.0), lineWidth: 1.0, - vertexSize: CGSize(width: 25.0, height: 25.0), - opportunities: [ - Opportunity(id: 1, origin: CGPoint(x: 2.0, y: 34.0), destination: CGPoint(x: 23.0, y: 76.2)) - ]) - } +#Preview { + MapOpportunities( + mapSize: CGSize(width: 400.0, height: 400.0), lineWidth: 1.0, + vertexSize: CGSize(width: 25.0, height: 25.0), + opportunities: [ + Opportunity(id: 1, origin: CGPoint(x: 2.0, y: 34.0), destination: CGPoint(x: 23.0, y: 76.2)) + ]) } diff --git a/Map/Presentation/Base Components/MapRender/MapStages.swift b/Map/Presentation/Base Components/MapRender/MapStages.swift index 0fc8f48..fc3bfa1 100644 --- a/Map/Presentation/Base Components/MapRender/MapStages.swift +++ b/Map/Presentation/Base Components/MapRender/MapStages.swift @@ -1,5 +1,5 @@ -import SwiftUI import Patterns +import SwiftUI struct MapStages: View { @@ -10,17 +10,29 @@ struct MapStages: View { var body: some View { ZStack(alignment: .topLeading) { - PatternView(design: .constant(.stitch), pixelSize: 1.0, foregroundColor: .map.stageForeground, backgroundColor: .map.stageBackground) - .frame(width: w(stages[0]), height: mapSize.height) - PatternView(design: .constant(.shingles), pixelSize: 1.0, foregroundColor: .map.stageForeground, backgroundColor: .map.stageBackground) - .offset(CGSize(width: w(stages[0]), height: 0)) - .frame(width: w(stages[1]) - w(stages[0]), height: mapSize.height) - PatternView(design: .constant(.shadowGrid), pixelSize: 1.0, foregroundColor: .map.stageForeground, backgroundColor: .map.stageBackground) - .offset(CGSize(width: w(stages[1]), height: 0)) - .frame(width: w(stages[2]) - w(stages[1]), height: mapSize.height) - PatternView(design: .constant(.wicker), pixelSize: 1.0, foregroundColor: .map.stageForeground, backgroundColor: .map.stageBackground) - .offset(CGSize(width: w(stages[2]), height: 0)) - .frame(width: mapSize.width - w(stages[2]), height: mapSize.height) + PatternView( + design: .constant(.stitch), pixelSize: 1.0, foregroundColor: .map.stageForeground, + backgroundColor: .map.stageBackground + ) + .frame(width: w(stages[0]), height: mapSize.height) + PatternView( + design: .constant(.shingles), pixelSize: 1.0, foregroundColor: .map.stageForeground, + backgroundColor: .map.stageBackground + ) + .offset(CGSize(width: w(stages[0]), height: 0)) + .frame(width: w(stages[1]) - w(stages[0]), height: mapSize.height) + PatternView( + design: .constant(.shadowGrid), pixelSize: 1.0, foregroundColor: .map.stageForeground, + backgroundColor: .map.stageBackground + ) + .offset(CGSize(width: w(stages[1]), height: 0)) + .frame(width: w(stages[2]) - w(stages[1]), height: mapSize.height) + PatternView( + design: .constant(.wicker), pixelSize: 1.0, foregroundColor: .map.stageForeground, + backgroundColor: .map.stageBackground + ) + .offset(CGSize(width: w(stages[2]), height: 0)) + .frame(width: mapSize.width - w(stages[2]), height: mapSize.height) Path { path in path.move(to: CGPoint(x: w(stages[0]), y: 0)) @@ -34,7 +46,8 @@ struct MapStages: View { path.closeSubpath() path.move(to: CGPoint(x: w(stages[0]), y: 0)) path.closeSubpath() - }.strokedPath(StrokeStyle(lineWidth: lineWidth / 4, dash: [10.0, 18.0])).stroke(Color.map.axisColor) + }.strokedPath(StrokeStyle(lineWidth: lineWidth / 4, dash: [10.0, 18.0])).stroke( + Color.map.axisColor) } } @@ -43,10 +56,8 @@ struct MapStages: View { } } -struct MapStages_Previews: PreviewProvider { - static var previews: some View { - MapStages( - mapSize: CGSize(width: 200.0, height: 200.0), lineWidth: CGFloat(0.5), - stages: [25.0, 50.0, 75.0]) - } +#Preview { + MapStages( + mapSize: CGSize(width: 200.0, height: 200.0), lineWidth: CGFloat(0.5), + stages: [25.0, 50.0, 75.0]) } diff --git a/Map/Presentation/Base Components/MapRender/MapVertices.swift b/Map/Presentation/Base Components/MapRender/MapVertices.swift index 74cac6d..5bc0a96 100644 --- a/Map/Presentation/Base Components/MapRender/MapVertices.swift +++ b/Map/Presentation/Base Components/MapRender/MapVertices.swift @@ -7,18 +7,29 @@ struct MapVertices: View { let vertices: [Vertex] let padding = CGFloat(5.0) + var onDragVertex: (Vertex, CGFloat, CGFloat) -> Void = { _, _, _ in } + var body: some View { ZStack(alignment: .topLeading) { ForEach(vertices, id: \.id) { vertex in - getVertexShape(vertex).fill(Color.map.vertexColor) - Text(vertex.label.replacingOccurrences(of: "\\n", with: "\n")).font(.theme.vertexLabel) + ZStack(alignment: .topLeading) { + getVertexShape(vertex).fill(Color.map.vertexColor) + Text(vertex.label.replacingOccurrences(of: "\\n", with: "\n")).font(.theme.vertexLabel) .foregroundColor(.map.labelColor) .shadow(color: .white, radius: 0, x: -0.5, y: -0.5) .shadow(color: .white, radius: 0, x: 0.5, y: 0.5) .offset( - CGSize( - width: w(vertex.position.x) + vertexSize.width + padding, - height: h(vertex.position.y) + 7.0)) + CGSize( + width: w(vertex.position.x) + vertexSize.width + padding, + height: h(vertex.position.y) + 7.0)) + }.gesture( + DragGesture() + .onChanged { value in + let deltaX = value.startLocation.x - value.location.x + let deltaY = value.startLocation.y - value.location.y + onDragVertex(vertex, deltaX, deltaY) + } + ) } } } @@ -78,15 +89,13 @@ struct MapVertices: View { } } -struct MapVertices_Previews: PreviewProvider { - static var previews: some View { - MapVertices( - mapSize: CGSize(width: 400.0, height: 400.0), vertexSize: CGSize(width: 25.0, height: 25.0), - vertices: [ - Vertex(id: 0, label: "A Circle", position: CGPoint(x: 50.0, y: 50.0)), - Vertex(id: 1, label: "A Square", position: CGPoint(x: 10.0, y: 20.0), shape: .square), - Vertex(id: 2, label: "A triangle", position: CGPoint(x: 25, y: 32.0), shape: .triangle), - Vertex(id: 3, label: "An X", position: CGPoint(x: 70.0, y: 70.0), shape: .x), - ]) - } +#Preview { + MapVertices( + mapSize: CGSize(width: 400.0, height: 400.0), vertexSize: CGSize(width: 25.0, height: 25.0), + vertices: [ + Vertex(id: 0, label: "A Circle", position: CGPoint(x: 50.0, y: 50.0)), + Vertex(id: 1, label: "A Square", position: CGPoint(x: 10.0, y: 20.0), shape: .square), + Vertex(id: 2, label: "A triangle", position: CGPoint(x: 25, y: 32.0), shape: .triangle), + Vertex(id: 3, label: "An X", position: CGPoint(x: 70.0, y: 70.0), shape: .x), + ]) } diff --git a/Map/Presentation/Base Components/MapTextEditor.swift b/Map/Presentation/Base Components/MapTextEditor.swift index f3838a6..d7aa9f8 100644 --- a/Map/Presentation/Base Components/MapTextEditor.swift +++ b/Map/Presentation/Base Components/MapTextEditor.swift @@ -3,7 +3,7 @@ import SwiftUI class MapTextEditorController: NSViewController { - @Binding var text: String + @Binding var document: MapDocument let onChange: () -> Void private let vertexRegex = MapParsingPatterns.vertex @@ -12,11 +12,12 @@ class MapTextEditorController: NSViewController { private let opportunityRegex = MapParsingPatterns.opportunity private let noteRegex = MapParsingPatterns.note private let stageRegex = MapParsingPatterns.stage + private let groupRegex = MapParsingPatterns.group private let changeDebouncer: Debouncer = Debouncer(seconds: 1) - init(text: Binding<String>, onChange: @escaping () -> Void) { - self._text = text + init(document: Binding<MapDocument>, onChange: @escaping () -> Void) { + self._document = document self.onChange = onChange super.init(nibName: nil, bundle: nil) } @@ -31,10 +32,11 @@ class MapTextEditorController: NSViewController { scrollView.translatesAutoresizingMaskIntoConstraints = false + textView.backgroundColor = .ui.background textView.allowsUndo = true textView.delegate = self textView.textStorage?.delegate = self - textView.string = self.text + textView.string = self.document.text textView.isEditable = true textView.font = .monospacedSystemFont(ofSize: 16.0, weight: .regular) self.view = scrollView @@ -49,14 +51,13 @@ extension MapTextEditorController: NSTextViewDelegate { func textDidChange(_ obj: Notification) { if let textField = obj.object as? NSTextView { - self.text = textField.string - - - changeDebouncer.debounce { - DispatchQueue.main.async { - self.onChange() - } + self.document.text = textField.string + + changeDebouncer.debounce { + DispatchQueue.main.async { + self.onChange() } + } } } @@ -86,65 +87,91 @@ extension MapTextEditorController: NSTextStorageDelegate { var matches = vertexRegex.matches(in: textStorage.string, options: [], range: range) for match in matches { - textStorage.addAttributes([.foregroundColor: NSColor.syntax.vertex], range: match.range(at: 1)) - textStorage.addAttributes([.foregroundColor: NSColor.syntax.number], range: match.range(at: 2)) - textStorage.addAttributes([.foregroundColor: NSColor.syntax.number], range: match.range(at: 3)) - textStorage.addAttributes([.foregroundColor: NSColor.syntax.option], range: match.range(at: 4)) + textStorage.addAttributes( + [.foregroundColor: NSColor.syntax.vertex], range: match.range(at: 1)) + textStorage.addAttributes( + [.foregroundColor: NSColor.syntax.number], range: match.range(at: 2)) + textStorage.addAttributes( + [.foregroundColor: NSColor.syntax.number], range: match.range(at: 3)) + textStorage.addAttributes( + [.foregroundColor: NSColor.syntax.option], range: match.range(at: 4)) } matches = edgeRegex.matches(in: textStorage.string, options: [], range: range) for match in matches { - textStorage.addAttributes([.foregroundColor: NSColor.syntax.vertex], range: match.range(at: 1)) + textStorage.addAttributes( + [.foregroundColor: NSColor.syntax.vertex], range: match.range(at: 1)) let arrowRange = match.range(at: 2) textStorage.addAttributes( [.foregroundColor: NSColor.syntax.symbol], range: NSMakeRange(arrowRange.lowerBound - 1, arrowRange.length + 1)) - textStorage.addAttributes([.foregroundColor: NSColor.syntax.vertex], range: match.range(at: 3)) + textStorage.addAttributes( + [.foregroundColor: NSColor.syntax.vertex], range: match.range(at: 3)) } matches = opportunityRegex.matches(in: textStorage.string, options: [], range: range) for match in matches { - textStorage.addAttributes([.foregroundColor: NSColor.syntax.option], range: match.range(at: 1)) - textStorage.addAttributes([.foregroundColor: NSColor.syntax.vertex], range: match.range(at: 2)) - textStorage.addAttributes([.foregroundColor: NSColor.syntax.symbol], range: match.range(at: 3)) - textStorage.addAttributes([.foregroundColor: NSColor.syntax.number], range: match.range(at: 4)) + textStorage.addAttributes( + [.foregroundColor: NSColor.syntax.option], range: match.range(at: 1)) + textStorage.addAttributes( + [.foregroundColor: NSColor.syntax.vertex], range: match.range(at: 2)) + textStorage.addAttributes( + [.foregroundColor: NSColor.syntax.symbol], range: match.range(at: 3)) + textStorage.addAttributes( + [.foregroundColor: NSColor.syntax.number], range: match.range(at: 4)) } matches = blockerRegex.matches(in: textStorage.string, options: [], range: range) for match in matches { - textStorage.addAttributes([.foregroundColor: NSColor.syntax.option], range: match.range(at: 1)) - textStorage.addAttributes([.foregroundColor: NSColor.syntax.vertex], range: match.range(at: 2)) + textStorage.addAttributes( + [.foregroundColor: NSColor.syntax.option], range: match.range(at: 1)) + textStorage.addAttributes( + [.foregroundColor: NSColor.syntax.vertex], range: match.range(at: 2)) } - + matches = noteRegex.matches(in: textStorage.string, options: [], range: range) for match in matches { - textStorage.addAttributes([.foregroundColor: NSColor.syntax.option], range: match.range(at: 1)) - textStorage.addAttributes([.foregroundColor: NSColor.syntax.number], range: match.range(at: 2)) - textStorage.addAttributes([.foregroundColor: NSColor.syntax.number], range: match.range(at: 3)) + textStorage.addAttributes( + [.foregroundColor: NSColor.syntax.option], range: match.range(at: 1)) + textStorage.addAttributes( + [.foregroundColor: NSColor.syntax.number], range: match.range(at: 2)) + textStorage.addAttributes( + [.foregroundColor: NSColor.syntax.number], range: match.range(at: 3)) } matches = stageRegex.matches(in: textStorage.string, options: [], range: range) for match in matches { - textStorage.addAttributes([.foregroundColor: NSColor.syntax.option], range: match.range(at: 1)) - textStorage.addAttributes([.foregroundColor: NSColor.syntax.number], range: match.range(at: 2)) + textStorage.addAttributes( + [.foregroundColor: NSColor.syntax.option], range: match.range(at: 1)) + textStorage.addAttributes( + [.foregroundColor: NSColor.syntax.number], range: match.range(at: 2)) + } + + matches = groupRegex.matches(in: textStorage.string, options: [], range: range) + + for match in matches { + textStorage.addAttributes( + [.foregroundColor: NSColor.syntax.option], range: match.range(at: 1)) + textStorage.addAttributes( + [.foregroundColor: NSColor.syntax.vertex], range: match.range(at: 2)) } } } struct MapTextEditor: NSViewControllerRepresentable { - @Binding var text: String + @Binding var document: MapDocument var onChange: () -> Void = {} func makeNSViewController( context: NSViewControllerRepresentableContext<MapTextEditor> ) -> MapTextEditorController { - return MapTextEditorController(text: $text, onChange: onChange) + return MapTextEditorController(document: $document, onChange: onChange) } func updateNSViewController( diff --git a/Map/Presentation/Commands/MapCommands.swift b/Map/Presentation/Commands/MapCommands.swift new file mode 100644 index 0000000..32b4958 --- /dev/null +++ b/Map/Presentation/Commands/MapCommands.swift @@ -0,0 +1,44 @@ +import SwiftUI + +struct MapCommands: Commands { + + @AppStorage("viewStyle") var viewStyle: ViewStyle = .horizontal + @AppStorage("zoom") var zoom = 1.0 + + var body: some Commands { + + // View + + CommandGroup(after: CommandGroupPlacement.toolbar) { + if viewStyle == .horizontal { + Button("Use Vertical Layout") { + viewStyle = .vertical + }.keyboardShortcut( + "l", modifiers: EventModifiers([.command]) + ) + } else { + Button("Use Horizontal Layout") { + viewStyle = .horizontal + }.keyboardShortcut( + "l", modifiers: EventModifiers([.command]) + ) + } + Divider() + Button("Zoom In") { + zoom = min(Constants.kMaxZoom, zoom + 0.1) + }.keyboardShortcut( + "+", modifiers: EventModifiers([.command]) + ) + Button("Zoom Out") { + zoom = max(Constants.kMinZoom, zoom - 0.1) + }.keyboardShortcut( + "-", modifiers: EventModifiers([.command]) + ) + Divider() + } + + CommandGroup(replacing: CommandGroupPlacement.help) { + Button("Map Help") { NSWorkspace.shared.open(URL(string: "https://map.tranquil.systems")!) } + } + } +} diff --git a/Map/Presentation/Complex Components/MapRender/MapRenderView.swift b/Map/Presentation/Complex Components/MapRender/MapRenderView.swift index b12dabc..b256861 100644 --- a/Map/Presentation/Complex Components/MapRender/MapRenderView.swift +++ b/Map/Presentation/Complex Components/MapRender/MapRenderView.swift @@ -5,20 +5,24 @@ import SwiftUI struct MapRenderView: View { - @Binding var content: String + @Binding var document: MapDocument @Binding var evolution: StageType - + var stage: Stage { Stage.stages(evolution) } - @State var parsedMap: ParsedMap = ParsedMap.empty + var parsedMap: ParsedMap { + MapParser.parse(content: document.text) + } - let mapSize = CGSize(width: 1300.0, height: 1000.0) + let mapSize = Dimensions.mapSize + let padding = Dimensions.mapPadding let lineWidth = CGFloat(0.5) let vertexSize = CGSize(width: 25.0, height: 25.0) - let padding = CGFloat(30.0) + + var onDragVertex: (Vertex, CGFloat, CGFloat) -> Void = { _, _, _ in } var body: some View { ZStack(alignment: .topLeading) { @@ -36,28 +40,27 @@ struct MapRenderView: View { MapEdges( mapSize: mapSize, lineWidth: lineWidth, vertexSize: vertexSize, edges: parsedMap.edges) MapBlockers(mapSize: mapSize, vertexSize: vertexSize, blockers: parsedMap.blockers) - MapVertices(mapSize: mapSize, vertexSize: vertexSize, vertices: parsedMap.vertices) + MapVertices( + mapSize: mapSize, vertexSize: vertexSize, vertices: parsedMap.vertices, + onDragVertex: onDragVertex) MapOpportunities( mapSize: mapSize, lineWidth: lineWidth, vertexSize: vertexSize, opportunities: parsedMap.opportunities) + MapGroups(mapSize: mapSize, vertexSize: vertexSize, groups: parsedMap.groups).drawingGroup( + opaque: true + ).opacity(0.1) MapNotes( mapSize: mapSize, lineWidth: lineWidth, notes: parsedMap.notes) - }.frame( - width: mapSize.width, + }.offset(x: padding, y: padding).frame( + width: mapSize.width + 2 * padding, height: mapSize.height + 2 * padding, alignment: .topLeading - ).onAppear { - self.parsedMap = Map.parse(content: content) - }.padding(padding).onChange(of: content) { newState in - self.parsedMap = Map.parse(content: newState) - } + ) } } -struct MapRenderView_Previews: PreviewProvider { - static var previews: some View { - MapRenderView( - content: Binding.constant(""), evolution: Binding.constant(StageType.general) - ).environment( - \.managedObjectContext, PersistenceController.preview.container.viewContext) - } +#Preview { + MapRenderView( + document: Binding.constant(MapDocument(text: "")), + evolution: Binding.constant(StageType.general) + ) } diff --git a/Map/Presentation/EvolutionPicker.swift b/Map/Presentation/EvolutionPicker.swift new file mode 100644 index 0000000..0f4c954 --- /dev/null +++ b/Map/Presentation/EvolutionPicker.swift @@ -0,0 +1,41 @@ +import SwiftUI + +struct EvolutionPicker: View { + + @EnvironmentObject private var store: AppStore + + private var selectedEvolution: Binding<StageType> { + Binding( + get: { store.state.selectedEvolution }, + set: { evolution in + store.send(.selectEvolution(evolution: evolution)) + } + ) + } + + var body: some View { + Picker("Evolution", selection: selectedEvolution) { + ForEach(StageType.types) { stage in + Text(Stage.title(stage)).font(.theme.body).tag(stage).padding(4.0) + } + Divider() + ForEach(StageType.characteristics) { stage in + Text(Stage.title(stage)).font(.theme.body).tag(stage).padding(4.0).font(.theme.body) + } + Divider() + ForEach(StageType.properties) { stage in + Text(Stage.title(stage)).font(.theme.body).tag(stage).padding(4.0) + } + Divider() + ForEach(StageType.custom) { stage in + Text(Stage.title(stage)).font(.theme.body).tag(stage).padding(4.0) + } + }.font(.theme.body).padding(.horizontal, 8.0).padding(.vertical, 4.0) + } +} + +struct EvolutionPicker_Previews: PreviewProvider { + static var previews: some View { + EvolutionPicker() + } +} diff --git a/Map/Presentation/MapEditor.swift b/Map/Presentation/MapEditor.swift new file mode 100644 index 0000000..bf33f75 --- /dev/null +++ b/Map/Presentation/MapEditor.swift @@ -0,0 +1,134 @@ +import SwiftUI + +struct MapEditor: View { + @Binding var document: MapDocument + var url: URL? + @State var selectedEvolution: StageType = .behavior + + @AppStorage("viewStyle") var viewStyle: ViewStyle = .horizontal + + let zoomRange = Constants.kMinZoom...Constants.kMaxZoom + @AppStorage("zoom") var zoom = 1.0 + @State var lastZoom = 1.0 + + var body: some View { + VStack(spacing: 0) { + adaptiveStack { + ZStack(alignment: .topLeading) { + MapTextEditor(document: $document) + .background(Color.ui.background) + .foregroundColor(Color.ui.foreground) + .frame(minHeight: 96.0) + }.padding(.top, 8.0).padding(.leading, 8.0).background(Color.ui.background).cornerRadius( + 5.0) + GeometryReader { geometry in + ScrollView([.horizontal, .vertical]) { + MapRenderView( + document: $document, evolution: $selectedEvolution, onDragVertex: onDragVertex + ).scaleEffect(zoom, anchor: .center).frame( + width: (Dimensions.mapSize.width + 2 * Dimensions.mapPadding) * zoom, + height: (Dimensions.mapSize.height + 2 * Dimensions.mapPadding) * zoom) + }.background(Color.ui.background) + .gesture( + MagnificationGesture() + .onChanged { value in + let delta = value / lastZoom + lastZoom = value + zoom = min(max(zoom * delta, zoomRange.lowerBound), zoomRange.upperBound) + } + .onEnded { _ in + lastZoom = 1.0 + } + ) + } + } + Divider() + HStack { + Spacer() + Slider( + value: $zoom, in: zoomRange, step: 0.1, + label: { + Text(formatZoom(zoom)) + .font(.theme.smallControl) + }, + minimumValueLabel: { + Image(systemName: "minus.magnifyingglass") + .font(.theme.smallControl) + .help("Zoom Out (⌘-)") + }, + maximumValueLabel: { + Image(systemName: "plus.magnifyingglass") + .font(.theme.smallControl) + .help("Zoom In (⌘+)") + } + ).frame(width: 200).padding(.trailing, 10.0) + }.padding(4.0) + }.toolbar { + HStack { + Button(action: saveImage) { + Image(systemName: "photo") + } + .help("Export Image (⌘E)") + .padding(.vertical, 4.0).padding(.leading, 4.0).padding(.trailing, 8.0) + } + EvolutionPicker(selectedEvolution: $selectedEvolution) + } + } + + @ViewBuilder + func adaptiveStack<Content: View>(@ViewBuilder content: () -> Content) -> some View { + if viewStyle == .horizontal { + VSplitView { + content() + } + } else { + HSplitView { + content() + } + } + } + + private func formatZoom(_ number: CGFloat) -> String { + let formatter = NumberFormatter() + formatter.numberStyle = .decimal + formatter.maximumFractionDigits = 1 + formatter.minimumFractionDigits = 1 + return (formatter.string(from: NSNumber(value: number)) ?? "") + "x" + } + + private func onDragVertex(vertex: Vertex, x: CGFloat, y: CGFloat) { + print("Dragging: \(vertex), \(x), \(y)") + } + + private func saveImage() { + if let image = document.exportAsImage(withEvolution: selectedEvolution) { + + let filename = url?.deletingPathExtension().lastPathComponent ?? "Untitled" + + let savePanel = NSSavePanel() + savePanel.allowedContentTypes = [.png] + savePanel.canCreateDirectories = true + savePanel.isExtensionHidden = false + savePanel.title = "Save \(filename) as image" + savePanel.message = "Choose a location to save the image" + savePanel.nameFieldStringValue = "\(filename).png" + savePanel.begin { result in + if result == .OK, let url = savePanel.url { + if let tiffRepresentation = image.tiffRepresentation { + let bitmapImage = NSBitmapImageRep(data: tiffRepresentation) + let pngData = bitmapImage?.representation(using: .png, properties: [:]) + do { + try pngData?.write(to: url) + } catch { + return + } + } + } + } + } + } +} + +#Preview { + MapEditor(document: .constant(MapDocument()), url: URL(filePath: "test.png")!) +} diff --git a/Map/Presentation/Screens/EmptyMapDetailScreen.swift b/Map/Presentation/Screens/EmptyMapDetailScreen.swift deleted file mode 100644 index f3c6d75..0000000 --- a/Map/Presentation/Screens/EmptyMapDetailScreen.swift +++ /dev/null @@ -1,16 +0,0 @@ -import CoreData -import SwiftUI - -struct EmptyMapDetailScreen: View { - - var body: some View { - Text("Select a map from the left hand side, or click on + to create one.") - } -} - -struct DefaultMapView_Previews: PreviewProvider { - static var previews: some View { - EmptyMapDetailScreen().environment( - \.managedObjectContext, PersistenceController.preview.container.viewContext) - } -} diff --git a/Map/Presentation/Screens/MapDetailScreen.swift b/Map/Presentation/Screens/MapDetailScreen.swift deleted file mode 100644 index a5c32fd..0000000 --- a/Map/Presentation/Screens/MapDetailScreen.swift +++ /dev/null @@ -1,72 +0,0 @@ -import Combine -import CoreData -import SwiftUI - -struct MapDetailScreen: View { - @Environment(\.managedObjectContext) private var viewContext - - @EnvironmentObject var store: AppStore - - @ObservedObject var map: Map - - @State var title: String - @State var content: String - - var body: some View { - if map.uuid != nil { - VSplitView { - VStack { - HStack { - TextField( - "Title", text: $title, onCommit: saveModel - ).font(.title2).textFieldStyle(PlainTextFieldStyle()).padding(.vertical, 4.0).padding( - .leading, 4.0) - Button(action: saveText) { - Image(systemName: "doc.text") - }.padding(.vertical, 4.0).padding(.leading, 4.0) - Button(action: saveImage) { - Image(systemName: "photo") - }.padding(.vertical, 4.0).padding(.leading, 4.0).padding(.trailing, 8.0) - } - EvolutionPicker() - - ZStack(alignment: .topLeading) { - MapTextEditor(text: $content, onChange: saveModel) - .background(Color.ui.background) - .foregroundColor(Color.ui.foreground) - .frame(minHeight: 96.0) - }.padding(.top, 8.0).padding(.leading, 8.0).background(Color.ui.background).cornerRadius( - 5.0) - }.padding(.horizontal, 8.0) - ScrollView([.horizontal, .vertical]) { - MapRenderView(content: $content, evolution: .constant(store.state.selectedEvolution)) - } - }.onDisappear { - saveModel() - } - } else { - EmptyMapDetailScreen() - } - } - - private func saveModel() { - map.content = content - map.title = title - try? viewContext.save() - } - - private func saveText() { - store.send(.exportMapAsText(map: map)) - } - - private func saveImage() { - store.send(.exportMapAsImage(map: map)) - } -} - -struct MapDetailView_Previews: PreviewProvider { - static var previews: some View { - MapDetailScreen(map: Map(), title: "", content: "").environment( - \.managedObjectContext, PersistenceController.preview.container.viewContext) - } -} diff --git a/Map/Presentation/Theme/Color+theme.swift b/Map/Presentation/Theme/Color+theme.swift new file mode 100644 index 0000000..1bd9b8e --- /dev/null +++ b/Map/Presentation/Theme/Color+theme.swift @@ -0,0 +1,38 @@ +import SwiftUI + +extension Color { + struct theme { + static let darkSlate = Color("Dark Slate") + static let jasperRed = Color("Jasper Red") + static let olympicBlue = Color("Olympic Blue") + static let lightPorcelainGreen = Color("Light Porcelain Green") + static let naplesYellow = Color("Naples Yellow") + static let hermosaPink = Color("Hermosa Pink") + static let neutralGray = Color("Neutral Gray") + static let lightNeutralGray = Color("Light Neutral Gray") + static let darkNeutralGray = Color("Dark Neutral Gray") + static let darkerNeutralGray = Color("Darker Neutral Gray") + } + + struct map { + static let labelColor = Color.theme.darkSlate + static let axisColor = Color.theme.darkSlate + static let vertexColor = Color.theme.darkSlate + static let blockerColor = Color.theme.jasperRed + static let opportunityColor = Color.theme.olympicBlue + static let stageForeground = Color.theme.lightNeutralGray + static let stageBackground = Color.white + static let groupColors = [ + Color.theme.olympicBlue, + Color.theme.jasperRed, + Color.theme.lightPorcelainGreen, + Color.theme.naplesYellow, + Color.theme.hermosaPink, + ] + } + + struct ui { + static let foreground = Color("Foreground") + static let background = Color("Background") + } +} diff --git a/Map/Presentation/Theme/Dimensions.swift b/Map/Presentation/Theme/Dimensions.swift new file mode 100644 index 0000000..6a58d58 --- /dev/null +++ b/Map/Presentation/Theme/Dimensions.swift @@ -0,0 +1,6 @@ +import Foundation + +struct Dimensions { + static let mapSize = CGSize(width: 1300.0, height: 1000.0) + static let mapPadding: CGFloat = 42.0 +} diff --git a/Map/Presentation/Theme/Font+theme.swift b/Map/Presentation/Theme/Font+theme.swift new file mode 100644 index 0000000..07e3b9b --- /dev/null +++ b/Map/Presentation/Theme/Font+theme.swift @@ -0,0 +1,15 @@ +import SwiftUI + +extension Font { + public struct theme { + + // Map + static let note = Font.system(size: 12, design: .serif) + static let axisLabel = Font.system(size: 14, design: .serif) + static let vertexLabel = Font.system(size: 12, design: .serif) + + // UI + static let smallControl = Font.system(size: 9) + static let body = Font.system(size: 13) + } +} diff --git a/Map/Presentation/Theme/NSColor+theme.swift b/Map/Presentation/Theme/NSColor+theme.swift new file mode 100644 index 0000000..d30332a --- /dev/null +++ b/Map/Presentation/Theme/NSColor+theme.swift @@ -0,0 +1,14 @@ +import AppKit + +extension NSColor { + struct syntax { + static let vertex = NSColor(named: "Vertex") ?? .textColor + static let number = NSColor(named: "Number") ?? .textColor + static let option = NSColor(named: "Option") ?? .textColor + static let symbol = NSColor(named: "Symbol") ?? .textColor + } + + struct ui { + static let background = NSColor(named: "Background") ?? .windowBackgroundColor + } +} diff --git a/Map/Presentation/ViewStyle.swift b/Map/Presentation/ViewStyle.swift new file mode 100644 index 0000000..cea3113 --- /dev/null +++ b/Map/Presentation/ViewStyle.swift @@ -0,0 +1,3 @@ +enum ViewStyle: String { + case vertical, horizontal +} diff --git a/Map/Presentation/Windows/MapEditorWindow.swift b/Map/Presentation/Windows/MapEditorWindow.swift deleted file mode 100644 index 65316cc..0000000 --- a/Map/Presentation/Windows/MapEditorWindow.swift +++ /dev/null @@ -1,109 +0,0 @@ -import CoreData -import SwiftUI - -struct MapEditorWindow: View { - @Environment(\.managedObjectContext) private var viewContext - - @EnvironmentObject var store: AppStore - - @FetchRequest( - sortDescriptors: [NSSortDescriptor(keyPath: \Map.createdAt, ascending: true)], - animation: .default) - private var maps: FetchedResults<Map> - - var body: some View { - NavigationView { - List { - if maps.count == 0 { - EmptyMapDetailScreen() - } - ForEach(maps) { map in - NavigationLink( - destination: MapDetailScreen(map: map, title: map.title ?? "", content: map.content ?? "") - ) { - HStack { - Text(map.title ?? "Untitled Map") - Spacer() - Text(mapFormatter.string(from: (map.createdAt ?? Date()))) - .font(.caption) - .padding(.vertical, 2.0) - .padding(.horizontal, 4.0) - .background(Color.accentColor) - .foregroundColor(Color.black) - .cornerRadius(2.0) - }.padding(.leading, 8.0) - }.contextMenu { - Button( - action: { store.send(.deleteMap(map: map)) }, - label: { - Image(systemName: "trash") - Text("Delete") - }) - } - } - .onDelete(perform: deleteMaps) - }.frame(minWidth: 250.0, alignment: .leading) - .toolbar { - HStack { - Button(action: toggleSidebar) { - Label("Toggle Sidebar", systemImage: "sidebar.left") - } - Button(action: addMap) { - Label("Add Map", systemImage: "plus") - } - } - } - EmptyMapDetailScreen() - } - } - - private func toggleSidebar() { - NSApp.keyWindow?.firstResponder?.tryToPerform( - #selector(NSSplitViewController.toggleSidebar(_:)), with: nil) - } - - private func addMap() { - withAnimation { - let newMap = Map(context: viewContext) - newMap.uuid = UUID() - newMap.createdAt = Date() - newMap.title = "Map \(newMap.createdAt!.format())" - newMap.content = "" - - do { - try viewContext.save() - } catch { - let nsError = error as NSError - fatalError("Unresolved error \(nsError), \(nsError.userInfo)") - } - } - } - - private func deleteMaps(offsets: IndexSet) { - - withAnimation { - offsets.map { maps[$0] }.forEach(viewContext.delete) - - do { - try viewContext.save() - } catch { - let nsError = error as NSError - fatalError("Unresolved error \(nsError), \(nsError.userInfo)") - } - } - } -} - -private let mapFormatter: DateFormatter = { - let formatter = DateFormatter() - formatter.dateStyle = .short - formatter.timeStyle = .none - return formatter -}() - -struct ContentView_Previews: PreviewProvider { - static var previews: some View { - MapEditorWindow().environment( - \.managedObjectContext, PersistenceController.preview.container.viewContext) - } -} |