diff --git a/Neon Vision Editor.xcodeproj/project.pbxproj b/Neon Vision Editor.xcodeproj/project.pbxproj index 9f32292..5017fec 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 = 519; + CURRENT_PROJECT_VERSION = 520; 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 = 519; + CURRENT_PROJECT_VERSION = 520; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = CS727NF72U; ENABLE_APP_SANDBOX = YES; diff --git a/Neon Vision Editor/UI/CodeSnapshotComposerView.swift b/Neon Vision Editor/UI/CodeSnapshotComposerView.swift new file mode 100644 index 0000000..4ec97a7 --- /dev/null +++ b/Neon Vision Editor/UI/CodeSnapshotComposerView.swift @@ -0,0 +1,427 @@ +import SwiftUI +import UniformTypeIdentifiers + +#if os(macOS) +import AppKit +private typealias PlatformColor = NSColor +#else +import UIKit +private typealias PlatformColor = UIColor +#endif + +struct CodeSnapshotPayload: Identifiable, Equatable { + let id = UUID() + let title: String + let language: String + let text: String +} + +enum CodeSnapshotAppearance: String, CaseIterable, Identifiable { + case dark + case light + + var id: String { rawValue } + + var title: String { + switch self { + case .dark: return "Dark" + case .light: return "Light" + } + } + + var colorScheme: ColorScheme { + switch self { + case .dark: return .dark + case .light: return .light + } + } +} + +enum CodeSnapshotBackgroundPreset: String, CaseIterable, Identifiable { + case aurora + case sunrise + case ocean + + var id: String { rawValue } + + var title: String { + switch self { + case .aurora: return "Aurora" + case .sunrise: return "Sunrise" + case .ocean: return "Ocean" + } + } + + var gradient: LinearGradient { + switch self { + case .aurora: + return LinearGradient( + colors: [Color(red: 0.12, green: 0.20, blue: 0.52), Color(red: 0.00, green: 0.67, blue: 0.73), Color(red: 0.62, green: 0.20, blue: 0.87)], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + case .sunrise: + return LinearGradient( + colors: [Color(red: 0.98, green: 0.38, blue: 0.33), Color(red: 1.00, green: 0.64, blue: 0.28), Color(red: 0.96, green: 0.20, blue: 0.55)], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + case .ocean: + return LinearGradient( + colors: [Color(red: 0.04, green: 0.22, blue: 0.42), Color(red: 0.06, green: 0.52, blue: 0.76), Color(red: 0.16, green: 0.78, blue: 0.80)], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + } + } +} + +enum CodeSnapshotFrameStyle: String, CaseIterable, Identifiable { + case macWindow + case clean + case glow + + var id: String { rawValue } + + var title: String { + switch self { + case .macWindow: return "Window" + case .clean: return "Clean" + case .glow: return "Glow" + } + } +} + +struct CodeSnapshotStyle: Equatable { + var appearance: CodeSnapshotAppearance = .dark + var backgroundPreset: CodeSnapshotBackgroundPreset = .sunrise + var frameStyle: CodeSnapshotFrameStyle = .macWindow + var showLineNumbers: Bool = true + var padding: CGFloat = 26 +} + +struct PNGSnapshotDocument: FileDocument { + static var readableContentTypes: [UTType] { [.png] } + + var data: Data + + init(data: Data = Data()) { + self.data = data + } + + init(configuration: ReadConfiguration) throws { + self.data = configuration.file.regularFileContents ?? Data() + } + + func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper { + FileWrapper(regularFileWithContents: data) + } +} + +private enum CodeSnapshotRenderer { + static func attributedLines( + text: String, + language: String, + appearance: CodeSnapshotAppearance + ) -> [AttributedString] { + let theme = currentEditorTheme(colorScheme: appearance.colorScheme) + let colors = SyntaxColors( + keyword: theme.syntax.keyword, + string: theme.syntax.string, + number: theme.syntax.number, + comment: theme.syntax.comment, + attribute: theme.syntax.attribute, + variable: theme.syntax.variable, + def: theme.syntax.def, + property: theme.syntax.property, + meta: theme.syntax.meta, + tag: theme.syntax.tag, + atom: theme.syntax.atom, + builtin: theme.syntax.builtin, + type: theme.syntax.type + ) + let nsText = text as NSString + let fullRange = NSRange(location: 0, length: nsText.length) + let attributed = NSMutableAttributedString( + string: text, + attributes: [ + .foregroundColor: platformColor(theme.text), + .font: snapshotFont() + ] + ) + + let patterns = getSyntaxPatterns(for: language, colors: colors, profile: .full) + for (pattern, color) in patterns { + guard let regex = cachedSyntaxRegex(pattern: pattern, options: [.anchorsMatchLines]) else { continue } + let attributes: [NSAttributedString.Key: Any] = [.foregroundColor: platformColor(color)] + regex.enumerateMatches(in: text, options: [], range: fullRange) { match, _, _ in + guard let match else { return } + attributed.addAttributes(attributes, range: match.range) + } + } + + let nsAttributed = attributed + let lines = nsText.components(separatedBy: "\n") + var cursor = 0 + var output: [AttributedString] = [] + for (index, line) in lines.enumerated() { + let lineLength = (line as NSString).length + let range = NSRange(location: min(cursor, nsAttributed.length), length: min(lineLength, max(0, nsAttributed.length - cursor))) + let attributedLine = range.length > 0 + ? nsAttributed.attributedSubstring(from: range) + : NSAttributedString(string: "") + output.append(AttributedString(attributedLine)) + cursor += lineLength + if index < lines.count - 1 { + cursor += 1 + } + } + + return output + } + + @MainActor + static func pngData( + payload: CodeSnapshotPayload, + style: CodeSnapshotStyle + ) -> Data? { + let card = CodeSnapshotCardView(payload: payload, style: style) + .frame(width: 940) + let renderer = ImageRenderer(content: card) + renderer.scale = 2 +#if os(macOS) + guard let image = renderer.nsImage, + let tiff = image.tiffRepresentation, + let bitmap = NSBitmapImageRep(data: tiff) else { return nil } + return bitmap.representation(using: .png, properties: [:]) +#else + return renderer.uiImage?.pngData() +#endif + } + + private static func snapshotFont() -> PlatformFont { +#if os(macOS) + return .monospacedSystemFont(ofSize: 15, weight: .regular) +#else + return .monospacedSystemFont(ofSize: 15, weight: .regular) +#endif + } + + private static func platformColor(_ color: Color) -> PlatformColor { +#if os(macOS) + return PlatformColor(color) +#else + return PlatformColor(color) +#endif + } +} + +#if os(macOS) +private typealias PlatformFont = NSFont +#else +private typealias PlatformFont = UIFont +#endif + +private struct CodeSnapshotCardView: View { + let payload: CodeSnapshotPayload + let style: CodeSnapshotStyle + + private var lines: [AttributedString] { + CodeSnapshotRenderer.attributedLines( + text: payload.text, + language: payload.language, + appearance: style.appearance + ) + } + + private var surfaceBackground: Color { + switch style.appearance { + case .dark: + return Color(red: 0.09, green: 0.10, blue: 0.14) + case .light: + return Color.white.opacity(0.97) + } + } + + private var surfaceBorder: Color { + switch style.appearance { + case .dark: + return Color.white.opacity(0.08) + case .light: + return Color.black.opacity(0.08) + } + } + + private var bodyTextColor: Color { + switch style.appearance { + case .dark: + return Color.white.opacity(0.92) + case .light: + return Color.black.opacity(0.78) + } + } + + var body: some View { + ZStack { + style.backgroundPreset.gradient + VStack(alignment: .leading, spacing: 0) { + if style.frameStyle == .macWindow { + HStack(spacing: 8) { + Circle().fill(Color(red: 1.00, green: 0.37, blue: 0.33)).frame(width: 12, height: 12) + Circle().fill(Color(red: 1.00, green: 0.76, blue: 0.20)).frame(width: 12, height: 12) + Circle().fill(Color(red: 0.18, green: 0.80, blue: 0.44)).frame(width: 12, height: 12) + Spacer() + Text(payload.title) + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(bodyTextColor.opacity(0.74)) + } + .padding(.horizontal, 18) + .padding(.vertical, 14) + .background(surfaceBackground.opacity(style.appearance == .dark ? 0.96 : 0.92)) + } + + VStack(alignment: .leading, spacing: 8) { + ForEach(Array(lines.enumerated()), id: \.offset) { index, line in + HStack(alignment: .top, spacing: 14) { + if style.showLineNumbers { + Text("\(index + 1)") + .font(.system(size: 13, weight: .medium, design: .monospaced)) + .foregroundStyle(bodyTextColor.opacity(0.42)) + .frame(width: 34, alignment: .trailing) + .accessibilityHidden(true) + } + Text(line) + .font(.system(size: 15, weight: .regular, design: .monospaced)) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + } + .padding(style.padding) + .background(surfaceBackground) + } + .clipShape(RoundedRectangle(cornerRadius: 24, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 24, style: .continuous) + .stroke(surfaceBorder, lineWidth: 1) + ) + .shadow(color: style.frameStyle == .glow ? Color.white.opacity(0.24) : Color.black.opacity(0.18), radius: style.frameStyle == .glow ? 26 : 16, y: 10) + .padding(42) + } + .aspectRatio(1.25, contentMode: .fit) + } +} + +struct CodeSnapshotComposerView: View { + let payload: CodeSnapshotPayload + + @Environment(\.dismiss) private var dismiss + @State private var style = CodeSnapshotStyle() + @State private var renderedPNGData: Data? + @State private var shareURL: URL? + @State private var showExporter = false + + var body: some View { + NavigationStack { + VStack(spacing: 20) { + snapshotControls + ScrollView([.vertical, .horizontal]) { + CodeSnapshotCardView(payload: payload, style: style) + .frame(maxWidth: 980) + .padding(.horizontal, 12) + .padding(.bottom, 20) + } + } + .padding(20) + .navigationTitle("Code Snapshot") +#if os(iOS) + .navigationBarTitleDisplayMode(.inline) +#endif + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Done") { dismiss() } + } + ToolbarItemGroup(placement: .confirmationAction) { + if let shareURL { + ShareLink(item: shareURL) { + Label("Share", systemImage: "square.and.arrow.up") + } + } + Button { + showExporter = true + } label: { + Label("Export PNG", systemImage: "photo") + } + .disabled(renderedPNGData == nil) + } + } + } + .task(id: style) { + await refreshRenderedSnapshot() + } + .fileExporter( + isPresented: $showExporter, + document: PNGSnapshotDocument(data: renderedPNGData ?? Data()), + contentType: .png, + defaultFilename: sanitizedFileName + ) { _ in } + } + + private var snapshotControls: some View { + VStack(alignment: .leading, spacing: 14) { + HStack(spacing: 16) { + Picker("Appearance", selection: $style.appearance) { + ForEach(CodeSnapshotAppearance.allCases) { appearance in + Text(appearance.title).tag(appearance) + } + } + Picker("Background", selection: $style.backgroundPreset) { + ForEach(CodeSnapshotBackgroundPreset.allCases) { preset in + Text(preset.title).tag(preset) + } + } + Picker("Frame", selection: $style.frameStyle) { + ForEach(CodeSnapshotFrameStyle.allCases) { frame in + Text(frame.title).tag(frame) + } + } + Toggle("Line Numbers", isOn: $style.showLineNumbers) + } + + HStack(spacing: 12) { + Text("Padding") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(.secondary) + Slider(value: $style.padding, in: 18...40, step: 2) + Text("\(Int(style.padding))") + .font(.system(size: 12, weight: .medium, design: .monospaced)) + .foregroundStyle(.secondary) + .frame(width: 36, alignment: .trailing) + } + } + .padding(16) + .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 18, style: .continuous)) + } + + @MainActor + private func refreshRenderedSnapshot() async { + let data = CodeSnapshotRenderer.pngData(payload: payload, style: style) + renderedPNGData = data + guard let data else { + shareURL = nil + return + } + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("\(sanitizedFileName).png") + do { + try data.write(to: url, options: .atomic) + shareURL = url + } catch { + shareURL = nil + } + } + + private var sanitizedFileName: String { + let base = payload.title.replacingOccurrences(of: " ", with: "-") + return base.isEmpty ? "code-snapshot" : base + } +} diff --git a/Neon Vision Editor/UI/ContentView+Toolbar.swift b/Neon Vision Editor/UI/ContentView+Toolbar.swift index 8b8e11a..9290351 100644 --- a/Neon Vision Editor/UI/ContentView+Toolbar.swift +++ b/Neon Vision Editor/UI/ContentView+Toolbar.swift @@ -130,6 +130,7 @@ extension ContentView { case newTab case closeAllTabs case saveFile + case codeSnapshot case markdownPreview case fontDecrease case fontIncrease @@ -155,6 +156,7 @@ extension ContentView { .newTab, .closeAllTabs, .saveFile, + .codeSnapshot, .markdownPreview, .fontDecrease, .fontIncrease, @@ -514,6 +516,16 @@ extension ContentView { .accessibilityLabel("Translucent Window Background") } + @ViewBuilder + private var codeSnapshotControl: some View { + Button(action: { presentCodeSnapshotComposer() }) { + Image(systemName: "camera.viewfinder") + } + .disabled(!canCreateCodeSnapshot) + .help("Create Code Snapshot from Selection") + .accessibilityLabel("Create Code Snapshot") + } + @ViewBuilder private func iPadToolbarActionControl(_ action: IPadToolbarAction) -> some View { switch action { @@ -522,6 +534,7 @@ extension ContentView { case .newTab: newTabControl case .closeAllTabs: closeAllTabsControl case .saveFile: saveFileControl + case .codeSnapshot: codeSnapshotControl case .markdownPreview: markdownPreviewControl case .fontDecrease: fontDecreaseControl case .fontIncrease: fontIncreaseControl @@ -570,6 +583,11 @@ extension ContentView { Label("Save File", systemImage: "square.and.arrow.down") } .disabled(viewModel.selectedTab == nil) + case .codeSnapshot: + Button(action: { presentCodeSnapshotComposer() }) { + Label("Create Code Snapshot", systemImage: "camera.viewfinder") + } + .disabled(!canCreateCodeSnapshot) case .markdownPreview: Button(action: { toggleMarkdownPreviewFromToolbar() }) { Label( @@ -713,6 +731,11 @@ extension ContentView { .disabled(viewModel.selectedTab == nil) .keyboardShortcut("s", modifiers: .command) + Button(action: { presentCodeSnapshotComposer() }) { + Label("Create Code Snapshot", systemImage: "camera.viewfinder") + } + .disabled(!canCreateCodeSnapshot) + Button(action: { toggleMarkdownPreviewFromToolbar() }) { Label( "Markdown Preview", diff --git a/Neon Vision Editor/UI/ContentView.swift b/Neon Vision Editor/UI/ContentView.swift index ed1d6e3..563bcbf 100644 --- a/Neon Vision Editor/UI/ContentView.swift +++ b/Neon Vision Editor/UI/ContentView.swift @@ -291,6 +291,8 @@ struct ContentView: View { @State var quickSwitcherProjectFileURLs: [URL] = [] @State private var quickSwitcherRecentItemIDs: [String] = [] @State private var recentFilesRefreshToken: UUID = UUID() + @State private var currentSelectionSnapshotText: String = "" + @State private var codeSnapshotPayload: CodeSnapshotPayload? @State var showFindInFiles: Bool = false @State var findInFilesQuery: String = "" @State var findInFilesCaseSensitive: Bool = false @@ -1410,6 +1412,10 @@ struct ContentView: View { scheduleWordCountRefresh(for: liveText) #endif } + .onReceive(NotificationCenter.default.publisher(for: .editorSelectionDidChange)) { notif in + let selection = (notif.object as? String) ?? "" + currentSelectionSnapshotText = selection + } .onReceive(NotificationCenter.default.publisher(for: .pastedText)) { notif in handlePastedTextNotification(notif) } @@ -2329,6 +2335,9 @@ struct ContentView: View { onTogglePin: { contentView.toggleQuickSwitcherPin($0) } ) } + .sheet(item: contentView.$codeSnapshotPayload) { payload in + CodeSnapshotComposerView(payload: payload) + } .sheet(isPresented: contentView.$showFindInFiles) { FindInFilesPanel( query: contentView.$findInFilesQuery, @@ -5094,6 +5103,25 @@ struct ContentView: View { recentFilesRefreshToken = UUID() } + var canCreateCodeSnapshot: Bool { + !normalizedCodeSnapshotSelection().isEmpty + } + + func presentCodeSnapshotComposer() { + let selection = normalizedCodeSnapshotSelection() + guard !selection.isEmpty else { return } + let title = viewModel.selectedTab?.name ?? "Code Snapshot" + codeSnapshotPayload = CodeSnapshotPayload( + title: title, + language: currentLanguage, + text: selection + ) + } + + private func normalizedCodeSnapshotSelection() -> String { + currentSelectionSnapshotText.trimmingCharacters(in: .whitespacesAndNewlines) + } + private func performQuickSwitcherCommand(_ commandID: String) { switch commandID { case "cmd:new_tab": diff --git a/Neon Vision Editor/UI/EditorTextView.swift b/Neon Vision Editor/UI/EditorTextView.swift index 5515ee1..2572d13 100644 --- a/Neon Vision Editor/UI/EditorTextView.swift +++ b/Neon Vision Editor/UI/EditorTextView.swift @@ -328,6 +328,7 @@ private enum EmmetExpander { ///MARK: - Paste Notifications extension Notification.Name { static let pastedFileURL = Notification.Name("pastedFileURL") + static let editorSelectionDidChange = Notification.Name("editorSelectionDidChange") } ///MARK: - Scope Match Models @@ -3131,10 +3132,23 @@ struct CustomTextEditor: NSViewRepresentable { eventType == .leftMouseDown || eventType == .leftMouseDragged || eventType == .leftMouseUp { noteRecentInteraction(source: "selection") } + publishSelectionSnapshot(from: tv.string as NSString, selectedRange: tv.selectedRange()) } updateCaretStatusAndHighlight(triggerHighlight: !parent.isLineWrapEnabled) } + private func publishSelectionSnapshot(from text: NSString, selectedRange: NSRange) { + guard selectedRange.location != NSNotFound, + selectedRange.length > 0, + NSMaxRange(selectedRange) <= text.length else { + NotificationCenter.default.post(name: .editorSelectionDidChange, object: "") + return + } + let cappedLength = min(selectedRange.length, 20_000) + let snippet = text.substring(with: NSRange(location: selectedRange.location, length: cappedLength)) + NotificationCenter.default.post(name: .editorSelectionDidChange, object: snippet) + } + // Compute (line, column), broadcast, and highlight the current line. private func updateCaretStatusAndHighlight(triggerHighlight: Bool = true) { guard let tv = textView else { return } @@ -4366,11 +4380,25 @@ struct CustomTextEditor: UIViewRepresentable { func textViewDidChangeSelection(_ textView: UITextView) { guard !isApplyingHighlight else { return } + let nsText = (textView.text ?? "") as NSString + publishSelectionSnapshot(from: nsText, selectedRange: textView.selectedRange) let nsLength = (textView.text as NSString?)?.length ?? 0 let immediateHighlight = nsLength < 200_000 scheduleHighlightIfNeeded(currentText: textView.text, immediate: immediateHighlight) } + private func publishSelectionSnapshot(from text: NSString, selectedRange: NSRange) { + guard selectedRange.location != NSNotFound, + selectedRange.length > 0, + NSMaxRange(selectedRange) <= text.length else { + NotificationCenter.default.post(name: .editorSelectionDidChange, object: "") + return + } + let cappedLength = min(selectedRange.length, 20_000) + let snippet = text.substring(with: NSRange(location: selectedRange.location, length: cappedLength)) + NotificationCenter.default.post(name: .editorSelectionDidChange, object: snippet) + } + func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool { EditorPerformanceMonitor.shared.markFirstKeystroke() if text == "\t" {