From 5f8041480e2ac51cf3c80505b622268d953304be Mon Sep 17 00:00:00 2001 From: h3p Date: Thu, 12 Feb 2026 23:20:39 +0100 Subject: [PATCH] Prepare v0.4.7: scope highlighting and settings persistence fixes --- .githooks/pre-commit | 10 + .../release-notarized-selfhosted.yml | 31 + Neon Vision Editor.xcodeproj/project.pbxproj | 4 +- .../App/NeonVisionEditorApp.swift | 140 +++-- .../Core/SyntaxHighlighting.swift | 16 +- Neon Vision Editor/Data/EditorViewModel.swift | 8 +- .../Data/SupportPurchaseManager.swift | 2 +- Neon Vision Editor/SupportOptional.storekit | 2 +- .../UI/ContentView+Actions.swift | 54 +- .../UI/ContentView+Toolbar.swift | 91 +-- Neon Vision Editor/UI/ContentView.swift | 111 +++- Neon Vision Editor/UI/EditorTextView.swift | 595 ++++++++++++++++-- Neon Vision Editor/UI/NeonSettingsView.swift | 225 ++++++- Neon Vision Editor/UI/PanelsAndHelpers.swift | 3 + README.md | 8 + scripts/bump_build_number.sh | 21 + scripts/install_git_hooks.sh | 10 + scripts/release_all.sh | 19 +- 18 files changed, 1131 insertions(+), 219 deletions(-) create mode 100755 .githooks/pre-commit create mode 100755 scripts/bump_build_number.sh create mode 100755 scripts/install_git_hooks.sh diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100755 index 0000000..799f7e5 --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(git rev-parse --show-toplevel)" +cd "$repo_root" + +if [[ -x "scripts/bump_build_number.sh" ]]; then + scripts/bump_build_number.sh + git add "Neon Vision Editor.xcodeproj/project.pbxproj" +fi diff --git a/.github/workflows/release-notarized-selfhosted.yml b/.github/workflows/release-notarized-selfhosted.yml index 3d08f7f..084c817 100644 --- a/.github/workflows/release-notarized-selfhosted.yml +++ b/.github/workflows/release-notarized-selfhosted.yml @@ -8,15 +8,46 @@ on: description: "Existing Git tag to release (e.g. v0.4.6)" required: true type: string + use_self_hosted: + description: "Allow self-hosted runner usage (requires trusted release context)" + required: true + default: false + type: boolean permissions: + actions: read contents: write jobs: release: + if: ${{ inputs.use_self_hosted == true }} runs-on: [self-hosted, macOS] + environment: self-hosted-release + concurrency: + group: release-notarized-selfhosted + cancel-in-progress: false steps: + - name: Validate trusted self-hosted request + env: + TAG_NAME: ${{ inputs.tag }} + REPO: ${{ github.repository }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + git init + git remote add origin "https://x-access-token:${GH_TOKEN}@github.com/${REPO}.git" + git fetch --depth=1 origin "refs/tags/${TAG_NAME}:refs/tags/${TAG_NAME}" + git fetch --depth=1 origin "refs/heads/main:refs/remotes/origin/main" + TAG_SHA="$(git rev-list -n1 "refs/tags/${TAG_NAME}")" + MAIN_SHA="$(git rev-list -n1 "refs/remotes/origin/main")" + echo "Tag SHA: ${TAG_SHA}" + echo "Main SHA: ${MAIN_SHA}" + if [[ "${TAG_SHA}" != "${MAIN_SHA}" ]]; then + echo "Self-hosted releases are only allowed for tags that point to origin/main HEAD." >&2 + exit 1 + fi + - name: Checkout tag env: TAG_NAME: ${{ inputs.tag }} diff --git a/Neon Vision Editor.xcodeproj/project.pbxproj b/Neon Vision Editor.xcodeproj/project.pbxproj index 1f8898a..670cae7 100644 --- a/Neon Vision Editor.xcodeproj/project.pbxproj +++ b/Neon Vision Editor.xcodeproj/project.pbxproj @@ -358,7 +358,7 @@ CODE_SIGNING_ALLOWED = YES; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 175; + CURRENT_PROJECT_VERSION = 176; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = CS727NF72U; ENABLE_APP_SANDBOX = YES; @@ -438,7 +438,7 @@ CODE_SIGNING_ALLOWED = YES; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 175; + CURRENT_PROJECT_VERSION = 176; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = CS727NF72U; ENABLE_APP_SANDBOX = YES; diff --git a/Neon Vision Editor/App/NeonVisionEditorApp.swift b/Neon Vision Editor/App/NeonVisionEditorApp.swift index cecd6bf..0285994 100644 --- a/Neon Vision Editor/App/NeonVisionEditorApp.swift +++ b/Neon Vision Editor/App/NeonVisionEditorApp.swift @@ -5,6 +5,9 @@ import FoundationModels #if os(macOS) import AppKit #endif +#if os(iOS) +import UIKit +#endif #if os(macOS) final class AppDelegate: NSObject, NSApplicationDelegate { @@ -73,6 +76,7 @@ private struct DetachedWindowContentView: View { struct NeonVisionEditorApp: App { @StateObject private var viewModel = EditorViewModel() @StateObject private var supportPurchaseManager = SupportPurchaseManager() + @AppStorage("SettingsAppearance") private var appearance: String = "system" #if os(macOS) @Environment(\.openWindow) private var openWindow @State private var useAppleIntelligence: Bool = true @@ -93,6 +97,66 @@ struct NeonVisionEditorApp: App { } #endif + private var preferredAppearance: ColorScheme? { + switch appearance { + case "light": + return .light + case "dark": + return .dark + default: + return nil + } + } + +#if os(macOS) + private var appKitAppearance: NSAppearance? { + switch appearance { + case "light": + return NSAppearance(named: .aqua) + case "dark": + return NSAppearance(named: .darkAqua) + default: + return nil + } + } + + private func applyGlobalAppearanceOverride() { + let override = appKitAppearance + NSApp.appearance = override + for window in NSApp.windows { + window.appearance = override + window.invalidateShadow() + window.displayIfNeeded() + } + } +#endif + +#if os(iOS) + private var userInterfaceStyle: UIUserInterfaceStyle { + switch appearance { + case "light": + return .light + case "dark": + return .dark + default: + return .unspecified + } + } + + private func applyIOSAppearanceOverride() { + let style = userInterfaceStyle + UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .forEach { scene in + scene.windows.forEach { window in + if window.overrideUserInterfaceStyle != style { + window.overrideUserInterfaceStyle = style + } + } + } + } +#endif + init() { let defaults = UserDefaults.standard SecureTokenStore.migrateLegacyUserDefaultsTokens() @@ -108,8 +172,12 @@ struct NeonVisionEditorApp: App { defaults.register(defaults: [ "SettingsShowLineNumbers": true, "SettingsHighlightCurrentLine": false, + "SettingsHighlightMatchingBrackets": false, + "SettingsShowScopeGuides": false, + "SettingsHighlightScopeBackground": false, "SettingsLineWrapEnabled": false, "SettingsShowInvisibleCharacters": false, + "SettingsUseSystemFont": false, "SettingsIndentStyle": "spaces", "SettingsIndentWidth": 4, "SettingsAutoIndent": true, @@ -118,7 +186,12 @@ struct NeonVisionEditorApp: App { "SettingsTrimWhitespaceForSyntaxDetection": false, "SettingsCompletionEnabled": false, "SettingsCompletionFromDocument": false, - "SettingsCompletionFromSyntax": false + "SettingsCompletionFromSyntax": false, + "SettingsReopenLastSession": true, + "SettingsOpenWithBlankDocument": true, + "SettingsDefaultNewFileLanguage": "plain", + "SettingsConfirmCloseDirtyTab": true, + "SettingsConfirmClearEditor": true ]) let whitespaceMigrationKey = "SettingsMigrationWhitespaceGlyphResetV1" if !defaults.bool(forKey: whitespaceMigrationKey) { @@ -158,8 +231,11 @@ struct NeonVisionEditorApp: App { .environmentObject(viewModel) .environmentObject(supportPurchaseManager) .onAppear { appDelegate.viewModel = viewModel } + .onAppear { applyGlobalAppearanceOverride() } + .onChange(of: appearance) { _, _ in applyGlobalAppearanceOverride() } .environment(\.showGrokError, $showGrokError) .environment(\.grokErrorMessage, $grokErrorMessage) + .preferredColorScheme(preferredAppearance) .frame(minWidth: 600, minHeight: 400) .task { #if USE_FOUNDATION_MODELS && canImport(FoundationModels) @@ -187,22 +263,25 @@ struct NeonVisionEditorApp: App { showGrokError: $showGrokError, grokErrorMessage: $grokErrorMessage ) + .onAppear { applyGlobalAppearanceOverride() } + .onChange(of: appearance) { _, _ in applyGlobalAppearanceOverride() } + .preferredColorScheme(preferredAppearance) } .defaultSize(width: 1000, height: 600) .handlesExternalEvents(matching: []) - - WindowGroup("Settings", id: "settings") { + Settings { NeonSettingsView() .environmentObject(supportPurchaseManager) - .background(NonRestorableWindow()) + .onAppear { applyGlobalAppearanceOverride() } + .onChange(of: appearance) { _, _ in applyGlobalAppearanceOverride() } + .preferredColorScheme(preferredAppearance) } - .defaultSize(width: 860, height: 620) .commands { CommandGroup(replacing: .appSettings) { Button("Settings…") { - openWindow(id: "settings") + showSettingsWindow() } .keyboardShortcut(",", modifiers: .command) } @@ -308,24 +387,6 @@ struct NeonVisionEditorApp: App { Button("API Settings…") { postWindowCommand(.showAPISettingsRequested) } - - Divider() - - Button("Use Apple Intelligence") { - postWindowCommand(.selectAIModelRequested, object: AIModel.appleIntelligence.rawValue) - } - Button("Use Grok") { - postWindowCommand(.selectAIModelRequested, object: AIModel.grok.rawValue) - } - Button("Use OpenAI") { - postWindowCommand(.selectAIModelRequested, object: AIModel.openAI.rawValue) - } - Button("Use Gemini") { - postWindowCommand(.selectAIModelRequested, object: AIModel.gemini.rawValue) - } - Button("Use Anthropic") { - postWindowCommand(.selectAIModelRequested, object: AIModel.anthropic.rawValue) - } } CommandGroup(after: .toolbar) { @@ -464,41 +525,22 @@ struct NeonVisionEditorApp: App { .environmentObject(supportPurchaseManager) .environment(\.showGrokError, $showGrokError) .environment(\.grokErrorMessage, $grokErrorMessage) + .onAppear { applyIOSAppearanceOverride() } + .onChange(of: appearance) { _, _ in applyIOSAppearanceOverride() } + .preferredColorScheme(preferredAppearance) } #endif } private func showSettingsWindow() { #if os(macOS) - openWindow(id: "settings") + if !NSApp.sendAction(Selector(("showSettingsWindow:")), to: nil, from: nil) { + _ = NSApp.sendAction(Selector(("showPreferencesWindow:")), to: nil, from: nil) + } #endif } } -#if os(macOS) -private struct NonRestorableWindow: NSViewRepresentable { - func makeNSView(context: Context) -> NSView { - let view = NSView() - DispatchQueue.main.async { - if let window = view.window { - window.isRestorable = false - window.identifier = nil - } - } - return view - } - - func updateNSView(_ nsView: NSView, context: Context) { - DispatchQueue.main.async { - if let window = nsView.window { - window.isRestorable = false - window.identifier = nil - } - } - } -} -#endif - struct ShowGrokErrorKey: EnvironmentKey { static let defaultValue: Binding = .constant(false) } diff --git a/Neon Vision Editor/Core/SyntaxHighlighting.swift b/Neon Vision Editor/Core/SyntaxHighlighting.swift index ac991d7..5131bc0 100644 --- a/Neon Vision Editor/Core/SyntaxHighlighting.swift +++ b/Neon Vision Editor/Core/SyntaxHighlighting.swift @@ -53,7 +53,21 @@ struct SyntaxColors { // Regex patterns per language mapped to colors. Keep light-weight for performance. func getSyntaxPatterns(for language: String, colors: SyntaxColors) -> [String: Color] { - switch language { + let normalized = language + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + let canonical: String + switch normalized { + case "py", "python3": + canonical = "python" + case "js", "mjs", "cjs": + canonical = "javascript" + case "ts", "tsx": + canonical = "typescript" + default: + canonical = normalized + } + switch canonical { case "swift": return [ // Keywords (extended to include `import`) diff --git a/Neon Vision Editor/Data/EditorViewModel.swift b/Neon Vision Editor/Data/EditorViewModel.swift index 4aaea68..4eec81a 100644 --- a/Neon Vision Editor/Data/EditorViewModel.swift +++ b/Neon Vision Editor/Data/EditorViewModel.swift @@ -142,7 +142,7 @@ class EditorViewModel: ObservableObject { func addNewTab() { // Keep language discovery active for new untitled tabs. - let newTab = TabData(name: "Untitled \(tabs.count + 1)", content: "", language: "plain", fileURL: nil, languageLocked: false) + let newTab = TabData(name: "Untitled \(tabs.count + 1)", content: "", language: defaultNewTabLanguage(), fileURL: nil, languageLocked: false) tabs.append(newTab) selectedTabID = newTab.id } @@ -438,4 +438,10 @@ class EditorViewModel: ObservableObject { print(message) #endif } + + private func defaultNewTabLanguage() -> String { + let stored = UserDefaults.standard.string(forKey: "SettingsDefaultNewFileLanguage") ?? "plain" + let trimmed = stored.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + return trimmed.isEmpty ? "plain" : trimmed + } } diff --git a/Neon Vision Editor/Data/SupportPurchaseManager.swift b/Neon Vision Editor/Data/SupportPurchaseManager.swift index 87d169b..32aec1c 100644 --- a/Neon Vision Editor/Data/SupportPurchaseManager.swift +++ b/Neon Vision Editor/Data/SupportPurchaseManager.swift @@ -40,7 +40,7 @@ final class SupportPurchaseManager: ObservableObject { } var supportPriceLabel: String { - supportProduct?.displayPrice ?? "EUR 4.90" + supportProduct?.displayPrice ?? "$4.99" } var canBypassInCurrentBuild: Bool { diff --git a/Neon Vision Editor/SupportOptional.storekit b/Neon Vision Editor/SupportOptional.storekit index d82f85f..8b8c18b 100644 --- a/Neon Vision Editor/SupportOptional.storekit +++ b/Neon Vision Editor/SupportOptional.storekit @@ -5,7 +5,7 @@ ], "products" : [ { - "displayPrice" : "4.90", + "displayPrice" : "4.99", "familyShareable" : false, "internalID" : "0D5E32E6-73E8-4DA0-9AE8-4C5A79EA9A20", "localizations" : [ diff --git a/Neon Vision Editor/UI/ContentView+Actions.swift b/Neon Vision Editor/UI/ContentView+Actions.swift index df41719..c81a8b0 100644 --- a/Neon Vision Editor/UI/ContentView+Actions.swift +++ b/Neon Vision Editor/UI/ContentView+Actions.swift @@ -101,6 +101,15 @@ extension ContentView { caretStatus = "Ln 1, Col 1" } + func requestClearEditorContent() { + let hasText = !currentContentBinding.wrappedValue.isEmpty + if confirmClearEditor && hasText { + showClearEditorConfirmDialog = true + } else { + clearEditorContent() + } + } + func toggleSidebarFromToolbar() { #if os(iOS) if horizontalSizeClass == .compact { @@ -112,7 +121,7 @@ extension ContentView { } func requestCloseTab(_ tab: TabData) { - if tab.isDirty { + if tab.isDirty && confirmCloseDirtyTab { pendingCloseTabID = tab.id showUnsavedCloseDialog = true } else { @@ -188,7 +197,48 @@ extension ContentView { } } #else - findStatusMessage = "Find next is currently available on macOS editor." + guard !findQuery.isEmpty else { return } + findStatusMessage = "" + let source = currentContentBinding.wrappedValue + let ns = source as NSString + let fingerprint = "\(findQuery)|\(findUsesRegex)|\(findCaseSensitive)" + if fingerprint != iOSLastFindFingerprint { + iOSLastFindFingerprint = fingerprint + iOSFindCursorLocation = 0 + } + + let clampedStart = min(max(0, iOSFindCursorLocation), ns.length) + let forwardRange = NSRange(location: clampedStart, length: max(0, ns.length - clampedStart)) + let wrapRange = NSRange(location: 0, length: max(0, clampedStart)) + + let foundRange: NSRange? + if findUsesRegex { + guard let regex = try? NSRegularExpression(pattern: findQuery, options: findCaseSensitive ? [] : [.caseInsensitive]) else { + findStatusMessage = "Invalid regex pattern" + return + } + foundRange = regex.firstMatch(in: source, options: [], range: forwardRange)?.range + ?? regex.firstMatch(in: source, options: [], range: wrapRange)?.range + } else { + let opts: NSString.CompareOptions = findCaseSensitive ? [] : [.caseInsensitive] + foundRange = ns.range(of: findQuery, options: opts, range: forwardRange).toOptional() + ?? ns.range(of: findQuery, options: opts, range: wrapRange).toOptional() + } + + guard let match = foundRange else { + findStatusMessage = "No matches found" + return + } + + iOSFindCursorLocation = match.upperBound + NotificationCenter.default.post( + name: .moveCursorToRange, + object: nil, + userInfo: [ + EditorCommandUserInfo.rangeLocation: match.location, + EditorCommandUserInfo.rangeLength: match.length + ] + ) #endif } diff --git a/Neon Vision Editor/UI/ContentView+Toolbar.swift b/Neon Vision Editor/UI/ContentView+Toolbar.swift index 95c4a7f..879dd6a 100644 --- a/Neon Vision Editor/UI/ContentView+Toolbar.swift +++ b/Neon Vision Editor/UI/ContentView+Toolbar.swift @@ -92,58 +92,6 @@ extension ContentView { .frame(width: isIPadToolbarLayout ? 160 : 120) } - @ViewBuilder - private var aiSelectorControl: some View { - Button(action: { - showAISelectorPopover.toggle() - }) { - Image(systemName: "brain.head.profile") - } - .help("AI Model & Settings") - .popover(isPresented: $showAISelectorPopover) { - VStack(alignment: .leading, spacing: 8) { - Text("AI Model").font(.headline) - Picker("AI Model", selection: $selectedModel) { - HStack(spacing: 6) { - Image(systemName: "brain.head.profile") - Text("Apple Intelligence") - } - .tag(AIModel.appleIntelligence) - Text("Grok").tag(AIModel.grok) - Text("OpenAI").tag(AIModel.openAI) - Text("Gemini").tag(AIModel.gemini) - Text("Anthropic").tag(AIModel.anthropic) - } - .labelsHidden() - .frame(width: 170) - .controlSize(.large) - - Button("API Settings…") { - showAISelectorPopover = false - openAPISettings() - } - .buttonStyle(.bordered) - } - .padding(12) - } - } - - @ViewBuilder - private var aiSelectorMenuControl: some View { - Menu { - Button("Apple Intelligence") { selectedModel = .appleIntelligence } - Button("Grok") { selectedModel = .grok } - Button("OpenAI") { selectedModel = .openAI } - Button("Gemini") { selectedModel = .gemini } - Button("Anthropic") { selectedModel = .anthropic } - Divider() - Button("API Settings…") { openAPISettings() } - } label: { - Image(systemName: "brain.head.profile") - } - .help("AI Model & Settings") - } - @ViewBuilder private var activeProviderBadgeControl: some View { Text(compactActiveProviderName) @@ -162,7 +110,7 @@ extension ContentView { @ViewBuilder private var clearEditorControl: some View { Button(action: { - clearEditorContent() + requestClearEditorContent() }) { Image(systemName: "trash") } @@ -280,7 +228,6 @@ extension ContentView { private var iOSToolbarControls: some View { languagePickerControl newTabControl - aiSelectorControl activeProviderBadgeControl clearEditorControl settingsControl @@ -289,7 +236,6 @@ extension ContentView { @ViewBuilder private var iPadDistributedToolbarControls: some View { - aiSelectorMenuControl activeProviderBadgeControl languagePickerControl newTabControl @@ -365,39 +311,6 @@ extension ContentView { .frame(width: 140) .padding(.vertical, 2) - Button(action: { - showAISelectorPopover.toggle() - }) { - Image(systemName: "brain.head.profile") - } - .help("AI Model & Settings") - .popover(isPresented: $showAISelectorPopover) { - VStack(alignment: .leading, spacing: 8) { - Text("AI Model").font(.headline) - Picker("AI Model", selection: $selectedModel) { - HStack(spacing: 6) { - Image(systemName: "brain.head.profile") - Text("Apple Intelligence") - } - .tag(AIModel.appleIntelligence) - Text("Grok").tag(AIModel.grok) - Text("OpenAI").tag(AIModel.openAI) - Text("Gemini").tag(AIModel.gemini) - Text("Anthropic").tag(AIModel.anthropic) - } - .labelsHidden() - .frame(width: 170) - .controlSize(.large) - - Button("API Settings…") { - showAISelectorPopover = false - openAPISettings() - } - .buttonStyle(.bordered) - } - .padding(12) - } - Text(compactActiveProviderName) .font(.caption) .foregroundColor(.secondary) @@ -428,7 +341,7 @@ extension ContentView { .help("Increase Font Size") Button(action: { - clearEditorContent() + requestClearEditorContent() }) { Image(systemName: "trash") } diff --git a/Neon Vision Editor/UI/ContentView.swift b/Neon Vision Editor/UI/ContentView.swift index 6b2f0e6..5610de2 100644 --- a/Neon Vision Editor/UI/ContentView.swift +++ b/Neon Vision Editor/UI/ContentView.swift @@ -44,7 +44,7 @@ struct ContentView: View { @Environment(\.grokErrorMessage) var grokErrorMessage // Single-document fallback state (used when no tab model is selected) - @State var selectedModel: AIModel = .appleIntelligence + @AppStorage("SelectedAIModel") private var selectedModelRaw: String = AIModel.appleIntelligence.rawValue @State var singleContent: String = "" @State var singleLanguage: String = "plain" @State var caretStatus: String = "Ln 1, Col 1" @@ -53,6 +53,9 @@ struct ContentView: View { @AppStorage("SettingsLineHeight") var editorLineHeight: Double = 1.0 @AppStorage("SettingsShowLineNumbers") var showLineNumbers: Bool = true @AppStorage("SettingsHighlightCurrentLine") var highlightCurrentLine: Bool = false + @AppStorage("SettingsHighlightMatchingBrackets") var highlightMatchingBrackets: Bool = false + @AppStorage("SettingsShowScopeGuides") var showScopeGuides: Bool = false + @AppStorage("SettingsHighlightScopeBackground") var highlightScopeBackground: Bool = false @AppStorage("SettingsLineWrapEnabled") var settingsLineWrapEnabled: Bool = false // Removed showHorizontalRuler and showVerticalRuler AppStorage properties @AppStorage("SettingsIndentStyle") var indentStyle: String = "spaces" @@ -63,6 +66,10 @@ struct ContentView: View { @AppStorage("SettingsCompletionEnabled") var isAutoCompletionEnabled: Bool = false @AppStorage("SettingsCompletionFromDocument") var completionFromDocument: Bool = false @AppStorage("SettingsCompletionFromSyntax") var completionFromSyntax: Bool = false + @AppStorage("SettingsReopenLastSession") var reopenLastSession: Bool = true + @AppStorage("SettingsOpenWithBlankDocument") var openWithBlankDocument: Bool = true + @AppStorage("SettingsConfirmCloseDirtyTab") var confirmCloseDirtyTab: Bool = true + @AppStorage("SettingsConfirmClearEditor") var confirmClearEditor: Bool = true @AppStorage("SettingsActiveTab") var settingsActiveTab: String = "general" @AppStorage("SettingsTemplateLanguage") private var settingsTemplateLanguage: String = "swift" @AppStorage("SettingsThemeName") private var settingsThemeName: String = "Neon Glow" @@ -80,9 +87,6 @@ struct ContentView: View { @State private var isApplyingCompletion: Bool = false @State var enableTranslucentWindow: Bool = UserDefaults.standard.bool(forKey: "EnableTranslucentWindow") - // Added missing popover UI state - @State var showAISelectorPopover: Bool = false - @State var showFindReplace: Bool = false @State var showSettingsSheet: Bool = false @State var findQuery: String = "" @@ -90,6 +94,8 @@ struct ContentView: View { @State var findUsesRegex: Bool = false @State var findCaseSensitive: Bool = false @State var findStatusMessage: String = "" + @State var iOSFindCursorLocation: Int = 0 + @State var iOSLastFindFingerprint: String = "" @State var showProjectStructureSidebar: Bool = false @State var showCompactSidebarSheet: Bool = false @State var projectRootFolderURL: URL? = nil @@ -98,6 +104,7 @@ struct ContentView: View { @State var projectFolderSecurityURL: URL? = nil @State var pendingCloseTabID: UUID? = nil @State var showUnsavedCloseDialog: Bool = false + @State var showClearEditorConfirmDialog: Bool = false @State var showIOSFileImporter: Bool = false @State var showIOSFileExporter: Bool = false @State var iosExportDocument: PlainTextDocument = PlainTextDocument(text: "") @@ -122,6 +129,7 @@ struct ContentView: View { @State private var languagePromptSelection: String = "plain" @State private var languagePromptInsertTemplate: Bool = false @State private var whitespaceInspectorMessage: String? = nil + @State private var didApplyStartupBehavior: Bool = false #if USE_FOUNDATION_MODELS && canImport(FoundationModels) var appleModelAvailable: Bool { true } @@ -131,6 +139,11 @@ struct ContentView: View { var activeProviderName: String { lastProviderUsed } + var selectedModel: AIModel { + get { AIModel(rawValue: selectedModelRaw) ?? .appleIntelligence } + set { selectedModelRaw = newValue.rawValue } + } + /// Prompts the user for a Grok token if none is saved. Persists to Keychain. /// Returns true if a token is present/was saved; false if cancelled or empty. private func promptForGrokTokenIfNeeded() -> Bool { @@ -942,7 +955,7 @@ struct ContentView: View { let viewWithEditorActions = view .onReceive(NotificationCenter.default.publisher(for: .clearEditorRequested)) { notif in guard matchesCurrentWindow(notif) else { return } - clearEditorContent() + requestClearEditorContent() } .onChange(of: isAutoCompletionEnabled) { _, enabled in if enabled && viewModel.isBrainDumpMode { @@ -1002,14 +1015,13 @@ struct ContentView: View { } .onReceive(NotificationCenter.default.publisher(for: .showAPISettingsRequested)) { notif in guard matchesCurrentWindow(notif) else { return } - showAISelectorPopover = false openAPISettings() } .onReceive(NotificationCenter.default.publisher(for: .selectAIModelRequested)) { notif in guard matchesCurrentWindow(notif) else { return } guard let modelRawValue = notif.object as? String, let model = AIModel(rawValue: modelRawValue) else { return } - selectedModel = model + selectedModelRaw = model.rawValue } return viewWithPanels @@ -1118,6 +1130,21 @@ struct ContentView: View { .onChange(of: settingsThemeName) { _, _ in highlightRefreshToken += 1 } + .onChange(of: highlightMatchingBrackets) { _, _ in + highlightRefreshToken += 1 + } + .onChange(of: showScopeGuides) { _, _ in + highlightRefreshToken += 1 + } + .onChange(of: highlightScopeBackground) { _, _ in + highlightRefreshToken += 1 + } + .onChange(of: viewModel.isLineWrapEnabled) { _, _ in + highlightRefreshToken += 1 + } + .onReceive(viewModel.$tabs) { _ in + persistSessionIfReady() + } .sheet(isPresented: $showFindReplace) { FindReplacePanel( findQuery: $findQuery, @@ -1131,6 +1158,11 @@ struct ContentView: View { ) #if canImport(UIKit) .frame(maxWidth: 420) +#if os(iOS) + .presentationDetents([.height(280), .medium]) + .presentationDragIndicator(.visible) + .presentationContentInteraction(.scrolls) +#endif #else .frame(width: 420) #endif @@ -1142,6 +1174,11 @@ struct ContentView: View { supportsTranslucency: false ) .environmentObject(supportPurchaseManager) +#if os(iOS) + .presentationDetents([.large]) + .presentationDragIndicator(.visible) + .presentationContentInteraction(.scrolls) +#endif } #endif #if os(iOS) @@ -1202,6 +1239,12 @@ struct ContentView: View { Text("This file has unsaved changes.") } } + .confirmationDialog("Clear editor content?", isPresented: $showClearEditorConfirmDialog, titleVisibility: .visible) { + Button("Clear", role: .destructive) { clearEditorContent() } + Button("Cancel", role: .cancel) {} + } message: { + Text("This will remove all text in the current editor.") + } #if canImport(UIKit) .fileImporter( isPresented: $showIOSFileImporter, @@ -1224,6 +1267,8 @@ struct ContentView: View { viewModel.showSidebar = false showProjectStructureSidebar = false + applyStartupBehaviorIfNeeded() + // Restore Brain Dump mode from defaults if UserDefaults.standard.object(forKey: "BrainDumpModeEnabled") != nil { viewModel.isBrainDumpMode = UserDefaults.standard.bool(forKey: "BrainDumpModeEnabled") @@ -1260,6 +1305,55 @@ struct ContentView: View { #endif } + private func applyStartupBehaviorIfNeeded() { + guard !didApplyStartupBehavior else { return } + + if viewModel.tabs.contains(where: { $0.fileURL != nil }) { + didApplyStartupBehavior = true + persistSessionIfReady() + return + } + + if openWithBlankDocument { + didApplyStartupBehavior = true + persistSessionIfReady() + return + } + + if reopenLastSession { + let paths = UserDefaults.standard.stringArray(forKey: "LastSessionFileURLs") ?? [] + let selectedPath = UserDefaults.standard.string(forKey: "LastSessionSelectedFileURL") + let urls = paths.compactMap { URL(string: $0) } + + if !urls.isEmpty { + viewModel.tabs.removeAll() + viewModel.selectedTabID = nil + + for url in urls { + viewModel.openFile(url: url) + } + + if let selectedPath, let selectedURL = URL(string: selectedPath) { + _ = viewModel.focusTabIfOpen(for: selectedURL) + } + + if viewModel.tabs.isEmpty { + viewModel.addNewTab() + } + } + } + + didApplyStartupBehavior = true + persistSessionIfReady() + } + + private func persistSessionIfReady() { + guard didApplyStartupBehavior else { return } + let urls = viewModel.tabs.compactMap { $0.fileURL?.absoluteString } + UserDefaults.standard.set(urls, forKey: "LastSessionFileURLs") + UserDefaults.standard.set(viewModel.selectedTab?.fileURL?.absoluteString, forKey: "LastSessionSelectedFileURL") + } + // Sidebar shows a lightweight table of contents (TOC) derived from the current document. @ViewBuilder var sidebarView: some View { @@ -1710,6 +1804,9 @@ struct ContentView: View { showLineNumbers: showLineNumbers, showInvisibleCharacters: false, highlightCurrentLine: highlightCurrentLine, + highlightMatchingBrackets: highlightMatchingBrackets, + showScopeGuides: showScopeGuides, + highlightScopeBackground: highlightScopeBackground, indentStyle: indentStyle, indentWidth: indentWidth, autoIndentEnabled: autoIndentEnabled, diff --git a/Neon Vision Editor/UI/EditorTextView.swift b/Neon Vision Editor/UI/EditorTextView.swift index 5c67447..9c44013 100644 --- a/Neon Vision Editor/UI/EditorTextView.swift +++ b/Neon Vision Editor/UI/EditorTextView.swift @@ -5,6 +5,329 @@ extension Notification.Name { static let pastedFileURL = Notification.Name("pastedFileURL") } +private struct BracketScopeMatch { + let openRange: NSRange + let closeRange: NSRange + let scopeRange: NSRange? + let guideMarkerRanges: [NSRange] +} + +private struct IndentationScopeMatch { + let scopeRange: NSRange + let guideMarkerRanges: [NSRange] +} + +private func matchingOpeningBracket(for closing: unichar) -> unichar? { + switch UnicodeScalar(closing) { + case "}": return unichar(UnicodeScalar("{").value) + case "]": return unichar(UnicodeScalar("[").value) + case ")": return unichar(UnicodeScalar("(").value) + default: return nil + } +} + +private func matchingClosingBracket(for opening: unichar) -> unichar? { + switch UnicodeScalar(opening) { + case "{": return unichar(UnicodeScalar("}").value) + case "[": return unichar(UnicodeScalar("]").value) + case "(": return unichar(UnicodeScalar(")").value) + default: return nil + } +} + +private func isBracket(_ c: unichar) -> Bool { + matchesAny(c, ["{", "}", "[", "]", "(", ")"]) +} + +private func matchesAny(_ c: unichar, _ chars: [Character]) -> Bool { + guard let scalar = UnicodeScalar(c) else { return false } + return chars.contains(Character(scalar)) +} + +private func computeBracketScopeMatch(text: String, caretLocation: Int) -> BracketScopeMatch? { + let ns = text as NSString + let length = ns.length + guard length > 0 else { return nil } + + func matchFrom(start: Int) -> BracketScopeMatch? { + guard start >= 0 && start < length else { return nil } + let startChar = ns.character(at: start) + let openIndex: Int + let closeIndex: Int + + if let wantedClose = matchingClosingBracket(for: startChar) { + var depth = 0 + var found: Int? + for i in start..= 0 { + let c = ns.character(at: i) + if c == startChar { depth += 1 } + if c == wantedOpen { + depth -= 1 + if depth == 0 { + found = i + break + } + } + i -= 1 + } + guard let found else { return nil } + openIndex = found + closeIndex = start + } else { + return nil + } + + let openRange = NSRange(location: openIndex, length: 1) + let closeRange = NSRange(location: closeIndex, length: 1) + let scopeLength = max(0, closeIndex - openIndex - 1) + let scopeRange: NSRange? = scopeLength > 0 ? NSRange(location: openIndex + 1, length: scopeLength) : nil + + let openLineRange = ns.lineRange(for: NSRange(location: openIndex, length: 0)) + let closeLineRange = ns.lineRange(for: NSRange(location: closeIndex, length: 0)) + let column = openIndex - openLineRange.location + + var markers: [NSRange] = [] + var lineStart = openLineRange.location + while lineStart <= closeLineRange.location && lineStart < length { + let lineRange = ns.lineRange(for: NSRange(location: lineStart, length: 0)) + let lineEndExcludingNewline = lineRange.location + max(0, lineRange.length - 1) + if lineEndExcludingNewline > lineRange.location { + let markerLoc = min(lineRange.location + column, lineEndExcludingNewline - 1) + if markerLoc >= lineRange.location && markerLoc < lineEndExcludingNewline { + markers.append(NSRange(location: markerLoc, length: 1)) + } + } + let nextLineStart = lineRange.location + lineRange.length + if nextLineStart <= lineStart { break } + lineStart = nextLineStart + } + + return BracketScopeMatch( + openRange: openRange, + closeRange: closeRange, + scopeRange: scopeRange, + guideMarkerRanges: markers + ) + } + + let safeCaret = max(0, min(caretLocation, length)) + var probeIndices: [Int] = [safeCaret] + if safeCaret > 0 { probeIndices.append(safeCaret - 1) } + + var candidateIndices: [Int] = [] + var seenCandidates = Set() + func addCandidate(_ index: Int) { + guard index >= 0 && index < length else { return } + if seenCandidates.insert(index).inserted { + candidateIndices.append(index) + } + } + for idx in probeIndices where idx >= 0 && idx < length { + if isBracket(ns.character(at: idx)) { + addCandidate(idx) + } + } + + // If caret is not directly on a bracket, find the nearest enclosing opening + // bracket whose matching close still contains the caret. + var stack: [Int] = [] + if safeCaret > 0 { + for i in 0..= candidate && safeCaret <= close { + addCandidate(candidate) + } + } + + // Add all brackets by nearest distance so we still find a valid scope even if + // early candidates are unmatched (e.g. bracket chars inside strings/comments). + let allBracketIndices = (0.. Bool { + let lang = language.lowercased() + return lang == "python" || lang == "yaml" || lang == "yml" +} + +private func computeIndentationScopeMatch(text: String, caretLocation: Int) -> IndentationScopeMatch? { + let ns = text as NSString + let length = ns.length + guard length > 0 else { return nil } + + struct LineInfo { + let range: NSRange + let contentEnd: Int + let indent: Int? + } + + func lineIndent(_ lineRange: NSRange) -> Int? { + guard lineRange.length > 0 else { return nil } + let line = ns.substring(with: lineRange) + var indent = 0 + var sawContent = false + for ch in line { + if ch == " " { + indent += 1 + continue + } + if ch == "\t" { + indent += 4 + continue + } + if ch == "\n" || ch == "\r" { + continue + } + sawContent = true + break + } + return sawContent ? indent : nil + } + + var lines: [LineInfo] = [] + var lineStart = 0 + while lineStart < length { + let lr = ns.lineRange(for: NSRange(location: lineStart, length: 0)) + let contentEnd = lr.location + max(0, lr.length - 1) + lines.append(LineInfo(range: lr, contentEnd: contentEnd, indent: lineIndent(lr))) + let next = lr.location + lr.length + if next <= lineStart { break } + lineStart = next + } + guard !lines.isEmpty else { return nil } + + let safeCaret = max(0, min(caretLocation, max(0, length - 1))) + guard let caretLineIndex = lines.firstIndex(where: { NSLocationInRange(safeCaret, $0.range) }) else { return nil } + + var blockStart = caretLineIndex + var baseIndent: Int? = lines[caretLineIndex].indent + + // If caret is on a block header line (e.g. Python ":"), use the next indented line. + if baseIndent == nil || baseIndent == 0 { + let currentLine = ns.substring(with: lines[caretLineIndex].range).trimmingCharacters(in: .whitespacesAndNewlines) + if currentLine.hasSuffix(":") { + var next = caretLineIndex + 1 + while next < lines.count { + if let nextIndent = lines[next].indent, nextIndent > 0 { + baseIndent = nextIndent + blockStart = next + break + } + next += 1 + } + } + } + + guard let indentLevel = baseIndent, indentLevel > 0 else { return nil } + + var start = blockStart + while start > 0 { + let prev = lines[start - 1] + guard let prevIndent = prev.indent else { + start -= 1 + continue + } + if prevIndent >= indentLevel { + start -= 1 + continue + } + break + } + + var end = blockStart + var idx = blockStart + 1 + while idx < lines.count { + let info = lines[idx] + if let infoIndent = info.indent { + if infoIndent < indentLevel { break } + end = idx + idx += 1 + continue + } + // Keep blank lines inside the current block. + end = idx + idx += 1 + } + + let startLoc = lines[start].range.location + let endLoc = lines[end].contentEnd + guard endLoc > startLoc else { return nil } + + var guideMarkers: [NSRange] = [] + for i in start...end { + let info = lines[i] + guard info.contentEnd > info.range.location else { continue } + guard let infoIndent = info.indent, infoIndent >= indentLevel else { continue } + let marker = min(info.range.location + max(0, indentLevel - 1), info.contentEnd - 1) + if marker >= info.range.location && marker < info.contentEnd { + guideMarkers.append(NSRange(location: marker, length: 1)) + } + } + + return IndentationScopeMatch( + scopeRange: NSRange(location: startLoc, length: endLoc - startLoc), + guideMarkerRanges: guideMarkers + ) +} + +private func isValidRange(_ range: NSRange, utf16Length: Int) -> Bool { + guard range.location != NSNotFound, range.length >= 0, range.location >= 0 else { return false } + return NSMaxRange(range) <= utf16Length +} + #if os(macOS) import AppKit @@ -932,6 +1255,9 @@ struct CustomTextEditor: NSViewRepresentable { let showLineNumbers: Bool let showInvisibleCharacters: Bool let highlightCurrentLine: Bool + let highlightMatchingBrackets: Bool + let showScopeGuides: Bool + let highlightScopeBackground: Bool let indentStyle: String let indentWidth: Int let autoIndentEnabled: Bool @@ -942,6 +1268,10 @@ struct CustomTextEditor: NSViewRepresentable { UserDefaults.standard.string(forKey: "SettingsEditorFontName") ?? "" } + private var useSystemFont: Bool { + UserDefaults.standard.bool(forKey: "SettingsUseSystemFont") + } + private var lineHeightMultiple: CGFloat { let stored = UserDefaults.standard.double(forKey: "SettingsLineHeight") return CGFloat(stored > 0 ? stored : 1.0) @@ -977,6 +1307,9 @@ struct CustomTextEditor: NSViewRepresentable { } private func resolvedFont() -> NSFont { + if useSystemFont { + return NSFont.systemFont(ofSize: fontSize) + } if let named = NSFont(name: fontName, size: fontSize) { return named } @@ -1054,7 +1387,7 @@ struct CustomTextEditor: NSViewRepresentable { textView.usesFontPanel = false textView.isVerticallyResizable = true textView.isHorizontallyResizable = false - textView.font = NSFont.monospacedSystemFont(ofSize: fontSize, weight: .regular) + textView.font = resolvedFont() // Apply visibility preference from Settings (off by default). applyInvisibleCharacterPreference(textView) @@ -1144,7 +1477,7 @@ struct CustomTextEditor: NSViewRepresentable { guard let sv = scrollView, let tv = textView else { return } sv.window?.makeFirstResponder(tv) } - context.coordinator.scheduleHighlightIfNeeded(currentText: text) + context.coordinator.scheduleHighlightIfNeeded(currentText: text, immediate: true) // Keep container width in sync when the scroll view resizes NotificationCenter.default.addObserver(forName: NSView.boundsDidChangeNotification, object: scrollView.contentView, queue: .main) { [weak textView, weak scrollView] _ in @@ -1244,6 +1577,8 @@ struct CustomTextEditor: NSViewRepresentable { .backgroundColor: NSColor(theme.selection) ] let showLineNumbersByDefault = showLineNumbers + textView.usesRuler = showLineNumbersByDefault + textView.isRulerVisible = showLineNumbersByDefault nsView.hasHorizontalRuler = false nsView.horizontalRulerView = nil nsView.hasVerticalRuler = showLineNumbersByDefault @@ -1321,6 +1656,9 @@ struct CustomTextEditor: NSViewRepresentable { private var lastColorScheme: ColorScheme? var lastLineHeight: CGFloat? private var lastHighlightToken: Int = 0 + private var lastSelectionLocation: Int = -1 + private var isApplyingHighlight = false + private var highlightGeneration: Int = 0 init(_ parent: CustomTextEditor) { self.parent = parent @@ -1338,11 +1676,12 @@ struct CustomTextEditor: NSViewRepresentable { lastColorScheme = nil lastLineHeight = nil lastHighlightToken = 0 + lastSelectionLocation = -1 } /// Schedules highlighting if text/language/theme changed. Skips very large documents /// and defers when a modal sheet is presented. - func scheduleHighlightIfNeeded(currentText: String? = nil) { + func scheduleHighlightIfNeeded(currentText: String? = nil, immediate: Bool = false) { guard textView != nil else { return } // Query NSApp.modalWindow on the main thread to avoid thread-check warnings @@ -1370,6 +1709,16 @@ struct CustomTextEditor: NSViewRepresentable { let scheme = parent.colorScheme let lineHeightValue: CGFloat = parent.lineHeightMultiple let token = parent.highlightRefreshToken + let selectionLocation: Int = { + if Thread.isMainThread { + return textView?.selectedRange().location ?? 0 + } + var result = 0 + DispatchQueue.main.sync { + result = textView?.selectedRange().location ?? 0 + } + return result + }() let text: String = { if let currentText = currentText { return currentText @@ -1392,6 +1741,7 @@ struct CustomTextEditor: NSViewRepresentable { self.lastColorScheme = scheme self.lastLineHeight = lineHeightValue self.lastHighlightToken = token + self.lastSelectionLocation = selectionLocation return } @@ -1404,14 +1754,22 @@ struct CustomTextEditor: NSViewRepresentable { return } - if text == lastHighlightedText && lastLanguage == lang && lastColorScheme == scheme && lastLineHeight == lineHeightValue && lastHighlightToken == token { + if text == lastHighlightedText && + lastLanguage == lang && + lastColorScheme == scheme && + lastLineHeight == lineHeightValue && + lastHighlightToken == token && + lastSelectionLocation == selectionLocation { return } - rehighlight(token: token) + let shouldRunImmediate = immediate || lastHighlightedText.isEmpty || lastHighlightToken != token + highlightGeneration &+= 1 + let generation = highlightGeneration + rehighlight(token: token, generation: generation, immediate: shouldRunImmediate) } /// Perform regex-based token coloring off-main, then apply attributes on the main thread. - func rehighlight(token: Int) { + func rehighlight(token: Int, generation: Int, immediate: Bool = false) { guard let textView = textView else { return } // Snapshot current state let textSnapshot = textView.string @@ -1440,9 +1798,12 @@ struct CustomTextEditor: NSViewRepresentable { DispatchQueue.main.async { [weak self] in guard let self = self, let tv = self.textView else { return } + guard generation == self.highlightGeneration else { return } // Discard if text changed since we started guard tv.string == textSnapshot else { return } let baseColor = self.parent.effectiveBaseTextColor() + self.isApplyingHighlight = true + defer { self.isApplyingHighlight = false } tv.textStorage?.beginEditing() // Clear previous coloring and apply base color @@ -1452,6 +1813,55 @@ struct CustomTextEditor: NSViewRepresentable { for (range, color) in coloredRanges { tv.textStorage?.addAttribute(.foregroundColor, value: NSColor(color), range: range) } + + let selectedLocation = min(max(0, selected.location), max(0, fullRange.length)) + let wantsBracketTokens = self.parent.highlightMatchingBrackets + let wantsScopeBackground = self.parent.highlightScopeBackground + let wantsScopeGuides = self.parent.showScopeGuides && !self.parent.isLineWrapEnabled && self.parent.language.lowercased() != "swift" + let bracketMatch = computeBracketScopeMatch(text: textSnapshot, caretLocation: selectedLocation) + let indentationMatch: IndentationScopeMatch? = { + guard supportsIndentationScopes(language: self.parent.language) else { return nil } + return computeIndentationScopeMatch(text: textSnapshot, caretLocation: selectedLocation) + }() + + if wantsBracketTokens, let match = bracketMatch { + let textLength = fullRange.length + let tokenColor = NSColor.systemOrange + if isValidRange(match.openRange, utf16Length: textLength) { + tv.textStorage?.addAttribute(.foregroundColor, value: tokenColor, range: match.openRange) + tv.textStorage?.addAttribute(.underlineStyle, value: NSUnderlineStyle.single.rawValue, range: match.openRange) + tv.textStorage?.addAttribute(.backgroundColor, value: NSColor.systemOrange.withAlphaComponent(0.22), range: match.openRange) + } + if isValidRange(match.closeRange, utf16Length: textLength) { + tv.textStorage?.addAttribute(.foregroundColor, value: tokenColor, range: match.closeRange) + tv.textStorage?.addAttribute(.underlineStyle, value: NSUnderlineStyle.single.rawValue, range: match.closeRange) + tv.textStorage?.addAttribute(.backgroundColor, value: NSColor.systemOrange.withAlphaComponent(0.22), range: match.closeRange) + } + } + + if wantsScopeBackground || wantsScopeGuides { + let textLength = fullRange.length + let scopeRange = bracketMatch?.scopeRange ?? indentationMatch?.scopeRange + let guideRanges = bracketMatch?.guideMarkerRanges ?? indentationMatch?.guideMarkerRanges ?? [] + + if wantsScopeBackground, let scope = scopeRange, isValidRange(scope, utf16Length: textLength) { + tv.textStorage?.addAttribute(.backgroundColor, value: NSColor.systemOrange.withAlphaComponent(0.18), range: scope) + } + + if wantsScopeGuides { + for marker in guideRanges { + if isValidRange(marker, utf16Length: textLength) { + tv.textStorage?.addAttribute(.backgroundColor, value: NSColor.systemBlue.withAlphaComponent(0.36), range: marker) + } + } + } + } + + if self.parent.highlightCurrentLine { + let caret = NSRange(location: selectedLocation, length: 0) + let lineRange = nsText.lineRange(for: caret) + tv.textStorage?.addAttribute(.backgroundColor, value: NSColor.selectedTextBackgroundColor.withAlphaComponent(0.12), range: lineRange) + } tv.textStorage?.endEditing() tv.typingAttributes[.foregroundColor] = baseColor @@ -1468,6 +1878,7 @@ struct CustomTextEditor: NSViewRepresentable { self.lastColorScheme = scheme self.lastLineHeight = lineHeightValue self.lastHighlightToken = token + self.lastSelectionLocation = selectedLocation // Re-apply visibility preference after recoloring. self.parent.applyInvisibleCharacterPreference(tv) @@ -1475,8 +1886,12 @@ struct CustomTextEditor: NSViewRepresentable { } pendingHighlight = work - // Debounce slightly to avoid thrashing while typing - highlightQueue.asyncAfter(deadline: .now() + 0.12, execute: work) + // Run immediately on first paint/explicit refresh, debounce while typing. + if immediate { + highlightQueue.async(execute: work) + } else { + highlightQueue.asyncAfter(deadline: .now() + 0.12, execute: work) + } } func textDidChange(_ notification: Notification) { @@ -1525,6 +1940,7 @@ struct CustomTextEditor: NSViewRepresentable { } func textViewDidChangeSelection(_ notification: Notification) { + if isApplyingHighlight { return } if let tv = notification.object as? AcceptingTextView { tv.clearInlineSuggestion() } @@ -1555,16 +1971,7 @@ struct CustomTextEditor: NSViewRepresentable { } }() NotificationCenter.default.post(name: .caretPositionDidChange, object: nil, userInfo: ["line": line, "column": col]) - - // Highlight current line - let lineRange = ns.lineRange(for: NSRange(location: location, length: 0)) - let fullRange = NSRange(location: 0, length: ns.length) - tv.textStorage?.beginEditing() - tv.textStorage?.removeAttribute(.backgroundColor, range: fullRange) - if parent.highlightCurrentLine { - tv.textStorage?.addAttribute(.backgroundColor, value: NSColor.selectedTextBackgroundColor.withAlphaComponent(0.12), range: lineRange) - } - tv.textStorage?.endEditing() + scheduleHighlightIfNeeded(currentText: tv.string, immediate: true) } /// Move caret to a 1-based line number, clamping to bounds, and emphasize the line. @@ -1611,15 +2018,7 @@ struct CustomTextEditor: NSViewRepresentable { tv.setSelectedRange(NSRange(location: location, length: 0)) tv.scrollRangeToVisible(NSRange(location: location, length: 0)) - // Stronger highlight for the entire target line - let fullRange = NSRange(location: 0, length: totalLength) - tv.textStorage?.beginEditing() - tv.textStorage?.removeAttribute(.backgroundColor, range: fullRange) - if self.parent.highlightCurrentLine { - let lineRange = ns.lineRange(for: NSRange(location: location, length: 0)) - tv.textStorage?.addAttribute(.backgroundColor, value: NSColor.selectedTextBackgroundColor.withAlphaComponent(0.18), range: lineRange) - } - tv.textStorage?.endEditing() + self.scheduleHighlightIfNeeded(currentText: tv.string, immediate: true) } } } @@ -1702,6 +2101,9 @@ struct CustomTextEditor: UIViewRepresentable { let showLineNumbers: Bool let showInvisibleCharacters: Bool let highlightCurrentLine: Bool + let highlightMatchingBrackets: Bool + let showScopeGuides: Bool + let highlightScopeBackground: Bool let indentStyle: String let indentWidth: Int let autoIndentEnabled: Bool @@ -1712,24 +2114,41 @@ struct CustomTextEditor: UIViewRepresentable { UserDefaults.standard.string(forKey: "SettingsEditorFontName") ?? "" } + private var useSystemFont: Bool { + UserDefaults.standard.bool(forKey: "SettingsUseSystemFont") + } + private var lineHeightMultiple: CGFloat { let stored = UserDefaults.standard.double(forKey: "SettingsLineHeight") return CGFloat(stored > 0 ? stored : 1.0) } + private func resolvedUIFont(size: CGFloat? = nil) -> UIFont { + let targetSize = size ?? fontSize + if useSystemFont { + return UIFont.systemFont(ofSize: targetSize) + } + if let named = UIFont(name: fontName, size: targetSize) { + return named + } + return UIFont.monospacedSystemFont(ofSize: targetSize, weight: .regular) + } + func makeUIView(context: Context) -> LineNumberedTextViewContainer { let container = LineNumberedTextViewContainer() let textView = container.textView textView.delegate = context.coordinator - if let named = UIFont(name: fontName, size: fontSize) { - textView.font = named - } else { - textView.font = UIFont.monospacedSystemFont(ofSize: fontSize, weight: .regular) - } + let initialFont = resolvedUIFont() + textView.font = initialFont let paragraphStyle = NSMutableParagraphStyle() paragraphStyle.lineHeightMultiple = max(0.9, lineHeightMultiple) - textView.typingAttributes[.paragraphStyle] = paragraphStyle + let baseColor: UIColor = colorScheme == .dark ? .white : .label + var typing = textView.typingAttributes + typing[.paragraphStyle] = paragraphStyle + typing[.foregroundColor] = baseColor + typing[.font] = textView.font ?? initialFont + textView.typingAttributes = typing textView.text = text if text.count <= 200_000 { textView.textStorage.beginEditing() @@ -1745,7 +2164,7 @@ struct CustomTextEditor: UIViewRepresentable { textView.textContainer.lineBreakMode = (isLineWrapEnabled && !isLargeFileMode) ? .byWordWrapping : .byClipping textView.textContainer.widthTracksTextView = isLineWrapEnabled && !isLargeFileMode - if isLargeFileMode { + if isLargeFileMode || !showLineNumbers { container.lineNumberView.isHidden = true } else { container.lineNumberView.isHidden = false @@ -1753,7 +2172,7 @@ struct CustomTextEditor: UIViewRepresentable { } context.coordinator.container = container context.coordinator.textView = textView - context.coordinator.scheduleHighlightIfNeeded(currentText: text) + context.coordinator.scheduleHighlightIfNeeded(currentText: text, immediate: true) return container } @@ -1763,8 +2182,9 @@ struct CustomTextEditor: UIViewRepresentable { if textView.text != text { textView.text = text } - if textView.font?.pointSize != fontSize { - textView.font = UIFont.monospacedSystemFont(ofSize: fontSize, weight: .regular) + let targetFont = resolvedUIFont() + if textView.font?.fontName != targetFont.fontName || textView.font?.pointSize != targetFont.pointSize { + textView.font = targetFont } let paragraphStyle = NSMutableParagraphStyle() paragraphStyle.lineHeightMultiple = max(0.9, lineHeightMultiple) @@ -1779,12 +2199,11 @@ struct CustomTextEditor: UIViewRepresentable { context.coordinator.lastLineHeight = lineHeightMultiple } let theme = currentEditorTheme(colorScheme: colorScheme) - textView.textColor = UIColor(theme.text) textView.tintColor = UIColor(theme.cursor) textView.backgroundColor = translucentBackgroundEnabled ? .clear : UIColor(theme.background) textView.textContainer.lineBreakMode = (isLineWrapEnabled && !isLargeFileMode) ? .byWordWrapping : .byClipping textView.textContainer.widthTracksTextView = isLineWrapEnabled && !isLargeFileMode - if isLargeFileMode { + if isLargeFileMode || !showLineNumbers { uiView.lineNumberView.isHidden = true } else { uiView.lineNumberView.isHidden = false @@ -1809,19 +2228,40 @@ struct CustomTextEditor: UIViewRepresentable { private var lastColorScheme: ColorScheme? var lastLineHeight: CGFloat? private var lastHighlightToken: Int = 0 + private var lastSelectionLocation: Int = -1 private var isApplyingHighlight = false + private var highlightGeneration: Int = 0 init(_ parent: CustomTextEditor) { self.parent = parent + super.init() + NotificationCenter.default.addObserver(self, selector: #selector(moveToRange(_:)), name: .moveCursorToRange, object: nil) } - func scheduleHighlightIfNeeded(currentText: String? = nil) { + deinit { + NotificationCenter.default.removeObserver(self) + } + + @objc private func moveToRange(_ notification: Notification) { + guard let textView else { return } + guard let location = notification.userInfo?[EditorCommandUserInfo.rangeLocation] as? Int, + let length = notification.userInfo?[EditorCommandUserInfo.rangeLength] as? Int else { return } + let textLength = (textView.text as NSString?)?.length ?? 0 + guard location >= 0, length >= 0, location + length <= textLength else { return } + let range = NSRange(location: location, length: length) + textView.becomeFirstResponder() + textView.selectedRange = range + textView.scrollRangeToVisible(range) + } + + func scheduleHighlightIfNeeded(currentText: String? = nil, immediate: Bool = false) { guard let textView else { return } let text = currentText ?? textView.text ?? "" let lang = parent.language let scheme = parent.colorScheme let lineHeight = parent.lineHeightMultiple let token = parent.highlightRefreshToken + let selectionLocation = textView.selectedRange.location if parent.isLargeFileMode { lastHighlightedText = text @@ -1829,26 +2269,45 @@ struct CustomTextEditor: UIViewRepresentable { lastColorScheme = scheme lastLineHeight = lineHeight lastHighlightToken = token + lastSelectionLocation = selectionLocation return } - if text == lastHighlightedText && lang == lastLanguage && scheme == lastColorScheme && lineHeight == lastLineHeight && lastHighlightToken == token { + if text == lastHighlightedText && + lang == lastLanguage && + scheme == lastColorScheme && + lineHeight == lastLineHeight && + lastHighlightToken == token && + lastSelectionLocation == selectionLocation { return } pendingHighlight?.cancel() + highlightGeneration &+= 1 + let generation = highlightGeneration let work = DispatchWorkItem { [weak self] in - self?.rehighlight(text: text, language: lang, colorScheme: scheme, token: token) + self?.rehighlight(text: text, language: lang, colorScheme: scheme, token: token, generation: generation) } pendingHighlight = work - highlightQueue.asyncAfter(deadline: .now() + 0.1, execute: work) + if immediate || lastHighlightedText.isEmpty || lastHighlightToken != token { + highlightQueue.async(execute: work) + } else { + highlightQueue.asyncAfter(deadline: .now() + 0.1, execute: work) + } } - private func rehighlight(text: String, language: String, colorScheme: ColorScheme, token: Int) { + private func rehighlight(text: String, language: String, colorScheme: ColorScheme, token: Int, generation: Int) { let nsText = text as NSString let fullRange = NSRange(location: 0, length: nsText.length) let baseColor: UIColor = colorScheme == .dark ? .white : .label - let baseFont = UIFont.monospacedSystemFont(ofSize: parent.fontSize, weight: .regular) + let baseFont: UIFont + if parent.useSystemFont { + baseFont = UIFont.systemFont(ofSize: parent.fontSize) + } else if let named = UIFont(name: parent.fontName, size: parent.fontSize) { + baseFont = named + } else { + baseFont = UIFont.monospacedSystemFont(ofSize: parent.fontSize, weight: .regular) + } let attributed = NSMutableAttributedString( string: text, @@ -1872,10 +2331,50 @@ struct CustomTextEditor: UIViewRepresentable { DispatchQueue.main.async { [weak self] in guard let self, let textView = self.textView else { return } + guard generation == self.highlightGeneration else { return } guard textView.text == text else { return } let selectedRange = textView.selectedRange self.isApplyingHighlight = true textView.attributedText = attributed + let wantsBracketTokens = self.parent.highlightMatchingBrackets + let wantsScopeBackground = self.parent.highlightScopeBackground + let wantsScopeGuides = self.parent.showScopeGuides && !self.parent.isLineWrapEnabled && self.parent.language.lowercased() != "swift" + let bracketMatch = computeBracketScopeMatch(text: text, caretLocation: selectedRange.location) + let indentationMatch: IndentationScopeMatch? = { + guard supportsIndentationScopes(language: self.parent.language) else { return nil } + return computeIndentationScopeMatch(text: text, caretLocation: selectedRange.location) + }() + + if wantsBracketTokens, let match = bracketMatch { + let textLength = fullRange.length + if isValidRange(match.openRange, utf16Length: textLength) { + textView.textStorage.addAttribute(.foregroundColor, value: UIColor.systemOrange, range: match.openRange) + textView.textStorage.addAttribute(.underlineStyle, value: NSUnderlineStyle.single.rawValue, range: match.openRange) + textView.textStorage.addAttribute(.backgroundColor, value: UIColor.systemOrange.withAlphaComponent(0.22), range: match.openRange) + } + if isValidRange(match.closeRange, utf16Length: textLength) { + textView.textStorage.addAttribute(.foregroundColor, value: UIColor.systemOrange, range: match.closeRange) + textView.textStorage.addAttribute(.underlineStyle, value: NSUnderlineStyle.single.rawValue, range: match.closeRange) + textView.textStorage.addAttribute(.backgroundColor, value: UIColor.systemOrange.withAlphaComponent(0.22), range: match.closeRange) + } + } + + if wantsScopeBackground || wantsScopeGuides { + let textLength = fullRange.length + let scopeRange = bracketMatch?.scopeRange ?? indentationMatch?.scopeRange + let guideRanges = bracketMatch?.guideMarkerRanges ?? indentationMatch?.guideMarkerRanges ?? [] + + if wantsScopeBackground, let scope = scopeRange, isValidRange(scope, utf16Length: textLength) { + textView.textStorage.addAttribute(.backgroundColor, value: UIColor.systemOrange.withAlphaComponent(0.18), range: scope) + } + if wantsScopeGuides { + for marker in guideRanges { + if isValidRange(marker, utf16Length: textLength) { + textView.textStorage.addAttribute(.backgroundColor, value: UIColor.systemBlue.withAlphaComponent(0.36), range: marker) + } + } + } + } if self.parent.highlightCurrentLine { let ns = text as NSString let lineRange = ns.lineRange(for: selectedRange) @@ -1892,6 +2391,7 @@ struct CustomTextEditor: UIViewRepresentable { self.lastColorScheme = colorScheme self.lastLineHeight = self.parent.lineHeightMultiple self.lastHighlightToken = token + self.lastSelectionLocation = selectedRange.location self.container?.updateLineNumbers(for: text, fontSize: self.parent.fontSize) self.syncLineNumberScroll() } @@ -1904,6 +2404,11 @@ struct CustomTextEditor: UIViewRepresentable { scheduleHighlightIfNeeded(currentText: textView.text) } + func textViewDidChangeSelection(_ textView: UITextView) { + guard !isApplyingHighlight else { return } + scheduleHighlightIfNeeded(currentText: textView.text, immediate: true) + } + func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool { if text == "\n", parent.autoIndentEnabled { let ns = textView.text as NSString diff --git a/Neon Vision Editor/UI/NeonSettingsView.swift b/Neon Vision Editor/UI/NeonSettingsView.swift index 877f594..440ba90 100644 --- a/Neon Vision Editor/UI/NeonSettingsView.swift +++ b/Neon Vision Editor/UI/NeonSettingsView.swift @@ -10,13 +10,22 @@ struct NeonSettingsView: View { @Environment(\.horizontalSizeClass) private var horizontalSizeClass @AppStorage("SettingsOpenInTabs") private var openInTabs: String = "system" @AppStorage("SettingsEditorFontName") private var editorFontName: String = "" + @AppStorage("SettingsUseSystemFont") private var useSystemFont: Bool = false @AppStorage("SettingsEditorFontSize") private var editorFontSize: Double = 14 @AppStorage("SettingsLineHeight") private var lineHeight: Double = 1.0 @AppStorage("SettingsAppearance") private var appearance: String = "system" @AppStorage("EnableTranslucentWindow") private var translucentWindow: Bool = false + @AppStorage("SettingsReopenLastSession") private var reopenLastSession: Bool = true + @AppStorage("SettingsOpenWithBlankDocument") private var openWithBlankDocument: Bool = true + @AppStorage("SettingsDefaultNewFileLanguage") private var defaultNewFileLanguage: String = "plain" + @AppStorage("SettingsConfirmCloseDirtyTab") private var confirmCloseDirtyTab: Bool = true + @AppStorage("SettingsConfirmClearEditor") private var confirmClearEditor: Bool = true @AppStorage("SettingsShowLineNumbers") private var showLineNumbers: Bool = true @AppStorage("SettingsHighlightCurrentLine") private var highlightCurrentLine: Bool = false + @AppStorage("SettingsHighlightMatchingBrackets") private var highlightMatchingBrackets: Bool = false + @AppStorage("SettingsShowScopeGuides") private var showScopeGuides: Bool = false + @AppStorage("SettingsHighlightScopeBackground") private var highlightScopeBackground: Bool = false @AppStorage("SettingsLineWrapEnabled") private var lineWrapEnabled: Bool = false @AppStorage("SettingsIndentStyle") private var indentStyle: String = "spaces" @AppStorage("SettingsIndentWidth") private var indentWidth: Int = 4 @@ -28,6 +37,7 @@ struct NeonSettingsView: View { @AppStorage("SettingsCompletionEnabled") private var completionEnabled: Bool = false @AppStorage("SettingsCompletionFromDocument") private var completionFromDocument: Bool = false @AppStorage("SettingsCompletionFromSyntax") private var completionFromSyntax: Bool = false + @AppStorage("SelectedAIModel") private var selectedAIModelRaw: String = AIModel.appleIntelligence.rawValue @AppStorage("SettingsActiveTab") private var settingsActiveTab: String = "general" @AppStorage("SettingsTemplateLanguage") private var settingsTemplateLanguage: String = "swift" #if os(macOS) @@ -39,6 +49,7 @@ struct NeonSettingsView: View { @State private var geminiAPIToken: String = SecureTokenStore.token(for: .gemini) @State private var anthropicAPIToken: String = SecureTokenStore.token(for: .anthropic) @State private var showSupportPurchaseDialog: Bool = false + @State private var availableEditorFonts: [String] = [] private let privacyPolicyURL = URL(string: "https://github.com/h3pdesign/Neon-Vision-Editor/blob/main/PRIVACY.md") @AppStorage("SettingsThemeName") private var selectedTheme: String = "Neon Glow" @@ -121,20 +132,43 @@ struct NeonSettingsView: View { #if os(macOS) .frame(minWidth: 860, minHeight: 620) #endif + .preferredColorScheme(preferredColorSchemeOverride) .onAppear { - if settingsActiveTab == "code" { - settingsActiveTab = "editor" - } + settingsActiveTab = "general" + refreshAvailableEditorFonts() if supportPurchaseManager.supportProduct == nil { Task { await supportPurchaseManager.refreshStoreState() } } #if os(macOS) fontPicker.onChange = { selected in + useSystemFont = false editorFontName = selected.fontName editorFontSize = Double(selected.pointSize) } + applyAppearanceImmediately() #endif } + .onChange(of: appearance) { _, _ in +#if os(macOS) + applyAppearanceImmediately() +#endif + } + .onChange(of: showScopeGuides) { _, enabled in + if enabled && lineWrapEnabled { + lineWrapEnabled = false + } + } + .onChange(of: highlightScopeBackground) { _, enabled in + if enabled && lineWrapEnabled { + lineWrapEnabled = false + } + } + .onChange(of: lineWrapEnabled) { _, enabled in + if enabled { + showScopeGuides = false + highlightScopeBackground = false + } + } .confirmationDialog("Support Neon Vision Editor", isPresented: $showSupportPurchaseDialog, titleVisibility: .visible) { Button("Support \(supportPurchaseManager.supportPriceLabel)") { Task { await supportPurchaseManager.purchaseSupport() } @@ -159,6 +193,36 @@ struct NeonSettingsView: View { } } + private var preferredColorSchemeOverride: ColorScheme? { + switch appearance { + case "light": + return .light + case "dark": + return .dark + default: + return nil + } + } + +#if os(macOS) + private func applyAppearanceImmediately() { + let target: NSAppearance? + switch appearance { + case "light": + target = NSAppearance(named: .aqua) + case "dark": + target = NSAppearance(named: .darkAqua) + default: + target = nil + } + NSApp.appearance = target + for window in NSApp.windows { + window.appearance = target + window.displayIfNeeded() + } + } +#endif + private var generalTab: some View { settingsContainer { GroupBox("Window") { @@ -197,29 +261,63 @@ struct NeonSettingsView: View { GroupBox("Editor Font") { VStack(alignment: .leading, spacing: 12) { + Toggle("Use System Font", isOn: $useSystemFont) + .frame(maxWidth: .infinity, alignment: .leading) + HStack(alignment: .center, spacing: 12) { - Text("Font Name") + Text("Font") .frame(width: isCompactSettingsLayout ? nil : 140, alignment: .leading) - TextField("Font Name", text: $editorFontName) - .textFieldStyle(.plain) - .padding(.vertical, 6) - .padding(.horizontal, 8) - .background(inputFieldBackground) - .overlay( - RoundedRectangle(cornerRadius: 6) - .stroke(Color.secondary.opacity(0.35), lineWidth: 1) - ) - .cornerRadius(6) - .frame(maxWidth: isCompactSettingsLayout ? .infinity : 240) + Picker("", selection: selectedFontBinding) { + Text("System").tag(systemFontSentinel) + ForEach(availableEditorFonts, id: \.self) { fontName in + Text(fontName).tag(fontName) + } + } + .pickerStyle(.menu) + .padding(.vertical, 6) + .padding(.horizontal, 8) + .background(inputFieldBackground) + .overlay( + RoundedRectangle(cornerRadius: 6) + .stroke(Color.secondary.opacity(0.35), lineWidth: 1) + ) + .cornerRadius(6) + .frame(maxWidth: isCompactSettingsLayout ? .infinity : 240, alignment: .leading) + .onChange(of: selectedFontValue) { _, _ in + useSystemFont = (selectedFontValue == systemFontSentinel) + if !useSystemFont && !selectedFontValue.isEmpty { + editorFontName = selectedFontValue + } + } + .onChange(of: useSystemFont) { _, isSystem in + if isSystem { + selectedFontValue = systemFontSentinel + } else if !editorFontName.isEmpty { + selectedFontValue = editorFontName + } + } + .onChange(of: editorFontName) { _, newValue in + guard !useSystemFont else { return } + if !newValue.isEmpty { + selectedFontValue = newValue + } + } #if os(macOS) Button("Choose…") { + useSystemFont = false fontPicker.open(currentName: editorFontName, size: editorFontSize) } + .disabled(useSystemFont) #endif + } + + HStack(alignment: .center, spacing: 12) { + Text("Font Size") + .frame(width: isCompactSettingsLayout ? nil : 140, alignment: .leading) Stepper(value: $editorFontSize, in: 10...28, step: 1) { Text("\(Int(editorFontSize)) pt") } - .frame(maxWidth: 120) + .frame(maxWidth: isCompactSettingsLayout ? .infinity : 220, alignment: .leading) } HStack(alignment: .center, spacing: 12) { @@ -233,9 +331,66 @@ struct NeonSettingsView: View { } .padding(12) } + + GroupBox("Startup") { + VStack(alignment: .leading, spacing: 12) { + Toggle("Open with Blank Document", isOn: $openWithBlankDocument) + Toggle("Reopen Last Session", isOn: $reopenLastSession) + .disabled(openWithBlankDocument) + HStack(alignment: .center, spacing: 12) { + Text("Default New File Language") + .frame(width: isCompactSettingsLayout ? nil : 180, alignment: .leading) + Picker("", selection: $defaultNewFileLanguage) { + ForEach(templateLanguages, id: \.self) { lang in + Text(languageLabel(for: lang)).tag(lang) + } + } + .pickerStyle(.menu) + } + } + .padding(12) + } + + GroupBox("Confirmations") { + VStack(alignment: .leading, spacing: 12) { + Toggle("Confirm Before Closing Dirty Tab", isOn: $confirmCloseDirtyTab) + Toggle("Confirm Before Clearing Editor", isOn: $confirmClearEditor) + } + .padding(12) + } } } + private let systemFontSentinel = "__system__" + @State private var selectedFontValue: String = "__system__" + + private var selectedFontBinding: Binding { + Binding( + get: { + if useSystemFont { return systemFontSentinel } + if editorFontName.isEmpty { return systemFontSentinel } + return editorFontName + }, + set: { selectedFontValue = $0 } + ) + } + + private func refreshAvailableEditorFonts() { +#if os(macOS) + let names = NSFontManager.shared.availableFonts +#else + let names = UIFont.familyNames + .sorted() + .flatMap { UIFont.fontNames(forFamilyName: $0) } +#endif + var merged = Array(Set(names)).sorted() + if !editorFontName.isEmpty && !merged.contains(editorFontName) { + merged.insert(editorFontName, at: 0) + } + availableEditorFonts = merged + selectedFontValue = useSystemFont ? systemFontSentinel : (editorFontName.isEmpty ? systemFontSentinel : editorFontName) + } + private var editorTab: some View { settingsContainer(maxWidth: 760) { GroupBox("Editor") { @@ -245,7 +400,16 @@ struct NeonSettingsView: View { .font(.headline) Toggle("Show Line Numbers", isOn: $showLineNumbers) Toggle("Highlight Current Line", isOn: $highlightCurrentLine) + Toggle("Highlight Matching Brackets", isOn: $highlightMatchingBrackets) + Toggle("Show Scope Guides (Non-Swift)", isOn: $showScopeGuides) + Toggle("Highlight Scoped Region", isOn: $highlightScopeBackground) Toggle("Line Wrap", isOn: $lineWrapEnabled) + Text("When Line Wrap is enabled, scope guides/scoped region are turned off to avoid layout conflicts.") + .font(.footnote) + .foregroundStyle(.secondary) + Text("Scope guides are intended for non-Swift languages. Swift favors matching-token highlight.") + .font(.footnote) + .foregroundStyle(.secondary) Text("Invisible character markers are disabled to avoid whitespace glyph artifacts.") .font(.footnote) .foregroundStyle(.secondary) @@ -318,7 +482,7 @@ struct NeonSettingsView: View { } TextEditor(text: templateBinding(for: settingsTemplateLanguage)) - .font(.system(.body, design: .monospaced)) + .font(.system(size: 13, weight: .regular, design: .monospaced)) .frame(minHeight: 200, maxHeight: 320) .scrollContentBackground(.hidden) .background(Color.clear) @@ -434,6 +598,26 @@ struct NeonSettingsView: View { private var aiTab: some View { settingsContainer(maxWidth: 520) { + GroupBox("AI Model") { + VStack(alignment: .leading, spacing: 12) { + Picker("Model", selection: selectedAIModelBinding) { + Text("Apple Intelligence").tag(AIModel.appleIntelligence) + Text("Grok").tag(AIModel.grok) + Text("OpenAI").tag(AIModel.openAI) + Text("Gemini").tag(AIModel.gemini) + Text("Anthropic").tag(AIModel.anthropic) + } + .pickerStyle(.menu) + + Text("Choose the default model used by editor AI actions.") + .font(.footnote) + .foregroundStyle(.secondary) + } + .padding(12) + } + .frame(maxWidth: 420) + .frame(maxWidth: .infinity, alignment: .center) + GroupBox("AI Provider API Keys") { VStack(alignment: .center, spacing: 12) { aiKeyRow(title: "Grok", placeholder: "sk-…", value: $grokAPIToken, provider: .grok) @@ -449,6 +633,13 @@ struct NeonSettingsView: View { } } + private var selectedAIModelBinding: Binding { + Binding( + get: { AIModel(rawValue: selectedAIModelRaw) ?? .appleIntelligence }, + set: { selectedAIModelRaw = $0.rawValue } + ) + } + private var supportTab: some View { settingsContainer(maxWidth: 520) { GroupBox("Support Development") { diff --git a/Neon Vision Editor/UI/PanelsAndHelpers.swift b/Neon Vision Editor/UI/PanelsAndHelpers.swift index ce0f23a..f77d632 100644 --- a/Neon Vision Editor/UI/PanelsAndHelpers.swift +++ b/Neon Vision Editor/UI/PanelsAndHelpers.swift @@ -558,6 +558,7 @@ extension Notification.Name { static let selectAIModelRequested = Notification.Name("selectAIModelRequested") static let showQuickSwitcherRequested = Notification.Name("showQuickSwitcherRequested") static let showWelcomeTourRequested = Notification.Name("showWelcomeTourRequested") + static let moveCursorToRange = Notification.Name("moveCursorToRange") static let toggleVimModeRequested = Notification.Name("toggleVimModeRequested") static let vimModeStateDidChange = Notification.Name("vimModeStateDidChange") static let droppedFileURL = Notification.Name("droppedFileURL") @@ -578,6 +579,8 @@ extension NSRange { enum EditorCommandUserInfo { static let windowNumber = "targetWindowNumber" static let inspectionMessage = "inspectionMessage" + static let rangeLocation = "rangeLocation" + static let rangeLength = "rangeLength" } #if os(macOS) diff --git a/README.md b/README.md index 12d8365..a5b5258 100644 --- a/README.md +++ b/README.md @@ -213,6 +213,14 @@ cd Neon-Vision-Editor open "Neon Vision Editor.xcodeproj" ``` +## Git hooks + +To auto-increment Xcode `CURRENT_PROJECT_VERSION` on every commit: + +```bash +scripts/install_git_hooks.sh +``` + ## Support If you want to support development: diff --git a/scripts/bump_build_number.sh b/scripts/bump_build_number.sh new file mode 100755 index 0000000..6b571d0 --- /dev/null +++ b/scripts/bump_build_number.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +set -euo pipefail + +PROJECT_FILE="${1:-Neon Vision Editor.xcodeproj/project.pbxproj}" + +if [[ ! -f "$PROJECT_FILE" ]]; then + echo "Project file not found: $PROJECT_FILE" >&2 + exit 2 +fi + +current="$(awk '/CURRENT_PROJECT_VERSION = [0-9]+;/{gsub(/[^0-9]/, "", $0); print; exit}' "$PROJECT_FILE")" +if [[ -z "${current:-}" ]]; then + echo "Could not find CURRENT_PROJECT_VERSION in $PROJECT_FILE" >&2 + exit 2 +fi + +next=$((current + 1)) + +perl -0pi -e "s/CURRENT_PROJECT_VERSION = $current;/CURRENT_PROJECT_VERSION = $next;/g" "$PROJECT_FILE" + +echo "Bumped CURRENT_PROJECT_VERSION: $current -> $next" diff --git a/scripts/install_git_hooks.sh b/scripts/install_git_hooks.sh new file mode 100755 index 0000000..dd6c349 --- /dev/null +++ b/scripts/install_git_hooks.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(git rev-parse --show-toplevel)" +cd "$repo_root" + +chmod +x .githooks/pre-commit scripts/bump_build_number.sh +git config core.hooksPath .githooks + +echo "Git hooks installed. pre-commit will auto-bump CURRENT_PROJECT_VERSION." diff --git a/scripts/release_all.sh b/scripts/release_all.sh index 32e9987..d844cd2 100755 --- a/scripts/release_all.sh +++ b/scripts/release_all.sh @@ -6,19 +6,20 @@ usage() { Run end-to-end release flow in one command. Usage: - scripts/release_all.sh [--date YYYY-MM-DD] [--notarized] + scripts/release_all.sh [--date YYYY-MM-DD] [--notarized] [--self-hosted] Examples: scripts/release_all.sh v0.4.6 scripts/release_all.sh 0.4.6 --date 2026-02-12 scripts/release_all.sh v0.4.6 --notarized + scripts/release_all.sh v0.4.6 --notarized --self-hosted What it does: 1) Prepare README/CHANGELOG docs 2) Commit docs changes 3) Create annotated tag 4) Push main and tag to origin - 5) (optional) Trigger self-hosted notarized release workflow + 5) (optional) Trigger notarized release workflow (GitHub-hosted by default) EOF } @@ -37,6 +38,7 @@ fi DATE_ARG=() TRIGGER_NOTARIZED=0 +USE_SELF_HOSTED=0 while [[ "${1:-}" != "" ]]; do case "$1" in @@ -51,6 +53,9 @@ while [[ "${1:-}" != "" ]]; do --notarized) TRIGGER_NOTARIZED=1 ;; + --self-hosted) + USE_SELF_HOSTED=1 + ;; *) echo "Unknown argument: $1" >&2 usage @@ -77,12 +82,18 @@ echo "Tag push completed. Unsigned release workflow should start automatically." if [[ "$TRIGGER_NOTARIZED" -eq 1 ]]; then echo "Triggering notarized workflow for ${TAG}..." - gh workflow run release-notarized-selfhosted.yml -f tag="$TAG" - echo "Triggered: release-notarized-selfhosted.yml (tag=${TAG})" + if [[ "$USE_SELF_HOSTED" -eq 1 ]]; then + gh workflow run release-notarized-selfhosted.yml -f tag="$TAG" -f use_self_hosted=true + echo "Triggered: release-notarized-selfhosted.yml (tag=${TAG}, use_self_hosted=true)" + else + gh workflow run release-notarized.yml -f tag="$TAG" + echo "Triggered: release-notarized.yml (tag=${TAG})" + fi fi echo echo "Done." echo "Check runs:" echo " gh run list --workflow release.yml --limit 5" +echo " gh run list --workflow release-notarized.yml --limit 5" echo " gh run list --workflow release-notarized-selfhosted.yml --limit 5"