From c99bbc903733ede60957882dbf3e01b9ef61a268 Mon Sep 17 00:00:00 2001 From: Jorge Trigger Date: Tue, 28 Jul 2026 16:30:35 +0200 Subject: [PATCH 1/2] design --- .../Editor/AssetBrowserView.swift | 600 +++++++++++------- .../Editor/ComponentEditorForm.swift | 2 +- .../UntoldEditor/Editor/DemoGalleryView.swift | 58 +- .../Editor/EditorMenuCommands.swift | 68 ++ .../UntoldEditor/Editor/EditorScheme.swift | 83 ++- Sources/UntoldEditor/Editor/EditorView.swift | 530 +++++++++++++--- .../UntoldEditor/Editor/EngineStatsView.swift | 20 +- .../UntoldEditor/Editor/EnvironmentView.swift | 99 +-- .../UntoldEditor/Editor/InspectorView.swift | 93 ++- .../Editor/LODComponentEditorView.swift | 26 +- .../Editor/LoadingIndicatorView.swift | 12 +- .../UntoldEditor/Editor/LogConsoleView.swift | 74 +-- .../Editor/ProjectSceneCatalog.swift | 53 ++ .../Editor/SceneHierarchyView.swift | 313 ++++++--- .../Editor/ScriptComponentInspector.swift | 42 +- .../Editor/SelectionManager.swift | 14 + .../Editor/StaticBatchingView.swift | 42 +- Sources/UntoldEditor/Editor/ToolbarView.swift | 306 +-------- .../Editor/TransformManipulationView.swift | 39 +- .../Systems/EditorInputSystemAppKit.swift | 6 + Sources/UntoldEditor/main.swift | 158 ++++- 21 files changed, 1705 insertions(+), 933 deletions(-) create mode 100644 Sources/UntoldEditor/Editor/EditorMenuCommands.swift create mode 100644 Sources/UntoldEditor/Editor/ProjectSceneCatalog.swift diff --git a/Sources/UntoldEditor/Editor/AssetBrowserView.swift b/Sources/UntoldEditor/Editor/AssetBrowserView.swift index 0638412..55e5d81 100644 --- a/Sources/UntoldEditor/Editor/AssetBrowserView.swift +++ b/Sources/UntoldEditor/Editor/AssetBrowserView.swift @@ -333,7 +333,7 @@ private struct RemoteStreamImportSheet: View { VStack(alignment: .leading, spacing: 6) { Text("Manifest URL") .font(.system(size: 12)) - .foregroundColor(.secondary) + .foregroundColor(.editorTextSecondary) TextField("https://cdn.example.com/dungeon/dungeon.json", text: $urlString) .textFieldStyle(RoundedBorderTextFieldStyle()) .focused($isURLFocused) @@ -363,12 +363,17 @@ struct AssetBrowserView: View { @ObservedObject var selectionManager: SelectionManager @ObservedObject var sceneGraphModel: SceneGraphModel @State private var folderPathStack: [URL] = [] + @State private var expandedDirs: Set = [] + // Non-empty when a folder outside the fixed categories (e.g. a root-level + // directory the user created) is selected. Overrides the category selection. + @State private var selectedDirURL: URL? + @State private var rootExpanded: Bool = true @State private var showSceneLoadConfirmation = false @State private var pendingSceneToLoad: URL? @State private var showDeleteConfirmation = false @State private var pendingDeleteAsset: Asset? @State private var showBasePathAlert = false - @State private var searchQuery: String = "" + @Binding var searchQuery: String @State private var statusMessage: String? @State private var statusIsError = false @State private var targetEntityName: String = "None" @@ -400,222 +405,368 @@ struct AssetBrowserView: View { folderPathStack.last } - var body: some View { - ZStack { - Color.editorBackground.ignoresSafeArea() + // MARK: - Finder helpers - VStack(alignment: .leading, spacing: 8) { - // MARK: - Top Bar - - HStack { - Text("Assets") - .font(.title3) - .bold() - .foregroundColor(.white) - - Menu { - ForEach(AssetCategory.allCases, id: \.self) { category in - Button(action: { importAssetForCategory(category) }) { - HStack { - Image(systemName: category.iconName) - Text("Import \(category.displayName)") - } - } - } - Divider() - Button(action: { showRemoteStreamSheet = true }) { - HStack { - Image(systemName: "globe") - Text("Import Remote Stream") - } - } - } label: { - HStack(spacing: 6) { - Text("Import") - Image(systemName: "plus.circle") - .foregroundColor(.white) - } - .padding(.vertical, 6) - .padding(.horizontal, 12) - .background(Color.editorAccent) - .foregroundColor(.black.opacity(0.9)) - .cornerRadius(8) - .shadow(color: Color.black.opacity(0.2), radius: 4, x: 0, y: 2) - } - .buttonStyle(PlainButtonStyle()) + private func categoryRootURL(_ category: AssetCategory) -> URL? { + assetBasePath?.appendingPathComponent(category.rawValue, isDirectory: true) + } - Button(action: loadSelectedSceneAuthoredPayload) { - HStack(spacing: 6) { - Text("Load Authored") - Image(systemName: "camera.badge.ellipsis") - .foregroundColor(.white) - } - .padding(.vertical, 6) - .padding(.horizontal, 12) - .background(selectedSceneAuthoredAsset() == nil ? Color.gray.opacity(0.5) : Color.editorSecondaryAccent) - .foregroundColor(.white) - .cornerRadius(8) - .shadow(color: Color.black.opacity(0.2), radius: 4, x: 0, y: 2) - } - .buttonStyle(PlainButtonStyle()) - .disabled(selectedSceneAuthoredAsset() == nil) - .help("Load scene-authored cameras and lights from the selected .untold asset or tiled scene manifest") - Button(action: promptDeleteAsset) { - HStack(spacing: 6) { - Text("Delete") - Image(systemName: "trash") - .foregroundColor(.white) - } - .padding(.vertical, 6) - .padding(.horizontal, 12) - .background(selectedAsset == nil ? Color.gray.opacity(0.5) : Color.red) - .foregroundColor(.white) - .cornerRadius(8) - .shadow(color: Color.black.opacity(0.2), radius: 4, x: 0, y: 2) - } - .buttonStyle(PlainButtonStyle()) - .disabled(selectedAsset == nil) + /// Root-level folders the user created that aren't one of the fixed categories. + private var customRootFolders: [URL] { + guard let root = assetBasePath else { return [] } + let categoryNames = Set(AssetCategory.allCases.map(\.rawValue)) + return subdirectories(of: root).filter { !categoryNames.contains($0.lastPathComponent) } + } - HStack(spacing: 6) { - Image(systemName: "magnifyingglass") - ExplicitClickTextField(text: $searchQuery, placeholder: "Filter assets") - } - .frame(maxWidth: 240) + /// The directory currently shown on the right: a generic (non-category) + /// selection wins, otherwise the open subfolder, otherwise the selected + /// category's root folder. + private var currentDirectoryURL: URL? { + if let generic = selectedDirURL { return generic } + if let folder = currentFolderPath { return folder } + guard let raw = selectedCategory, let category = AssetCategory(rawValue: raw) else { return nil } + return categoryRootURL(category) + } - Spacer() - } - .padding(.horizontal, 10) - .padding(.vertical, 6) - .background(Color.editorPanelBackground.opacity(0.9)) - .cornerRadius(8) + /// Category owning `url` (matched by the top-level folder under the asset + /// root), used to pick import file types for generic folders. + private func inferCategory(for url: URL?) -> AssetCategory? { + guard let url, let root = assetBasePath?.standardizedFileURL else { return nil } + let rootComponents = root.pathComponents + let comps = url.standardizedFileURL.pathComponents + guard comps.count > rootComponents.count else { return nil } + let top = comps[rootComponents.count] + return AssetCategory(rawValue: top) + } - // MARK: - Target Entity Indicator + private func subdirectories(of url: URL) -> [URL] { + let fm = FileManager.default + guard let items = try? fm.contentsOfDirectory( + at: url, + includingPropertiesForKeys: [.isDirectoryKey], + options: [.skipsHiddenFiles] + ) else { return [] } + return items + .filter { (try? $0.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true } + .sorted { $0.lastPathComponent.localizedCaseInsensitiveCompare($1.lastPathComponent) == .orderedAscending } + } - HStack(spacing: 12) { - Text("Target Entity:") - .font(.caption) - .foregroundColor(.secondary) - Text(targetEntityName) - .font(.caption) - .foregroundColor(.white) - .lineLimit(1) + private func toggleDir(_ url: URL) { + if expandedDirs.contains(url) { expandedDirs.remove(url) } else { expandedDirs.insert(url) } + } + + private func isDirectorySelected(url: URL, category: String) -> Bool { + guard selectedDirURL == nil else { return false } + guard selectedCategory == category else { return false } + guard let current = currentDirectoryURL else { return false } + return current.standardizedFileURL == url.standardizedFileURL + } + + private func selectDirectory(url: URL, category: String) { + selectedDirURL = nil + selectedCategory = category + selectedAsset = nil + selectedAssetName = nil + + guard let cat = AssetCategory(rawValue: category), let root = categoryRootURL(cat), + url.standardizedFileURL != root.standardizedFileURL + else { + folderPathStack = [] + return + } - Spacer() + // Build the folder chain from the category root down to `url`. + let rootComponents = root.standardizedFileURL.pathComponents + let relative = Array(url.standardizedFileURL.pathComponents.dropFirst(rootComponents.count)) + var stack: [URL] = [] + var cursor = root + for component in relative { + cursor = cursor.appendingPathComponent(component, isDirectory: true) + stack.append(cursor) + } + folderPathStack = stack + } + + /// Create a uniquely-named subfolder inside `parent` (creating `parent` if + /// needed, e.g. an empty category root) and reveal it. + private func createFolder(in parent: URL) { + let fm = FileManager.default + try? fm.createDirectory(at: parent, withIntermediateDirectories: true) + + var name = "New Folder" + var index = 1 + var dest = parent.appendingPathComponent(name, isDirectory: true) + while fm.fileExists(atPath: dest.path) { + index += 1 + name = "New Folder \(index)" + dest = parent.appendingPathComponent(name, isDirectory: true) + } + try? fm.createDirectory(at: dest, withIntermediateDirectories: true) + expandedDirs.insert(parent) + loadAssets() + } + + private func importIntoCurrentDirectory() { + let category = selectedCategory.flatMap { AssetCategory(rawValue: $0) } + ?? inferCategory(for: currentDirectoryURL) + ?? .models + importAssetForCategory(category, into: currentDirectoryURL) + } + + // Returns AnyView (not `some View`) so the recursive child call is allowed. + // `category == nil` marks a generic (non-category) folder such as the root + // or a custom directory created at root level. `url` may be nil for a + // category root when no project folder is set yet. + private func directoryNode(url: URL?, name: String, category: String?, depth: Int) -> AnyView { + let isGeneric = (category == nil) + let subfolders: [URL] = { + guard let url else { return [] } + if category == AssetCategory.scripts.rawValue { return [] } + return subdirectories(of: url) + }() + let hasChildren = !subfolders.isEmpty + let isExpanded = url.map { expandedDirs.contains($0) } ?? false + let isSelected: Bool = { + if isGeneric { + guard let url, let sel = selectedDirURL else { return false } + return sel.standardizedFileURL == url.standardizedFileURL + } + if let url { return isDirectorySelected(url: url, category: category!) } + return selectedDirURL == nil && selectedCategory == category && folderPathStack.isEmpty + }() + + return AnyView( + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 6) { + Button(action: { if let url { toggleDir(url) } }) { + Image(systemName: hasChildren ? (isExpanded ? "chevron.down" : "chevron.right") : "chevron.right") + .font(.system(size: 9, weight: .semibold)) + .foregroundColor(hasChildren ? .editorTextSecondary : .clear) + .frame(width: 10) + } + .buttonStyle(.plain) + .focusable(false) + .disabled(!hasChildren) + + Image(systemName: isSelected ? "folder.fill" : "folder") + .foregroundColor(isSelected ? Color.editorAccent : .editorTextTertiary) + Text(name) + .font(.system(size: 13, weight: .medium)) + .foregroundColor(.editorTextPrimary) + .lineLimit(1) + Spacer(minLength: 0) + } + .padding(.vertical, 4) + .padding(.horizontal, 6) + .padding(.leading, CGFloat(depth) * 12) + .background(isSelected ? Color.editorAccentSoft : Color.clear) + .cornerRadius(6) + .contentShape(Rectangle()) + .onTapGesture { + if isGeneric { + if let url { + selectedDirURL = url + selectedCategory = nil + folderPathStack = [] + selectedAsset = nil + selectedAssetName = nil + } + } else if let url { + selectDirectory(url: url, category: category!) + } else { + selectedDirURL = nil + selectedCategory = category + folderPathStack = [] + selectedAsset = nil + selectedAssetName = nil + } + } + .contextMenu { + Button { + if let url { createFolder(in: url) } + } label: { + Label("New Directory", systemImage: "folder.badge.plus") + } + .disabled(url == nil) } - .padding(.horizontal, 10) - .padding(.bottom, 5) - // MARK: - Sidebar and Asset List Layout + if isExpanded { + ForEach(subfolders, id: \.self) { sub in + directoryNode(url: sub, name: sub.lastPathComponent, category: category, depth: depth + 1) + } + } + } + ) + } - HStack(spacing: 8) { - // MARK: - Sidebar + // Root node of the directory tree (the project's asset folder). Right-click + // to create a directory at root level. + private var rootDirectoryRow: some View { + let root = assetBasePath + let isSelected = root.map { r in selectedDirURL?.standardizedFileURL == r.standardizedFileURL } ?? false + return HStack(spacing: 6) { + Button(action: { rootExpanded.toggle() }) { + Image(systemName: rootExpanded ? "chevron.down" : "chevron.right") + .font(.system(size: 9, weight: .semibold)) + .foregroundColor(.editorTextSecondary) + .frame(width: 10) + } + .buttonStyle(.plain) + .focusable(false) + + Image(systemName: "folder.fill") + .foregroundColor(isSelected ? Color.editorAccent : .editorAccent) + Text(editorBaseAssetPath.projectName ?? "Assets") + .font(.system(size: 13, weight: .bold)) + .foregroundColor(.editorTextPrimary) + .lineLimit(1) + Spacer(minLength: 0) + } + .padding(.vertical, 4) + .padding(.horizontal, 6) + .background(isSelected ? Color.editorAccentSoft : Color.clear) + .cornerRadius(6) + .contentShape(Rectangle()) + .onTapGesture { + if let root { + selectedDirURL = root + selectedCategory = nil + folderPathStack = [] + selectedAsset = nil + selectedAssetName = nil + } + } + .contextMenu { + Button { + if let root { createFolder(in: root) } + } label: { + Label("New Directory", systemImage: "folder.badge.plus") + } + .disabled(root == nil) + } + } - ScrollView(.vertical, showsIndicators: false) { - VStack(alignment: .leading, spacing: 8) { - Text("Categories") - .font(.caption) - .foregroundColor(.secondary) - .padding(.horizontal, 8) - .padding(.bottom, 2) - - ForEach(AssetCategory.allCases, id: \.self) { category in - HStack { - Image(systemName: selectedCategory == category.rawValue ? "folder.fill" : "folder") - .foregroundColor(selectedCategory == category.rawValue ? Color.editorAccent : .gray) - Text(category.displayName) - .font(.system(size: 14, weight: .bold, design: .monospaced)) - .foregroundColor(selectedCategory == category.rawValue ? .white : .primary) + @ViewBuilder + private var rightPaneContents: some View { + if let selectedDirURL { + folderContentsView(for: selectedDirURL, selectionManager: selectionManager) + } else if let selectedCategory { + let isScripts = (selectedCategory == AssetCategory.scripts.rawValue) + if let currentFolderPath, !isScripts { + folderContentsView(for: currentFolderPath, selectionManager: selectionManager) + } else if let categoryAssets = assets[selectedCategory] { + let filtered = categoryAssets.filter { matchesSearch($0) } + if filtered.isEmpty { + Text("No assets available") + .foregroundColor(.editorTextTertiary) + .padding() + } else { + VStack(alignment: .leading, spacing: 4) { + ForEach(filtered) { asset in + assetRow(asset) + .contextMenu { + Button(role: .destructive) { + pendingDeleteAsset = asset + showDeleteConfirmation = true + } label: { + Label("Eliminar", systemImage: "trash") + } + } + .onTapGesture(count: 2) { + handle_add_model_double_click(asset: asset) } - .padding(.vertical, 6) - .padding(.horizontal, 8) - .background(selectedCategory == category.rawValue ? Color.editorAccentSoft : Color.clear) - .cornerRadius(6) - .onTapGesture { - // If reselecting the same category, force a reload - if selectedCategory == category.rawValue { - loadAssets() + .onTapGesture(count: 1) { + if asset.isFolder { + if !isScripts { folderPathStack.append(asset.path) } } else { - selectedCategory = category.rawValue + selectAsset(asset) } - // Reset folder navigation when switching category, - // but Scripts will not use folder navigation at all. - folderPathStack = [] } - } } - .padding(8) } + } + } else { + Text("No assets available") + .foregroundColor(.editorTextTertiary) + .padding() + } + } else { + Text("Select a folder") + .foregroundColor(.editorTextTertiary) + .padding() + } + } - .frame(width: 140) - .background(Color.secondary.opacity(0.05)) - .cornerRadius(8) + var body: some View { + ZStack { + Color.editorBackground.ignoresSafeArea() - // MARK: - Asset List + VStack(alignment: .leading, spacing: 8) { + // MARK: - Finder-style split: directory tree | folder contents - ScrollView(.vertical, showsIndicators: true) { - VStack(alignment: .leading, spacing: 10) { - if let selectedCategory { - // Scripts: flat, no breadcrumbs or folders - let isScripts = (selectedCategory == AssetCategory.scripts.rawValue) - - if !isScripts, !folderPathStack.isEmpty { - // Show breadcrumb if inside folders (non-Scripts only) - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 4) { - Button("Assets") { - folderPathStack = [] - } - - ForEach(Array(folderPathStack.enumerated()), id: \.element) { index, url in - Text(">") - Button(url.lastPathComponent) { - folderPathStack = Array(folderPathStack.prefix(upTo: index + 1)) - } - } - } - .font(.caption) - .padding(.horizontal, 10) - .padding(.vertical, 4) - .background(Color.secondary.opacity(0.05)) - .cornerRadius(6) - } + HStack(spacing: 8) { + // Left: directory tree with a root node (right-click any + // folder — including the root — to create a subfolder). + ScrollView(.vertical, showsIndicators: false) { + VStack(alignment: .leading, spacing: 2) { + rootDirectoryRow + + if rootExpanded { + ForEach(AssetCategory.allCases, id: \.self) { category in + directoryNode( + url: categoryRootURL(category), + name: category.displayName, + category: category.rawValue, + depth: 1 + ) } - - // Show either folder contents or top-level categories - if let currentFolderPath, !isScripts { - folderContentsView(for: currentFolderPath, selectionManager: selectionManager) - } else { - if let categoryAssets = assets[selectedCategory] { - ForEach(categoryAssets.filter { matchesSearch($0) }) { asset in - // For Scripts, we never navigate into folders (we won't list folders anyway) - assetRow(asset) - .onTapGesture(count: 2) { - handle_add_model_double_click(asset: asset) - } - .onTapGesture(count: 1) { - if asset.isFolder { - // Only allow folder navigation for non-Scripts categories - if !isScripts { - folderPathStack.append(asset.path) - } - } else { - selectAsset(asset) - } - } - } - } else { - Text("No assets available") - .foregroundColor(.gray) - .padding() - } + ForEach(customRootFolders, id: \.self) { url in + directoryNode( + url: url, + name: url.lastPathComponent, + category: nil, + depth: 1 + ) } } } - .padding(.horizontal, 8) + .padding(6) + .frame(maxWidth: .infinity, alignment: .leading) } - .frame(maxHeight: 300) + .frame(width: 225) + .frame(maxHeight: .infinity) + .background(Color.editorFillSubtle) + .cornerRadius(8) + + // Right: contents of the selected directory + ScrollView(.vertical, showsIndicators: true) { + rightPaneContents + .frame(maxWidth: .infinity, alignment: .topLeading) + .padding(8) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) .background(Color.editorSurface.opacity(0.7)) .cornerRadius(8) + .contextMenu { + Button { + importIntoCurrentDirectory() + } label: { + Label("Import…", systemImage: "plus.circle") + } + Button { + showRemoteStreamSheet = true + } label: { + Label("Import Remote Stream", systemImage: "globe") + } + if selectedSceneAuthoredAsset() != nil { + Divider() + Button { + loadSelectedSceneAuthoredPayload() + } label: { + Label("Load Authored", systemImage: "camera.badge.ellipsis") + } + } + } } .frame(maxHeight: 300) } @@ -693,10 +844,10 @@ struct AssetBrowserView: View { if let statusMessage { Text(statusMessage) .font(.system(size: 12, weight: .semibold)) - .foregroundColor(.white) + .foregroundColor(.editorTextPrimary) .padding(.vertical, 6) .padding(.horizontal, 12) - .background(statusIsError ? Color.red.opacity(0.85) : Color.green.opacity(0.85)) + .background(statusIsError ? Color.editorError.opacity(0.85) : Color.editorSuccess.opacity(0.85)) .cornerRadius(8) .padding(.bottom, 8) .transition(.move(edge: .bottom).combined(with: .opacity)) @@ -704,7 +855,7 @@ struct AssetBrowserView: View { } } - private func importAssetForCategory(_ category: AssetCategory) { + private func importAssetForCategory(_ category: AssetCategory, into destinationOverride: URL? = nil) { guard editorBaseAssetPath.basePath != nil else { showBasePathAlert = true return @@ -741,8 +892,10 @@ struct AssetBrowserView: View { guard AssetCategory.allCases.map(\.rawValue).contains(categoryString) else { return } let fm = FileManager.default - let categoryRoot = basePath.appendingPathComponent(categoryString, isDirectory: true) - // Ensure category folder exists (e.g., /Models) + // Import into the folder the user has open (Finder-style), falling back + // to the category root. + let categoryRoot = destinationOverride ?? basePath.appendingPathComponent(categoryString, isDirectory: true) + // Ensure the destination folder exists (e.g., /Models or a subfolder) try? fm.createDirectory(at: categoryRoot, withIntermediateDirectories: true) if openPanel.runModal() == .OK { @@ -890,14 +1043,14 @@ struct AssetBrowserView: View { VStack(alignment: .leading, spacing: 6) { Text("Source") .font(.caption) - .foregroundColor(.secondary) + .foregroundColor(.editorTextSecondary) Text(request.sourceURL.path) .font(.system(size: 12, design: .monospaced)) .lineLimit(2) Text("Output") .font(.caption) - .foregroundColor(.secondary) + .foregroundColor(.editorTextSecondary) .padding(.top, 6) Text(request.outputURL.path) .font(.system(size: 12, design: .monospaced)) @@ -918,7 +1071,7 @@ struct AssetBrowserView: View { if exportCompressGeometry { Text("Requires: pip install lz4") .font(.caption) - .foregroundColor(.secondary) + .foregroundColor(.editorTextSecondary) .padding(.leading, 20) } @@ -931,15 +1084,15 @@ struct AssetBrowserView: View { .font(.caption) Text("·") .font(.caption) - .foregroundColor(.secondary) + .foregroundColor(.editorTextSecondary) Text("Also requires: pip install Pillow") .font(.caption) - .foregroundColor(.secondary) + .foregroundColor(.editorTextSecondary) } VStack(alignment: .leading, spacing: 4) { Text("astcenc path (optional)") .font(.caption) - .foregroundColor(.secondary) + .foregroundColor(.editorTextSecondary) HStack { TextField("/opt/homebrew/bin/astcenc", text: $astcencBinPath) .textFieldStyle(.roundedBorder) @@ -966,7 +1119,7 @@ struct AssetBrowserView: View { ProgressView() .controlSize(.small) Text("Exporting...") - .foregroundColor(.secondary) + .foregroundColor(.editorTextSecondary) } } @@ -1140,14 +1293,14 @@ struct AssetBrowserView: View { VStack(alignment: .leading, spacing: 6) { Text("Source") .font(.caption) - .foregroundColor(.secondary) + .foregroundColor(.editorTextSecondary) Text(request.sourceURL.path) .font(.system(size: 12, design: .monospaced)) .lineLimit(2) Text("Output directory") .font(.caption) - .foregroundColor(.secondary) + .foregroundColor(.editorTextSecondary) .padding(.top, 6) Text(request.outputDirURL.path) .font(.system(size: 12, design: .monospaced)) @@ -1157,7 +1310,7 @@ struct AssetBrowserView: View { VStack(alignment: .leading, spacing: 10) { Text("Tile size (world units)") .font(.caption) - .foregroundColor(.secondary) + .foregroundColor(.editorTextSecondary) HStack(spacing: 12) { VStack(alignment: .leading, spacing: 4) { @@ -1188,7 +1341,7 @@ struct AssetBrowserView: View { if exportCompressGeometry { Text("Requires: pip install lz4") .font(.caption) - .foregroundColor(.secondary) + .foregroundColor(.editorTextSecondary) .padding(.leading, 20) } @@ -1201,15 +1354,15 @@ struct AssetBrowserView: View { .font(.caption) Text("·") .font(.caption) - .foregroundColor(.secondary) + .foregroundColor(.editorTextSecondary) Text("Also requires: pip install Pillow") .font(.caption) - .foregroundColor(.secondary) + .foregroundColor(.editorTextSecondary) } VStack(alignment: .leading, spacing: 4) { Text("astcenc path (optional)") .font(.caption) - .foregroundColor(.secondary) + .foregroundColor(.editorTextSecondary) HStack { TextField("/opt/homebrew/bin/astcenc", text: $astcencBinPath) .textFieldStyle(.roundedBorder) @@ -1239,7 +1392,7 @@ struct AssetBrowserView: View { ProgressView() .controlSize(.small) Text("Exporting tiles...") - .foregroundColor(.secondary) + .foregroundColor(.editorTextSecondary) } } @@ -1541,7 +1694,7 @@ struct AssetBrowserView: View { let isRemote = asset.path.pathExtension.lowercased() == "remotestream" return HStack { Image(systemName: asset.isFolder ? "folder.fill" : isRemote ? "globe" : "cube.fill") - .foregroundColor(isRemote ? .blue : .gray) + .foregroundColor(isRemote ? .editorInfo : .editorTextTertiary) Text(asset.name) .font(.system(size: 14, weight: .regular, design: .monospaced)) Spacer() @@ -1549,7 +1702,7 @@ struct AssetBrowserView: View { .padding(.vertical, 6) .padding(.horizontal, 10) .background( - selectedAssetName == asset.name ? Color.secondary.opacity(0.1) : Color.clear + selectedAssetName == asset.name ? Color.editorFill : Color.clear ) .cornerRadius(6) } @@ -1560,14 +1713,15 @@ struct AssetBrowserView: View { let items = contents.compactMap { item -> Asset? in var isDir: ObjCBool = false if FileManager.default.fileExists(atPath: item.path, isDirectory: &isDir) { + let itemCategory = selectedCategory ?? inferCategory(for: item)?.rawValue ?? "" if isDir.boolValue { - return Asset(name: item.lastPathComponent, category: selectedCategory ?? "", path: item, isFolder: true) + return Asset(name: item.lastPathComponent, category: itemCategory, path: item, isFolder: true) } else { let allowedExtensions: Set = [runtimeAssetExtension, "utex", "png", "jpg", "jpeg", "hdr", "tif", "tiff", "ply", "json", "uscript", "remotestream"] guard allowedExtensions.contains(item.pathExtension.lowercased()) else { return nil } return Asset(name: item.lastPathComponent, - category: selectedCategory ?? "", + category: itemCategory, path: item) } } @@ -1577,13 +1731,23 @@ struct AssetBrowserView: View { VStack(alignment: .leading, spacing: 8) { ForEach(items.filter { matchesSearch($0) }) { asset in assetRow(asset) + .contextMenu { + Button(role: .destructive) { + pendingDeleteAsset = asset + showDeleteConfirmation = true + } label: { + Label("Eliminar", systemImage: "trash") + } + } .onTapGesture(count: 2) { handle_add_model_double_click(asset: asset) } .onTapGesture(count: 1) { if asset.isFolder { - // Only navigate for non-Scripts categories - if selectedCategory != AssetCategory.scripts.rawValue { + if selectedDirURL != nil { + // Generic navigation (root-level / custom folders) + selectedDirURL = asset.path + } else if selectedCategory != AssetCategory.scripts.rawValue { folderPathStack.append(asset.path) } } else { @@ -1594,7 +1758,7 @@ struct AssetBrowserView: View { } } else { Text("Folder is empty or inaccessible.") - .foregroundColor(.gray) + .foregroundColor(.editorTextTertiary) .padding() } } diff --git a/Sources/UntoldEditor/Editor/ComponentEditorForm.swift b/Sources/UntoldEditor/Editor/ComponentEditorForm.swift index 00034cc..c19e231 100644 --- a/Sources/UntoldEditor/Editor/ComponentEditorForm.swift +++ b/Sources/UntoldEditor/Editor/ComponentEditorForm.swift @@ -58,7 +58,7 @@ public struct ComponentForm: View { HStack { Text(label) .font(.caption) - .foregroundColor(.secondary) + .foregroundColor(.editorTextSecondary) TextField(placeholder ?? "", text: Binding( get: { get(entityId) }, diff --git a/Sources/UntoldEditor/Editor/DemoGalleryView.swift b/Sources/UntoldEditor/Editor/DemoGalleryView.swift index b036e92..c711f26 100644 --- a/Sources/UntoldEditor/Editor/DemoGalleryView.swift +++ b/Sources/UntoldEditor/Editor/DemoGalleryView.swift @@ -56,7 +56,7 @@ struct DemoGalleryView: View { .stroke(Color.editorDivider, lineWidth: 1) ) .cornerRadius(14) - .shadow(color: .black.opacity(0.34), radius: 24, x: 0, y: 14) + .shadow(color: .editorShadowStrong, radius: 24, x: 0, y: 14) } private var header: some View { @@ -64,11 +64,11 @@ struct DemoGalleryView: View { VStack(alignment: .leading, spacing: 8) { Text("Explore Untold Engine") .font(.largeTitle.bold()) - .foregroundColor(.white) + .foregroundColor(.editorTextPrimary) Text("Open a ready-to-navigate scene. No project setup, asset import, or scene graph knowledge required.") .font(.body) - .foregroundColor(.white.opacity(0.78)) + .foregroundColor(.editorTextSecondary) .fixedSize(horizontal: false, vertical: true) } @@ -105,7 +105,7 @@ struct DemoGalleryView: View { Text("You can switch to the full editor after loading a scene.") .font(.caption) - .foregroundColor(.white.opacity(0.58)) + .foregroundColor(.editorTextTertiary) } } } @@ -150,7 +150,7 @@ struct PreviewImportGalleryView: View { .stroke(Color.editorDivider, lineWidth: 1) ) .cornerRadius(14) - .shadow(color: .black.opacity(0.34), radius: 24, x: 0, y: 14) + .shadow(color: .editorShadowStrong, radius: 24, x: 0, y: 14) } private var header: some View { @@ -158,11 +158,11 @@ struct PreviewImportGalleryView: View { VStack(alignment: .leading, spacing: 8) { Text("Try Your Own Scene") .font(.largeTitle.bold()) - .foregroundColor(.white) + .foregroundColor(.editorTextPrimary) Text("Load an exported Untold scene file without creating a project. You can navigate immediately, then open the full editor when you are ready.") .font(.body) - .foregroundColor(.white.opacity(0.78)) + .foregroundColor(.editorTextSecondary) .fixedSize(horizontal: false, vertical: true) } @@ -186,11 +186,11 @@ struct PreviewImportGalleryView: View { VStack(alignment: .leading, spacing: 10) { Label("Need to create one of these files?", systemImage: "wand.and.stars") .font(.headline) - .foregroundColor(.white) + .foregroundColor(.editorTextPrimary) Text("Export from Blender with the Untold exporter add-on, or use the CLI exporter to produce .untold runtime assets and tiled .json scene manifests. Gaussian splats can be loaded directly from .ply files.") .font(.caption) - .foregroundColor(.white.opacity(0.68)) + .foregroundColor(.editorTextSecondary) .fixedSize(horizontal: false, vertical: true) Link( @@ -208,13 +208,13 @@ struct PreviewImportGalleryView: View { Text("4. Load here") } .font(.caption2.weight(.semibold)) - .foregroundColor(.white.opacity(0.72)) + .foregroundColor(.editorTextSecondary) } .padding(12) .background(Color.editorSurface.opacity(0.56)) .overlay( RoundedRectangle(cornerRadius: 10) - .stroke(Color.white.opacity(0.08), lineWidth: 1) + .stroke(Color.editorDivider, lineWidth: 1) ) .cornerRadius(10) } @@ -233,26 +233,26 @@ private struct PreviewImportCard: View { Image(systemName: mode.systemImageName) .font(.system(size: 38, weight: .semibold)) - .foregroundColor(.white.opacity(0.88)) + .foregroundColor(.editorTextPrimary) } .frame(height: 96) Text(mode.exploreTitle) .font(.headline) - .foregroundColor(.white) + .foregroundColor(.editorTextPrimary) Text(mode.exploreSubtitle) .font(.caption) - .foregroundColor(.white.opacity(0.68)) + .foregroundColor(.editorTextSecondary) .lineLimit(3) .fixedSize(horizontal: false, vertical: true) Text(mode.exploreFileTypes) .font(.caption2.weight(.semibold)) - .foregroundColor(.white.opacity(0.74)) + .foregroundColor(.editorTextSecondary) .padding(.horizontal, 8) .padding(.vertical, 4) - .background(Color.black.opacity(0.18)) + .background(Color.editorBadgeBackground) .cornerRadius(7) } .padding(12) @@ -260,7 +260,7 @@ private struct PreviewImportCard: View { .background(Color.editorSurface.opacity(0.72)) .overlay( RoundedRectangle(cornerRadius: 12) - .stroke(Color.white.opacity(0.08), lineWidth: 1) + .stroke(Color.editorDivider, lineWidth: 1) ) .cornerRadius(12) } @@ -281,10 +281,10 @@ struct QuickPreviewSceneOverlayView: View { VStack(alignment: .leading, spacing: 3) { Text(title) .font(.headline) - .foregroundColor(.white) + .foregroundColor(.editorTextPrimary) Text(mode?.exploreLoadedSubtitle ?? "Your Scene") .font(.caption) - .foregroundColor(.white.opacity(0.66)) + .foregroundColor(.editorTextSecondary) } Spacer() @@ -306,7 +306,7 @@ struct QuickPreviewSceneOverlayView: View { .stroke(Color.editorDivider, lineWidth: 1) ) .cornerRadius(10) - .shadow(color: .black.opacity(0.25), radius: 14, x: 0, y: 8) + .shadow(color: .editorShadow, radius: 14, x: 0, y: 8) } } @@ -396,7 +396,7 @@ private struct DemoSceneCard: View { } else { Image(systemName: demo.systemImageName) .font(.system(size: 42, weight: .semibold)) - .foregroundColor(.white.opacity(0.88)) + .foregroundColor(.editorTextPrimary) } } .frame(height: 112) @@ -404,11 +404,11 @@ private struct DemoSceneCard: View { VStack(alignment: .leading, spacing: 6) { Text(demo.title) .font(.headline) - .foregroundColor(.white) + .foregroundColor(.editorTextPrimary) Text(demo.subtitle) .font(.caption) - .foregroundColor(.white.opacity(0.68)) + .foregroundColor(.editorTextSecondary) .lineLimit(3) .fixedSize(horizontal: false, vertical: true) } @@ -420,7 +420,7 @@ private struct DemoSceneCard: View { .background(Color.editorSurface.opacity(0.72)) .overlay( RoundedRectangle(cornerRadius: 12) - .stroke(Color.white.opacity(0.08), lineWidth: 1) + .stroke(Color.editorDivider, lineWidth: 1) ) .cornerRadius(12) } @@ -433,10 +433,10 @@ private struct DemoSceneCard: View { ForEach(demo.tags.prefix(3), id: \.self) { tag in Text(tag) .font(.caption2.weight(.semibold)) - .foregroundColor(.white.opacity(0.74)) + .foregroundColor(.editorTextSecondary) .padding(.horizontal, 8) .padding(.vertical, 4) - .background(Color.black.opacity(0.18)) + .background(Color.editorBadgeBackground) .cornerRadius(7) } } @@ -454,10 +454,10 @@ struct ExploreSceneOverlayView: View { VStack(alignment: .leading, spacing: 3) { Text(demo.title) .font(.headline) - .foregroundColor(.white) + .foregroundColor(.editorTextPrimary) Text("Explore Mode") .font(.caption) - .foregroundColor(.white.opacity(0.66)) + .foregroundColor(.editorTextSecondary) } Spacer() @@ -479,6 +479,6 @@ struct ExploreSceneOverlayView: View { .stroke(Color.editorDivider, lineWidth: 1) ) .cornerRadius(10) - .shadow(color: .black.opacity(0.25), radius: 14, x: 0, y: 8) + .shadow(color: .editorShadow, radius: 14, x: 0, y: 8) } } diff --git a/Sources/UntoldEditor/Editor/EditorMenuCommands.swift b/Sources/UntoldEditor/Editor/EditorMenuCommands.swift new file mode 100644 index 0000000..bfde7f2 --- /dev/null +++ b/Sources/UntoldEditor/Editor/EditorMenuCommands.swift @@ -0,0 +1,68 @@ +// +// EditorMenuCommands.swift +// +// +// Copyright (C) Untold Engine Studios +// Licensed under the GNU LGPL v3.0 or later. +// See the LICENSE file or for details. +// +// Bridges the native macOS menu bar (built in AppKit, see main.swift) with the +// SwiftUI editor. Menu items post these notifications; EditorView listens and +// runs the matching action. Shared toggle state lives in a singleton so both +// the menu (checkmarks) and SwiftUI can read/write it. +// +import Foundation + +extension Notification.Name { + static let editorMenuNew = Notification.Name("editorMenuNew") + static let editorMenuOpen = Notification.Name("editorMenuOpen") + static let editorMenuSave = Notification.Name("editorMenuSave") + static let editorMenuSaveAs = Notification.Name("editorMenuSaveAs") + static let editorMenuReset = Notification.Name("editorMenuReset") +} + +/// Playback-related settings that must be reachable from both the AppKit menu +/// bar and SwiftUI views. Currently holds the "use the scene camera while +/// playing" toggle that used to live in the top toolbar. +final class EditorPlaybackSettings: ObservableObject { + static let shared = EditorPlaybackSettings() + + @Published var useSceneCameraDuringPlay: Bool = false + + private init() {} +} + +/// Which editor panels are visible. Shared between the AppKit View menu (for +/// checkmarks) and SwiftUI (to show/hide the panels). +final class EditorPanelVisibility: ObservableObject { + static let shared = EditorPanelVisibility() + + @Published var showLeftPanel: Bool = true + @Published var showBottomPanel: Bool = true + @Published var showRightPanel: Bool = true + + // Saved layout used by the "focus viewport" toggle (⌘F). + private var savedLayout: (left: Bool, bottom: Bool, right: Bool)? + + private init() {} + + private var anyPanelVisible: Bool { + showLeftPanel || showBottomPanel || showRightPanel + } + + /// Hide every panel to show only the viewport; toggling again restores the + /// panels that were visible before (not necessarily all of them). + func toggleFocusViewport() { + if anyPanelVisible { + savedLayout = (showLeftPanel, showBottomPanel, showRightPanel) + showLeftPanel = false + showBottomPanel = false + showRightPanel = false + } else { + let layout = savedLayout ?? (true, true, true) + showLeftPanel = layout.left + showBottomPanel = layout.bottom + showRightPanel = layout.right + } + } +} diff --git a/Sources/UntoldEditor/Editor/EditorScheme.swift b/Sources/UntoldEditor/Editor/EditorScheme.swift index 8d699a8..6fc1c8b 100644 --- a/Sources/UntoldEditor/Editor/EditorScheme.swift +++ b/Sources/UntoldEditor/Editor/EditorScheme.swift @@ -8,12 +8,93 @@ // import SwiftUI +// MARK: - Editor color scheme (Dracula-inspired) +// +// Single source of truth for the editor UI palette. All views should reference +// these semantic tokens instead of hardcoding `Color.white`, `.secondary`, +// `.red`, opacities, etc. Grouped by role so a re-theme only touches this file. extension Color { + // MARK: Base surfaces static let editorBackground = Color(red: 0.15, green: 0.16, blue: 0.21) // dracula background static let editorPanelBackground = Color(red: 0.19, green: 0.20, blue: 0.26) // dracula current line static let editorSurface = Color(red: 0.24, green: 0.25, blue: 0.32) // dracula selection + + // MARK: Accents static let editorAccent = Color(red: 0.91, green: 0.64, blue: 0.35) // muted dracula orange static let editorAccentSoft = Color(red: 0.91, green: 0.64, blue: 0.35, opacity: 0.16) static let editorSecondaryAccent = Color(red: 0.74, green: 0.58, blue: 0.98) // dracula purple - static let editorDivider = Color.white.opacity(0.10) + + // MARK: Text hierarchy + static let editorTextPrimary = Color.white // titles, primary labels, text on accent buttons + static let editorTextSecondary = Color.white.opacity(0.70) // supporting / secondary labels + static let editorTextTertiary = Color.white.opacity(0.45) // muted / disabled-looking labels + static let editorTextInverse = Color.black.opacity(0.90) // dark text on light/accent fills + + // MARK: Semantic status + static let editorError = Color(red: 0.94, green: 0.38, blue: 0.42) // dracula red + static let editorSuccess = Color(red: 0.31, green: 0.82, blue: 0.55) // dracula green + static let editorWarning = Color(red: 0.95, green: 0.78, blue: 0.42) // dracula yellow/orange + static let editorInfo = Color(red: 0.55, green: 0.73, blue: 0.98) // dracula blue/cyan + + // MARK: Fills & separators + static let editorFillSubtle = Color.white.opacity(0.05) // faint row / zebra backgrounds + static let editorFill = Color.white.opacity(0.10) // subtle panel / hover fills + static let editorDivider = Color.white.opacity(0.10) // borders, strokes, separators + static let editorDisabled = Color.white.opacity(0.15) // disabled control backgrounds + + // MARK: Overlays & shadows + static let editorShadow = Color.black.opacity(0.20) // default drop shadows + static let editorShadowStrong = Color.black.opacity(0.34) // elevated cards / popovers + static let editorScrim = Color.black.opacity(0.40) // floating stat cards over the scene + static let editorBadgeBackground = Color.black.opacity(0.18) // small badges / pills + static let editorOverlay = Color.black.opacity(0.70) // full-screen dimming overlays +} + +extension View { + /// Standard editor panel card: subtle fill, rounded corners and a soft + /// shadow. Used to give right-panel editors (Environment, Effects, + /// Inspector) a consistent look. Inner content sits 5pt from every edge. + func editorPanel() -> some View { + padding(5) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .background(Color.editorFillSubtle) + .cornerRadius(8) + .shadow(color: Color.editorShadow, radius: 3, x: 0, y: 1) + } +} + +/// Disclosure style where each nesting level is indented by exactly the width of +/// the expand/collapse chevron, so a child's content lines up with its parent's +/// label text. Also themes the chevron to match the editor. +struct EditorDisclosureStyle: DisclosureGroupStyle { + private let chevronWidth: CGFloat = 12 + private let spacing: CGFloat = 6 + + func makeBody(configuration: Configuration) -> some View { + VStack(alignment: .leading, spacing: 6) { + Button { + withAnimation(.easeInOut(duration: 0.15)) { + configuration.isExpanded.toggle() + } + } label: { + HStack(spacing: spacing) { + Image(systemName: configuration.isExpanded ? "chevron.down" : "chevron.right") + .font(.system(size: 10, weight: .semibold)) + .foregroundColor(.editorTextSecondary) + .frame(width: chevronWidth) + configuration.label + Spacer(minLength: 0) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .focusable(false) + + if configuration.isExpanded { + configuration.content + .padding(.leading, chevronWidth + spacing) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + } } diff --git a/Sources/UntoldEditor/Editor/EditorView.swift b/Sources/UntoldEditor/Editor/EditorView.swift index eb10169..b2c88a9 100644 --- a/Sources/UntoldEditor/Editor/EditorView.swift +++ b/Sources/UntoldEditor/Editor/EditorView.swift @@ -1,3 +1,4 @@ +import Combine import MetalKit import SwiftUI import UniformTypeIdentifiers @@ -19,12 +20,12 @@ private struct CameraControlHintsView: View { HStack(spacing: 8) { Label("Camera Controls", systemImage: "video.fill") .font(.caption.weight(.semibold)) - .foregroundColor(.white) + .foregroundColor(.editorTextPrimary) Spacer() Button(action: onDismiss) { Image(systemName: "xmark") .font(.system(size: 10, weight: .bold)) - .foregroundColor(.white.opacity(0.8)) + .foregroundColor(.editorTextSecondary) .frame(width: 20, height: 20) } .buttonStyle(.plain) @@ -38,7 +39,7 @@ private struct CameraControlHintsView: View { Label("WASD moves, Q/E raises and lowers", systemImage: "keyboard") } .font(.caption) - .foregroundColor(.white.opacity(0.82)) + .foregroundColor(.editorTextSecondary) .labelStyle(.titleAndIcon) } .padding(12) @@ -49,7 +50,7 @@ private struct CameraControlHintsView: View { .stroke(Color.editorDivider, lineWidth: 1) ) .cornerRadius(8) - .shadow(color: .black.opacity(0.25), radius: 12, x: 0, y: 6) + .shadow(color: .editorShadow, radius: 12, x: 0, y: 6) } } @@ -57,11 +58,18 @@ public struct EditorView: View { @State private var editor_entities: [EntityID] = getAllGameEntities() @StateObject private var selectionManager = SelectionManager() @StateObject private var sceneGraphModel = SceneGraphModel() + @StateObject private var sceneCatalog = ProjectSceneCatalog() @ObservedObject private var editorBasePath = EditorAssetBasePath.shared + @State private var pendingSceneToLoad: URL? + @State private var showSceneSwitchAlert = false @State private var assets: [String: [Asset]] = [:] @State private var selectedAsset: Asset? = nil @State private var isPlaying = false @State private var showCreateProject = false + @State private var bottomPanelTab: BottomPanelTab = .assets + @State private var rightPanelEnvTab: EnvEffectsTab = .environment + @State private var bottomSearchQuery: String = "" + @State private var consoleAutoScroll: Bool = true @State private var showInvalidProjectAlert = false @State private var invalidProjectMessage = "" @State private var showSaveNamePrompt = false @@ -70,7 +78,10 @@ public struct EditorView: View { @State private var pendingTargetURL: URL? @State private var isSaveAs = false @State private var showSaveBasePathAlert = false - @State private var useSceneCameraDuringPlay = false + @ObservedObject private var playbackSettings = EditorPlaybackSettings.shared + @ObservedObject private var panelVisibility = EditorPanelVisibility.shared + @State private var renderPauseGeneration = 0 + private let panelAnimationDuration = 0.28 @State private var showWelcomeStart = true @State private var showCameraControlHints = false @State private var cameraControlHintsDismissed = false @@ -118,79 +129,88 @@ public struct EditorView: View { public var body: some View { ZStack { VStack { - if experienceMode == .edit { - editorToolbar - Divider() - } - HStack { + HStack(spacing: 0) { if experienceMode == .edit { - VStack { - SceneHierarchyView( - selectionManager: selectionManager, - sceneGraphModel: sceneGraphModel, - entityList: editor_entities, - onAddEntity_Editor: editor_addNewEntity, - onRemoveEntity_Editor: editor_removeEntity, - onAddCube: editor_createCube, - onAddSphere: editor_createSphere, - onAddPlane: editor_createPlane, - onAddDirLight: editor_createDirLight, - onAddPointLight: editor_createPointLight, - onAddSpotLight: editor_createSpotLight, - onAddAreaLight: editor_createAreaLight, - onParentEntity: editor_parentEntity, - onUnparentEntity: editor_unparentEntity - ) + ZStack { + if panelVisibility.showLeftPanel { + VStack { + SceneHierarchyView( + selectionManager: selectionManager, + sceneGraphModel: sceneGraphModel, + sceneCatalog: sceneCatalog, + projectName: editorBasePath.projectName ?? "Untitled Project", + activeSceneURL: editorController?.currentSceneURL, + onSelectScene: editor_requestLoadScene, + isPlaying: isPlaying, + onTogglePlay: { editor_handlePlayToggle(!isPlaying) }, + entityList: editor_entities, + onAddEntity_Editor: editor_addNewEntity, + onRemoveEntity_Editor: editor_removeEntity, + onAddCube: editor_createCube, + onAddSphere: editor_createSphere, + onAddPlane: editor_createPlane, + onAddDirLight: editor_createDirLight, + onAddPointLight: editor_createPointLight, + onAddSpotLight: editor_createSpotLight, + onAddAreaLight: editor_createAreaLight, + onParentEntity: editor_parentEntity, + onUnparentEntity: editor_unparentEntity + ) + } + } + } + .frame(maxHeight: .infinity) + .overlay(alignment: .trailing) { + panelEdgeTabVertical( + isOpen: panelVisibility.showLeftPanel, + openIcon: "chevron.left", + closedIcon: "chevron.right", + help: panelVisibility.showLeftPanel ? "Hide left panel" : "Show left panel" + ) { panelVisibility.showLeftPanel.toggle() } + .offset(x: 10) } + .zIndex(1) } VStack(spacing: 0) { editorSceneViewport if experienceMode == .edit { - TransformManipulationToolbar(controller: editorController!) - .frame(height: 40) - TabView { - AssetBrowserView( - assets: $assets, - selectedAsset: $selectedAsset, - selectionManager: selectionManager, - sceneGraphModel: sceneGraphModel, - editor_addEntityWithAsset: editor_addEntityWithAsset, - editor_loadSceneAuthoredFromAsset: editor_loadSceneAuthoredFromAsset - ) - .tabItem { Label("Assets", systemImage: "shippingbox") } - - LogConsoleView() - .tabItem { Label("Console", systemImage: "terminal") } + ZStack { + if panelVisibility.showBottomPanel { + editorBottomPanel + } + } + .frame(maxWidth: .infinity) + .overlay(alignment: .top) { + panelEdgeTabHorizontal( + isOpen: panelVisibility.showBottomPanel, + help: panelVisibility.showBottomPanel ? "Hide bottom panel" : "Show bottom panel" + ) { panelVisibility.showBottomPanel.toggle() } + .offset(y: -10) } - .frame(height: 200) - .clipped() + .zIndex(1) } } + .padding(.top, 5) if experienceMode == .edit { - TabView { - EnvironmentView(selectedAsset: $selectedAsset) - .tabItem { - Label("Environment", systemImage: "sun.max") - } - - PostProcessingEditorView() - .tabItem { - Label("Effects", systemImage: "cube") - } - - InspectorView( - selectionManager: selectionManager, - sceneGraphModel: sceneGraphModel, - onAddName_Editor: editor_addName, - selectedAsset: $selectedAsset - ) - .tabItem { - Label("Inspector", systemImage: "cube") + ZStack { + if panelVisibility.showRightPanel { + editorRightPanel + .frame(minWidth: 200, maxWidth: 250, maxHeight: .infinity, alignment: .top) } } - .frame(minWidth: 200, maxWidth: 250) + .frame(maxHeight: .infinity) + .overlay(alignment: .leading) { + panelEdgeTabVertical( + isOpen: panelVisibility.showRightPanel, + openIcon: "chevron.right", + closedIcon: "chevron.left", + help: panelVisibility.showRightPanel ? "Hide right panel" : "Show right panel" + ) { panelVisibility.showRightPanel.toggle() } + .offset(x: -10) + } + .zIndex(1) } } } @@ -202,11 +222,22 @@ public struct EditorView: View { ) .ignoresSafeArea() ) + // Animate panel show/hide from any trigger (edge tabs, ⌘1/2/3, ⌘F) + // and pause the render loop for the duration so it stays fluid. + .animation(.easeInOut(duration: panelAnimationDuration), value: panelVisibility.showLeftPanel) + .animation(.easeInOut(duration: panelAnimationDuration), value: panelVisibility.showBottomPanel) + .animation(.easeInOut(duration: panelAnimationDuration), value: panelVisibility.showRightPanel) + .onChange(of: panelVisibility.showLeftPanel) { _, _ in pauseRenderForPanelAnimation() } + .onChange(of: panelVisibility.showBottomPanel) { _, _ in pauseRenderForPanelAnimation() } + .onChange(of: panelVisibility.showRightPanel) { _, _ in pauseRenderForPanelAnimation() } // Loading indicator overlay LoadingIndicatorView() .allowsHitTesting(false) } + // Force dark appearance so system controls (tabs, segmented pickers, + // menus, buttons) render light-on-dark to match the editor theme. + .preferredColorScheme(.dark) .onAppear { EditorUndoManager.shared.onStateRestored = { editor_entities = getAllGameEntities() @@ -215,6 +246,7 @@ public struct EditorView: View { } sceneGraphModel.refreshHierarchy() + sceneCatalog.refresh() syncEditorAvailabilityForExperienceMode() // Listen for asset instance loading completion @@ -236,9 +268,32 @@ public struct EditorView: View { cleanupForProjectSwitch() } } - .onChange(of: useSceneCameraDuringPlay) { _, _ in + .onChange(of: playbackSettings.useSceneCameraDuringPlay) { _, _ in updateActiveCameraForPlayMode() } + // Pause the render loop while the user drags to resize the window so the + // viewport doesn't stutter against the live resize; resume when done. + .onReceive(NotificationCenter.default.publisher(for: NSWindow.willStartLiveResizeNotification)) { _ in + renderer?.metalView.isPaused = true + } + .onReceive(NotificationCenter.default.publisher(for: NSWindow.didEndLiveResizeNotification)) { _ in + renderer?.metalView.isPaused = false + } + .onReceive(NotificationCenter.default.publisher(for: .editorMenuNew)) { _ in + showCreateProject = true + } + .onReceive(NotificationCenter.default.publisher(for: .editorMenuOpen)) { _ in + openExistingProjectFromWelcome() + } + .onReceive(NotificationCenter.default.publisher(for: .editorMenuSave)) { _ in + editor_handleSave() + } + .onReceive(NotificationCenter.default.publisher(for: .editorMenuSaveAs)) { _ in + editor_handleSaveAs() + } + .onReceive(NotificationCenter.default.publisher(for: .editorMenuReset)) { _ in + editor_clearScene() + } .onChange(of: experienceMode) { _, _ in syncEditorAvailabilityForExperienceMode() } @@ -284,6 +339,17 @@ public struct EditorView: View { .sheet(item: $pendingQuickPreviewExport) { request in quickPreviewRuntimeExportSheet(for: request) } + .alert("Load Scene?", isPresented: $showSceneSwitchAlert) { + Button("Cancel", role: .cancel) { + pendingSceneToLoad = nil + } + Button("Load Scene") { + editor_confirmLoadPendingScene() + } + } message: { + let name = pendingSceneToLoad?.deletingPathExtension().lastPathComponent ?? "this scene" + return Text("Loading \"\(name)\" will replace the current scene. Any unsaved changes will be lost.") + } } private var editorSceneViewport: some View { @@ -348,37 +414,269 @@ public struct EditorView: View { .padding(.bottom, 14) } } + .overlay(alignment: .top) { + if experienceMode == .edit, let controller = editorController { + TransformModeCluster(controller: controller) + .padding(.top, 12) + } + } + } + + private enum BottomPanelTab: Hashable { + case assets + case console + } + + private enum EnvEffectsTab: Hashable { + case environment + case effects + } + + // Right panel is contextual: the project shows Environment/Effects (with a + // themed segmented switch); a selected object shows the Inspector. + private var editorRightPanel: some View { + Group { + if selectionManager.projectSelected { + VStack(spacing: 0) { + HStack { + envEffectsTabs + Spacer() + } + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(Color.editorPanelBackground.opacity(0.9)) + .padding(.top, 5) + + Group { + switch rightPanelEnvTab { + case .environment: + EnvironmentView(selectedAsset: $selectedAsset) + case .effects: + PostProcessingEditorView() + } + } + .editorPanel() + .padding(5) + } + } else { + InspectorView( + selectionManager: selectionManager, + sceneGraphModel: sceneGraphModel, + onAddName_Editor: editor_addName, + selectedAsset: $selectedAsset + ) + .editorPanel() + .padding(5) + } + } + } + + private var envEffectsTabs: some View { + HStack(spacing: 2) { + envTabButton(.environment, title: "Environment", icon: "sun.max") + envTabButton(.effects, title: "Effects", icon: "cube") + } + .padding(3) + .background(Color.editorSurface.opacity(0.6)) + .cornerRadius(7) + .overlay( + RoundedRectangle(cornerRadius: 7) + .stroke(Color.editorDivider, lineWidth: 1) + ) } - private var editorToolbar: some View { - ToolbarView( - selectionManager: selectionManager, - onSave: editor_handleSave, - onSaveAs: editor_handleSaveAs, - onClear: editor_clearScene, - onPlayToggled: { isPlaying in - editor_handlePlayToggle(isPlaying) - }, - useSceneCameraDuringPlay: $useSceneCameraDuringPlay, - dirLightCreate: editor_createDirLight, - pointLightCreate: editor_createPointLight, - spotLightCreate: editor_createSpotLight, - areaLightCreate: editor_createAreaLight, - onCreateCube: editor_createCube, - onCreateSphere: editor_createSphere, - onCreatePlane: editor_createPlane, - onCreateCylinder: editor_createCylinder, - onCreateCone: editor_createCone + private func envTabButton(_ tab: EnvEffectsTab, title: String, icon: String) -> some View { + let isSelected = rightPanelEnvTab == tab + return Button(action: { rightPanelEnvTab = tab }) { + HStack(spacing: 5) { + Image(systemName: icon) + .font(.system(size: 11, weight: .semibold)) + Text(title) + .font(.system(size: 12, weight: .semibold)) + } + .padding(.vertical, 5) + .padding(.horizontal, 12) + .foregroundColor(isSelected ? .editorTextPrimary : .editorTextSecondary) + .background(isSelected ? Color.editorAccent : Color.clear) + .cornerRadius(5) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .focusable(false) + } + + // Themed segmented selector matching the editor style (accent-filled active + // segment inside a rounded surface container). + private var editorPanelTabs: some View { + HStack(spacing: 2) { + panelTabButton(.assets, title: "Assets", icon: "shippingbox") + panelTabButton(.console, title: "Console", icon: "terminal") + } + .padding(3) + .background(Color.editorSurface.opacity(0.6)) + .cornerRadius(7) + .overlay( + RoundedRectangle(cornerRadius: 7) + .stroke(Color.editorDivider, lineWidth: 1) ) } + private func panelTabButton(_ tab: BottomPanelTab, title: String, icon: String) -> some View { + let isSelected = bottomPanelTab == tab + return Button(action: { bottomPanelTab = tab }) { + HStack(spacing: 5) { + Image(systemName: icon) + .font(.system(size: 11, weight: .semibold)) + Text(title) + .font(.system(size: 12, weight: .semibold)) + } + .padding(.vertical, 5) + .padding(.horizontal, 12) + .foregroundColor(isSelected ? .editorTextPrimary : .editorTextSecondary) + .background(isSelected ? Color.editorAccent : Color.clear) + .cornerRadius(5) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .focusable(false) + } + + // Bottom dock: a segmented Assets/Console selector (replacing the old + // native TabView tab strip) plus the selected panel below it. + // Pause the Metal render loop for the duration of a panel show/hide + // animation so the viewport doesn't compete with the layout change (which + // caused stutter). Called from onChange, so it covers every trigger: edge + // tabs, the View menu (⌘1/2/3) and Focus Viewport (⌘F). The viewport freezes + // on its last frame, then resumes. + private func pauseRenderForPanelAnimation() { + renderer?.metalView.isPaused = true + renderPauseGeneration += 1 + let generation = renderPauseGeneration + DispatchQueue.main.asyncAfter(deadline: .now() + panelAnimationDuration + 0.05) { + if generation == renderPauseGeneration { + renderer?.metalView.isPaused = false + } + } + } + + // Small always-visible tab that protrudes from a panel's inner edge (placed + // as an overlay above the viewport) to collapse/expand the panel. + private func panelEdgeTabVertical( + isOpen: Bool, + openIcon: String, + closedIcon: String, + help: String, + action: @escaping () -> Void + ) -> some View { + Button(action: action) { + Image(systemName: isOpen ? openIcon : closedIcon) + .font(.system(size: 9, weight: .bold)) + .foregroundColor(.editorTextSecondary) + .frame(width: 16, height: 48) + .background(Color.editorPanelBackground) + .clipShape(RoundedRectangle(cornerRadius: 5)) + .overlay( + RoundedRectangle(cornerRadius: 5) + .stroke(Color.editorDivider, lineWidth: 1) + ) + .shadow(color: Color.editorShadow, radius: 4, x: 0, y: 1) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .focusable(false) + .help(help) + } + + private func panelEdgeTabHorizontal( + isOpen: Bool, + help: String, + action: @escaping () -> Void + ) -> some View { + Button(action: action) { + Image(systemName: isOpen ? "chevron.down" : "chevron.up") + .font(.system(size: 9, weight: .bold)) + .foregroundColor(.editorTextSecondary) + .frame(width: 48, height: 16) + .background(Color.editorPanelBackground) + .clipShape(RoundedRectangle(cornerRadius: 5)) + .overlay( + RoundedRectangle(cornerRadius: 5) + .stroke(Color.editorDivider, lineWidth: 1) + ) + .shadow(color: Color.editorShadow, radius: 4, x: 0, y: 1) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .focusable(false) + .help(help) + } + + private var editorBottomPanel: some View { + VStack(spacing: 0) { + HStack(spacing: 8) { + editorPanelTabs + Spacer() + HStack(spacing: 6) { + Image(systemName: "magnifyingglass") + .font(.system(size: 11)) + .foregroundColor(.editorTextSecondary) + ExplicitClickTextField( + text: $bottomSearchQuery, + placeholder: bottomPanelTab == .assets ? "Filter assets" : "Filter console" + ) + } + .padding(.horizontal, 8) + .padding(.vertical, 3) + .frame(maxWidth: 240) + .background(Color.editorSurface.opacity(0.6)) + .cornerRadius(6) + + if bottomPanelTab == .console { + Toggle("Auto‑scroll", isOn: $consoleAutoScroll) + .toggleStyle(.checkbox) + .font(.system(size: 11)) + + Button(action: { LogStore.shared.clear() }) { + Image(systemName: "trash") + .foregroundColor(.editorTextSecondary) + } + .buttonStyle(.plain) + .focusable(false) + .help("Clear console") + } + } + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(Color.editorPanelBackground.opacity(0.9)) + + Group { + switch bottomPanelTab { + case .assets: + AssetBrowserView( + assets: $assets, + selectedAsset: $selectedAsset, + selectionManager: selectionManager, + sceneGraphModel: sceneGraphModel, + searchQuery: $bottomSearchQuery, + editor_addEntityWithAsset: editor_addEntityWithAsset, + editor_loadSceneAuthoredFromAsset: editor_loadSceneAuthoredFromAsset + ) + case .console: + LogConsoleView(searchQuery: $bottomSearchQuery, autoScroll: $consoleAutoScroll) + } + } + .frame(height: 200) + .clipped() + } + } + private var saveScenePrompt: some View { VStack(spacing: 12) { Text("Save Scene") .font(.headline) Text("Scenes are saved to the Scenes folder in your Asset Folder.") .font(.caption) - .foregroundColor(.secondary) + .foregroundColor(.editorTextSecondary) TextField("Scene name", text: $pendingSceneName) .textFieldStyle(RoundedBorderTextFieldStyle()) @@ -611,6 +909,7 @@ public struct EditorView: View { if let sceneURL = editorController?.currentSceneURL { let sceneData: SceneData = serializeScene() saveSceneDirect(sceneData: sceneData, to: sceneURL) + sceneCatalog.refresh() return } @@ -687,6 +986,7 @@ public struct EditorView: View { showSaveNamePrompt = false showOverwriteAlert = false isSaveAs = false + sceneCatalog.refresh() } private func editor_handleLoad() { @@ -723,6 +1023,48 @@ public struct EditorView: View { } } + // Load a scene file from the project into the (single) ECS world, replacing + // whatever is currently loaded. Used by the Scene Graph panel. + private func editor_loadScene(from url: URL) { + guard let sceneData = loadGameScene(from: url) else { + print("❌ Failed to load scene from \(url.lastPathComponent)") + return + } + + destroyAllEntities() + removeGizmo() + EditorComponentsState.shared.clear() + EditorUndoManager.shared.clear() + sceneAuthoredGameCamera = nil + + deserializeScene(sceneData: sceneData) + editorController?.currentSceneURL = url + + editor_entities = getAllGameEntities() + selectionManager.selectedEntity = nil + activeEntity = .invalid + gizmoActive = false + selectionManager.objectWillChange.send() + sceneGraphModel.refreshHierarchy() + sceneCatalog.refresh() + + CameraSystem.shared.activeCamera = findSceneCamera() + print("✅ Scene loaded: \(url.lastPathComponent)") + } + + // Ask before switching scenes: loading discards the current world. + private func editor_requestLoadScene(_ url: URL) { + if url == editorController?.currentSceneURL { return } + pendingSceneToLoad = url + showSceneSwitchAlert = true + } + + private func editor_confirmLoadPendingScene() { + guard let url = pendingSceneToLoad else { return } + pendingSceneToLoad = nil + editor_loadScene(from: url) + } + private func editor_clearScene() { destroyAllEntities() removeGizmo() @@ -854,7 +1196,7 @@ public struct EditorView: View { } private func enableExploreNavigationMode() { - useSceneCameraDuringPlay = true + playbackSettings.useSceneCameraDuringPlay = true setEditorPlayMode(true) CameraSystem.shared.activeCamera = findSceneCamera() } @@ -867,7 +1209,7 @@ public struct EditorView: View { private func updateActiveCameraForPlayMode() { if gameMode { - CameraSystem.shared.activeCamera = useSceneCameraDuringPlay ? findSceneCamera() : findEditorGameCamera() + CameraSystem.shared.activeCamera = playbackSettings.useSceneCameraDuringPlay ? findSceneCamera() : findEditorGameCamera() } else { CameraSystem.shared.activeCamera = findSceneCamera() } @@ -1648,14 +1990,14 @@ public struct EditorView: View { VStack(alignment: .leading, spacing: 6) { Text("Source") .font(.caption) - .foregroundColor(.secondary) + .foregroundColor(.editorTextSecondary) Text(request.sourceURL.path) .font(.system(size: 12, design: .monospaced)) .lineLimit(2) Text("Output") .font(.caption) - .foregroundColor(.secondary) + .foregroundColor(.editorTextSecondary) .padding(.top, 6) Text(request.outputURL.path) .font(.system(size: 12, design: .monospaced)) @@ -1676,7 +2018,7 @@ public struct EditorView: View { if quickPreviewCompressGeometry { Text("Requires: pip install lz4") .font(.caption) - .foregroundColor(.secondary) + .foregroundColor(.editorTextSecondary) .padding(.leading, 20) } @@ -1689,15 +2031,15 @@ public struct EditorView: View { .font(.caption) Text("-") .font(.caption) - .foregroundColor(.secondary) + .foregroundColor(.editorTextSecondary) Text("Also requires: pip install Pillow") .font(.caption) - .foregroundColor(.secondary) + .foregroundColor(.editorTextSecondary) } VStack(alignment: .leading, spacing: 4) { Text("astcenc path (optional)") .font(.caption) - .foregroundColor(.secondary) + .foregroundColor(.editorTextSecondary) HStack { TextField("/opt/homebrew/bin/astcenc", text: $quickPreviewAstcencBinPath) .textFieldStyle(.roundedBorder) @@ -1724,7 +2066,7 @@ public struct EditorView: View { ProgressView() .controlSize(.small) Text("Exporting...") - .foregroundColor(.secondary) + .foregroundColor(.editorTextSecondary) } } diff --git a/Sources/UntoldEditor/Editor/EngineStatsView.swift b/Sources/UntoldEditor/Editor/EngineStatsView.swift index 04c2c40..4adca03 100644 --- a/Sources/UntoldEditor/Editor/EngineStatsView.swift +++ b/Sources/UntoldEditor/Editor/EngineStatsView.swift @@ -106,22 +106,22 @@ struct EngineStatsOverlayView: View { VStack(alignment: .leading, spacing: 4) { Text("Engine Stats") .font(.system(size: 11, weight: .semibold)) - .foregroundColor(.white) + .foregroundColor(.editorTextPrimary) Text(compactOverlayLine(snapshot)) .font(.system(size: 10, design: .monospaced)) - .foregroundColor(.white.opacity(0.95)) + .foregroundColor(.editorTextPrimary) .lineLimit(3) } .padding(.horizontal, 10) .padding(.vertical, 8) - .background(Color.black.opacity(0.45)) + .background(Color.editorScrim) .overlay( RoundedRectangle(cornerRadius: 8) - .stroke(Color.white.opacity(0.12), lineWidth: 1) + .stroke(Color.editorDivider, lineWidth: 1) ) .cornerRadius(8) - .shadow(color: Color.black.opacity(0.2), radius: 8, x: 0, y: 2) + .shadow(color: Color.editorShadow, radius: 8, x: 0, y: 2) .allowsHitTesting(false) .padding(12) } @@ -130,22 +130,22 @@ struct EngineStatsOverlayView: View { VStack(alignment: .leading, spacing: 6) { Text("Engine Stats (Advanced)") .font(.system(size: 11, weight: .semibold)) - .foregroundColor(.white) + .foregroundColor(.editorTextPrimary) Text(formatEngineStatsOverlay(snapshot)) .font(.system(size: 10, design: .monospaced)) - .foregroundColor(.white.opacity(0.95)) + .foregroundColor(.editorTextPrimary) .fixedSize(horizontal: false, vertical: true) } .padding(.horizontal, 10) .padding(.vertical, 8) - .background(Color.black.opacity(0.38)) + .background(Color.editorScrim) .overlay( RoundedRectangle(cornerRadius: 8) - .stroke(Color.white.opacity(0.12), lineWidth: 1) + .stroke(Color.editorDivider, lineWidth: 1) ) .cornerRadius(8) - .shadow(color: Color.black.opacity(0.2), radius: 8, x: 0, y: 2) + .shadow(color: Color.editorShadow, radius: 8, x: 0, y: 2) .allowsHitTesting(false) .padding(12) } diff --git a/Sources/UntoldEditor/Editor/EnvironmentView.swift b/Sources/UntoldEditor/Editor/EnvironmentView.swift index da492c8..43e3402 100644 --- a/Sources/UntoldEditor/Editor/EnvironmentView.swift +++ b/Sources/UntoldEditor/Editor/EnvironmentView.swift @@ -48,12 +48,9 @@ struct EnvironmentView: View { // MARK: - Header HStack(spacing: 6) { - Image(systemName: "leaf.arrow.triangle.circlepath") - .foregroundColor(.accentColor) - .font(.system(size: 14)) // Smaller icon Text("Environment Settings") .font(.headline) // Smaller title - .foregroundColor(.primary) + .foregroundColor(.editorTextPrimary) } .padding(.bottom, 6) @@ -66,7 +63,7 @@ struct EnvironmentView: View { }) { HStack(spacing: 6) { Image(systemName: "plus.circle.fill") - .foregroundColor(.white) + .foregroundColor(.editorTextPrimary) .font(.system(size: 12)) // Smaller icon Text("Add IBL") .font(.system(size: 12)) @@ -75,7 +72,7 @@ struct EnvironmentView: View { .padding(.vertical, 4) .padding(.horizontal, 8) .background(Color.editorAccent) - .foregroundColor(.white) + .foregroundColor(.editorTextPrimary) .cornerRadius(6) } .buttonStyle(PlainButtonStyle()) @@ -90,7 +87,7 @@ struct EnvironmentView: View { .font(.system(size: 12)) } .toggleStyle(SwitchToggleStyle()) - .scaleEffect(0.85) // Make toggle smaller + .scaleEffect(0.85, anchor: .leading) // Make toggle smaller, keep left edge aligned .onChange(of: enableApplyIBL) { _, newValue in applyIBL = newValue } @@ -100,7 +97,7 @@ struct EnvironmentView: View { .font(.system(size: 12)) } .toggleStyle(SwitchToggleStyle()) - .scaleEffect(0.85) + .scaleEffect(0.85, anchor: .leading) .onChange(of: enableRenderEnvironment) { _, newValue in renderEnvironment = newValue } @@ -113,7 +110,7 @@ struct EnvironmentView: View { VStack(alignment: .leading, spacing: 4) { Text("Ambient Intensity") .font(.system(size: 12)) - .foregroundColor(.primary) + .foregroundColor(.editorTextPrimary) TextInputNumberView(label: "Intensity", value: Binding( get: { intensity }, @@ -125,10 +122,6 @@ struct EnvironmentView: View { .frame(maxWidth: 80) // Make the input field smaller } } - .padding(8) // Reduce padding - .background(Color.secondary.opacity(0.1)) - .cornerRadius(8) - .shadow(color: Color.black.opacity(0.1), radius: 3, x: 0, y: 1) .onAppear { enableApplyIBL = applyIBL enableRenderEnvironment = renderEnvironment @@ -215,7 +208,7 @@ struct ColorGradingEditorView: View { UndoableEffectSlider(label: "Contrast", undoName: "Change Contrast", range: -5.0 ... 5.0, get: { settings.contrast }, set: { settings.contrast = $0 }) UndoableEffectSlider(label: "Saturation", undoName: "Change Saturation", range: 0.0 ... 5.0, get: { settings.saturation }, set: { settings.saturation = $0 }) } - .padding() + .padding(.vertical, 4) } } @@ -245,7 +238,7 @@ struct WhiteBalanceEditorView: View { // settings.gain = newGain // })) } - .padding() + .padding(.vertical, 4) } } @@ -264,7 +257,7 @@ struct BloomEditorView: View { UndoableEffectSlider(label: "Threshold", undoName: "Change Bloom Threshold", range: 0.0 ... 5.0, get: { settings.threshold }, set: { settings.threshold = $0 }) UndoableEffectSlider(label: "Intensity", undoName: "Change Bloom Intensity", range: 0.0 ... 100.0, get: { settings.intensity }, set: { settings.intensity = $0 }) } - .padding() + .padding(.vertical, 4) } } @@ -290,7 +283,7 @@ struct VignetteEditorView: View { // settings.center = newCenter // })) } - .padding() + .padding(.vertical, 4) } } @@ -314,7 +307,7 @@ struct ChromaticAberrationEditorView: View { // settings.center = newCenter // })) } - .padding() + .padding(.vertical, 4) } } @@ -334,7 +327,7 @@ struct DepthOfFieldEditorView: View { UndoableEffectSlider(label: "Focus Range", undoName: "Change Focus Range", range: 0.0 ... 10.0, format: "%.4f", get: { settings.focusRange }, set: { settings.focusRange = $0 }) UndoableEffectSlider(label: "Max Blur", undoName: "Change Max Blur", range: 0.0 ... 0.05, format: "%.4f", get: { settings.maxBlur }, set: { settings.maxBlur = $0 }) } - .padding() + .padding(.vertical, 4) } } @@ -354,7 +347,7 @@ struct SSAOEditorView: View { UndoableEffectSlider(label: "Bias", undoName: "Change SSAO Bias", range: 0.0 ... 0.1, format: "%.4f", get: { settings.bias }, set: { settings.bias = $0 }) UndoableEffectSlider(label: "Intensity", undoName: "Change SSAO Intensity", range: 0.0 ... 2.0, get: { settings.intensity }, set: { settings.intensity = $0 }) } - .padding() + .padding(.vertical, 4) } } @@ -401,26 +394,54 @@ struct AntiAliasingEditorView: View { @State private var selectedMode = EditorAntiAliasingOption.currentEngineMode() @State private var showAdvanced = false + private func selectMode(_ newValue: EditorAntiAliasingOption) { + let oldValue = selectedMode + guard oldValue != newValue else { return } + selectedMode = newValue + antiAliasingMode = newValue.engineMode + EditorUndoManager.shared.registerValueChange( + name: "Change Anti-Aliasing", + oldValue: oldValue, + newValue: newValue, + apply: { option in + antiAliasingMode = option.engineMode + selectedMode = option + } + ) + } + var body: some View { VStack(alignment: .leading, spacing: 8) { - Picker("Mode", selection: $selectedMode) { + // Themed segmented control (equal-width segments that adapt to the + // panel width — unlike a native .segmented picker, which has a large + // intrinsic minimum width and would push the panel out of alignment). + HStack(spacing: 2) { ForEach(EditorAntiAliasingOption.allCases) { option in - Text(option.rawValue).tag(option) - } - } - .pickerStyle(.segmented) - .onChange(of: selectedMode) { oldValue, newValue in - antiAliasingMode = newValue.engineMode - EditorUndoManager.shared.registerValueChange( - name: "Change Anti-Aliasing", - oldValue: oldValue, - newValue: newValue, - apply: { option in - antiAliasingMode = option.engineMode - selectedMode = option + let isSelected = selectedMode == option + Button(action: { selectMode(option) }) { + Text(option.rawValue) + .font(.system(size: 11, weight: .semibold)) + .lineLimit(1) + .minimumScaleFactor(0.7) + .frame(maxWidth: .infinity) + .padding(.vertical, 5) + .foregroundColor(isSelected ? .editorTextPrimary : .editorTextSecondary) + .background(isSelected ? Color.editorAccent : Color.clear) + .cornerRadius(5) + .contentShape(Rectangle()) } - ) + .buttonStyle(.plain) + .focusable(false) + } } + .padding(3) + .frame(maxWidth: .infinity) + .background(Color.editorSurface.opacity(0.6)) + .cornerRadius(7) + .overlay( + RoundedRectangle(cornerRadius: 7) + .stroke(Color.editorDivider, lineWidth: 1) + ) switch selectedMode { case .off: @@ -472,7 +493,7 @@ struct AntiAliasingEditorView: View { EmptyView() } } - .padding() + .padding(.vertical, 4) .onAppear { selectedMode = EditorAntiAliasingOption.currentEngineMode() } @@ -535,7 +556,7 @@ struct PostProcessingEditorView: View { ) } } - .padding() + .padding(.vertical, 4) } DisclosureGroup("Anti-Aliasing", isExpanded: $showAntiAliasing) { @@ -570,7 +591,9 @@ struct PostProcessingEditorView: View { SSAOEditorView() } } - .padding() + .padding(.vertical, 4) + .frame(maxWidth: .infinity, alignment: .leading) } + .disclosureGroupStyle(EditorDisclosureStyle()) } } diff --git a/Sources/UntoldEditor/Editor/InspectorView.swift b/Sources/UntoldEditor/Editor/InspectorView.swift index cadf8cf..66148e4 100644 --- a/Sources/UntoldEditor/Editor/InspectorView.swift +++ b/Sources/UntoldEditor/Editor/InspectorView.swift @@ -351,7 +351,7 @@ struct InspectorView: View { removeComponentFromEntity_Editor(componentType: editor_component.type) }) { Image(systemName: "trash") - .foregroundColor(.red) + .foregroundColor(.editorError) } .buttonStyle(BorderlessButtonStyle()) } @@ -380,7 +380,7 @@ struct InspectorView: View { .padding(.vertical, 6) .padding(.horizontal, 10) .background(Color.accentColor) - .foregroundColor(.white) + .foregroundColor(.editorTextPrimary) .cornerRadius(6) } .menuStyle(.borderlessButton) @@ -388,7 +388,7 @@ struct InspectorView: View { } } else { - Text("No entity selected").foregroundColor(.gray) + Text("No entity selected").foregroundColor(.editorTextTertiary) } } } @@ -396,11 +396,10 @@ struct InspectorView: View { } else { Text("No entity selected") - .foregroundColor(.gray) + .foregroundColor(.editorTextTertiary) } } - .frame(minWidth: 200, maxWidth: 250) - .padding() + .frame(maxWidth: .infinity, alignment: .leading) } func addComponentToEntity_Editor(componentType: Any.Type) { @@ -580,14 +579,14 @@ struct StaticBatchingEditorView: View { )) { HStack { Image(systemName: "square.3.layers.3d") - .foregroundColor(.blue) + .foregroundColor(.editorInfo) Text(labelText) .font(.callout) } } .padding(.vertical, 6) .padding(.horizontal, 8) - .background(Color.secondary.opacity(0.05)) + .background(Color.editorFillSubtle) .cornerRadius(8) .help(helpText) .onAppear { @@ -633,22 +632,22 @@ struct RenderingEditorView: View { }) { HStack(spacing: 6) { Image(systemName: "plus.circle.fill") - .foregroundColor(.white) + .foregroundColor(.editorTextPrimary) Text("Assign") .fontWeight(.regular) } .padding(.vertical, 8) .padding(.horizontal, 12) .background(Color.editorAccent) - .foregroundColor(.white) + .foregroundColor(.editorTextPrimary) .cornerRadius(8) - .shadow(color: Color.black.opacity(0.2), radius: 4, x: 0, y: 2) + .shadow(color: Color.editorShadow, radius: 4, x: 0, y: 2) } .buttonStyle(PlainButtonStyle()) } } .padding(8) - .background(Color.secondary.opacity(0.05)) + .background(Color.editorFillSubtle) .cornerRadius(8) if hasComponent(entityId: entityId, componentType: RenderComponent.self), @@ -685,7 +684,7 @@ struct RenderingEditorView: View { Image(systemName: "photo") .resizable() .frame(width: 64, height: 64) - .foregroundColor(.gray) + .foregroundColor(.editorTextTertiary) } } .buttonStyle(PlainButtonStyle()) @@ -696,7 +695,7 @@ struct RenderingEditorView: View { refreshView() }) { Image(systemName: "minus.circle.fill") - .foregroundColor(.red) + .foregroundColor(.editorError) } .buttonStyle(BorderlessButtonStyle()) @@ -706,7 +705,7 @@ struct RenderingEditorView: View { refreshView() }) { Image(systemName: "arrow.counterclockwise.circle.fill") - .foregroundColor(.blue) + .foregroundColor(.editorInfo) } .buttonStyle(BorderlessButtonStyle()) .help("Restore original embedded texture") @@ -724,7 +723,7 @@ struct RenderingEditorView: View { VStack(alignment: .leading, spacing: 4) { Text("Wrap Mode") .font(.caption) - .foregroundColor(.secondary) + .foregroundColor(.editorTextSecondary) Picker("", selection: bindingForWrapMode(entityId: entityId, textureType: type, meshIndex: meshIndex, onChange: refreshView)) { ForEach(WrapMode.allCases) { mode in @@ -746,7 +745,7 @@ struct RenderingEditorView: View { HStack { Text("UV Scale") .font(.callout) - .foregroundColor(.secondary) + .foregroundColor(.editorTextSecondary) TextInputNumberView( label: "", @@ -799,7 +798,7 @@ struct RenderingEditorView: View { Text("Roughness") .font(.caption) - .foregroundColor(.secondary) + .foregroundColor(.editorTextSecondary) } // Metallic Input @@ -822,7 +821,7 @@ struct RenderingEditorView: View { Text("Metallic") .font(.caption) - .foregroundColor(.secondary) + .foregroundColor(.editorTextSecondary) } } @@ -846,7 +845,7 @@ struct RenderingEditorView: View { Text("Opacity") .font(.caption) - .foregroundColor(.secondary) + .foregroundColor(.editorTextSecondary) } VStack { @@ -870,7 +869,7 @@ struct RenderingEditorView: View { Text("Alpha Mask") .font(.caption) - .foregroundColor(.secondary) + .foregroundColor(.editorTextSecondary) } } @@ -883,7 +882,7 @@ struct RenderingEditorView: View { if let untoldUpdateStatus { Text(untoldUpdateStatus) .font(.caption) - .foregroundColor(.secondary) + .foregroundColor(.editorTextSecondary) } } @@ -904,7 +903,7 @@ struct RenderingEditorView: View { Text("Emmisive") .font(.caption) - .foregroundColor(.secondary) + .foregroundColor(.editorTextSecondary) } } } @@ -969,11 +968,11 @@ private struct AssetNodeInspectorBanner: View { var body: some View { HStack(spacing: 8) { Image(systemName: isBindableAssetMeshNode(entityId) ? "cube.fill" : "square.stack.3d.up") - .foregroundColor(.secondary) + .foregroundColor(.editorTextSecondary) Text(isBindableAssetMeshNode(entityId) ? "Mesh Node" : "Asset Node") .font(.caption) - .foregroundColor(.secondary) + .foregroundColor(.editorTextSecondary) Spacer() @@ -992,7 +991,7 @@ private struct AssetNodeInspectorBanner: View { } } .padding(6) - .background(Color.secondary.opacity(0.12)) + .background(Color.editorFill) .cornerRadius(6) } } @@ -1030,7 +1029,7 @@ private struct TileMeshListInspectorView: View { Spacer() Text("\(meshEntries.count)") .font(.caption) - .foregroundColor(.secondary) + .foregroundColor(.editorTextSecondary) } if let tileComponent { @@ -1045,14 +1044,14 @@ private struct TileMeshListInspectorView: View { if meshEntries.isEmpty { Text("No resident meshes for this tile yet.") .font(.caption) - .foregroundColor(.secondary) + .foregroundColor(.editorTextSecondary) } else { VStack(alignment: .leading, spacing: 5) { ForEach(meshEntries) { entry in HStack(spacing: 6) { Image(systemName: "cube.fill") .font(.caption) - .foregroundColor(.secondary) + .foregroundColor(.editorTextSecondary) Text(entry.displayName) .font(.caption) @@ -1063,24 +1062,24 @@ private struct TileMeshListInspectorView: View { Text("\(entry.submeshCount) sub") .font(.caption2) - .foregroundColor(.secondary) + .foregroundColor(.editorTextSecondary) } } } } } .padding(8) - .background(Color.secondary.opacity(0.08)) + .background(Color.editorFill) .cornerRadius(8) } private func tileInfoRow(_ label: String, _ value: String) -> some View { HStack { Text(label) - .foregroundColor(.secondary) + .foregroundColor(.editorTextSecondary) Spacer() Text(value) - .foregroundColor(.primary) + .foregroundColor(.editorTextPrimary) .lineLimit(1) .truncationMode(.middle) } @@ -1124,7 +1123,7 @@ private struct ReadOnlyVectorView: View { Text(value, format: .number.precision(.fractionLength(3))) .frame(width: 60) .padding(.vertical, 3) - .background(Color.secondary.opacity(0.12)) + .background(Color.editorFill) .cornerRadius(4) } } @@ -1142,13 +1141,13 @@ struct TransformationEditorView: View { if hasComponent(entityId: entityId, componentType: StaticBatchComponent.self) { HStack { Image(systemName: "exclamationmark.triangle.fill") - .foregroundColor(.orange) + .foregroundColor(.editorWarning) Text("This entity is marked for static batching. Transforming it will disable batching.") .font(.caption) - .foregroundColor(.orange) + .foregroundColor(.editorWarning) } .padding(6) - .background(Color.orange.opacity(0.1)) + .background(Color.editorWarning.opacity(0.1)) .cornerRadius(6) } @@ -1204,7 +1203,7 @@ struct TransformationEditorView: View { } else { Text("No transform data") .font(.caption) - .foregroundColor(.secondary) + .foregroundColor(.editorTextSecondary) } } @@ -1239,14 +1238,14 @@ struct AnimationEditorView: View { refreshView() }) { Image(systemName: "trash") - .foregroundColor(.red) + .foregroundColor(.editorError) } } } } .frame(height: 100) .scrollContentBackground(.hidden) // Hide default background - .background(Color.gray.opacity(0.3)) // Apply a dark background + .background(Color.editorFill) // Apply a dark background .cornerRadius(8) // Optional: Add corner radius for a sleek look // Add animation UI HStack { @@ -1259,16 +1258,16 @@ struct AnimationEditorView: View { }) { HStack { Image(systemName: "plus.circle.fill") - .foregroundColor(.white) + .foregroundColor(.editorTextPrimary) Text("Assign") .fontWeight(.regular) } .padding(.vertical, 8) .padding(.horizontal, 12) .background(Color.editorAccent) - .foregroundColor(.white) + .foregroundColor(.editorTextPrimary) .cornerRadius(8) - .shadow(color: Color.black.opacity(0.2), radius: 4, x: 0, y: 2) + .shadow(color: Color.editorShadow, radius: 4, x: 0, y: 2) } .buttonStyle(PlainButtonStyle()) } @@ -1532,21 +1531,21 @@ struct GaussianEditorView: View { }) { HStack(spacing: 6) { Image(systemName: "plus.circle.fill") - .foregroundColor(.white) + .foregroundColor(.editorTextPrimary) Text("Assign") .fontWeight(.regular) } .padding(.vertical, 8) .padding(.horizontal, 12) .background(Color.editorAccent) - .foregroundColor(.white) + .foregroundColor(.editorTextPrimary) .cornerRadius(8) - .shadow(color: Color.black.opacity(0.2), radius: 4, x: 0, y: 2) + .shadow(color: Color.editorShadow, radius: 4, x: 0, y: 2) } .buttonStyle(PlainButtonStyle()) } .padding(8) - .background(Color.secondary.opacity(0.05)) + .background(Color.editorFillSubtle) .cornerRadius(8) } } diff --git a/Sources/UntoldEditor/Editor/LODComponentEditorView.swift b/Sources/UntoldEditor/Editor/LODComponentEditorView.swift index 1f2b268..8951806 100644 --- a/Sources/UntoldEditor/Editor/LODComponentEditorView.swift +++ b/Sources/UntoldEditor/Editor/LODComponentEditorView.swift @@ -18,7 +18,7 @@ struct LODComponentEditorView: View { VStack(alignment: .leading, spacing: 12) { HStack { Image(systemName: "square.3.layers.3d") - .foregroundColor(.blue) + .foregroundColor(.editorInfo) Text("LOD Levels") .font(.headline) } @@ -40,14 +40,14 @@ struct LODComponentEditorView: View { }) { HStack(spacing: 6) { Image(systemName: "plus.circle.fill") - .foregroundColor(.white) + .foregroundColor(.editorTextPrimary) Text("Add LOD Level") .fontWeight(.regular) } .padding(.vertical, 6) .padding(.horizontal, 10) - .background(Color.green) - .foregroundColor(.white) + .background(Color.editorSuccess) + .foregroundColor(.editorTextPrimary) .cornerRadius(6) } .buttonStyle(PlainButtonStyle()) @@ -55,7 +55,7 @@ struct LODComponentEditorView: View { } } .padding(12) - .background(Color.blue.opacity(0.05)) + .background(Color.editorInfo.opacity(0.05)) .cornerRadius(8) } @@ -114,15 +114,15 @@ struct LODLevelRow: View { HStack(spacing: 8) { Text("LOD\(lodIndex)") .font(.system(size: 10, weight: .bold)) - .foregroundColor(.white) + .foregroundColor(.editorTextPrimary) .padding(.horizontal, 6) .padding(.vertical, 3) - .background(Color.blue) + .background(Color.editorInfo) .cornerRadius(4) Text(lodLevel.url?.deletingPathExtension().lastPathComponent ?? "Unknown") .font(.system(size: 11)) - .foregroundColor(.primary) + .foregroundColor(.editorTextPrimary) .lineLimit(1) Spacer() @@ -132,7 +132,7 @@ struct LODLevelRow: View { HStack(spacing: 8) { Text("Distance:") .font(.system(size: 10)) - .foregroundColor(.secondary) + .foregroundColor(.editorTextSecondary) // Distance editor if editingDistance { @@ -152,10 +152,10 @@ struct LODLevelRow: View { }) { Text(String(format: "%.0f", lodLevel.maxDistance)) .font(.system(size: 10)) - .foregroundColor(.secondary) + .foregroundColor(.editorTextSecondary) .padding(.horizontal, 8) .padding(.vertical, 4) - .background(Color.secondary.opacity(0.1)) + .background(Color.editorFill) .cornerRadius(4) } .buttonStyle(PlainButtonStyle()) @@ -169,7 +169,7 @@ struct LODLevelRow: View { refreshView() }) { Image(systemName: "trash") - .foregroundColor(.red) + .foregroundColor(.editorError) .font(.system(size: 12)) } .buttonStyle(BorderlessButtonStyle()) @@ -177,7 +177,7 @@ struct LODLevelRow: View { } .padding(.vertical, 8) .padding(.horizontal, 8) - .background(Color.secondary.opacity(0.05)) + .background(Color.editorFillSubtle) .cornerRadius(6) } diff --git a/Sources/UntoldEditor/Editor/LoadingIndicatorView.swift b/Sources/UntoldEditor/Editor/LoadingIndicatorView.swift index a235dbb..05fc829 100644 --- a/Sources/UntoldEditor/Editor/LoadingIndicatorView.swift +++ b/Sources/UntoldEditor/Editor/LoadingIndicatorView.swift @@ -40,7 +40,7 @@ public struct LoadingIndicatorView: View { // Loading text Text(loadingSummary) .font(.system(size: 12)) - .foregroundColor(.white) + .foregroundColor(.editorTextPrimary) // Progress bar if we have total count if totalCount > 0 { @@ -50,11 +50,11 @@ public struct LoadingIndicatorView: View { Text("\(Int(currentProgress * 100))%") .font(.system(size: 10)) - .foregroundColor(.white.opacity(0.8)) + .foregroundColor(.editorTextSecondary) } } .padding(16) - .background(Color.black.opacity(0.8)) + .background(Color.editorOverlay) .cornerRadius(8) .shadow(radius: 10) @@ -111,10 +111,10 @@ public struct MinimalLoadingIndicator: View { Text("Loading...") .font(.system(size: 10)) - .foregroundColor(.secondary) + .foregroundColor(.editorTextSecondary) } .padding(6) - .background(Color.black.opacity(0.6)) + .background(Color.editorOverlay) .cornerRadius(4) } } @@ -135,7 +135,7 @@ public struct MinimalLoadingIndicator: View { #Preview { ZStack { - Color.gray.ignoresSafeArea() + Color.editorBackground.ignoresSafeArea() LoadingIndicatorView() } } diff --git a/Sources/UntoldEditor/Editor/LogConsoleView.swift b/Sources/UntoldEditor/Editor/LogConsoleView.swift index f9c49dd..5d69245 100644 --- a/Sources/UntoldEditor/Editor/LogConsoleView.swift +++ b/Sources/UntoldEditor/Editor/LogConsoleView.swift @@ -12,64 +12,26 @@ import SwiftUI import UntoldEngine struct LogConsoleView: View { + @Binding var searchQuery: String + @Binding var autoScroll: Bool @StateObject private var store = LogStore.shared @State private var selectedLevel: LogLevel? = nil - @State private var search = "" - @State private var autoScroll = true @State private var clearLog = false private func passes(_ e: LogEvent) -> Bool { (selectedLevel == nil || e.level == selectedLevel!) && - (search.isEmpty || - e.message.localizedCaseInsensitiveContains(search) || - e.category.localizedCaseInsensitiveContains(search)) + (searchQuery.isEmpty || + e.message.localizedCaseInsensitiveContains(searchQuery) || + e.category.localizedCaseInsensitiveContains(searchQuery)) } var body: some View { VStack(alignment: .leading, spacing: 8) { - HStack { - Text("Console") - .font(.title3) - .bold() - .foregroundColor(.primary) -// Spacer().frame(width: 16) -// Picker("Level", selection: $selectedLevel) { -// Text("All").tag(LogLevel?.none) -// Text("Error").tag(LogLevel?.some(.error)) -// Text("Warning").tag(LogLevel?.some(.warning)) -// Text("Info").tag(LogLevel?.some(.info)) -// Text("Debug").tag(LogLevel?.some(.debug)) -// Text("Test").tag(LogLevel?.some(.test)) -// } -// .pickerStyle(.segmented) -// .frame(width: 360) -// .accentColor(.gray) - Spacer() - TextField("Search…", text: $search) - .textFieldStyle(.roundedBorder) - .frame(maxWidth: 200) - - Toggle("Auto‑scroll", isOn: $autoScroll) - .toggleStyle(.checkbox) - - Button(action: { - LogStore.shared.clear() - }) { - Image(systemName: "trash") - } - .buttonStyle(BorderlessButtonStyle()) - .help("Clear console") - } - .padding(.horizontal, 10) - .padding(.vertical, 6) - .background(Color.editorPanelBackground.opacity(0.8)) - .cornerRadius(8) - ScrollViewReader { proxy in List(store.entries.filter(passes)) { e in HStack(alignment: .firstTextBaseline, spacing: 8) { Text(shortTime(e.timestamp)) - .font(.caption).foregroundColor(.secondary) + .font(.caption).foregroundColor(.editorTextSecondary) .frame(width: 84, alignment: .leading) Text(e.message) @@ -96,8 +58,8 @@ struct LogConsoleView: View { } } } - .frame(minHeight: 140) - .padding(10) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(8) } private func shortTime(_ d: Date) -> String { @@ -108,12 +70,12 @@ struct LogConsoleView: View { private func colorForLevel(_ level: LogLevel) -> Color { switch level { - case .error: return .red - case .warning: return .yellow - case .info: return .primary - case .debug: return .gray + case .error: return .editorError + case .warning: return .editorWarning + case .info: return .editorTextPrimary + case .debug: return .editorTextTertiary case .test: return Color.editorAccent - case .none: return .primary + case .none: return .editorTextPrimary } } @@ -130,11 +92,11 @@ struct LogConsoleView: View { private func badgeColor(for level: LogLevel) -> Color { switch level { - case .error: return .red - case .warning: return .yellow - case .info: return .blue - case .debug: return .gray - case .test: return .green + case .error: return .editorError + case .warning: return .editorWarning + case .info: return .editorInfo + case .debug: return .editorTextTertiary + case .test: return .editorSuccess case .none: return .clear } } diff --git a/Sources/UntoldEditor/Editor/ProjectSceneCatalog.swift b/Sources/UntoldEditor/Editor/ProjectSceneCatalog.swift new file mode 100644 index 0000000..11fdbb2 --- /dev/null +++ b/Sources/UntoldEditor/Editor/ProjectSceneCatalog.swift @@ -0,0 +1,53 @@ +// +// ProjectSceneCatalog.swift +// +// +// Copyright (C) Untold Engine Studios +// Licensed under the GNU LGPL v3.0 or later. +// See the LICENSE file or for details. +// +// Lists the scene files that live in the current project's `Scenes` folder so +// the Scene Graph panel can present them as children of the project. The engine +// only keeps one scene loaded at a time, so only the active scene shows live +// elements; the rest are file references that load on demand. +// +import Combine +import Foundation +import UntoldEngine + +struct ProjectSceneFile: Identifiable, Hashable { + let url: URL + let name: String + + var id: URL { url } +} + +final class ProjectSceneCatalog: ObservableObject { + @Published private(set) var scenes: [ProjectSceneFile] = [] + + /// Rescan the project's `Scenes` directory. Safe to call often; it only + /// touches the filesystem, not the ECS world. + func refresh() { + guard let basePath = EditorAssetBasePath.shared.basePath else { + scenes = [] + return + } + + let scenesDir = basePath.appendingPathComponent("Scenes", isDirectory: true) + let fm = FileManager.default + + guard let items = try? fm.contentsOfDirectory( + at: scenesDir, + includingPropertiesForKeys: nil, + options: [.skipsHiddenFiles] + ) else { + scenes = [] + return + } + + scenes = items + .filter { $0.pathExtension.lowercased() == untoldSceneFileExtension.lowercased() } + .map { ProjectSceneFile(url: $0, name: $0.deletingPathExtension().lastPathComponent) } + .sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } + } +} diff --git a/Sources/UntoldEditor/Editor/SceneHierarchyView.swift b/Sources/UntoldEditor/Editor/SceneHierarchyView.swift index b66eb91..cee165c 100644 --- a/Sources/UntoldEditor/Editor/SceneHierarchyView.swift +++ b/Sources/UntoldEditor/Editor/SceneHierarchyView.swift @@ -9,9 +9,37 @@ import SwiftUI import UntoldEngine +/// Icon for a hierarchy row. Lights map to their primitive (matching the Add +/// menu); asset nodes keep their asset icons. The engine doesn't store which +/// mesh primitive an entity is, so everything else falls back to a cube. +func hierarchyIconName(for entityId: EntityID) -> String { + if hasComponent(entityId: entityId, componentType: DirectionalLightComponent.self) { + return "sun.max" + } + if hasComponent(entityId: entityId, componentType: PointLightComponent.self) { + return "lightbulb" + } + if hasComponent(entityId: entityId, componentType: SpotLightComponent.self) { + return "flashlight.on.fill" + } + if hasComponent(entityId: entityId, componentType: AreaLightComponent.self) { + return "square" + } + if isDerivedAssetNode(entityId) { + return isBindableAssetMeshNode(entityId) ? "cube.fill" : "square.stack.3d.up" + } + return "cube" +} + struct SceneHierarchyView: View { @ObservedObject var selectionManager: SelectionManager @ObservedObject var sceneGraphModel: SceneGraphModel + @ObservedObject var sceneCatalog: ProjectSceneCatalog + var projectName: String + var activeSceneURL: URL? + var onSelectScene: (URL) -> Void + var isPlaying: Bool + var onTogglePlay: () -> Void var entityList: [EntityID] var onAddEntity_Editor: () -> Void var onRemoveEntity_Editor: () -> Void @@ -25,90 +53,217 @@ struct SceneHierarchyView: View { var onParentEntity: (EntityID, EntityID) -> Void = { _, _ in } var onUnparentEntity: (EntityID) -> Void = { _ in } + @State private var activeSceneExpanded = true + + // A scene node in the tree. `url == nil` represents the current, not-yet-saved + // ("Untitled") scene, which is always the active one. + private struct SceneItem: Identifiable { + let url: URL? + let name: String + let isActive: Bool + var id: String { url?.absoluteString ?? "__untitled__" } + } + + private var sceneItems: [SceneItem] { + var items: [SceneItem] = [] + let active = activeSceneURL + let activeInCatalog = active.map { url in sceneCatalog.scenes.contains { $0.url == url } } ?? false + + // Active scene always shows live elements. If it isn't an on-disk scene + // in the catalog, surface it as a synthetic "Untitled Scene" entry. + if active == nil || activeInCatalog == false { + items.append(SceneItem( + url: active, + name: active?.deletingPathExtension().lastPathComponent ?? "Untitled Scene", + isActive: true + )) + } + + for scene in sceneCatalog.scenes { + items.append(SceneItem(url: scene.url, name: scene.name, isActive: scene.url == active)) + } + return items + } + var body: some View { VStack(alignment: .leading, spacing: 8) { - // MARK: - Header with Add/Remove Buttons - - HStack { - Image(systemName: "list.bullet.indent") - .foregroundColor(.accentColor) - Text("Scene Graph") - .font(.title3) - .fontWeight(.bold) - .foregroundColor(.primary) - - Spacer() - - // Add Entity Menu - Menu { - Button("Empty Entity", systemImage: "plus") { onAddEntity_Editor() } - Divider() - Button("Cube", systemImage: "cube") { onAddCube() } - Button("Sphere", systemImage: "circle") { onAddSphere() } - Button("Plane", systemImage: "square") { onAddPlane() } - Divider() - Button("Directional Light", systemImage: "sun.max") { onAddDirLight() } - Button("Point Light", systemImage: "lightbulb") { onAddPointLight() } - Button("Spot Light", systemImage: "flashlight.on.fill") { onAddSpotLight() } - Button("Area Light", systemImage: "square") { onAddAreaLight() } - } label: { - Image(systemName: "plus") - .foregroundColor(.white) - .font(.system(size: 14, weight: .bold)) - .padding(8) - .background(Color.blue) - .clipShape(Circle()) - } - .menuStyle(.borderlessButton) - .menuIndicator(.hidden) - .help("Add Entity") - - // Remove Entity Button - Button(action: onRemoveEntity_Editor) { - Image(systemName: "minus.circle.fill") - .foregroundColor(.red) - .font(.system(size: 18)) - } - .buttonStyle(PlainButtonStyle()) - .help("Remove Selected Entity") - .disabled(selectionManager.selectedEntity.map { isDerivedAssetNode($0) } ?? false) - .opacity(selectionManager.selectedEntity.map { isDerivedAssetNode($0) } ?? false ? 0.45 : 1.0) - } - .padding(.horizontal, 10) - .padding(.vertical, 6) - .background(Color.secondary.opacity(0.1)) - .cornerRadius(8) + // MARK: - Project header + + projectRow - // MARK: - Entity List + // MARK: - Scenes / Elements tree ScrollView { VStack(alignment: .leading, spacing: 4) { - ForEach(sceneGraphModel.getChildren(entityId: nil), id: \.self) { entityId in - HierarchyNode( - entityId: entityId, - entityName: getEntityName(entityId: entityId), - depth: 0, - sceneGraphModel: sceneGraphModel, - selectionManager: selectionManager, - onParentEntity: onParentEntity, - onUnparentEntity: onUnparentEntity - ) + ForEach(sceneItems) { item in + sceneRow(item) + + if item.isActive, activeSceneExpanded { + ForEach(sceneGraphModel.getChildren(entityId: nil), id: \.self) { entityId in + HierarchyNode( + entityId: entityId, + entityName: getEntityName(entityId: entityId), + depth: 0, + sceneGraphModel: sceneGraphModel, + selectionManager: selectionManager, + onParentEntity: onParentEntity, + onUnparentEntity: onUnparentEntity + ) + } + } } } .padding(.horizontal, 8) } .scrollContentBackground(.hidden) - .frame(maxHeight: 300) - .background(Color.secondary.opacity(0.05)) + .frame(maxHeight: .infinity) + .background(Color.editorFillSubtle) .cornerRadius(8) - Spacer() // Pushes content to the top + // MARK: - Bottom toolbar (add / remove) + + bottomToolbar + } + .padding(5) + .frame(minWidth: 320, maxWidth: 320, maxHeight: .infinity) + .background(Color.editorBackground) + .cornerRadius(8) + .shadow(color: Color.editorShadow, radius: 3, x: 0, y: 1) + .padding(5) + } + + // MARK: - Bottom toolbar + + private var bottomToolbar: some View { + HStack(spacing: 12) { + // Add Entity Menu + Menu { + Button("Empty Entity", systemImage: "plus") { onAddEntity_Editor() } + Divider() + Button("Cube", systemImage: "cube") { onAddCube() } + Button("Sphere", systemImage: "circle") { onAddSphere() } + Button("Plane", systemImage: "square") { onAddPlane() } + Divider() + Button("Directional Light", systemImage: "sun.max") { onAddDirLight() } + Button("Point Light", systemImage: "lightbulb") { onAddPointLight() } + Button("Spot Light", systemImage: "flashlight.on.fill") { onAddSpotLight() } + Button("Area Light", systemImage: "square") { onAddAreaLight() } + } label: { + Image(systemName: "plus") + .foregroundColor(.editorTextPrimary) + .font(.system(size: 13, weight: .bold)) + .padding(6) + .background(Color.editorInfo) + .clipShape(Circle()) + } + .menuStyle(.borderlessButton) + .menuIndicator(.hidden) + .help("Add Entity") + + // Remove Entity Button + Button(action: onRemoveEntity_Editor) { + Image(systemName: "minus.circle.fill") + .foregroundColor(.editorError) + .font(.system(size: 18)) + } + .buttonStyle(PlainButtonStyle()) + .help("Remove Selected Entity") + .disabled(selectionManager.selectedEntity.map { isDerivedAssetNode($0) } ?? false) + .opacity(selectionManager.selectedEntity.map { isDerivedAssetNode($0) } ?? false ? 0.45 : 1.0) + + Spacer() + } + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(Color.editorFill) + .cornerRadius(8) + } + + // MARK: - Project row (tree root) + + private var projectRow: some View { + HStack(spacing: 8) { + Image(systemName: "folder.fill") + .foregroundColor(.editorAccent) + Text(projectName) + .font(.headline) + .fontWeight(.bold) + .foregroundColor(.editorTextPrimary) + .lineLimit(1) + + Spacer() + + playButton + } + .padding(.horizontal, 10) + .padding(.vertical, 8) + .background(selectionManager.projectSelected ? Color.editorAccentSoft : Color.editorFill) + .cornerRadius(8) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(selectionManager.projectSelected ? Color.editorAccent : Color.clear, lineWidth: 1) + ) + .contentShape(Rectangle()) + .onTapGesture { + selectionManager.selectProject() + } + } + + private var playButton: some View { + Button(action: onTogglePlay) { + Image(systemName: isPlaying ? "pause.fill" : "play.fill") + .font(.system(size: 12, weight: .bold)) + .foregroundColor(.editorTextPrimary) + .frame(width: 26, height: 26) + .background(isPlaying ? Color.editorSecondaryAccent : Color.editorAccent) + .clipShape(Circle()) + } + .buttonStyle(.plain) + .focusable(false) + .help(isPlaying ? "Stop play mode" : "Enter play mode") + } + + // MARK: - Scene row (second level) + + private func sceneRow(_ item: SceneItem) -> some View { + HStack(spacing: 8) { + if item.isActive { + Button(action: { activeSceneExpanded.toggle() }) { + Image(systemName: activeSceneExpanded ? "chevron.down" : "chevron.right") + .font(.system(size: 10, weight: .semibold)) + .foregroundColor(.editorTextSecondary) + .frame(width: 12) + } + .buttonStyle(.plain) + .focusable(false) + } else { + Color.clear.frame(width: 12, height: 12) + } + + Image(systemName: item.isActive ? "film.fill" : "film") + .foregroundColor(item.isActive ? .editorAccent : .editorTextTertiary) + + Text(item.name) + .fontWeight(item.isActive ? .semibold : .regular) + .foregroundColor(item.isActive ? .editorTextPrimary : .editorTextSecondary) + .lineLimit(1) + + Spacer() + } + .padding(.vertical, 4) + .padding(.horizontal, 6) + .background(item.isActive ? Color.editorSurface.opacity(0.5) : Color.clear) + .cornerRadius(6) + .contentShape(Rectangle()) + .onTapGesture { + if item.isActive { + activeSceneExpanded.toggle() + } else if let url = item.url { + onSelectScene(url) + } } - .frame(minWidth: 200, maxWidth: 200) - .padding(8) - .background(Color.editorBackground.ignoresSafeArea()) - .cornerRadius(12) + .help(item.isActive ? "Active scene" : "Load this scene") } } @@ -143,7 +298,7 @@ struct EntityRow: View { private var styledEntityRow: some View { entityRowContent .padding(8) - .background(isSelected ? Color.gray.opacity(0.8) : Color.clear) + .background(isSelected ? Color.editorSurface : Color.clear) .cornerRadius(6) } @@ -152,7 +307,7 @@ struct EntityRow: View { Button(action: onToggleExpanded) { Image(systemName: hasChildren ? (isExpanded ? "chevron.down" : "chevron.right") : "chevron.right") .font(.system(size: 10, weight: .semibold)) - .foregroundColor(hasChildren ? .secondary : .clear) + .foregroundColor(hasChildren ? .editorTextSecondary : .clear) .frame(width: 12, height: 12) } .buttonStyle(.plain) @@ -160,12 +315,12 @@ struct EntityRow: View { .disabled(hasChildren == false) .help(isExpanded ? "Collapse Children" : "Expand Children") - Image(systemName: isAssetNode ? (isBindableAssetMeshNode(entityid) ? "cube.fill" : "square.stack.3d.up") : "cube") - .foregroundColor(isSelected ? .white : (isAssetNode ? .secondary : .gray)) + Image(systemName: hierarchyIconName(for: entityid)) + .foregroundColor(isSelected ? .editorTextPrimary : (isAssetNode ? .editorTextSecondary : .editorTextTertiary)) Text(entityName) .fontWeight(isSelected ? .bold : .regular) - .foregroundColor(isSelected ? .white : (isAssetNode ? .secondary : .primary)) + .foregroundColor(isSelected ? .editorTextPrimary : (isAssetNode ? .editorTextSecondary : .editorTextPrimary)) Spacer() } @@ -203,7 +358,9 @@ struct HierarchyNode: View { selectionManager: selectionManager ) .contentShape(Rectangle()) - .padding(.leading, CGFloat(depth * 12)) + // Indent one chevron-slot (chevron width 12 + HStack spacing 8) per + // level, so a child's chevron lines up under its parent's icon. + .padding(.leading, CGFloat(depth) * 20) .onTapGesture { selectionManager.inspectEntity(entityId: entityId) } @@ -215,7 +372,7 @@ struct HierarchyNode: View { } .background( isDragOver ? - Color.blue.opacity(0.2) : + Color.editorInfo.opacity(0.2) : Color.clear ) @@ -296,7 +453,7 @@ struct HierarchyNode: View { VStack { if isDerivedAssetNode(entityId) { Text("Asset node") - .foregroundColor(.gray) + .foregroundColor(.editorTextTertiary) } else if hasParent { Button(action: { DispatchQueue.main.async { @@ -310,7 +467,7 @@ struct HierarchyNode: View { } } else { Text("No parent") - .foregroundColor(.gray) + .foregroundColor(.editorTextTertiary) } } } diff --git a/Sources/UntoldEditor/Editor/ScriptComponentInspector.swift b/Sources/UntoldEditor/Editor/ScriptComponentInspector.swift index 686429a..49ea031 100644 --- a/Sources/UntoldEditor/Editor/ScriptComponentInspector.swift +++ b/Sources/UntoldEditor/Editor/ScriptComponentInspector.swift @@ -36,10 +36,10 @@ struct ScriptComponentInspector: View { if comp.scripts.isEmpty { Text("No scripts attached") .font(.caption) - .foregroundColor(.secondary) + .foregroundColor(.editorTextSecondary) .padding(8) .frame(maxWidth: .infinity, alignment: .leading) - .background(Color.secondary.opacity(0.1)) + .background(Color.editorFill) .cornerRadius(6) } else { VStack(alignment: .leading, spacing: 8) { @@ -51,10 +51,10 @@ struct ScriptComponentInspector: View { } else { Text("No Script Component found") .font(.caption) - .foregroundColor(.secondary) + .foregroundColor(.editorTextSecondary) .padding(8) .frame(maxWidth: .infinity, alignment: .leading) - .background(Color.secondary.opacity(0.1)) + .background(Color.editorFill) .cornerRadius(6) } @@ -65,14 +65,14 @@ struct ScriptComponentInspector: View { }) { HStack(spacing: 4) { Image(systemName: "doc.badge.plus") - .foregroundColor(.white) + .foregroundColor(.editorTextPrimary) Text("Load Script") .fontWeight(.regular) } .padding(.vertical, 6) .padding(.horizontal, 10) - .background(Color.blue) - .foregroundColor(.white) + .background(Color.editorInfo) + .foregroundColor(.editorTextPrimary) .cornerRadius(6) } .buttonStyle(PlainButtonStyle()) @@ -82,9 +82,9 @@ struct ScriptComponentInspector: View { if showError { Text(errorMessage) .font(.caption) - .foregroundColor(.red) + .foregroundColor(.editorError) .padding(6) - .background(Color.red.opacity(0.1)) + .background(Color.editorError.opacity(0.1)) .cornerRadius(4) } } @@ -100,10 +100,10 @@ struct ScriptComponentInspector: View { if let statusMessage { Text(statusMessage) .font(.system(size: 12, weight: .semibold)) - .foregroundColor(.white) + .foregroundColor(.editorTextPrimary) .padding(.vertical, 6) .padding(.horizontal, 12) - .background(statusIsError ? Color.red.opacity(0.85) : Color.green.opacity(0.85)) + .background(statusIsError ? Color.editorError.opacity(0.85) : Color.editorSuccess.opacity(0.85)) .cornerRadius(8) .padding(.bottom, 8) .transition(.move(edge: .bottom).combined(with: .opacity)) @@ -122,7 +122,7 @@ struct ScriptComponentInspector: View { HStack { Text("Script:") .font(.caption) - .foregroundColor(.secondary) + .foregroundColor(.editorTextSecondary) Text(script.name) .font(.caption) .lineLimit(1) @@ -132,7 +132,7 @@ struct ScriptComponentInspector: View { HStack { Text("Trigger:") .font(.caption) - .foregroundColor(.secondary) + .foregroundColor(.editorTextSecondary) Text(describeTriggerType(script.metadata.triggerType)) .font(.caption) } @@ -140,7 +140,7 @@ struct ScriptComponentInspector: View { HStack { Text("Mode:") .font(.caption) - .foregroundColor(.secondary) + .foregroundColor(.editorTextSecondary) Text(describeExecutionMode(script.metadata.executionMode)) .font(.caption) } @@ -148,7 +148,7 @@ struct ScriptComponentInspector: View { if let path, !path.isEmpty { Text(path) .font(.system(size: 9)) - .foregroundColor(.gray) + .foregroundColor(.editorTextTertiary) .lineLimit(2) .truncationMode(.middle) } @@ -160,14 +160,14 @@ struct ScriptComponentInspector: View { }) { HStack(spacing: 4) { Image(systemName: "arrow.clockwise") - .foregroundColor(.white) + .foregroundColor(.editorTextPrimary) Text("Reload") .fontWeight(.regular) } .padding(.vertical, 6) .padding(.horizontal, 10) - .background(Color.orange) - .foregroundColor(.white) + .background(Color.editorWarning) + .foregroundColor(.editorTextPrimary) .cornerRadius(6) } .buttonStyle(PlainButtonStyle()) @@ -182,8 +182,8 @@ struct ScriptComponentInspector: View { } .padding(.vertical, 6) .padding(.horizontal, 10) - .background(Color.red) - .foregroundColor(.white) + .background(Color.editorError) + .foregroundColor(.editorTextPrimary) .cornerRadius(6) } .buttonStyle(PlainButtonStyle()) @@ -191,7 +191,7 @@ struct ScriptComponentInspector: View { } .padding(8) .frame(maxWidth: .infinity, alignment: .leading) - .background(Color.secondary.opacity(0.1)) + .background(Color.editorFill) .cornerRadius(6) } diff --git a/Sources/UntoldEditor/Editor/SelectionManager.swift b/Sources/UntoldEditor/Editor/SelectionManager.swift index 6b3a532..4395515 100644 --- a/Sources/UntoldEditor/Editor/SelectionManager.swift +++ b/Sources/UntoldEditor/Editor/SelectionManager.swift @@ -94,21 +94,35 @@ class SceneGraphModel: ObservableObject { class SelectionManager: ObservableObject { @Published var selectedEntity: EntityID? = .invalid @Published var inspectedMesh: MeshInspectionSelection? + /// True when the project itself is selected in the Scene Graph panel. Drives + /// the right panel to show Environment/Effects instead of the Inspector. + @Published var projectSelected: Bool = false init() {} + /// Select the project (deselects any entity). The right panel switches to + /// the Environment/Effects editors. + func selectProject() { + projectSelected = true + inspectedMesh = nil + selectedEntity = nil + } + func selectEntity(entityId: EntityID) { + projectSelected = false inspectedMesh = nil let selectedEntityId = editableAssetRootEntity(for: entityId) selectEntity(entityId: selectedEntityId, inspectEntityId: selectedEntityId) } func inspectEntity(entityId: EntityID) { + projectSelected = false inspectedMesh = nil selectEntity(entityId: sceneTransformEntity(for: entityId), inspectEntityId: entityId) } func inspectMesh(entityId: EntityID, meshIndex: Int) { + projectSelected = false let transformEntityId = sceneTransformEntity(for: entityId) selectedEntity = entityId inspectedMesh = MeshInspectionSelection( diff --git a/Sources/UntoldEditor/Editor/StaticBatchingView.swift b/Sources/UntoldEditor/Editor/StaticBatchingView.swift index 12c8400..ffb8e6f 100644 --- a/Sources/UntoldEditor/Editor/StaticBatchingView.swift +++ b/Sources/UntoldEditor/Editor/StaticBatchingView.swift @@ -25,7 +25,7 @@ struct StaticBatchingView: View { .font(.system(size: 14)) Text("Static Batching") .font(.headline) - .foregroundColor(.primary) + .foregroundColor(.editorTextPrimary) } .padding(.bottom, 6) @@ -46,7 +46,7 @@ struct StaticBatchingView: View { Text("Enable the batching system globally") .font(.system(size: 10)) - .foregroundColor(.secondary) + .foregroundColor(.editorTextSecondary) } Divider() @@ -67,7 +67,7 @@ struct StaticBatchingView: View { }) { HStack(spacing: 6) { Image(systemName: "square.stack.3d.up.fill") - .foregroundColor(.white) + .foregroundColor(.editorTextPrimary) .font(.system(size: 12)) Text("Generate Batches") .font(.system(size: 12)) @@ -76,8 +76,8 @@ struct StaticBatchingView: View { .frame(maxWidth: .infinity) .padding(.vertical, 6) .padding(.horizontal, 8) - .background(Color.blue) - .foregroundColor(.white) + .background(Color.editorInfo) + .foregroundColor(.editorTextPrimary) .cornerRadius(6) } .buttonStyle(PlainButtonStyle()) @@ -90,7 +90,7 @@ struct StaticBatchingView: View { }) { HStack(spacing: 6) { Image(systemName: "trash.fill") - .foregroundColor(.white) + .foregroundColor(.editorTextPrimary) .font(.system(size: 12)) Text("Clear Batches") .font(.system(size: 12)) @@ -99,8 +99,8 @@ struct StaticBatchingView: View { .frame(maxWidth: .infinity) .padding(.vertical, 6) .padding(.horizontal, 8) - .background(Color.red.opacity(0.8)) - .foregroundColor(.white) + .background(Color.editorError.opacity(0.8)) + .foregroundColor(.editorTextPrimary) .cornerRadius(6) } .buttonStyle(PlainButtonStyle()) @@ -110,13 +110,13 @@ struct StaticBatchingView: View { if showGenerateSuccess { HStack { Image(systemName: "checkmark.circle.fill") - .foregroundColor(.green) + .foregroundColor(.editorSuccess) Text("Batches generated successfully!") .font(.system(size: 11)) - .foregroundColor(.green) + .foregroundColor(.editorSuccess) } .padding(6) - .background(Color.green.opacity(0.1)) + .background(Color.editorSuccess.opacity(0.1)) .cornerRadius(6) .transition(.opacity) } @@ -129,17 +129,17 @@ struct StaticBatchingView: View { Text("Batch Statistics") .font(.system(size: 12)) .fontWeight(.semibold) - .foregroundColor(.primary) + .foregroundColor(.editorTextPrimary) HStack { Text("Active Batches:") .font(.system(size: 11)) - .foregroundColor(.secondary) + .foregroundColor(.editorTextSecondary) Spacer() Text("\(batchCount)") .font(.system(size: 11)) .fontWeight(.medium) - .foregroundColor(.primary) + .foregroundColor(.editorTextPrimary) } } .padding(.vertical, 4) @@ -152,23 +152,23 @@ struct StaticBatchingView: View { Text("How to use:") .font(.system(size: 11)) .fontWeight(.semibold) - .foregroundColor(.primary) + .foregroundColor(.editorTextPrimary) Text("1. Mark entities as static in Inspector") .font(.system(size: 10)) - .foregroundColor(.secondary) + .foregroundColor(.editorTextSecondary) Text("2. Enable batching toggle above") .font(.system(size: 10)) - .foregroundColor(.secondary) + .foregroundColor(.editorTextSecondary) Text("3. Click 'Generate Batches'") .font(.system(size: 10)) - .foregroundColor(.secondary) + .foregroundColor(.editorTextSecondary) Text("Note: Moving a static entity will automatically disable its batching.") .font(.system(size: 9)) - .foregroundColor(.orange) + .foregroundColor(.editorWarning) .padding(.top, 4) } .padding(.vertical, 4) @@ -176,9 +176,9 @@ struct StaticBatchingView: View { Spacer() } .padding(8) - .background(Color.secondary.opacity(0.1)) + .background(Color.editorFill) .cornerRadius(8) - .shadow(color: Color.black.opacity(0.1), radius: 3, x: 0, y: 1) + .shadow(color: Color.editorShadow, radius: 3, x: 0, y: 1) .onAppear { isBatchingEnabled = UntoldEngine.isBatchingEnabled() updateBatchCount() diff --git a/Sources/UntoldEditor/Editor/ToolbarView.swift b/Sources/UntoldEditor/Editor/ToolbarView.swift index 6b2892f..1068432 100644 --- a/Sources/UntoldEditor/Editor/ToolbarView.swift +++ b/Sources/UntoldEditor/Editor/ToolbarView.swift @@ -6,301 +6,11 @@ // Licensed under the GNU LGPL v3.0 or later. // See the LICENSE file or for details. // -#if canImport(AppKit) - import AppKit - import SwiftUI - import UntoldEngine - - struct ToolbarView: View { - @ObservedObject var selectionManager: SelectionManager - @ObservedObject var editorBasePath = EditorAssetBasePath.shared - @ObservedObject private var statsStore = EditorEngineStatsStore.shared - private let editorVersionLabel = "v0.14.2" - - var onSave: () -> Void - var onSaveAs: () -> Void - var onClear: () -> Void - var onPlayToggled: (Bool) -> Void - @Binding var useSceneCameraDuringPlay: Bool - var dirLightCreate: () -> Void - var pointLightCreate: () -> Void - var spotLightCreate: () -> Void - var areaLightCreate: () -> Void - var onCreateCube: () -> Void - var onCreateSphere: () -> Void - var onCreatePlane: () -> Void - var onCreateCylinder: () -> Void - var onCreateCone: () -> Void - - @State private var isPlaying = false - @State private var showCreateProject = false - @State private var showInvalidProjectAlert = false - @State private var invalidProjectMessage = "" - - var body: some View { - HStack { - if EditorFeatureFlags.enableBuildButton { - leftSection - } - - Spacer() - centeredButtons - Spacer() - - rightSection - } - .padding(.horizontal, 20) - .padding(.vertical, 6) - .background( - LinearGradient( - colors: [Color.editorPanelBackground.opacity(0.95), Color.editorPanelBackground.opacity(0.85)], - startPoint: .top, - endPoint: .bottom - ) - .ignoresSafeArea() - ) - .cornerRadius(8) - .shadow(color: Color.black.opacity(0.08), radius: 4, x: 0, y: 2) - .sheet(isPresented: $showCreateProject) { - CreateProjectView() - } - .alert("Invalid Project", isPresented: $showInvalidProjectAlert) { - Button("OK", role: .cancel) {} - } message: { - Text(invalidProjectMessage) - } - } - - var leftSection: some View { - HStack(spacing: 12) { - Button(action: { showCreateProject = true }) { - HStack(spacing: 6) { - Image(systemName: "hammer.fill") - Text("New") - } - .padding(.vertical, 6) - .padding(.horizontal, 12) - .background(Color.editorSurface) - .foregroundColor(.white) - .cornerRadius(6) - } - .buttonStyle(.plain) - .focusable(false) - - Button(action: openExistingProject) { - HStack(spacing: 6) { - Image(systemName: "folder.fill") - Text("Open") - } - .padding(.vertical, 6) - .padding(.horizontal, 12) - .background(Color.editorSurface) - .foregroundColor(.white) - .cornerRadius(6) - } - .buttonStyle(.plain) - .focusable(false) - - Divider().frame(height: 24) - } - } - - var centeredButtons: some View { - HStack(spacing: 12) { - ToolbarButton(iconName: "gobackward", action: onClear, tooltip: "Clear Scene") - - Button(action: { - isPlaying.toggle() - onPlayToggled(isPlaying) - }) { - HStack(spacing: 6) { - Image(systemName: isPlaying ? "pause.fill" : "play.fill") - Text(isPlaying ? "Pause" : "Play") - } - .padding(.vertical, 6) - .padding(.horizontal, 12) - .background(isPlaying ? Color.editorSecondaryAccent : Color.editorAccent) - .foregroundColor(.white) - .cornerRadius(6) - } - .buttonStyle(.plain) - .focusable(false) - - Toggle(isOn: $useSceneCameraDuringPlay) { - Text("Scene Cam") - .font(.system(size: 11, weight: .semibold)) - } - .toggleStyle(.switch) - .scaleEffect(0.85) - .frame(height: 20) - - Divider().frame(height: 24) - - Menu { - Button("Save Scene", systemImage: "square.and.arrow.down.on.square", action: onSave) - Button("Save Scene As…", systemImage: "square.and.arrow.down", action: onSaveAs) - } label: { - HStack(spacing: 6) { - Image(systemName: "square.and.arrow.down.on.square") - Text("Save Scene") - } - .padding(.vertical, 6) - .padding(.horizontal, 10) - .background(Color.editorAccent) - .foregroundColor(.white) - .cornerRadius(8) - } - .menuStyle(.borderlessButton) - .focusable(false) - - Divider().frame(height: 24) - - Toggle(isOn: Binding( - get: { statsStore.overlayMode != .off }, - set: { enabled in - if enabled { - statsStore.setOverlaySimplifiedEnabled(true) - } else { - statsStore.setOverlaySimplifiedEnabled(false) - statsStore.setOverlayAdvancedEnabled(false) - } - } - )) { - Text("FPS") - .font(.system(size: 11, weight: .semibold)) - } - .toggleStyle(.switch) - .scaleEffect(0.85) - .frame(height: 20) - - Toggle(isOn: Binding( - get: { statsStore.overlayMode == .advanced }, - set: { enabled in - if enabled { - statsStore.setOverlayAdvancedEnabled(true) - } else if statsStore.overlayMode != .off { - statsStore.setOverlaySimplifiedEnabled(true) - } - } - )) { - Text("FPS Advanced") - .font(.system(size: 11, weight: .semibold)) - } - .toggleStyle(.checkbox) - .disabled(statsStore.overlayMode == .off) - } - } - - var rightSection: some View { - HStack(spacing: 8) { - Divider().frame(height: 24) - - // Show project name if loaded - if let projectName = editorBasePath.projectName { - Text(projectName) - .font(.system(size: 14, weight: .semibold, design: .monospaced)) - .foregroundColor(.white) - .padding(.horizontal, 12) - .padding(.vertical, 6) - .background(Color.editorAccent.opacity(0.3)) - .cornerRadius(6) - } - - Text(editorVersionLabel) - .font(.system(size: 11, weight: .semibold, design: .monospaced)) - .foregroundColor(.white) - .padding(.horizontal, 8) - .padding(.vertical, 4) - .background(Color.editorSurface.opacity(0.6)) - .cornerRadius(6) - } - } - - private func openExistingProject() { - let panel = NSOpenPanel() - panel.canChooseFiles = false - panel.canChooseDirectories = true - panel.allowsMultipleSelection = false - panel.canCreateDirectories = false - panel.message = "Select the UntoldEngine project folder (the folder containing the .xcodeproj file)" - panel.prompt = "Open Project" - - guard panel.runModal() == .OK, let projectURL = panel.url else { - return - } - - // Validate project structure - let fm = FileManager.default - let projectName = projectURL.lastPathComponent - - // Check for .xcodeproj - let xcodeProjectPath = projectURL.appendingPathComponent("\(projectName).xcodeproj") - guard fm.fileExists(atPath: xcodeProjectPath.path) else { - invalidProjectMessage = "This doesn't appear to be a valid UntoldEngine project.\n\nExpected to find: \(projectName).xcodeproj" - showInvalidProjectAlert = true - return - } - - // Build the GameData path - let gameDataPath = projectURL - .appendingPathComponent("Sources") - .appendingPathComponent(projectName) - .appendingPathComponent("GameData") - - // Check if GameData exists, create if not - if !fm.fileExists(atPath: gameDataPath.path) { - do { - try fm.createDirectory(at: gameDataPath, withIntermediateDirectories: true) - print("📁 Created missing GameData folder structure") - } catch { - invalidProjectMessage = "Failed to create GameData folder structure:\n\n\(error.localizedDescription)" - showInvalidProjectAlert = true - return - } - } - - // Create standard asset subfolders if they don't exist - let assetFolders = ["Models", "StreamModels", "Animations", "Scenes", "Scripts", "Gaussians", "Materials", "HDR", "Shaders"] - for folder in assetFolders { - let folderURL = gameDataPath.appendingPathComponent(folder, isDirectory: true) - if !fm.fileExists(atPath: folderURL.path) { - try? fm.createDirectory(at: folderURL, withIntermediateDirectories: true) - } - } - - // Notify editor to clean up before switching projects - NotificationCenter.default.post(name: .projectWillSwitch, object: nil) - - // Set the asset base path - assetBasePath = gameDataPath - EditorAssetBasePath.shared.basePath = gameDataPath - - print("✅ Opened project: \(projectName)") - print("📁 Asset base path set to: \(gameDataPath.path)") - } - } - - // MARK: - Toolbar Button Component - - struct ToolbarButton: View { - let iconName: String - let action: () -> Void - let tooltip: String - - var body: some View { - Button(action: action) { - Image(systemName: iconName) - .font(.system(size: 14, weight: .bold)) - .foregroundColor(.white) - .padding(6) - .background(Color.editorSurface) - .cornerRadius(6) - .shadow(color: Color.black.opacity(0.1), radius: 2, x: 0, y: 1) - } - .buttonStyle(PlainButtonStyle()) - .focusable(false) - .help(tooltip) - } - } - -#endif +// DEPRECATED: The top editor toolbar has been removed. Its actions now live in +// the native macOS menu bar (File / View — see main.swift and +// EditorMenuCommands.swift) and the Play button moved into the Scene Graph +// project header (see SceneHierarchyView.playButton). +// +// This file is intentionally left empty and can be deleted from the repository +// with `git rm`. +// diff --git a/Sources/UntoldEditor/Editor/TransformManipulationView.swift b/Sources/UntoldEditor/Editor/TransformManipulationView.swift index 025df07..397f8dd 100644 --- a/Sources/UntoldEditor/Editor/TransformManipulationView.swift +++ b/Sources/UntoldEditor/Editor/TransformManipulationView.swift @@ -52,6 +52,43 @@ struct ModeButton: View { } } +/// Compact translate/rotate/scale cluster designed to float inside the scene +/// viewport (bottom-left corner) instead of sitting in a full-width toolbar. +struct TransformModeCluster: View { + @ObservedObject var controller: EditorController + + var body: some View { + HStack(spacing: 4) { + ModeButton( + icon: "arrow.up.and.down.and.arrow.left.and.right", + label: "Translate", + mode: .translate, + activeMode: $controller.activeMode + ) + ModeButton( + icon: "rotate.3d", + label: "Rotate", + mode: .rotate, + activeMode: $controller.activeMode + ) + ModeButton( + icon: "arrow.up.left.and.down.right.magnifyingglass", + label: "Scale", + mode: .scale, + activeMode: $controller.activeMode + ) + } + .padding(4) + .background(Color.editorPanelBackground.opacity(0.9)) + .cornerRadius(8) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(Color.editorDivider, lineWidth: 1) + ) + .shadow(color: Color.editorShadow, radius: 6, x: 0, y: 2) + } +} + struct TransformManipulationToolbar: View { @ObservedObject var controller: EditorController @@ -84,7 +121,7 @@ struct TransformManipulationToolbar: View { Spacer() } .padding(.horizontal) - .background(Color.secondary.opacity(0.1)) + .background(Color.editorFill) .cornerRadius(5) } } diff --git a/Sources/UntoldEditor/Systems/EditorInputSystemAppKit.swift b/Sources/UntoldEditor/Systems/EditorInputSystemAppKit.swift index 27c3cd7..ac4d20d 100644 --- a/Sources/UntoldEditor/Systems/EditorInputSystemAppKit.swift +++ b/Sources/UntoldEditor/Systems/EditorInputSystemAppKit.swift @@ -57,6 +57,12 @@ return nil } + // Let Command-based shortcuts (native menu key equivalents like + // ⌘1/⌘2/⌘3) reach the menu instead of being eaten as game input. + if event.modifierFlags.contains(.command) { + return event + } + if self?.shouldHandleKey(event) == true { self?.keyPressed(event.keyCode) return nil // Mark event as handled diff --git a/Sources/UntoldEditor/main.swift b/Sources/UntoldEditor/main.swift index 325f72c..cefc225 100644 --- a/Sources/UntoldEditor/main.swift +++ b/Sources/UntoldEditor/main.swift @@ -13,12 +13,24 @@ import SwiftUI import UntoldEngine // AppDelegate: Boiler plate code -class AppDelegate: NSObject, NSApplicationDelegate { +class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { var window: NSWindow! + private let appName = "Untold Engine Editor" + + // View-menu items whose checkmark / enabled state is synced on open. + private var showFPSItem: NSMenuItem? + private var showFPSAdvancedItem: NSMenuItem? + private var sceneCamItem: NSMenuItem? + private var leftPanelItem: NSMenuItem? + private var bottomPanelItem: NSMenuItem? + private var rightPanelItem: NSMenuItem? + func applicationDidFinishLaunching(_: Notification) { Logger.log(message: "Launching Untold Engine Editor v0.14.2") + setupMainMenu() + // Step 1. Create and configure the window window = NSWindow( contentRect: NSRect(x: 0, y: 0, width: 1920, height: 1080), @@ -28,6 +40,14 @@ class AppDelegate: NSObject, NSApplicationDelegate { ) window.title = "Untold Engine Editor v0.14.2" + // Force dark appearance so AppKit-drawn chrome (title bar, native tab + // strips, segmented controls) matches the dark editor theme. + window.appearance = NSAppearance(named: .darkAqua) + // Tint the title bar with the editor background color instead of the + // default near-black. Transparent title bar lets the window background + // color (editorBackground) show through. + window.titlebarAppearsTransparent = true + window.backgroundColor = NSColor(red: 0.15, green: 0.16, blue: 0.21, alpha: 1.0) window.center() let hostingView = NSHostingView(rootView: EditorView()) @@ -41,6 +61,142 @@ class AppDelegate: NSObject, NSApplicationDelegate { func applicationShouldTerminateAfterLastWindowClosed(_: NSApplication) -> Bool { true } + + // MARK: - Menu bar + + private func setupMainMenu() { + let mainMenu = NSMenu() + + // App menu (first submenu is always treated as the application menu). + let appMenuItem = NSMenuItem() + mainMenu.addItem(appMenuItem) + let appMenu = NSMenu() + appMenuItem.submenu = appMenu + appMenu.addItem(withTitle: "About \(appName)", + action: #selector(NSApplication.orderFrontStandardAboutPanel(_:)), + keyEquivalent: "") + appMenu.addItem(.separator()) + appMenu.addItem(withTitle: "Hide \(appName)", + action: #selector(NSApplication.hide(_:)), + keyEquivalent: "h") + let hideOthers = appMenu.addItem(withTitle: "Hide Others", + action: #selector(NSApplication.hideOtherApplications(_:)), + keyEquivalent: "h") + hideOthers.keyEquivalentModifierMask = [.command, .option] + appMenu.addItem(withTitle: "Show All", + action: #selector(NSApplication.unhideAllApplications(_:)), + keyEquivalent: "") + appMenu.addItem(.separator()) + appMenu.addItem(withTitle: "Quit \(appName)", + action: #selector(NSApplication.terminate(_:)), + keyEquivalent: "q") + + // File menu + let fileMenuItem = NSMenuItem() + mainMenu.addItem(fileMenuItem) + let fileMenu = NSMenu(title: "File") + fileMenuItem.submenu = fileMenu + addItem(to: fileMenu, title: "New", action: #selector(menuNew), key: "n") + addItem(to: fileMenu, title: "Open…", action: #selector(menuOpen), key: "o") + fileMenu.addItem(.separator()) + addItem(to: fileMenu, title: "Save Scene", action: #selector(menuSave), key: "s") + let saveAs = addItem(to: fileMenu, title: "Save Scene As…", action: #selector(menuSaveAs), key: "s") + saveAs.keyEquivalentModifierMask = [.command, .shift] + fileMenu.addItem(.separator()) + addItem(to: fileMenu, title: "Reset Scene", action: #selector(menuReset), key: "") + + // View menu (checkmarks are managed manually in menuNeedsUpdate). + let viewMenuItem = NSMenuItem() + mainMenu.addItem(viewMenuItem) + let viewMenu = NSMenu(title: "View") + viewMenu.autoenablesItems = false + viewMenu.delegate = self + viewMenuItem.submenu = viewMenu + leftPanelItem = addItem(to: viewMenu, title: "Show Left Panel", action: #selector(menuToggleLeftPanel), key: "1") + bottomPanelItem = addItem(to: viewMenu, title: "Show Bottom Panel", action: #selector(menuToggleBottomPanel), key: "2") + rightPanelItem = addItem(to: viewMenu, title: "Show Right Panel", action: #selector(menuToggleRightPanel), key: "3") + addItem(to: viewMenu, title: "Focus Viewport", action: #selector(menuToggleFocusViewport), key: "f") + viewMenu.addItem(.separator()) + showFPSItem = addItem(to: viewMenu, title: "Show FPS", action: #selector(menuToggleFPS), key: "") + showFPSAdvancedItem = addItem(to: viewMenu, title: "Show FPS Advanced", action: #selector(menuToggleFPSAdvanced), key: "") + viewMenu.addItem(.separator()) + sceneCamItem = addItem(to: viewMenu, title: "Use Scene Camera During Play", action: #selector(menuToggleSceneCam), key: "") + + NSApp.mainMenu = mainMenu + } + + @discardableResult + private func addItem(to menu: NSMenu, title: String, action: Selector, key: String) -> NSMenuItem { + let item = NSMenuItem(title: title, action: action, keyEquivalent: key) + item.target = self + menu.addItem(item) + return item + } + + // Keep the View-menu checkmarks in sync with the current overlay / camera state. + func menuNeedsUpdate(_ menu: NSMenu) { + let store = EditorEngineStatsStore.shared + showFPSItem?.state = store.overlayMode != .off ? .on : .off + showFPSAdvancedItem?.state = store.overlayMode == .advanced ? .on : .off + showFPSAdvancedItem?.isEnabled = store.overlayMode != .off + sceneCamItem?.state = EditorPlaybackSettings.shared.useSceneCameraDuringPlay ? .on : .off + + let panels = EditorPanelVisibility.shared + leftPanelItem?.state = panels.showLeftPanel ? .on : .off + bottomPanelItem?.state = panels.showBottomPanel ? .on : .off + rightPanelItem?.state = panels.showRightPanel ? .on : .off + } + + // MARK: - File actions (bridged to SwiftUI via notifications) + + @objc private func menuNew() { NotificationCenter.default.post(name: .editorMenuNew, object: nil) } + @objc private func menuOpen() { NotificationCenter.default.post(name: .editorMenuOpen, object: nil) } + @objc private func menuSave() { NotificationCenter.default.post(name: .editorMenuSave, object: nil) } + @objc private func menuSaveAs() { NotificationCenter.default.post(name: .editorMenuSaveAs, object: nil) } + @objc private func menuReset() { NotificationCenter.default.post(name: .editorMenuReset, object: nil) } + + // MARK: - View actions (mutate shared stores directly) + + @objc private func menuToggleFPS() { + let store = EditorEngineStatsStore.shared + if store.overlayMode == .off { + store.setOverlaySimplifiedEnabled(true) + } else { + store.setOverlaySimplifiedEnabled(false) + store.setOverlayAdvancedEnabled(false) + } + } + + @objc private func menuToggleFPSAdvanced() { + let store = EditorEngineStatsStore.shared + if store.overlayMode == .advanced { + store.setOverlaySimplifiedEnabled(true) + } else { + store.setOverlayAdvancedEnabled(true) + } + } + + @objc private func menuToggleSceneCam() { + EditorPlaybackSettings.shared.useSceneCameraDuringPlay.toggle() + } + + // Animation + render-pause are driven by EditorView (which observes these + // values), so the menu just flips the state. + @objc private func menuToggleLeftPanel() { + EditorPanelVisibility.shared.showLeftPanel.toggle() + } + + @objc private func menuToggleBottomPanel() { + EditorPanelVisibility.shared.showBottomPanel.toggle() + } + + @objc private func menuToggleRightPanel() { + EditorPanelVisibility.shared.showRightPanel.toggle() + } + + @objc private func menuToggleFocusViewport() { + EditorPanelVisibility.shared.toggleFocusViewport() + } } // Entry point From dcd1766e39630dfce8ec799067e6a278c3044d38 Mon Sep 17 00:00:00 2001 From: Jorge Trigger Date: Wed, 29 Jul 2026 11:10:54 +0200 Subject: [PATCH 2/2] UntoldEditor --- .../Editor/AssetBrowserView.swift | 16 +- .../Editor/EditorMenuCommands.swift | 5 + .../UntoldEditor/Editor/EditorSceneView.swift | 24 ++- Sources/UntoldEditor/Editor/EditorView.swift | 92 +++++++- .../UntoldEditor/Editor/EnvironmentView.swift | 63 ++++-- .../UntoldEditor/Editor/InspectorView.swift | 56 +++-- .../Editor/SceneHierarchyView.swift | 197 ++++++++++++------ .../Editor/SelectionManager.swift | 34 ++- .../Editor/TransformManipulationView.swift | 3 +- Sources/UntoldEditor/main.swift | 6 + UI_Changes.md | 104 +++++++++ 11 files changed, 479 insertions(+), 121 deletions(-) create mode 100644 UI_Changes.md diff --git a/Sources/UntoldEditor/Editor/AssetBrowserView.swift b/Sources/UntoldEditor/Editor/AssetBrowserView.swift index 55e5d81..c72e2b6 100644 --- a/Sources/UntoldEditor/Editor/AssetBrowserView.swift +++ b/Sources/UntoldEditor/Editor/AssetBrowserView.swift @@ -487,6 +487,16 @@ struct AssetBrowserView: View { folderPathStack = stack } + /// Entry point for the "New Directory" menu items. If there's no project + /// asset folder yet, tell the user instead of silently doing nothing. + private func requestNewDirectory(in parent: URL?) { + guard let parent else { + showBasePathAlert = true + return + } + createFolder(in: parent) + } + /// Create a uniquely-named subfolder inside `parent` (creating `parent` if /// needed, e.g. an empty category root) and reveal it. private func createFolder(in parent: URL) { @@ -583,11 +593,10 @@ struct AssetBrowserView: View { } .contextMenu { Button { - if let url { createFolder(in: url) } + requestNewDirectory(in: url) } label: { Label("New Directory", systemImage: "folder.badge.plus") } - .disabled(url == nil) } if isExpanded { @@ -638,11 +647,10 @@ struct AssetBrowserView: View { } .contextMenu { Button { - if let root { createFolder(in: root) } + requestNewDirectory(in: root) } label: { Label("New Directory", systemImage: "folder.badge.plus") } - .disabled(root == nil) } } diff --git a/Sources/UntoldEditor/Editor/EditorMenuCommands.swift b/Sources/UntoldEditor/Editor/EditorMenuCommands.swift index bfde7f2..50050ae 100644 --- a/Sources/UntoldEditor/Editor/EditorMenuCommands.swift +++ b/Sources/UntoldEditor/Editor/EditorMenuCommands.swift @@ -14,8 +14,13 @@ import Foundation extension Notification.Name { + /// Posted when asynchronous asset/tile loading finishes, so the Scene Graph + /// can refresh to show the newly-loaded entities. + static let sceneGraphNeedsRefresh = Notification.Name("sceneGraphNeedsRefresh") static let editorMenuNew = Notification.Name("editorMenuNew") static let editorMenuOpen = Notification.Name("editorMenuOpen") + static let editorMenuNewScene = Notification.Name("editorMenuNewScene") + static let editorMenuSaveProject = Notification.Name("editorMenuSaveProject") static let editorMenuSave = Notification.Name("editorMenuSave") static let editorMenuSaveAs = Notification.Name("editorMenuSaveAs") static let editorMenuReset = Notification.Name("editorMenuReset") diff --git a/Sources/UntoldEditor/Editor/EditorSceneView.swift b/Sources/UntoldEditor/Editor/EditorSceneView.swift index fc2d73b..9ce3f49 100644 --- a/Sources/UntoldEditor/Editor/EditorSceneView.swift +++ b/Sources/UntoldEditor/Editor/EditorSceneView.swift @@ -40,5 +40,27 @@ struct EditorSceneView: View, UntoldRendererDelegate { } } - func didDraw(in _: MTKView) {} + func didDraw(in _: MTKView) { + // Detect the moment async asset/tile loading finishes and ask the Scene + // Graph to refresh, so streamed-in entities appear without a manual action. + SceneGraphLoadWatcher.shared.poll() + } +} + +/// Watches the engine's async-loading gate on the render loop and posts a +/// refresh notification on the falling edge (loading → idle). +final class SceneGraphLoadWatcher { + static let shared = SceneGraphLoadWatcher() + private var wasLoading = false + private init() {} + + func poll() { + let loading = AssetLoadingGate.shared.isLoadingAny + if wasLoading, loading == false { + DispatchQueue.main.async { + NotificationCenter.default.post(name: .sceneGraphNeedsRefresh, object: nil) + } + } + wasLoading = loading + } } diff --git a/Sources/UntoldEditor/Editor/EditorView.swift b/Sources/UntoldEditor/Editor/EditorView.swift index b2c88a9..69222d8 100644 --- a/Sources/UntoldEditor/Editor/EditorView.swift +++ b/Sources/UntoldEditor/Editor/EditorView.swift @@ -154,7 +154,8 @@ public struct EditorView: View { onAddSpotLight: editor_createSpotLight, onAddAreaLight: editor_createAreaLight, onParentEntity: editor_parentEntity, - onUnparentEntity: editor_unparentEntity + onUnparentEntity: editor_unparentEntity, + onDeleteEntity: editor_removeEntity(_:) ) } } @@ -273,6 +274,13 @@ public struct EditorView: View { } // Pause the render loop while the user drags to resize the window so the // viewport doesn't stutter against the live resize; resume when done. + // Refresh the hierarchy when entities appear/disappear asynchronously + // (streaming/tiled assets create their nodes over several frames). The + // render loop (EditorSceneView.didDraw) detects the change and posts this. + .onReceive(NotificationCenter.default.publisher(for: .sceneGraphNeedsRefresh)) { _ in + editor_entities = getAllGameEntities() + sceneGraphModel.refreshHierarchy() + } .onReceive(NotificationCenter.default.publisher(for: NSWindow.willStartLiveResizeNotification)) { _ in renderer?.metalView.isPaused = true } @@ -285,6 +293,12 @@ public struct EditorView: View { .onReceive(NotificationCenter.default.publisher(for: .editorMenuOpen)) { _ in openExistingProjectFromWelcome() } + .onReceive(NotificationCenter.default.publisher(for: .editorMenuNewScene)) { _ in + editor_newScene() + } + .onReceive(NotificationCenter.default.publisher(for: .editorMenuSaveProject)) { _ in + editor_saveProject() + } .onReceive(NotificationCenter.default.publisher(for: .editorMenuSave)) { _ in editor_handleSave() } @@ -458,6 +472,10 @@ public struct EditorView: View { .editorPanel() .padding(5) } + } else if selectionManager.sceneSelected { + sceneInspector + .editorPanel() + .padding(5) } else { InspectorView( selectionManager: selectionManager, @@ -471,6 +489,47 @@ public struct EditorView: View { } } + // Inspector shown when the active scene is selected in the Scene Graph. + private var sceneInspector: some View { + let sceneName = editorController?.currentSceneURL?.deletingPathExtension().lastPathComponent ?? "Untitled Scene" + return VStack(alignment: .leading, spacing: 10) { + Text("Scene") + .font(.headline) + .foregroundColor(.editorTextPrimary) + + Divider() + + HStack { + Text("Name") + .foregroundColor(.editorTextSecondary) + Spacer() + Text(sceneName) + .foregroundColor(.editorTextPrimary) + .lineLimit(1) + } + .font(.system(size: 12)) + + if let url = editorController?.currentSceneURL { + VStack(alignment: .leading, spacing: 2) { + Text("Path") + .foregroundColor(.editorTextSecondary) + Text(url.path) + .foregroundColor(.editorTextTertiary) + .lineLimit(3) + .textSelection(.enabled) + } + .font(.system(size: 11)) + } else { + Text("This scene hasn't been saved yet.") + .font(.system(size: 11)) + .foregroundColor(.editorTextTertiary) + } + + Spacer() + } + .frame(maxWidth: .infinity, alignment: .leading) + } + private var envEffectsTabs: some View { HStack(spacing: 2) { envTabButton(.environment, title: "Environment", icon: "sun.max") @@ -1065,6 +1124,19 @@ public struct EditorView: View { editor_loadScene(from: url) } + // Save the project. Projects persist as folders (scenes + imported assets on + // disk), so for now this writes the active scene's work into the project. + private func editor_saveProject() { + editor_handleSave() + } + + // Start a fresh, unsaved scene (File → Add New Scene). + private func editor_newScene() { + editor_clearScene() + editorController?.currentSceneURL = nil + selectionManager.selectScene() + } + private func editor_clearScene() { destroyAllEntities() removeGizmo() @@ -1164,6 +1236,24 @@ public struct EditorView: View { sceneGraphModel.refreshHierarchy() } + // Delete a specific entity (used by the Scene Graph right-click menu). + private func editor_removeEntity(_ entityId: EntityID) { + guard isDerivedAssetNode(entityId) == false else { + print("⚠️ Asset nodes cannot be removed directly") + return + } + + destroyEntity(entityId: entityId) + + editor_entities = getAllGameEntities() + if selectionManager.selectedEntity == entityId { + selectionManager.selectedEntity = nil + activeEntity = .invalid + removeGizmo() + } + sceneGraphModel.refreshHierarchy() + } + private func editor_addName() { guard let entity = selectionManager.selectedEntity else { print("No entity is selected.") // Handle case where no entity is selected diff --git a/Sources/UntoldEditor/Editor/EnvironmentView.swift b/Sources/UntoldEditor/Editor/EnvironmentView.swift index 43e3402..fd2f931 100644 --- a/Sources/UntoldEditor/Editor/EnvironmentView.swift +++ b/Sources/UntoldEditor/Editor/EnvironmentView.swift @@ -71,9 +71,13 @@ struct EnvironmentView: View { } .padding(.vertical, 4) .padding(.horizontal, 8) - .background(Color.editorAccent) + .background(Color.editorSurface) .foregroundColor(.editorTextPrimary) .cornerRadius(6) + .overlay( + RoundedRectangle(cornerRadius: 6) + .stroke(Color.editorDivider, lineWidth: 1) + ) } .buttonStyle(PlainButtonStyle()) @@ -82,22 +86,30 @@ struct EnvironmentView: View { // MARK: - IBL and Environment Toggles (Compact) VStack(alignment: .leading, spacing: 6) { - Toggle(isOn: $enableApplyIBL) { + HStack { Label("Apply IBL", systemImage: enableApplyIBL ? "checkmark.circle.fill" : "circle") .font(.system(size: 12)) + Spacer() + Toggle("", isOn: $enableApplyIBL) + .labelsHidden() + .toggleStyle(.switch) + .controlSize(.small) + .tint(Color.editorAccent) } - .toggleStyle(SwitchToggleStyle()) - .scaleEffect(0.85, anchor: .leading) // Make toggle smaller, keep left edge aligned .onChange(of: enableApplyIBL) { _, newValue in applyIBL = newValue } - Toggle(isOn: $enableRenderEnvironment) { + HStack { Label("Render Environment", systemImage: enableRenderEnvironment ? "checkmark.circle.fill" : "circle") .font(.system(size: 12)) + Spacer() + Toggle("", isOn: $enableRenderEnvironment) + .labelsHidden() + .toggleStyle(.switch) + .controlSize(.small) + .tint(Color.editorAccent) } - .toggleStyle(SwitchToggleStyle()) - .scaleEffect(0.85, anchor: .leading) .onChange(of: enableRenderEnvironment) { _, newValue in renderEnvironment = newValue } @@ -136,21 +148,29 @@ private struct UndoableEffectToggle: View { @ViewBuilder let label: () -> Label var body: some View { - Toggle(isOn: Binding( - get: { isOn }, - set: { newValue in - let oldValue = isOn - isOn = newValue - DispatchQueue.main.async { - EditorUndoManager.shared.registerValueChange( - name: undoName, - oldValue: oldValue, - newValue: newValue, - apply: { isOn = $0 } - ) + HStack { + label() + Spacer() + Toggle("", isOn: Binding( + get: { isOn }, + set: { newValue in + let oldValue = isOn + isOn = newValue + DispatchQueue.main.async { + EditorUndoManager.shared.registerValueChange( + name: undoName, + oldValue: oldValue, + newValue: newValue, + apply: { isOn = $0 } + ) + } } - } - ), label: label) + )) + .labelsHidden() + .toggleStyle(.switch) + .controlSize(.small) + .tint(Color.editorAccent) + } } } @@ -187,6 +207,7 @@ private struct UndoableEffectSlider: View { } } ) + .tint(Color.editorAccent) Text(String(format: format, get())) } } diff --git a/Sources/UntoldEditor/Editor/InspectorView.swift b/Sources/UntoldEditor/Editor/InspectorView.swift index 66148e4..9138de9 100644 --- a/Sources/UntoldEditor/Editor/InspectorView.swift +++ b/Sources/UntoldEditor/Editor/InspectorView.swift @@ -565,24 +565,28 @@ struct StaticBatchingEditorView: View { ? "Enable static batching for this entity (combines geometry to reduce draw calls)" : "Enable static batching for all children of this entity (combines geometry to reduce draw calls)" - Toggle(isOn: Binding( - get: { staticBatchCheckboxState }, - set: { isStatic in - if isStatic { - setEntityStaticBatchComponent(entityId: entityId) - } else { - removeEntityStaticBatchComponent(entityId: entityId) + HStack { + Image(systemName: "square.3.layers.3d") + .foregroundColor(.editorInfo) + Text(labelText) + .font(.callout) + Spacer() + Toggle("", isOn: Binding( + get: { staticBatchCheckboxState }, + set: { isStatic in + if isStatic { + setEntityStaticBatchComponent(entityId: entityId) + } else { + removeEntityStaticBatchComponent(entityId: entityId) + } + staticBatchCheckboxState = isStatic + refreshView() } - staticBatchCheckboxState = isStatic - refreshView() - } - )) { - HStack { - Image(systemName: "square.3.layers.3d") - .foregroundColor(.editorInfo) - Text(labelText) - .font(.callout) - } + )) + .labelsHidden() + .toggleStyle(.switch) + .controlSize(.small) + .tint(Color.editorAccent) } .padding(.vertical, 6) .padding(.horizontal, 8) @@ -638,9 +642,13 @@ struct RenderingEditorView: View { } .padding(.vertical, 8) .padding(.horizontal, 12) - .background(Color.editorAccent) + .background(Color.editorSurface) .foregroundColor(.editorTextPrimary) .cornerRadius(8) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(Color.editorDivider, lineWidth: 1) + ) .shadow(color: Color.editorShadow, radius: 4, x: 0, y: 2) } .buttonStyle(PlainButtonStyle()) @@ -1264,9 +1272,13 @@ struct AnimationEditorView: View { } .padding(.vertical, 8) .padding(.horizontal, 12) - .background(Color.editorAccent) + .background(Color.editorSurface) .foregroundColor(.editorTextPrimary) .cornerRadius(8) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(Color.editorDivider, lineWidth: 1) + ) .shadow(color: Color.editorShadow, radius: 4, x: 0, y: 2) } .buttonStyle(PlainButtonStyle()) @@ -1537,9 +1549,13 @@ struct GaussianEditorView: View { } .padding(.vertical, 8) .padding(.horizontal, 12) - .background(Color.editorAccent) + .background(Color.editorSurface) .foregroundColor(.editorTextPrimary) .cornerRadius(8) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(Color.editorDivider, lineWidth: 1) + ) .shadow(color: Color.editorShadow, radius: 4, x: 0, y: 2) } .buttonStyle(PlainButtonStyle()) diff --git a/Sources/UntoldEditor/Editor/SceneHierarchyView.swift b/Sources/UntoldEditor/Editor/SceneHierarchyView.swift index cee165c..5a20c9e 100644 --- a/Sources/UntoldEditor/Editor/SceneHierarchyView.swift +++ b/Sources/UntoldEditor/Editor/SceneHierarchyView.swift @@ -31,6 +31,33 @@ func hierarchyIconName(for entityId: EntityID) -> String { return "cube" } +/// The "add entity" actions, bundled so the same menu can be reused by the +/// bottom "+" toolbar and the right-click context menu. +struct AddEntityActions { + var empty: () -> Void = {} + var cube: () -> Void = {} + var sphere: () -> Void = {} + var plane: () -> Void = {} + var dirLight: () -> Void = {} + var pointLight: () -> Void = {} + var spotLight: () -> Void = {} + var areaLight: () -> Void = {} +} + +@ViewBuilder +func addEntityMenuItems(_ actions: AddEntityActions) -> some View { + Button("Empty Entity", systemImage: "plus") { actions.empty() } + Divider() + Button("Cube", systemImage: "cube") { actions.cube() } + Button("Sphere", systemImage: "circle") { actions.sphere() } + Button("Plane", systemImage: "square") { actions.plane() } + Divider() + Button("Directional Light", systemImage: "sun.max") { actions.dirLight() } + Button("Point Light", systemImage: "lightbulb") { actions.pointLight() } + Button("Spot Light", systemImage: "flashlight.on.fill") { actions.spotLight() } + Button("Area Light", systemImage: "square") { actions.areaLight() } +} + struct SceneHierarchyView: View { @ObservedObject var selectionManager: SelectionManager @ObservedObject var sceneGraphModel: SceneGraphModel @@ -52,9 +79,23 @@ struct SceneHierarchyView: View { var onAddAreaLight: () -> Void var onParentEntity: (EntityID, EntityID) -> Void = { _, _ in } var onUnparentEntity: (EntityID) -> Void = { _ in } + var onDeleteEntity: (EntityID) -> Void = { _ in } @State private var activeSceneExpanded = true + private var addActions: AddEntityActions { + AddEntityActions( + empty: onAddEntity_Editor, + cube: onAddCube, + sphere: onAddSphere, + plane: onAddPlane, + dirLight: onAddDirLight, + pointLight: onAddPointLight, + spotLight: onAddSpotLight, + areaLight: onAddAreaLight + ) + } + // A scene node in the tree. `url == nil` represents the current, not-yet-saved // ("Untitled") scene, which is always the active one. private struct SceneItem: Identifiable { @@ -107,7 +148,9 @@ struct SceneHierarchyView: View { sceneGraphModel: sceneGraphModel, selectionManager: selectionManager, onParentEntity: onParentEntity, - onUnparentEntity: onUnparentEntity + onUnparentEntity: onUnparentEntity, + onDeleteEntity: onDeleteEntity, + addActions: addActions ) } } @@ -120,10 +163,6 @@ struct SceneHierarchyView: View { .frame(maxHeight: .infinity) .background(Color.editorFillSubtle) .cornerRadius(8) - - // MARK: - Bottom toolbar (add / remove) - - bottomToolbar } .padding(5) .frame(minWidth: 320, maxWidth: 320, maxHeight: .infinity) @@ -133,53 +172,6 @@ struct SceneHierarchyView: View { .padding(5) } - // MARK: - Bottom toolbar - - private var bottomToolbar: some View { - HStack(spacing: 12) { - // Add Entity Menu - Menu { - Button("Empty Entity", systemImage: "plus") { onAddEntity_Editor() } - Divider() - Button("Cube", systemImage: "cube") { onAddCube() } - Button("Sphere", systemImage: "circle") { onAddSphere() } - Button("Plane", systemImage: "square") { onAddPlane() } - Divider() - Button("Directional Light", systemImage: "sun.max") { onAddDirLight() } - Button("Point Light", systemImage: "lightbulb") { onAddPointLight() } - Button("Spot Light", systemImage: "flashlight.on.fill") { onAddSpotLight() } - Button("Area Light", systemImage: "square") { onAddAreaLight() } - } label: { - Image(systemName: "plus") - .foregroundColor(.editorTextPrimary) - .font(.system(size: 13, weight: .bold)) - .padding(6) - .background(Color.editorInfo) - .clipShape(Circle()) - } - .menuStyle(.borderlessButton) - .menuIndicator(.hidden) - .help("Add Entity") - - // Remove Entity Button - Button(action: onRemoveEntity_Editor) { - Image(systemName: "minus.circle.fill") - .foregroundColor(.editorError) - .font(.system(size: 18)) - } - .buttonStyle(PlainButtonStyle()) - .help("Remove Selected Entity") - .disabled(selectionManager.selectedEntity.map { isDerivedAssetNode($0) } ?? false) - .opacity(selectionManager.selectedEntity.map { isDerivedAssetNode($0) } ?? false ? 0.45 : 1.0) - - Spacer() - } - .padding(.horizontal, 10) - .padding(.vertical, 6) - .background(Color.editorFill) - .cornerRadius(8) - } - // MARK: - Project row (tree root) private var projectRow: some View { @@ -227,7 +219,8 @@ struct SceneHierarchyView: View { // MARK: - Scene row (second level) private func sceneRow(_ item: SceneItem) -> some View { - HStack(spacing: 8) { + let isSceneSelected = item.isActive && selectionManager.sceneSelected + return HStack(spacing: 8) { if item.isActive { Button(action: { activeSceneExpanded.toggle() }) { Image(systemName: activeSceneExpanded ? "chevron.down" : "chevron.right") @@ -250,20 +243,42 @@ struct SceneHierarchyView: View { .lineLimit(1) Spacer() + + if item.isActive { + Menu { + addEntityMenuItems(addActions) + } label: { + Image(systemName: "plus") + .font(.system(size: 11, weight: .bold)) + .foregroundColor(.editorTextPrimary) + .frame(width: 20, height: 20) + .background(Color.editorInfo) + .clipShape(Circle()) + } + .menuStyle(.borderlessButton) + .menuIndicator(.hidden) + .fixedSize() + .help("Add to scene") + } } .padding(.vertical, 4) .padding(.horizontal, 6) - .background(item.isActive ? Color.editorSurface.opacity(0.5) : Color.clear) + .background(isSceneSelected ? Color.editorAccentSoft : (item.isActive ? Color.editorSurface.opacity(0.5) : Color.clear)) .cornerRadius(6) + .overlay( + RoundedRectangle(cornerRadius: 6) + .stroke(isSceneSelected ? Color.editorAccent : Color.clear, lineWidth: 1) + ) .contentShape(Rectangle()) .onTapGesture { if item.isActive { - activeSceneExpanded.toggle() + // Select the active scene (its properties show in the Inspector). + selectionManager.selectScene() } else if let url = item.url { onSelectScene(url) } } - .help(item.isActive ? "Active scene" : "Load this scene") + .help(item.isActive ? "Select scene" : "Load this scene") } } @@ -335,6 +350,8 @@ struct HierarchyNode: View { let selectionManager: SelectionManager var onParentEntity: (EntityID, EntityID) -> Void = { _, _ in } var onUnparentEntity: (EntityID) -> Void = { _ in } + var onDeleteEntity: (EntityID) -> Void = { _ in } + var addActions: AddEntityActions = AddEntityActions() @State private var isDragOver = false var body: some View { @@ -386,7 +403,9 @@ struct HierarchyNode: View { sceneGraphModel: sceneGraphModel, selectionManager: selectionManager, onParentEntity: onParentEntity, - onUnparentEntity: onUnparentEntity + onUnparentEntity: onUnparentEntity, + onDeleteEntity: onDeleteEntity, + addActions: addActions ) } } @@ -448,26 +467,74 @@ struct HierarchyNode: View { getEntityParent(entityId: entityId) != nil } + /// Run an "add" action and parent whatever new root entity it created to + /// this node, so adding from an object nests the new object under it. + private func addChild(_ create: () -> Void) { + let before = Set(getAllGameEntities()) + create() + let newRoots = getAllGameEntities().filter { + before.contains($0) == false && getEntityParent(entityId: $0) == nil + } + for child in newRoots { + onParentEntity(child, entityId) + } + } + + /// Add actions that parent the created entity to this node. + private var parentedAddActions: AddEntityActions { + AddEntityActions( + empty: { addChild(addActions.empty) }, + cube: { addChild(addActions.cube) }, + sphere: { addChild(addActions.sphere) }, + plane: { addChild(addActions.plane) }, + dirLight: { addChild(addActions.dirLight) }, + pointLight: { addChild(addActions.pointLight) }, + spotLight: { addChild(addActions.spotLight) }, + areaLight: { addChild(addActions.areaLight) } + ) + } + /// Context menu for entity row private var contextMenuContent: some View { VStack { + Menu { + // Adding from an object nests the new entity under it; asset + // nodes can't be parents, so those add at scene root. + addEntityMenuItems(isDerivedAssetNode(entityId) ? addActions : parentedAddActions) + } label: { + Label("Add", systemImage: "plus") + } + + Divider() + if isDerivedAssetNode(entityId) { Text("Asset node") .foregroundColor(.editorTextTertiary) - } else if hasParent { - Button(action: { + } else { + if hasParent { + Button(action: { + DispatchQueue.main.async { + onUnparentEntity(entityId) + } + }) { + HStack { + Image(systemName: "arrow.up.left") + Text("Unparent") + } + } + Divider() + } + + Button(role: .destructive, action: { DispatchQueue.main.async { - onUnparentEntity(entityId) + onDeleteEntity(entityId) } }) { HStack { - Image(systemName: "arrow.up.left") - Text("Unparent") + Image(systemName: "trash") + Text("Delete") } } - } else { - Text("No parent") - .foregroundColor(.editorTextTertiary) } } } diff --git a/Sources/UntoldEditor/Editor/SelectionManager.swift b/Sources/UntoldEditor/Editor/SelectionManager.swift index 4395515..aacceb9 100644 --- a/Sources/UntoldEditor/Editor/SelectionManager.swift +++ b/Sources/UntoldEditor/Editor/SelectionManager.swift @@ -53,7 +53,9 @@ struct MeshInspectionSelection: Equatable { class SceneGraphModel: ObservableObject { @Published var childrenMap: [EntityID: [EntityID]] = [:] - @Published private(set) var collapsedEntityIds: Set = [] + // Every node is collapsed by default; we track only the ones the user + // expanded. Opening a project therefore shows a tidy, collapsed tree. + @Published private(set) var expandedEntityIds: Set = [] func refreshHierarchy() { let allEntities = getAllGameEntities() @@ -67,7 +69,7 @@ class SceneGraphModel: ObservableObject { } let currentEntityIds = Set(allEntities) - collapsedEntityIds = collapsedEntityIds.intersection(currentEntityIds) + expandedEntityIds = expandedEntityIds.intersection(currentEntityIds) } func getChildren(entityId: EntityID?) -> [EntityID] { @@ -79,14 +81,14 @@ class SceneGraphModel: ObservableObject { } func isExpanded(entityId: EntityID) -> Bool { - collapsedEntityIds.contains(entityId) == false + expandedEntityIds.contains(entityId) } func toggleExpanded(entityId: EntityID) { - if collapsedEntityIds.contains(entityId) { - collapsedEntityIds.remove(entityId) + if expandedEntityIds.contains(entityId) { + expandedEntityIds.remove(entityId) } else { - collapsedEntityIds.insert(entityId) + expandedEntityIds.insert(entityId) } } } @@ -97,19 +99,33 @@ class SelectionManager: ObservableObject { /// True when the project itself is selected in the Scene Graph panel. Drives /// the right panel to show Environment/Effects instead of the Inspector. @Published var projectSelected: Bool = false + /// True when the active scene node is selected. Drives the right panel to + /// show the scene inspector. + @Published var sceneSelected: Bool = false init() {} - /// Select the project (deselects any entity). The right panel switches to - /// the Environment/Effects editors. + /// Select the project (deselects any entity/scene). The right panel switches + /// to the Environment/Effects editors. func selectProject() { projectSelected = true + sceneSelected = false + inspectedMesh = nil + selectedEntity = nil + } + + /// Select the active scene (deselects project/entity). The right panel shows + /// the scene inspector. + func selectScene() { + sceneSelected = true + projectSelected = false inspectedMesh = nil selectedEntity = nil } func selectEntity(entityId: EntityID) { projectSelected = false + sceneSelected = false inspectedMesh = nil let selectedEntityId = editableAssetRootEntity(for: entityId) selectEntity(entityId: selectedEntityId, inspectEntityId: selectedEntityId) @@ -117,12 +133,14 @@ class SelectionManager: ObservableObject { func inspectEntity(entityId: EntityID) { projectSelected = false + sceneSelected = false inspectedMesh = nil selectEntity(entityId: sceneTransformEntity(for: entityId), inspectEntityId: entityId) } func inspectMesh(entityId: EntityID, meshIndex: Int) { projectSelected = false + sceneSelected = false let transformEntityId = sceneTransformEntity(for: entityId) selectedEntity = entityId inspectedMesh = MeshInspectionSelection( diff --git a/Sources/UntoldEditor/Editor/TransformManipulationView.swift b/Sources/UntoldEditor/Editor/TransformManipulationView.swift index 397f8dd..2f9a0a1 100644 --- a/Sources/UntoldEditor/Editor/TransformManipulationView.swift +++ b/Sources/UntoldEditor/Editor/TransformManipulationView.swift @@ -45,8 +45,9 @@ struct ModeButton: View { // Text(label) } .padding(8) - .background(isActive ? Color.accentColor.opacity(0.2) : Color.clear) + .background(isActive ? Color.editorAccentSoft : Color.clear) .cornerRadius(6) + .contentShape(Rectangle()) } .buttonStyle(PlainButtonStyle()) } diff --git a/Sources/UntoldEditor/main.swift b/Sources/UntoldEditor/main.swift index cefc225..e1ac7d8 100644 --- a/Sources/UntoldEditor/main.swift +++ b/Sources/UntoldEditor/main.swift @@ -98,6 +98,10 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { fileMenuItem.submenu = fileMenu addItem(to: fileMenu, title: "New", action: #selector(menuNew), key: "n") addItem(to: fileMenu, title: "Open…", action: #selector(menuOpen), key: "o") + addItem(to: fileMenu, title: "Save Project", action: #selector(menuSaveProject), key: "") + fileMenu.addItem(.separator()) + let newScene = addItem(to: fileMenu, title: "Add New Scene", action: #selector(menuNewScene), key: "n") + newScene.keyEquivalentModifierMask = [.command, .shift] fileMenu.addItem(.separator()) addItem(to: fileMenu, title: "Save Scene", action: #selector(menuSave), key: "s") let saveAs = addItem(to: fileMenu, title: "Save Scene As…", action: #selector(menuSaveAs), key: "s") @@ -151,6 +155,8 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { @objc private func menuNew() { NotificationCenter.default.post(name: .editorMenuNew, object: nil) } @objc private func menuOpen() { NotificationCenter.default.post(name: .editorMenuOpen, object: nil) } + @objc private func menuNewScene() { NotificationCenter.default.post(name: .editorMenuNewScene, object: nil) } + @objc private func menuSaveProject() { NotificationCenter.default.post(name: .editorMenuSaveProject, object: nil) } @objc private func menuSave() { NotificationCenter.default.post(name: .editorMenuSave, object: nil) } @objc private func menuSaveAs() { NotificationCenter.default.post(name: .editorMenuSaveAs, object: nil) } @objc private func menuReset() { NotificationCenter.default.post(name: .editorMenuReset, object: nil) } diff --git a/UI_Changes.md b/UI_Changes.md new file mode 100644 index 0000000..defd877 --- /dev/null +++ b/UI_Changes.md @@ -0,0 +1,104 @@ +# Untold Engine Editor — UI Overhaul + +Summary of changes, restructurings and fixes. + +## Theme & colors + +- Expanded **`EditorScheme.swift`** from 7 to ~23 semantic tokens: text hierarchy (primary/secondary/tertiary/inverse), status (error/success/warning/info in Dracula tones), fills/dividers and overlays/shadows. +- Migrated ~240 hardcoded colors across 15 views to those tokens. +- Added reusable helpers: **`editorPanel()`** (card look) and **`EditorDisclosureStyle`** (chevron-width indentation). + +## Dark appearance & window + +- Forced dark mode (`preferredColorScheme(.dark)` + `NSWindow` `.darkAqua`) — fixed invisible black text over dark backgrounds. +- Transparent title bar and window background tinted with the theme color (was black). + +## Top toolbar → native macOS menu + +- Removed the top toolbar (New/Open/Reset/Play/Save/FPS). +- Built the native menu (App / File / View) in `main.swift`, bridged via notifications (`EditorMenuCommands.swift`). +- **Shortcut fix:** the engine's `keyDown` monitor was eating all keys; now ⌘ combos pass through to the menu. + +## Right panel — contextual + +- Removed the Environment/Effects/Inspector `TabView`. +- Project selected → **Environment/Effects** (themed segmented); Scene selected → **scene inspector**; Object selected → **Inspector**. +- Uniform `editorPanel()` styling, 5px content inset, 5px card margin; content top-aligned. +- Fixed card misalignment when expanding a `DisclosureGroup` (native AA segmented → themed control + `EditorDisclosureStyle`); removed the blue leaf icon; toggles now label-left / switch-right. +- Sliders and switches tinted orange (selected state); action buttons (Add IBL, Assign) made neutral — orange now means *selected* only (reverted a global tint that turned every button orange). + +## Panel show / hide + +- Toggles in the View menu (⌘1 / ⌘2 / ⌘3) plus ⌘F (Focus Viewport, restores the previous layout). +- Edge tabs on each panel that protrude over the viewport (`zIndex`). +- Animated show/hide with the render loop paused during the animation. + +## Performance / async loading + +- Render paused during window live-resize. +- Hierarchy refreshes when async loading finishes (tiles/streaming): detects the falling edge of `AssetLoadingGate` in `EditorSceneView.didDraw` → notification (replaced the polling patch). + +## Assets panel — Finder-style redesign + +- Removed the toolbar (Import/Load Authored/Delete) and the "Target Entity" row. +- Split view: directory tree on the left (project root node + categories + subfolders), folder contents on the right. +- Right-click: empty area → Import / Import Remote Stream / Load Authored; item → Delete; folder → New Directory. Import targets the selected folder. +- Search moved to the top (shared with the console); left split widened 25%. + +## Bottom panel (Assets / Console) + +- Native tab strip → themed segmented control. +- Shared search in the bar (filters assets or console depending on the tab). +- Console lost its internal header; Auto-scroll + Clear moved to the bar (Console only); the log fills the area. + +## Viewport + +- Move/Rotate/Scale cluster moved to the top-center; fixed hit area (`contentShape`) and themed active color. + +## Files + +- **New:** `EditorMenuCommands.swift`, `ProjectSceneCatalog.swift`. +- **To remove:** `ToolbarView.swift` (left empty) → `git rm`. + +## Keyboard shortcuts + +New shortcuts wired through the native menu bar: + +| Shortcut | Action | Menu | +|----------|--------|------| +| ⌘N | New project | File | +| ⌘O | Open project | File | +| ⇧⌘N | Add new scene | File | +| ⌘S | Save scene | File | +| ⇧⌘S | Save scene as… | File | +| ⌘1 | Toggle left panel (Scene Graph) | View | +| ⌘2 | Toggle bottom panel (Assets / Console) | View | +| ⌘3 | Toggle right panel (Inspector) | View | +| ⌘F | Focus viewport (hide all panels / restore) | View | +| ⌘Q | Quit | App | + +> **Note:** Save Project has no shortcut (avoids clashing with ⌘S for Save Scene). Undo/redo (⌘Z / ⇧⌘Z) are handled by the engine's editor undo manager. + +## Project > Scenes restructuring + +### What was restructured + +The left panel changed from a flat entity list into a three-level tree: **Project → Scenes → elements**. + +- The **project** is a fixed header (folder + name), non-collapsible, carrying the Play/Pause button and acting as a selectable node that drives the Environment/Effects editors on the right. +- **Scenes** are listed from a new `ProjectSceneCatalog`, which scans the project's `Scenes/` folder for `.untoldscene` files. Because the engine keeps a single scene loaded at a time, only the **active** scene expands to show live ECS elements; the others are file references. Clicking a non-active scene loads it (replacing the world) after a confirmation. +- The active scene is **selectable** (shows a scene inspector on the right) with an independent expand chevron and a "+" add menu. +- **Elements** are the live entities, collapsed by default, with icons matching their type, chevron-width indentation, and right-click Add (nests under the node) / Delete / Unparent. +- Selection state lives in `SelectionManager` (`projectSelected` / `sceneSelected` / entity), mutually exclusive. +- "Add New Scene" resets the world to a fresh, unsaved scene; the catalog auto-refreshes on save and on load. + +### What's left to do + +- **Real multi-scene** is not possible yet — `loadScene` calls `destroyAllEntities()`, so only one scene is live. Several scenes at once (or additive loading) needs engine work: tagging entities by scene and load/unload per scene. +- **Dirty tracking:** the "unsaved changes" confirmation on scene switch always fires because there's no modified flag. Hook a dirty state (e.g. via the undo manager) so it only warns on real changes. +- **Scene inspector is a placeholder** — only name and path. Define and implement real per-scene properties (environment/effects per scene, default camera, streaming settings…). +- **Scene management from the tree:** no rename / duplicate / delete of scene files yet, only load and "Add New Scene". Add a right-click menu on scene rows (Rename / Duplicate / Delete / Reveal in Assets). +- **"Add New Scene" doesn't create a file** until Save As — it only resets the world. Consider creating a `.untoldscene` file immediately and adding it to the catalog. +- **Save Project** is a placeholder (saves the active scene) because the engine has no project file/config. Define what a project persists (settings, scene list, active scene) and implement a real project manifest. +- **Catalog freshness:** refreshes on save/appear/load but not on external filesystem changes. A folder watcher or a manual refresh action would close that gap. +- **Non-active scene UX:** clicking loads immediately; a thumbnail/preview or a distinct "open" affordance would make it clearer.