diff --git a/ScreenShader/config.swift b/ScreenShader/config.swift index dd167ae..6b4dc26 100644 --- a/ScreenShader/config.swift +++ b/ScreenShader/config.swift @@ -330,20 +330,8 @@ class Config: Codable { } if config.effects.effectList().isEmpty { - // Add predefined Metal shaders - for (name, shader) in predefinedShaders { - let effect = config.effects.new(language: .metal) - config.effects.setName(effect: effect, newName: name) - config.effects.setShader(effect: effect, shader: shader) - } - // Add predefined Slang shaders (only if slangc is available) - if SlangCompiler.isAvailable { - for (name, shader) in predefinedSlangShaders { - let effect = config.effects.new(language: .slang) - config.effects.setName(effect: effect, newName: name) - config.effects.setShader(effect: effect, shader: shader) - } - } + // Create a default empty effect for the user to configure with a shader file + let _ = config.effects.new(language: .slang) } return config @@ -356,17 +344,19 @@ class Effects: Codable { private var deletedEffects: [UUID] = [] private var effectToName: [UUID: String] = [:] private var effectToShader: [UUID: String] = [:] + private var effectToShaderPath: [UUID: String] = [:] private var effectToActive: [UUID: Bool] = [:] private var effectToLanguage: [UUID: ShaderLanguage] = [:] private var mostRecentActiveEffect: UUID? = nil - // Custom CodingKeys to handle optional effectToLanguage + // Custom CodingKeys to handle optional effectToLanguage and effectToShaderPath private enum CodingKeys: String, CodingKey { case nextEffectNumber case effects case deletedEffects case effectToName case effectToShader + case effectToShaderPath case effectToActive case effectToLanguage case mostRecentActiveEffect @@ -383,6 +373,7 @@ class Effects: Codable { deletedEffects = try container.decode([UUID].self, forKey: .deletedEffects) effectToName = try container.decode([UUID: String].self, forKey: .effectToName) effectToShader = try container.decode([UUID: String].self, forKey: .effectToShader) + effectToShaderPath = try container.decodeIfPresent([UUID: String].self, forKey: .effectToShaderPath) ?? [:] effectToActive = try container.decode([UUID: Bool].self, forKey: .effectToActive) // Handle missing effectToLanguage for backwards compatibility effectToLanguage = try container.decodeIfPresent([UUID: ShaderLanguage].self, forKey: .effectToLanguage) ?? [:] @@ -393,14 +384,14 @@ class Effects: Codable { return self.effects } - func new(language: ShaderLanguage = .metal) -> UUID { + func new(language: ShaderLanguage = .slang) -> UUID { let newEffectID = UUID() let newEffectName = "Effect \(self.nextEffectNumber)" self.nextEffectNumber += 1 self.effects.append(newEffectID) self.effectToName[newEffectID] = newEffectName - self.effectToShader[newEffectID] = language == .slang ? defaultSlangShaderSource : defaultShaderSource + self.effectToShader[newEffectID] = "" // Empty - will be loaded from file self.effectToActive[newEffectID] = false self.effectToLanguage[newEffectID] = language @@ -445,13 +436,49 @@ class Effects: Codable { } func getShader(effect: UUID) -> String { - return self.effectToShader[effect]! + // If a shader path is set, read from file + if let path = effectToShaderPath[effect], !path.isEmpty { + do { + let shaderSource = try String(contentsOfFile: path, encoding: .utf8) + return shaderSource + } catch { + print("Failed to read shader from file: \(error)") + // Fall back to cached shader if file read fails + return self.effectToShader[effect] ?? "" + } + } + return self.effectToShader[effect] ?? "" } func setShader(effect: UUID, shader: String) { self.effectToShader[effect] = shader } + func getShaderPath(effect: UUID) -> String? { + return self.effectToShaderPath[effect] + } + + func setShaderPath(effect: UUID, path: String?) { + if let path = path, !path.isEmpty { + self.effectToShaderPath[effect] = path + // Infer language from file extension + if path.hasSuffix(".slang") { + self.effectToLanguage[effect] = .slang + } else if path.hasSuffix(".metal") { + self.effectToLanguage[effect] = .metal + } + } else { + self.effectToShaderPath.removeValue(forKey: effect) + } + } + + func hasShaderPath(effect: UUID) -> Bool { + if let path = effectToShaderPath[effect] { + return !path.isEmpty + } + return false + } + func getLanguage(effect: UUID) -> ShaderLanguage { return self.effectToLanguage[effect] ?? .metal } diff --git a/ScreenShader/effect_view.swift b/ScreenShader/effect_view.swift index 6bcb12e..61cda54 100644 --- a/ScreenShader/effect_view.swift +++ b/ScreenShader/effect_view.swift @@ -1,33 +1,6 @@ import AppKit -class SourceTextView: NSTextView { - override func insertTab(_ sender: Any?) { - self.insertText(" ", replacementRange: self.selectedRange()) - } - - // Disable the context menu to prevent Metal library loading crash - override func menu(for event: NSEvent) -> NSMenu? { - return nil - } - - // Override performKeyEquivalent to handle Cmd key events without triggering system UI - override func performKeyEquivalent(with event: NSEvent) -> Bool { - // Let standard text editing shortcuts through (Cmd+C, Cmd+V, etc.) - if event.modifierFlags.contains(.command) { - let key = event.charactersIgnoringModifiers ?? "" - switch key { - case "c", "v", "x", "a", "z", "s": - return super.performKeyEquivalent(with: event) - default: - // Consume other Cmd key events to prevent system UI crashes - return true - } - } - return super.performKeyEquivalent(with: event) - } -} - -class EffectViewController: NSViewController, NSTextFieldDelegate, NSTextViewDelegate { +class EffectViewController: NSViewController, NSTextFieldDelegate { var effects: Effects! = nil var effect: UUID! = nil var onUpdate: () -> Void = {} @@ -37,10 +10,11 @@ class EffectViewController: NSViewController, NSTextFieldDelegate, NSTextViewDel private var nameField: NSTextField! = nil private var activeButton: NSButton! = nil private var languageLabel: NSTextField! = nil - private var languagePopup: NSPopUpButton! = nil private var deleteButton: NSButton! = nil - private var shaderField: NSTextView! = nil - private var saveButton: NSButton! = nil + private var shaderPathLabel: NSTextField! = nil + private var shaderPathField: NSTextField! = nil + private var browseButton: NSButton! = nil + private var reloadButton: NSButton! = nil override func loadView() { self.view = NSView() @@ -69,35 +43,48 @@ class EffectViewController: NSViewController, NSTextFieldDelegate, NSTextViewDel self.activeButton.state = self.effects.isActive(effect: self.effect) ? .on : .off self.stackView.addArrangedSubview(self.activeButton) - // Shader language selector - let languageStack = NSStackView() - languageStack.orientation = .horizontal - languageStack.spacing = 8 - languageStack.alignment = .centerY - languageStack.translatesAutoresizingMaskIntoConstraints = false + // Shader file path section + let shaderPathStack = NSStackView() + shaderPathStack.orientation = .horizontal + shaderPathStack.spacing = 8 + shaderPathStack.alignment = .centerY + shaderPathStack.translatesAutoresizingMaskIntoConstraints = false - self.languageLabel = NSTextField(labelWithString: "Shader Language:") - self.languageLabel.translatesAutoresizingMaskIntoConstraints = false - languageStack.addArrangedSubview(self.languageLabel) + self.shaderPathLabel = NSTextField(labelWithString: "Shader File:") + self.shaderPathLabel.translatesAutoresizingMaskIntoConstraints = false + shaderPathStack.addArrangedSubview(self.shaderPathLabel) + + self.shaderPathField = NSTextField() + self.shaderPathField.placeholderString = "Select a .slang or .metal file..." + self.shaderPathField.isEditable = false + self.shaderPathField.focusRingType = .none + self.shaderPathField.translatesAutoresizingMaskIntoConstraints = false + self.shaderPathField.stringValue = self.effects.getShaderPath(effect: self.effect) ?? "" + shaderPathStack.addArrangedSubview(self.shaderPathField) + + self.browseButton = NSButton(title: "Browse...", target: self, action: #selector(self.browseForShader)) + self.browseButton.translatesAutoresizingMaskIntoConstraints = false + shaderPathStack.addArrangedSubview(self.browseButton) - self.languagePopup = NSPopUpButton(frame: .zero, pullsDown: false) - self.languagePopup.translatesAutoresizingMaskIntoConstraints = false - self.languagePopup.addItems(withTitles: ShaderLanguage.allCases.map { $0.displayName }) - let currentLanguage = self.effects.getLanguage(effect: self.effect) - self.languagePopup.selectItem(withTitle: currentLanguage.displayName) - self.languagePopup.target = self - self.languagePopup.action = #selector(self.languageChanged) - languageStack.addArrangedSubview(self.languagePopup) + self.reloadButton = NSButton(title: "Reload", target: self, action: #selector(self.reloadShader)) + self.reloadButton.translatesAutoresizingMaskIntoConstraints = false + self.reloadButton.isEnabled = self.effects.hasShaderPath(effect: self.effect) + shaderPathStack.addArrangedSubview(self.reloadButton) + + self.stackView.addArrangedSubview(shaderPathStack) + + // Show current shader language + self.languageLabel = NSTextField(labelWithString: "Language: \(self.effects.getLanguage(effect: self.effect).displayName)") + self.languageLabel.translatesAutoresizingMaskIntoConstraints = false + self.stackView.addArrangedSubview(self.languageLabel) // Add Slang availability indicator if !SlangCompiler.isAvailable { - let warningLabel = NSTextField(labelWithString: "⚠️ slangc not found") + let warningLabel = NSTextField(labelWithString: "⚠️ slangc not found - Slang shaders won't compile") warningLabel.textColor = .systemOrange warningLabel.translatesAutoresizingMaskIntoConstraints = false - languageStack.addArrangedSubview(warningLabel) + self.stackView.addArrangedSubview(warningLabel) } - - self.stackView.addArrangedSubview(languageStack) self.deleteButton = NSButton( title: "Delete effect", @@ -106,38 +93,13 @@ class EffectViewController: NSViewController, NSTextFieldDelegate, NSTextViewDel self.deleteButton.translatesAutoresizingMaskIntoConstraints = false self.stackView.addArrangedSubview(self.deleteButton) - self.shaderField = SourceTextView() - self.shaderField.isEditable = true - self.shaderField.isVerticallyResizable = true - self.shaderField.isHorizontallyResizable = true - self.shaderField.font = NSFont.monospacedSystemFont( - ofSize: NSFont.systemFontSize, weight: .regular) - self.shaderField.string = self.effects.getShader(effect: self.effect) - self.shaderField.delegate = self - self.shaderField.allowsUndo = true - - let shaderScrollView = NSScrollView() - shaderScrollView.documentView = self.shaderField - shaderScrollView.hasVerticalScroller = true - shaderScrollView.hasHorizontalScroller = true - shaderScrollView.translatesAutoresizingMaskIntoConstraints = false - self.stackView.addArrangedSubview(shaderScrollView) - - self.saveButton = NSButton( - title: "Save", - target: self, - action: #selector(self.onSaveButton)) - self.saveButton.translatesAutoresizingMaskIntoConstraints = false - self.saveButton.isEnabled = false - self.stackView.addArrangedSubview(self.saveButton) - NSLayoutConstraint.activate([ self.stackView.leadingAnchor.constraint(equalTo: self.view.leadingAnchor, constant: 10), self.stackView.trailingAnchor.constraint(equalTo: self.view.trailingAnchor, constant: -10), self.stackView.topAnchor.constraint(equalTo: self.view.topAnchor, constant: 10), self.stackView.bottomAnchor.constraint(equalTo: self.view.bottomAnchor, constant: -10), - - self.shaderField.heightAnchor.constraint(greaterThanOrEqualToConstant: 200), + + self.shaderPathField.widthAnchor.constraint(greaterThanOrEqualToConstant: 300), ]) } @@ -151,35 +113,33 @@ class EffectViewController: NSViewController, NSTextFieldDelegate, NSTextViewDel self.onDelete() } - @objc func languageChanged() { - guard let selectedTitle = self.languagePopup.selectedItem?.title, - let newLanguage = ShaderLanguage.allCases.first(where: { $0.displayName == selectedTitle }) else { - return - } + @objc func browseForShader() { + let openPanel = NSOpenPanel() + openPanel.title = "Select Shader File" + openPanel.allowedContentTypes = [.init(filenameExtension: "slang")!, .init(filenameExtension: "metal")!] + openPanel.allowsMultipleSelection = false + openPanel.canChooseDirectories = false + openPanel.canChooseFiles = true - let currentLanguage = self.effects.getLanguage(effect: self.effect) - if newLanguage != currentLanguage { - // Update the language - self.effects.setLanguage(effect: self.effect, language: newLanguage) - - // Optionally update the shader template to the default for the new language - // if the current shader is the default template for the old language - let currentShader = self.effects.getShader(effect: self.effect) - let oldDefaultShader = currentLanguage == .slang ? defaultSlangShaderSource : defaultShaderSource - - if currentShader == oldDefaultShader { - let newDefaultShader = newLanguage == .slang ? defaultSlangShaderSource : defaultShaderSource - self.effects.setShader(effect: self.effect, shader: newDefaultShader) - self.shaderField.string = newDefaultShader + openPanel.begin { [weak self] result in + guard let self = self else { return } + if result == .OK, let url = openPanel.url { + let path = url.path + self.effects.setShaderPath(effect: self.effect, path: path) + self.shaderPathField.stringValue = path + self.reloadButton.isEnabled = true + + // Update language label based on file extension + let language = self.effects.getLanguage(effect: self.effect) + self.languageLabel.stringValue = "Language: \(language.displayName)" + + self.onUpdate() } - - self.onUpdate() } } - - @objc func onSaveButton() { - self.effects.setShader(effect: self.effect, shader: self.shaderField.string) - self.saveButton.isEnabled = false + + @objc func reloadShader() { + // Force re-read of the shader file by triggering an update self.onUpdate() } @@ -191,10 +151,6 @@ class EffectViewController: NSViewController, NSTextFieldDelegate, NSTextViewDel } } - func textDidChange(_ notification: Notification) { - self.saveButton.isEnabled = true - } - func refreshActiveCheckbox() { self.activeButton.state = self.effects.isActive(effect: self.effect) ? .on : .off } diff --git a/ScreenShader/slang_compiler.swift b/ScreenShader/slang_compiler.swift index 9c740a1..f8c5c5b 100644 --- a/ScreenShader/slang_compiler.swift +++ b/ScreenShader/slang_compiler.swift @@ -37,7 +37,10 @@ class SlangCompiler { "/usr/local/slang/bin/slangc", // User home directory FileManager.default.homeDirectoryForCurrentUser - .appendingPathComponent(".slang/bin/slangc").path + .appendingPathComponent(".slang/bin/slangc").path, + // Common Slang download locations + FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent("slang/bin/slangc").path ].compactMap { $0 } /// Find the slangc executable