diff --git a/DBSyn/MainView.swift b/DBSyn/MainView.swift index 2d35778..b5723ae 100644 --- a/DBSyn/MainView.swift +++ b/DBSyn/MainView.swift @@ -123,7 +123,7 @@ struct MainView: View { set: { if !$0 { schemaDiagramDatabase = nil } } )) { if let database = schemaDiagramDatabase { - DBSynSchemaDiagramView(model: model, database: database) + DBSynSchemaDiagramView(model: model, database: database, onClose: { schemaDiagramDatabase = nil }) } } .sheet(isPresented: $showingQueryPlan) { diff --git a/DBSyn/SchemaDiagramPrintView.swift b/DBSyn/SchemaDiagramPrintView.swift index 966bf6b..56429b1 100644 --- a/DBSyn/SchemaDiagramPrintView.swift +++ b/DBSyn/SchemaDiagramPrintView.swift @@ -11,23 +11,32 @@ final class SchemaDiagramPrintView: NSView { let diagram: String let entries: [Entry] let links: [Link] + let theme: String private let cardWidth: CGFloat = 260 private let headerHeight: CGFloat = 34 private let rowHeight: CGFloat = 19 private let margin: CGFloat = 55 private let titleHeight: CGFloat = 76 private let navy = NSColor(calibratedRed: 0.12, green: 0.20, blue: 0.34, alpha: 1) - private let blue = NSColor(calibratedRed: 0.14, green: 0.38, blue: 0.64, alpha: 1) - private let violet = NSColor(calibratedRed: 0.34, green: 0.30, blue: 0.56, alpha: 1) + private var blue: NSColor { + switch theme { + case "Slate": return NSColor(calibratedRed: 0.30, green: 0.38, blue: 0.49, alpha: 1) + case "Forest": return NSColor(calibratedRed: 0.16, green: 0.43, blue: 0.36, alpha: 1) + case "Plum": return NSColor(calibratedRed: 0.42, green: 0.32, blue: 0.55, alpha: 1) + default: return NSColor(calibratedRed: 0.12, green: 0.38, blue: 0.66, alpha: 1) + } + } + private var violet: NSColor { blue.blended(withFraction: 0.25, of: .systemPurple) ?? blue } private let ink = NSColor(calibratedRed: 0.16, green: 0.23, blue: 0.32, alpha: 1) private let muted = NSColor(calibratedRed: 0.43, green: 0.49, blue: 0.57, alpha: 1) private let border = NSColor(calibratedRed: 0.79, green: 0.83, blue: 0.88, alpha: 1) - init(database: String, diagram: String, entries: [Entry], links: [Link]) { + init(database: String, diagram: String, entries: [Entry], links: [Link], theme: String = "Ocean") { self.database = database self.diagram = diagram self.entries = entries self.links = links + self.theme = theme let width = max(900, (entries.map { $0.origin.x + 260 }.max() ?? 750) + 110) let height = max(650, (entries.map { $0.origin.y + CGFloat($0.columns.count) * 19 + 46 }.max() ?? 500) + 130) super.init(frame: CGRect(x: 0, y: 0, width: width, height: height)) @@ -117,10 +126,15 @@ final class SchemaDiagramPrintView: NSView { let delta: CGFloat = right ? 55 : -55 svg += "\n" } + let svgBlue = blue.usingColorSpace(.deviceRGB) ?? blue + let svgViolet = violet.usingColorSpace(.deviceRGB) ?? violet + func hex(_ color: NSColor) -> String { + String(format: "#%02X%02X%02X", Int(color.redComponent * 255), Int(color.greenComponent * 255), Int(color.blueComponent * 255)) + } for entry in entries { let r = cardRect(entry) svg += "\n" - svg += "\n" + svg += "\n" svg += "\(escape(entry.name))\n" for (index, column) in entry.columns.enumerated() { let y = r.minY + 34 + CGFloat(index) * 19 diff --git a/DBSyn/SchemaDiagramView.swift b/DBSyn/SchemaDiagramView.swift index e1805f8..8c1db0a 100644 --- a/DBSyn/SchemaDiagramView.swift +++ b/DBSyn/SchemaDiagramView.swift @@ -18,6 +18,7 @@ private struct DiagramPoint: Codable { struct DBSynSchemaDiagramView: View { @ObservedObject var model: DatabaseModel let database: String + var onClose: () -> Void = {} @State private var positions: [String: CGPoint] = [:] @State private var relations: [DiagramRelation] = [] @State private var zoom: CGFloat = 1 @@ -26,6 +27,10 @@ struct DBSynSchemaDiagramView: View { @State private var loadingRelationships = false @State private var errorMessage: String? @State private var showViews = true + @State private var showingObjects = true + @State private var includedObjects: Set = [] + @State private var theme = "Ocean" + @State private var showAllColumns = false @State private var relatedOnly = false @State private var diagramName = "Main" @State private var diagrams: [String] = ["Main"] @@ -38,13 +43,13 @@ struct DBSynSchemaDiagramView: View { private let nodeWidth: CGFloat = 245 private let headerHeight: CGFloat = 36 private let rowHeight: CGFloat = 21 - private let maxColumns = 10 + private var maxColumns: Int { showAllColumns ? 200 : 10 } private var snapshot: DatabaseModel.SchemaIntrospection? { model.introspection(for: database) } private var objects: [String] { guard let snapshot else { return [] } let all = snapshot.tables + (showViews ? snapshot.views : []) return all.filter { name in - (search.isEmpty || name.localizedCaseInsensitiveContains(search)) && + includedObjects.contains(name) && (search.isEmpty || name.localizedCaseInsensitiveContains(search)) && (!relatedOnly || selectedObject == nil || name == selectedObject || relations.contains { ($0.from == selectedObject && $0.to == name) || ($0.to == selectedObject && $0.from == name) }) } } @@ -53,6 +58,27 @@ struct DBSynSchemaDiagramView: View { } private var storageKey: String { "\(diagramNamespace).layout.\(diagramName)" } private var catalogKey: String { "\(diagramNamespace).catalog" } + private var selectionKey: String { "\(diagramNamespace).objects.\(diagramName)" } + private var themeKey: String { "\(diagramNamespace).theme.\(diagramName)" } + private var allObjects: [String] { (snapshot?.tables ?? []) + (snapshot?.views ?? []) } + private var nodeColor: Color { + switch theme { + case "Slate": return Color(red: 0.30, green: 0.38, blue: 0.49) + case "Forest": return Color(red: 0.16, green: 0.43, blue: 0.36) + case "Plum": return Color(red: 0.42, green: 0.32, blue: 0.55) + default: return Color(red: 0.12, green: 0.38, blue: 0.66) + } + } + private func restoreOptions() { + if let stored = UserDefaults.standard.stringArray(forKey: selectionKey) { + includedObjects = Set(stored) + } else { includedObjects = Set(allObjects) } + theme = UserDefaults.standard.string(forKey: themeKey) ?? "Ocean" + } + private func saveOptions() { + UserDefaults.standard.set(Array(includedObjects).sorted(), forKey: selectionKey) + UserDefaults.standard.set(theme, forKey: themeKey) + } private func columns(_ name: String) -> [DatabaseModel.IntrospectedColumn] { snapshot?.columnsByTable[name] ?? [] } @@ -69,9 +95,18 @@ struct DBSynSchemaDiagramView: View { var body: some View { VStack(spacing: 0) { - HStack(spacing: 12) { - Label("Schema Diagram — \(database)", systemImage: "point.3.connected.trianglepath.dotted") - .font(.headline) + // Two compact toolbar rows avoid the truncated controls shown in the original sheet. + HStack(spacing: 10) { + Label(database, systemImage: "point.3.connected.trianglepath.dotted") + .font(.headline).lineLimit(1) + Text("SCHEMA DESIGNER").font(.caption2.weight(.semibold)).foregroundStyle(.secondary) + Spacer(minLength: 12) + Button("Close", systemImage: "xmark") { savePositions(); saveOptions(); onClose() } + .labelStyle(.iconOnly).help("Close diagram") + .keyboardShortcut(.cancelAction) + } + .padding(.horizontal, 16).padding(.top, 12).padding(.bottom, 8) + HStack(spacing: 10) { Picker("Diagram", selection: $diagramName) { ForEach(diagrams, id: \.self) { Text($0).tag($0) } } @@ -80,22 +115,37 @@ struct DBSynSchemaDiagramView: View { savePositions(named: oldValue) positions = [:] restorePositions() + restoreOptions() if positions.isEmpty { arrange() } } Button { showingNewDiagram = true } label: { Image(systemName: "plus") } .help("New diagram") Button { showingDeleteConfirmation = true } label: { Image(systemName: "trash") } - .disabled(diagrams.count <= 1) - .help("Delete current diagram") - Spacer() + .disabled(diagrams.count <= 1).help("Delete diagram") + Divider().frame(height: 18) + Button { showingObjects.toggle() } label: { + Label("Tables", systemImage: "sidebar.left") + }.help("Show or hide the table selection sidebar") TextField("Find table or view", text: $search) - .textFieldStyle(.roundedBorder) - .frame(width: 200) + .textFieldStyle(.roundedBorder).frame(minWidth: 130, idealWidth: 220, maxWidth: 260) Toggle("Views", isOn: $showViews).toggleStyle(.checkbox) Toggle("Related", isOn: $relatedOnly).toggleStyle(.checkbox) - Button("Auto Layout") { arrange(); savePositions() } + Spacer(minLength: 8) + Menu { + ForEach(["Ocean", "Slate", "Forest", "Plum"], id: \.self) { option in + Button { theme = option; saveOptions() } label: { + if theme == option { Label(option, systemImage: "checkmark") } + else { Text(option) } + } + } + } label: { Label("Colours", systemImage: "paintpalette") } + Menu { + Toggle("Show all columns", isOn: $showAllColumns) + Button("Auto Layout") { arrange(); savePositions() } + Button("Refresh Metadata") { model.introspect(database: database); loadRelationships() } + } label: { Label("Layout", systemImage: "slider.horizontal.3") } Button { zoom = max(0.4, zoom - 0.15) } label: { Image(systemName: "minus.magnifyingglass") } - Text("\(Int(zoom * 100))%").monospacedDigit().frame(width: 46) + Text("\(Int(zoom * 100))%").monospacedDigit().frame(width: 45) Button { zoom = min(2, zoom + 0.15) } label: { Image(systemName: "plus.magnifyingglass") } Menu { Button("Export PDF…") { exportDiagram(.pdf) } @@ -104,10 +154,46 @@ struct DBSynSchemaDiagramView: View { Divider() Button("Print…") { printDiagram() } } label: { Label("Export / Print", systemImage: "square.and.arrow.up") } - Button("Refresh") { model.introspect(database: database); loadRelationships() } } - .padding(12) + .controlSize(.small) + .padding(.horizontal, 16).padding(.bottom, 12) Divider() + HStack(spacing: 0) { + if showingObjects { + VStack(alignment: .leading, spacing: 10) { + HStack { + Text("OBJECTS").font(.caption.weight(.semibold)).foregroundStyle(.secondary) + Spacer() + Text("\(includedObjects.count)/\(allObjects.count)") + .font(.caption.monospacedDigit()).foregroundStyle(.secondary) + } + HStack { + Button("All") { includedObjects = Set(allObjects); saveOptions() } + Button("None") { includedObjects.removeAll(); saveOptions() } + Spacer() + }.buttonStyle(.link) + ScrollView { + LazyVStack(alignment: .leading, spacing: 5) { + ForEach(allObjects.filter { search.isEmpty || $0.localizedCaseInsensitiveContains(search) }, id: \.self) { name in + Toggle(isOn: Binding( + get: { includedObjects.contains(name) }, + set: { enabled in + if enabled { includedObjects.insert(name) } + else { includedObjects.remove(name) } + saveOptions() + })) { + Label(name, systemImage: snapshot?.views.contains(name) == true ? "eye" : "tablecells") + .lineLimit(1).font(.system(size: 12)) + }.toggleStyle(.checkbox) + } + }.frame(maxWidth: .infinity, alignment: .leading) + } + } + .padding(12).frame(width: 235) + .background(Color(nsColor: .controlBackgroundColor)) + Divider() + } + VStack(spacing: 0) { if snapshot == nil { ContentUnavailableView("No Introspection Data", systemImage: "externaldrive.badge.questionmark", description: Text("Run Introspection on this database to load tables and views.")) Button("Introspect Now") { model.introspect(database: database); loadRelationships() }.padding() @@ -142,9 +228,14 @@ struct DBSynSchemaDiagramView: View { } .background(Color(nsColor: .textBackgroundColor)) .onChange(of: snapshot?.loadedAt) { _, _ in + if UserDefaults.standard.stringArray(forKey: selectionKey) == nil { + includedObjects = Set(allObjects) + } if positions.isEmpty { arrange() } loadRelationships() } + } + } } Divider() HStack { @@ -157,9 +248,10 @@ struct DBSynSchemaDiagramView: View { .font(.caption) .padding(9) } - .frame(minWidth: 950, minHeight: 650) - .onAppear { loadCatalog(); restorePositions(); if positions.isEmpty { arrange() }; loadRelationships() } - .onDisappear { savePositions() } + .frame(minWidth: 850, idealWidth: 1320, minHeight: 570, idealHeight: 850) + .background(DiagramResizableWindow()) + .onAppear { loadCatalog(); restorePositions(); restoreOptions(); if positions.isEmpty { arrange() }; loadRelationships() } + .onDisappear { savePositions(); saveOptions() } .alert("New Schema Diagram", isPresented: $showingNewDiagram) { TextField("Diagram name", text: $newDiagramName) Button("Create") { createDiagram() } @@ -194,7 +286,7 @@ struct DBSynSchemaDiagramView: View { .padding(.horizontal, 10) .frame(height: headerHeight) .foregroundStyle(.white) - .background(isView ? Color.indigo : Color.accentColor) + .background(isView ? nodeColor.opacity(0.76) : nodeColor) ForEach(Array(columns(name).prefix(maxColumns)), id: \.id) { column in HStack(spacing: 5) { Image(systemName: column.key == "PRI" ? "key.fill" : "circle.fill") @@ -236,7 +328,7 @@ struct DBSynSchemaDiagramView: View { @State private var dragOrigins: [String: CGPoint] = [:] private func arrange() { - let names = (snapshot?.tables ?? []) + (snapshot?.views ?? []) + let names = allObjects.filter { includedObjects.contains($0) } let countPerRow = max(1, Int(ceil(sqrt(Double(max(1, names.count)))))) for (index, name) in names.enumerated() { positions[name] = CGPoint(x: CGFloat(index % countPerRow) * 290 + 30, @@ -308,7 +400,7 @@ struct DBSynSchemaDiagramView: View { } let links = relations.filter { objects.contains($0.from) && objects.contains($0.to) } .map { SchemaDiagramPrintView.Link(from: $0.from, to: $0.to, column: $0.column, target: $0.targetColumn) } - return SchemaDiagramPrintView(database: database, diagram: diagramName, entries: entries, links: links) + return SchemaDiagramPrintView(database: database, diagram: diagramName, entries: entries, links: links, theme: theme) } private func exportDiagram(_ format: DiagramFormat) { @@ -352,3 +444,17 @@ struct DBSynSchemaDiagramView: View { } } + +/// Allows the schema designer sheet to be resized rather than locking its initial dimensions. +private struct DiagramResizableWindow: NSViewRepresentable { + func makeNSView(context: Context) -> NSView { + let view = NSView(frame: .zero) + DispatchQueue.main.async { [weak view] in + guard let window = view?.window else { return } + window.styleMask.insert(.resizable) + window.minSize = NSSize(width: 850, height: 570) + } + return view + } + func updateNSView(_ nsView: NSView, context: Context) { } +} diff --git a/docs/PHASE73-4-SCHEMA-DESIGNER-UI.md b/docs/PHASE73-4-SCHEMA-DESIGNER-UI.md new file mode 100644 index 0000000..1ad3384 --- /dev/null +++ b/docs/PHASE73-4-SCHEMA-DESIGNER-UI.md @@ -0,0 +1,15 @@ +# Phase 73.4 — Schema Designer UI + +- Two-row toolbar avoids clipping and narrow, vertical labels. +- Explicit Close control in the header. +- Resizable schema designer sheet with a practical minimum size. +- Show/hide object sidebar; include/exclude individual tables and views. +- All/None selection and search. +- Four colour palettes (Ocean, Slate, Forest, Plum) applied to on-screen cards and PDF/PNG/SVG output. +- Diagram-specific saved object selections and colour palette. +- Optional full column display and auto-layout controls. +- Existing printing and export retained. + +The schema designer remains read-only and does not alter MySQL objects. + +Full Xcode compilation and macOS UI verification are required.