diff --git a/CHANGELOG.md b/CHANGELOG.md index af083b9..fa3a035 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,10 @@ The format follows *Keep a Changelog*. Versions use semantic versioning with pre ### Fixed - Fixed missing diagnostics reset workflow by adding a dedicated `Clear Diagnostics` action that also clears file-open timing snapshots. +- Fixed macOS editor-window top-bar jumping when toggling the toolbar translucency control by keeping chrome flags stable. +- Fixed CSV/TSV mode header transparency so the mode bar now uses a solid standard window background. +- Fixed settings-window translucency consistency on macOS so title/tab and content regions render as one unified surface. +- Fixed cross-platform updater diagnostics compilation by adding a non-macOS bundle-version reader fallback. ## [Unreleased] diff --git a/Neon Vision Editor.xcodeproj/project.pbxproj b/Neon Vision Editor.xcodeproj/project.pbxproj index f1e5594..11e910e 100644 --- a/Neon Vision Editor.xcodeproj/project.pbxproj +++ b/Neon Vision Editor.xcodeproj/project.pbxproj @@ -361,7 +361,7 @@ CODE_SIGNING_ALLOWED = YES; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 451; + CURRENT_PROJECT_VERSION = 452; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = CS727NF72U; ENABLE_APP_SANDBOX = YES; @@ -444,7 +444,7 @@ CODE_SIGNING_ALLOWED = YES; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 451; + CURRENT_PROJECT_VERSION = 452; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = CS727NF72U; ENABLE_APP_SANDBOX = YES; diff --git a/Neon Vision Editor/AI/AIClient.swift b/Neon Vision Editor/AI/AIClient.swift index 569a075..176ba59 100644 --- a/Neon Vision Editor/AI/AIClient.swift +++ b/Neon Vision Editor/AI/AIClient.swift @@ -6,6 +6,7 @@ import FoundationModels #if false // This enum is defined in ContentView.swift; this is here to avoid redefinition errors. + public enum AIModel { case appleIntelligence case grok @@ -15,6 +16,8 @@ public enum AIModel { } #endif +/// MARK: - Types + public protocol AIClient { func streamSuggestions(prompt: String) -> AsyncStream } diff --git a/Neon Vision Editor/App/AppMenus.swift b/Neon Vision Editor/App/AppMenus.swift index 5387e80..ab3cb83 100644 --- a/Neon Vision Editor/App/AppMenus.swift +++ b/Neon Vision Editor/App/AppMenus.swift @@ -4,6 +4,10 @@ import SwiftUI import FoundationModels #endif + + +/// MARK: - Types + struct NeonVisionMacAppCommands: Commands { let activeEditorViewModel: () -> EditorViewModel let hasActiveEditorWindow: () -> Bool diff --git a/Neon Vision Editor/App/NeonVisionEditorApp.swift b/Neon Vision Editor/App/NeonVisionEditorApp.swift index 9720de6..8a54a88 100644 --- a/Neon Vision Editor/App/NeonVisionEditorApp.swift +++ b/Neon Vision Editor/App/NeonVisionEditorApp.swift @@ -10,6 +10,10 @@ import UIKit #endif #if os(macOS) + + +/// MARK: - Types + final class AppDelegate: NSObject, NSApplicationDelegate { weak var viewModel: EditorViewModel? { didSet { diff --git a/Neon Vision Editor/Core/AIActivityLog.swift b/Neon Vision Editor/Core/AIActivityLog.swift index 08e98cc..df0e8be 100644 --- a/Neon Vision Editor/Core/AIActivityLog.swift +++ b/Neon Vision Editor/Core/AIActivityLog.swift @@ -3,6 +3,10 @@ import Observation @MainActor @Observable + + +/// MARK: - Types + final class AIActivityLog { enum Level: String, CaseIterable { case info = "INFO" diff --git a/Neon Vision Editor/Core/AppUpdateManager.swift b/Neon Vision Editor/Core/AppUpdateManager.swift index b0c9373..899c2a5 100644 --- a/Neon Vision Editor/Core/AppUpdateManager.swift +++ b/Neon Vision Editor/Core/AppUpdateManager.swift @@ -13,6 +13,10 @@ import AppKit import UIKit #endif + + +/// MARK: - Types + enum AppUpdateCheckInterval: String, CaseIterable, Identifiable { case hourly = "hourly" case daily = "daily" @@ -1094,6 +1098,21 @@ final class AppUpdateManager: ObservableObject { } #endif +#if !os(macOS) + private nonisolated static func readBundleShortVersionString(of appBundleURL: URL) -> String? { + let infoPlistURL = appBundleURL.appendingPathComponent("Info.plist") + guard + let data = try? Data(contentsOf: infoPlistURL), + let plist = try? PropertyListSerialization.propertyList(from: data, options: [], format: nil) as? [String: Any], + let version = plist["CFBundleShortVersionString"] as? String + else { + return nil + } + let trimmed = version.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } +#endif + private func openURL(_ url: URL) { #if canImport(AppKit) NSWorkspace.shared.open(url) diff --git a/Neon Vision Editor/Core/AppleFMHelper.swift b/Neon Vision Editor/Core/AppleFMHelper.swift index 35a823d..2f9818f 100644 --- a/Neon Vision Editor/Core/AppleFMHelper.swift +++ b/Neon Vision Editor/Core/AppleFMHelper.swift @@ -3,6 +3,10 @@ import Foundation import FoundationModels @Generable(description: "Plain generated text") + + +/// MARK: - Types + public struct GeneratedText { public var text: String } public enum AppleFM { diff --git a/Neon Vision Editor/Core/EditorPerformanceMonitor.swift b/Neon Vision Editor/Core/EditorPerformanceMonitor.swift index d5a5abb..5acaee3 100644 --- a/Neon Vision Editor/Core/EditorPerformanceMonitor.swift +++ b/Neon Vision Editor/Core/EditorPerformanceMonitor.swift @@ -2,6 +2,10 @@ import Foundation import OSLog @MainActor + + +/// MARK: - Types + final class EditorPerformanceMonitor { struct FileOpenEvent: Codable, Identifiable { let id: UUID diff --git a/Neon Vision Editor/Core/LanguageDetector.swift b/Neon Vision Editor/Core/LanguageDetector.swift index 8cf1341..ad11432 100644 --- a/Neon Vision Editor/Core/LanguageDetector.swift +++ b/Neon Vision Editor/Core/LanguageDetector.swift @@ -1,5 +1,9 @@ import Foundation + + +/// MARK: - Types + public struct LanguageDetector { public static let shared = LanguageDetector() private init() {} diff --git a/Neon Vision Editor/Core/ReleaseRuntimePolicy.swift b/Neon Vision Editor/Core/ReleaseRuntimePolicy.swift index 9f5d4fd..045db64 100644 --- a/Neon Vision Editor/Core/ReleaseRuntimePolicy.swift +++ b/Neon Vision Editor/Core/ReleaseRuntimePolicy.swift @@ -1,6 +1,10 @@ import Foundation import SwiftUI + + +/// MARK: - Types + enum ReleaseRuntimePolicy { static var isUpdaterEnabledForCurrentDistribution: Bool { #if os(macOS) diff --git a/Neon Vision Editor/Core/RuntimeReliabilityMonitor.swift b/Neon Vision Editor/Core/RuntimeReliabilityMonitor.swift index a21efaa..bb447e6 100644 --- a/Neon Vision Editor/Core/RuntimeReliabilityMonitor.swift +++ b/Neon Vision Editor/Core/RuntimeReliabilityMonitor.swift @@ -2,6 +2,10 @@ import Foundation import OSLog @MainActor + + +/// MARK: - Types + final class RuntimeReliabilityMonitor { static let shared = RuntimeReliabilityMonitor() diff --git a/Neon Vision Editor/Core/SyntaxHighlighting.swift b/Neon Vision Editor/Core/SyntaxHighlighting.swift index 0efaf65..c75be97 100644 --- a/Neon Vision Editor/Core/SyntaxHighlighting.swift +++ b/Neon Vision Editor/Core/SyntaxHighlighting.swift @@ -1,6 +1,10 @@ import SwiftUI import Foundation + + +/// MARK: - Types + private enum SyntaxRegexCache { static var storage: [String: NSRegularExpression] = [:] static let lock = NSLock() diff --git a/Neon Vision Editor/Models/AIModel.swift b/Neon Vision Editor/Models/AIModel.swift index 887aeb7..1d46dba 100644 --- a/Neon Vision Editor/Models/AIModel.swift +++ b/Neon Vision Editor/Models/AIModel.swift @@ -5,6 +5,10 @@ import Foundation // Supported AI providers for suggestions. Extend as needed. + + +/// MARK: - Types + public enum AIModel: String, CaseIterable, Identifiable { case appleIntelligence case grok diff --git a/Neon Vision Editor/UI/AIActivityLogView.swift b/Neon Vision Editor/UI/AIActivityLogView.swift index b6fec09..34cbbd8 100644 --- a/Neon Vision Editor/UI/AIActivityLogView.swift +++ b/Neon Vision Editor/UI/AIActivityLogView.swift @@ -1,5 +1,9 @@ import SwiftUI + + +/// MARK: - Types + struct AIActivityLogView: View { @State private var log = AIActivityLog.shared diff --git a/Neon Vision Editor/UI/AppUpdaterDialog.swift b/Neon Vision Editor/UI/AppUpdaterDialog.swift index a5a5ab8..7ee2ff6 100644 --- a/Neon Vision Editor/UI/AppUpdaterDialog.swift +++ b/Neon Vision Editor/UI/AppUpdaterDialog.swift @@ -1,5 +1,9 @@ import SwiftUI + + +/// MARK: - Types + struct AppUpdaterDialog: View { @EnvironmentObject private var appUpdateManager: AppUpdateManager @Environment(\.accessibilityReduceTransparency) private var reduceTransparency diff --git a/Neon Vision Editor/UI/ContentView+Actions.swift b/Neon Vision Editor/UI/ContentView+Actions.swift index 810dc78..4d3ab45 100644 --- a/Neon Vision Editor/UI/ContentView+Actions.swift +++ b/Neon Vision Editor/UI/ContentView+Actions.swift @@ -6,6 +6,10 @@ import AppKit import UIKit #endif + + +/// MARK: - Types + extension ContentView { private struct ProjectEditorOverrides: Decodable { let indentWidth: Int? @@ -629,9 +633,13 @@ extension ContentView { func applyWindowTranslucency(_ enabled: Bool) { #if os(macOS) for window in NSApp.windows { + // Apply only to editor windows registered by ContentView instances. + guard WindowViewModelRegistry.shared.viewModel(for: window.windowNumber) != nil else { + continue + } window.isOpaque = !enabled window.backgroundColor = enabled ? .clear : NSColor.windowBackgroundColor - // Keep window chrome layout stable across both modes to avoid frame/titlebar jumps. + // Keep chrome flags constant; toggling these causes visible top-bar jumps. window.titlebarAppearsTransparent = true window.toolbarStyle = .unified window.styleMask.insert(.fullSizeContentView) @@ -760,7 +768,16 @@ extension ContentView { return [] } - let sorted = urls.sorted { $0.lastPathComponent.localizedCaseInsensitiveCompare($1.lastPathComponent) == .orderedAscending } + let sorted = urls.sorted { lhs, rhs in + let lhsValues = try? lhs.resourceValues(forKeys: [.isDirectoryKey]) + let rhsValues = try? rhs.resourceValues(forKeys: [.isDirectoryKey]) + let lhsIsDirectory = lhsValues?.isDirectory == true + let rhsIsDirectory = rhsValues?.isDirectory == true + if lhsIsDirectory != rhsIsDirectory { + return lhsIsDirectory && !rhsIsDirectory + } + return lhs.lastPathComponent.localizedCaseInsensitiveCompare(rhs.lastPathComponent) == .orderedAscending + } var nodes: [ProjectTreeNode] = [] for url in sorted { if Task.isCancelled { break } diff --git a/Neon Vision Editor/UI/ContentView+Toolbar.swift b/Neon Vision Editor/UI/ContentView+Toolbar.swift index 5b15b29..6384b69 100644 --- a/Neon Vision Editor/UI/ContentView+Toolbar.swift +++ b/Neon Vision Editor/UI/ContentView+Toolbar.swift @@ -5,6 +5,10 @@ import AppKit import UIKit #endif + + +/// MARK: - Types + extension ContentView { private var compactActiveProviderName: String { activeProviderName.components(separatedBy: " (").first ?? activeProviderName diff --git a/Neon Vision Editor/UI/ContentView.swift b/Neon Vision Editor/UI/ContentView.swift index 593de6b..02ccd9a 100644 --- a/Neon Vision Editor/UI/ContentView.swift +++ b/Neon Vision Editor/UI/ContentView.swift @@ -347,6 +347,9 @@ struct ContentView: View { @State private var recoverySnapshotIdentifier: String = UUID().uuidString @State private var lastCaretLocation: Int = 0 @State private var sessionCaretByFileURL: [String: Int] = [:] +#if os(macOS) + @State private var isProjectSidebarResizeHandleHovered: Bool = false +#endif private let quickSwitcherRecentsDefaultsKey = "QuickSwitcherRecentItemsV1" #if USE_FOUNDATION_MODELS && canImport(FoundationModels) @@ -3463,19 +3466,51 @@ struct ContentView: View { } return ZStack { - Color.clear + // Match the same surface as the editor area so the splitter doesn't look like a foreign strip. Rectangle() - .fill(Color.secondary.opacity(0.32)) + .fill(projectSidebarHandleSurfaceStyle) + Rectangle() + .fill(Color.secondary.opacity(0.22)) .frame(width: 1) } .frame(width: 10) .contentShape(Rectangle()) .gesture(drag) +#if os(macOS) + .onHover { hovering in + guard hovering != isProjectSidebarResizeHandleHovered else { return } + isProjectSidebarResizeHandleHovered = hovering + if hovering { + NSCursor.resizeLeftRight.push() + } else { + NSCursor.pop() + } + } + .onDisappear { + if isProjectSidebarResizeHandleHovered { + isProjectSidebarResizeHandleHovered = false + NSCursor.pop() + } + } +#endif .accessibilityElement() .accessibilityLabel("Resize Project Sidebar") .accessibilityHint("Drag left or right to adjust project sidebar width") } + private var projectSidebarHandleSurfaceStyle: AnyShapeStyle { + if enableTranslucentWindow { + return editorSurfaceBackgroundStyle + } +#if os(iOS) + return useIOSUnifiedSolidSurfaces + ? AnyShapeStyle(iOSNonTranslucentSurfaceColor) + : AnyShapeStyle(Color.clear) +#else + return AnyShapeStyle(Color.clear) +#endif + } + private var projectStructureSidebarBody: some View { ProjectStructureSidebarView( rootFolderURL: projectRootFolderURL, @@ -3524,6 +3559,15 @@ struct ContentView: View { } .padding(.horizontal, 10) .padding(.vertical, 8) + .background(delimitedHeaderBackgroundColor) + } + + private var delimitedHeaderBackgroundColor: Color { +#if os(macOS) + Color(nsColor: .windowBackgroundColor) +#else + Color(.systemBackground) +#endif } private var delimitedTableView: some View { diff --git a/Neon Vision Editor/UI/GlassSurface.swift b/Neon Vision Editor/UI/GlassSurface.swift index 4ea8006..d81a38a 100644 --- a/Neon Vision Editor/UI/GlassSurface.swift +++ b/Neon Vision Editor/UI/GlassSurface.swift @@ -1,5 +1,9 @@ import SwiftUI + + +/// MARK: - Types + enum GlassShapeKind { case capsule case circle diff --git a/Neon Vision Editor/UI/IPadKeyboardShortcutBridge.swift b/Neon Vision Editor/UI/IPadKeyboardShortcutBridge.swift index 56d24aa..9894dc1 100644 --- a/Neon Vision Editor/UI/IPadKeyboardShortcutBridge.swift +++ b/Neon Vision Editor/UI/IPadKeyboardShortcutBridge.swift @@ -2,6 +2,10 @@ import SwiftUI #if canImport(UIKit) import UIKit + + +/// MARK: - Types + struct IPadKeyboardShortcutBridge: UIViewRepresentable { let onNewTab: () -> Void let onOpenFile: () -> Void diff --git a/Neon Vision Editor/UI/LineNumberRulerView.swift b/Neon Vision Editor/UI/LineNumberRulerView.swift index 78cd4b3..a1e3fa7 100644 --- a/Neon Vision Editor/UI/LineNumberRulerView.swift +++ b/Neon Vision Editor/UI/LineNumberRulerView.swift @@ -1,6 +1,10 @@ #if os(macOS) import AppKit + + +/// MARK: - Types + private struct RulerObserverToken: @unchecked Sendable { let raw: NSObjectProtocol } diff --git a/Neon Vision Editor/UI/MarkdownPreviewWebView.swift b/Neon Vision Editor/UI/MarkdownPreviewWebView.swift index 7a3caa0..d29ff7d 100644 --- a/Neon Vision Editor/UI/MarkdownPreviewWebView.swift +++ b/Neon Vision Editor/UI/MarkdownPreviewWebView.swift @@ -2,6 +2,10 @@ import SwiftUI import WebKit #if os(macOS) + + +/// MARK: - Types + struct MarkdownPreviewWebView: NSViewRepresentable { let html: String diff --git a/Neon Vision Editor/UI/NeonSettingsView.swift b/Neon Vision Editor/UI/NeonSettingsView.swift index 61e9d9f..acd5f24 100644 --- a/Neon Vision Editor/UI/NeonSettingsView.swift +++ b/Neon Vision Editor/UI/NeonSettingsView.swift @@ -9,6 +9,10 @@ import CoreText import UIKit #endif + + +/// MARK: - Types + struct NeonSettingsView: View { private static var cachedEditorFonts: [String] = [] let supportsOpenInTabs: Bool @@ -254,6 +258,7 @@ struct NeonSettingsView: View { var body: some View { settingsTabs #if os(macOS) + .background(settingsWindowBackground) .frame( minWidth: macSettingsWindowSize.min.width, idealWidth: macSettingsWindowSize.ideal.width, @@ -264,14 +269,19 @@ struct NeonSettingsView: View { SettingsWindowConfigurator( minSize: macSettingsWindowSize.min, idealSize: macSettingsWindowSize.ideal, - translucentEnabled: supportsTranslucency && translucentWindow + translucentEnabled: supportsTranslucency && translucentWindow, + translucencyModeRaw: macTranslucencyModeRaw ) ) #endif .preferredColorScheme(preferredColorSchemeOverride) .onAppear { - settingsActiveTab = "general" - moreSectionTab = "support" + if settingsActiveTab.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + settingsActiveTab = "general" + } + if moreSectionTab.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + moreSectionTab = "support" + } selectedTheme = canonicalThemeName(selectedTheme) migrateLegacyPinkSettingsIfNeeded() loadAvailableEditorFontsIfNeeded() @@ -2039,16 +2049,23 @@ struct NeonSettingsView: View { @ViewBuilder private var settingsContainerBackground: some View { #if os(macOS) - if supportsTranslucency && translucentWindow { - Color.clear.background(.ultraThinMaterial) - } else { - Color(nsColor: .windowBackgroundColor) - } + Color.clear #else Color.clear.background(.ultraThinMaterial) #endif } +#if os(macOS) + @ViewBuilder + private var settingsWindowBackground: some View { + if supportsTranslucency && translucentWindow { + Color.clear + } else { + Color(nsColor: .windowBackgroundColor) + } + } +#endif + private func settingsEffectiveMaxWidth(base: CGFloat) -> CGFloat { #if os(iOS) if useTwoColumnSettingsLayout { return max(base, 780) } @@ -2186,24 +2203,8 @@ struct NeonSettingsView: View { } private var macSettingsWindowSize: (min: NSSize, ideal: NSSize) { - switch settingsActiveTab { - case "themes": - return (NSSize(width: 740, height: 900), NSSize(width: 840, height: 980)) - case "editor": - return (NSSize(width: 640, height: 820), NSSize(width: 720, height: 900)) - case "templates": - return (NSSize(width: 600, height: 760), NSSize(width: 680, height: 840)) - case "general": - return (NSSize(width: 600, height: 760), NSSize(width: 680, height: 840)) - case "ai": - return (NSSize(width: 640, height: 780), NSSize(width: 720, height: 860)) - case "updates": - return (NSSize(width: 580, height: 720), NSSize(width: 660, height: 780)) - case "support": - return (NSSize(width: 580, height: 720), NSSize(width: 660, height: 780)) - default: - return (NSSize(width: 600, height: 760), NSSize(width: 680, height: 840)) - } + // Keep a stable window envelope across tabs to avoid toolbar-tab jump/overflow relayout. + (NSSize(width: 740, height: 900), NSSize(width: 840, height: 980)) } #endif @@ -2323,13 +2324,13 @@ struct SettingsWindowConfigurator: NSViewRepresentable { let minSize: NSSize let idealSize: NSSize let translucentEnabled: Bool + let translucencyModeRaw: String final class Coordinator { var didInitialApply = false var pendingApply: DispatchWorkItem? - var lastMinSize: NSSize? - var lastIdealSize: NSSize? var lastTranslucentEnabled: Bool? + var lastTranslucencyModeRaw: String? var didConfigureWindowChrome = false } @@ -2350,13 +2351,11 @@ struct SettingsWindowConfigurator: NSViewRepresentable { } private func scheduleApply(to window: NSWindow?, coordinator: Coordinator) { - if coordinator.didInitialApply, - coordinator.lastMinSize == minSize, - coordinator.lastIdealSize == idealSize, - coordinator.lastTranslucentEnabled == translucentEnabled { + coordinator.pendingApply?.cancel() + if !coordinator.didInitialApply, let window { + apply(to: window, coordinator: coordinator) return } - coordinator.pendingApply?.cancel() let work = DispatchWorkItem { apply(to: window, coordinator: coordinator) } @@ -2367,59 +2366,90 @@ struct SettingsWindowConfigurator: NSViewRepresentable { private func apply(to window: NSWindow?, coordinator: Coordinator) { guard let window else { return } let isFirstApply = !coordinator.didInitialApply - let translucencyChanged = coordinator.lastTranslucentEnabled != translucentEnabled - coordinator.lastMinSize = minSize - coordinator.lastIdealSize = idealSize coordinator.lastTranslucentEnabled = translucentEnabled + coordinator.lastTranslucencyModeRaw = translucencyModeRaw window.minSize = minSize + // Always enforce native macOS Settings toolbar chrome; other window updaters may have changed it. + window.toolbarStyle = .preference + window.titleVisibility = .hidden + window.title = "" + if isFirstApply { + let targetWidth = max(minSize.width, idealSize.width) + let targetHeight = max(minSize.height, idealSize.height) + if abs(targetWidth - window.frame.size.width) > 1 || abs(targetHeight - window.frame.size.height) > 1 { + // Apply initial geometry once; avoid frame churn during tab/content updates. + var frame = window.frame + let oldHeight = frame.size.height + frame.size = NSSize(width: targetWidth, height: targetHeight) + frame.origin.y += oldHeight - targetHeight + window.setFrame(frame, display: true, animate: false) + } + centerSettingsWindow(window) + } + if !coordinator.didConfigureWindowChrome { - // Match native macOS Settings layout: centered preference tabs and hidden title text. - window.toolbarStyle = .preference - window.titleVisibility = .hidden - window.title = "" + // Keep settings chrome stable for the lifetime of this window. + window.isOpaque = false + window.titlebarAppearsTransparent = true + window.styleMask.insert(.fullSizeContentView) + if #available(macOS 13.0, *) { + window.titlebarSeparatorStyle = .none + } coordinator.didConfigureWindowChrome = true } - - let targetWidth: CGFloat - let targetHeight: CGFloat - if coordinator.didInitialApply { - // Respect manual window size changes while enforcing per-tab minimums. - targetWidth = max(minSize.width, window.frame.size.width) - targetHeight = max(minSize.height, window.frame.size.height) - } else { - targetWidth = max(minSize.width, idealSize.width) - targetHeight = max(minSize.height, idealSize.height) - } - if abs(targetWidth - window.frame.size.width) > 1 || abs(targetHeight - window.frame.size.height) > 1 { - // Keep the top edge visually stable while adapting size per tab. - var frame = window.frame - let oldHeight = frame.size.height - frame.size = NSSize(width: targetWidth, height: targetHeight) - frame.origin.y += oldHeight - targetHeight - window.setFrame(frame, display: true, animate: false) - } - - // Keep settings-window translucency in sync without relying on editor view events. - if translucencyChanged || isFirstApply { - window.isOpaque = !translucentEnabled - window.backgroundColor = translucentEnabled ? .clear : NSColor.windowBackgroundColor - window.titlebarAppearsTransparent = translucentEnabled - if translucentEnabled { - window.styleMask.insert(.fullSizeContentView) - } else { - window.styleMask.remove(.fullSizeContentView) - } - if #available(macOS 13.0, *) { - window.titlebarSeparatorStyle = translucentEnabled ? .none : .automatic - } - } + // Keep a non-clear background to avoid fully transparent titlebar artifacts. + window.backgroundColor = translucencyEnabledColor(enabled: translucentEnabled, window: window) // Some macOS states restore the title from the selected settings tab. // Force an empty, hidden title for native Settings appearance. window.title = "" window.titleVisibility = .hidden + window.representedURL = nil coordinator.didInitialApply = true } + + private func translucencyEnabledColor(enabled: Bool, window: NSWindow) -> NSColor { + guard enabled else { return NSColor.windowBackgroundColor } + let isDark = window.effectiveAppearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua + let whiteLevel: CGFloat + switch translucencyModeRaw { + case "subtle": + whiteLevel = isDark ? 0.18 : 0.90 + case "vibrant": + whiteLevel = isDark ? 0.12 : 0.82 + default: + whiteLevel = isDark ? 0.15 : 0.86 + } + // Keep settings tint almost opaque to avoid "more transparent" appearance. + return NSColor(calibratedWhite: whiteLevel, alpha: 0.98) + } + + private func centerSettingsWindow(_ settingsWindow: NSWindow) { + let referenceWindow = preferredReferenceWindow(excluding: settingsWindow) + let size = settingsWindow.frame.size + let referenceFrame = referenceWindow?.frame ?? settingsWindow.frame + var origin = NSPoint( + x: round(referenceFrame.midX - size.width / 2), + y: round(referenceFrame.midY - size.height / 2) + ) + if let visibleFrame = referenceWindow?.screen?.visibleFrame ?? settingsWindow.screen?.visibleFrame { + origin.x = min(max(origin.x, visibleFrame.minX), visibleFrame.maxX - size.width) + origin.y = min(max(origin.y, visibleFrame.minY), visibleFrame.maxY - size.height) + } + settingsWindow.setFrameOrigin(origin) + } + + private func preferredReferenceWindow(excluding settingsWindow: NSWindow) -> NSWindow? { + if let key = NSApp.keyWindow, key !== settingsWindow, key.isVisible { + return key + } + if let main = NSApp.mainWindow, main !== settingsWindow, main.isVisible { + return main + } + return NSApp.windows.first(where: { window in + window !== settingsWindow && window.isVisible && window.level == .normal + }) + } } #endif diff --git a/Neon Vision Editor/UI/PanelsAndHelpers.swift b/Neon Vision Editor/UI/PanelsAndHelpers.swift index 1d9a1bb..a4ee632 100644 --- a/Neon Vision Editor/UI/PanelsAndHelpers.swift +++ b/Neon Vision Editor/UI/PanelsAndHelpers.swift @@ -5,6 +5,10 @@ import UniformTypeIdentifiers import AppKit #endif + + +/// MARK: - Types + enum NeonUIStyle { static let accentBlue = Color(red: 0.17, green: 0.49, blue: 0.98) static let accentBlueSoft = Color(red: 0.44, green: 0.72, blue: 0.99) diff --git a/Neon Vision Editor/UI/ProjectFolderPicker.swift b/Neon Vision Editor/UI/ProjectFolderPicker.swift index bd31461..ce772e4 100644 --- a/Neon Vision Editor/UI/ProjectFolderPicker.swift +++ b/Neon Vision Editor/UI/ProjectFolderPicker.swift @@ -3,6 +3,10 @@ import SwiftUI import UniformTypeIdentifiers import UIKit + + +/// MARK: - Types + struct ProjectFolderPicker: UIViewControllerRepresentable { let onPick: (URL) -> Void let onCancel: () -> Void diff --git a/Neon Vision Editor/UI/SidebarViews.swift b/Neon Vision Editor/UI/SidebarViews.swift index 81fa98c..319d24e 100644 --- a/Neon Vision Editor/UI/SidebarViews.swift +++ b/Neon Vision Editor/UI/SidebarViews.swift @@ -2,6 +2,10 @@ import SwiftUI import Foundation #if os(macOS) + + +/// MARK: - Types + private enum MacTranslucencyMode: String { case subtle case balanced @@ -355,6 +359,18 @@ struct SidebarView: View { } } struct ProjectStructureSidebarView: View { + private enum SidebarDensity: String, CaseIterable, Identifiable { + case compact + case comfortable + + var id: String { rawValue } + } + + private struct FileIconStyle { + let symbol: String + let color: Color + } + let rootFolderURL: URL? let nodes: [ProjectTreeNode] let selectedFileURL: URL? @@ -370,6 +386,8 @@ struct ProjectStructureSidebarView: View { #if os(macOS) @AppStorage("SettingsMacTranslucencyMode") private var macTranslucencyModeRaw: String = "balanced" #endif + @AppStorage("SettingsProjectSidebarDensity") private var sidebarDensityRaw: String = SidebarDensity.compact.rawValue + @AppStorage("SettingsProjectSidebarAutoCollapseDeep") private var autoCollapseDeepFolders: Bool = true var body: some View { VStack(alignment: .leading, spacing: 0) { @@ -405,6 +423,12 @@ struct ProjectStructureSidebarView: View { ) } Divider() + Picker("Density", selection: $sidebarDensityRaw) { + Text("Compact").tag(SidebarDensity.compact.rawValue) + Text("Comfortable").tag(SidebarDensity.comfortable.rawValue) + } + Toggle("Auto-collapse Deep Folders", isOn: $autoCollapseDeepFolders) + Divider() Button("Expand All") { expandAllDirectories() } @@ -419,20 +443,20 @@ struct ProjectStructureSidebarView: View { .accessibilityLabel("Expand or collapse all folders") .accessibilityHint("Expands or collapses all folders in the project tree") } - .padding(.horizontal, 10) - .padding(.top, 10) - .padding(.bottom, 8) + .padding(.horizontal, headerHorizontalPadding) + .padding(.top, headerTopPadding) + .padding(.bottom, headerBottomPadding) #if os(macOS) .background(sidebarHeaderFill) #endif if let rootFolderURL { Text(rootFolderURL.path) - .font(.caption2) + .font(.system(size: isCompactDensity ? 11 : 12)) .foregroundStyle(.secondary) - .lineLimit(2) + .lineLimit(isCompactDensity ? 1 : 2) .textSelection(.enabled) - .padding(.horizontal, 10) + .padding(.horizontal, headerHorizontalPadding) } List { @@ -560,18 +584,21 @@ struct ProjectStructureSidebarView: View { } private func expandAllDirectories() { - expandedDirectories = allDirectoryNodeIDs(in: nodes) + expandedDirectories = allDirectoryNodeIDs(in: nodes, level: 0) } private func collapseAllDirectories() { expandedDirectories.removeAll() } - private func allDirectoryNodeIDs(in treeNodes: [ProjectTreeNode]) -> Set { + private func allDirectoryNodeIDs(in treeNodes: [ProjectTreeNode], level: Int) -> Set { var result: Set = [] for node in treeNodes where node.isDirectory { - result.insert(node.id) - result.formUnion(allDirectoryNodeIDs(in: node.children)) + let shouldInclude = !autoCollapseDeepFolders || level < 2 + if shouldInclude { + result.insert(node.id) + } + result.formUnion(allDirectoryNodeIDs(in: node.children, level: level + 1)) } return result } @@ -593,21 +620,30 @@ struct ProjectStructureSidebarView: View { projectNodeView(child, level: level + 1) } } label: { - Label(node.url.lastPathComponent, systemImage: "folder") - .lineLimit(1) + HStack(spacing: 8) { + Image(systemName: "folder") + .foregroundStyle(.blue) + .symbolRenderingMode(.hierarchical) + Text(node.url.lastPathComponent) + .lineLimit(1) + } + .padding(.vertical, rowVerticalPadding) } - .padding(.leading, CGFloat(level) * 10) + .padding(.leading, CGFloat(level) * levelIndent) + .listRowInsets(rowInsets) .listRowBackground(Color.clear) .listRowSeparator(.hidden) ) } else { + let style = fileIconStyle(for: node.url) return AnyView( Button { onOpenProjectFile(node.url) } label: { HStack(spacing: 8) { - Image(systemName: "doc.text") - .foregroundColor(.secondary) + Image(systemName: style.symbol) + .foregroundStyle(style.color) + .symbolRenderingMode(.hierarchical) Text(node.url.lastPathComponent) .lineLimit(1) Spacer() @@ -616,14 +652,100 @@ struct ProjectStructureSidebarView: View { .foregroundColor(.accentColor) } } + .padding(.vertical, rowVerticalPadding) } .buttonStyle(.plain) - .padding(.leading, CGFloat(level) * 10) + .padding(.leading, CGFloat(level) * levelIndent) + .listRowInsets(rowInsets) .listRowBackground(Color.clear) .listRowSeparator(.hidden) ) } } + + private var sidebarDensity: SidebarDensity { + SidebarDensity(rawValue: sidebarDensityRaw) ?? .compact + } + + private var isCompactDensity: Bool { sidebarDensity == .compact } + + private var levelIndent: CGFloat { + isCompactDensity ? 8 : 12 + } + + private var rowVerticalPadding: CGFloat { + isCompactDensity ? 1 : 4 + } + + private var headerHorizontalPadding: CGFloat { + isCompactDensity ? 8 : 10 + } + + private var headerTopPadding: CGFloat { + isCompactDensity ? 8 : 10 + } + + private var headerBottomPadding: CGFloat { + isCompactDensity ? 6 : 8 + } + + private var rowInsets: EdgeInsets { + EdgeInsets(top: 0, leading: isCompactDensity ? 4 : 6, bottom: 0, trailing: 4) + } + + private func fileIconStyle(for url: URL) -> FileIconStyle { + let ext = url.pathExtension.lowercased() + let name = url.lastPathComponent.lowercased() + + switch ext { + case "swift": + return .init(symbol: "swift", color: .orange) + case "js", "mjs", "cjs": + return .init(symbol: "curlybraces.square", color: .yellow) + case "ts", "tsx": + return .init(symbol: "chevron.left.forwardslash.chevron.right", color: .blue) + case "json", "jsonc", "json5": + return .init(symbol: "curlybraces", color: .green) + case "md", "markdown": + return .init(symbol: "text.alignleft", color: .teal) + case "yml", "yaml", "toml", "ini", "env": + return .init(symbol: "slider.horizontal.3", color: .mint) + case "html", "htm": + return .init(symbol: "chevron.left.slash.chevron.right", color: .orange) + case "css": + return .init(symbol: "paintbrush.pointed", color: .cyan) + case "xml", "svg": + return .init(symbol: "diamond", color: .pink) + case "sh", "bash", "zsh", "ps1": + return .init(symbol: "terminal", color: .indigo) + case "py": + return .init(symbol: "chevron.left.forwardslash.chevron.right", color: .yellow) + case "rb": + return .init(symbol: "diamond.fill", color: .red) + case "go": + return .init(symbol: "g.circle", color: .cyan) + case "rs": + return .init(symbol: "gearshape.2", color: .orange) + case "sql": + return .init(symbol: "cylinder", color: .purple) + case "csv", "tsv": + return .init(symbol: "tablecells", color: .green) + case "txt", "log": + return .init(symbol: "doc.plaintext", color: .secondary) + case "png", "jpg", "jpeg", "gif", "webp", "heic": + return .init(symbol: "photo", color: .purple) + case "pdf": + return .init(symbol: "doc.richtext", color: .red) + default: + if name.hasPrefix(".git") { + return .init(symbol: "arrow.triangle.branch", color: .orange) + } + if name.hasPrefix(".env") { + return .init(symbol: "lock.doc", color: .mint) + } + return .init(symbol: "doc.text", color: .secondary) + } + } } struct ProjectTreeNode: Identifiable { diff --git a/Neon Vision EditorTests/AppUpdateManagerTests.swift b/Neon Vision EditorTests/AppUpdateManagerTests.swift index d8db6b9..674d1b6 100644 --- a/Neon Vision EditorTests/AppUpdateManagerTests.swift +++ b/Neon Vision EditorTests/AppUpdateManagerTests.swift @@ -1,6 +1,10 @@ import XCTest @testable import Neon_Vision_Editor + + +/// MARK: - Tests + final class AppUpdateManagerTests: XCTestCase { func testHostAllowlistBehavior() { XCTAssertTrue(AppUpdateManager.isTrustedGitHubHost("github.com")) diff --git a/Neon Vision EditorTests/LanguageDetectorTests.swift b/Neon Vision EditorTests/LanguageDetectorTests.swift index 0763336..9117c82 100644 --- a/Neon Vision EditorTests/LanguageDetectorTests.swift +++ b/Neon Vision EditorTests/LanguageDetectorTests.swift @@ -1,5 +1,9 @@ import XCTest + + +/// MARK: - Tests + final class LanguageDetectorTests: XCTestCase { func testPreferredLanguageForExtensions() { let cases: [(String, String)] = [ diff --git a/Neon Vision EditorTests/MarkdownSyntaxHighlightingTests.swift b/Neon Vision EditorTests/MarkdownSyntaxHighlightingTests.swift index 1ef76f0..1dd5961 100644 --- a/Neon Vision EditorTests/MarkdownSyntaxHighlightingTests.swift +++ b/Neon Vision EditorTests/MarkdownSyntaxHighlightingTests.swift @@ -2,6 +2,10 @@ import XCTest import SwiftUI @testable import Neon_Vision_Editor + + +/// MARK: - Tests + final class MarkdownSyntaxHighlightingTests: XCTestCase { private func markdownPatterns() -> [String: Color] { getSyntaxPatterns( diff --git a/Neon Vision EditorTests/ReleaseRuntimePolicyTests.swift b/Neon Vision EditorTests/ReleaseRuntimePolicyTests.swift index 0f74add..a4e4d43 100644 --- a/Neon Vision EditorTests/ReleaseRuntimePolicyTests.swift +++ b/Neon Vision EditorTests/ReleaseRuntimePolicyTests.swift @@ -2,6 +2,10 @@ import XCTest import SwiftUI @testable import Neon_Vision_Editor + + +/// MARK: - Tests + final class ReleaseRuntimePolicyTests: XCTestCase { func testSettingsTabFallsBackToGeneral() { XCTAssertEqual(ReleaseRuntimePolicy.settingsTab(from: nil), "general") diff --git a/Neon Vision EditorTests/WindowTranslucencyTests.swift b/Neon Vision EditorTests/WindowTranslucencyTests.swift index f9846be..0380a30 100644 --- a/Neon Vision EditorTests/WindowTranslucencyTests.swift +++ b/Neon Vision EditorTests/WindowTranslucencyTests.swift @@ -5,6 +5,10 @@ import XCTest import AppKit @MainActor + + +/// MARK: - Tests + final class WindowTranslucencyTests: XCTestCase { // Verifies that the translucency toggle updates AppKit window flags used by the toolbar/titlebar. func testApplyWindowTranslucencyUpdatesMacWindowFlags() {