From 7642b766d78be22b6084fbad899cc5fe094298ed Mon Sep 17 00:00:00 2001 From: Siim Raud Date: Sat, 28 Feb 2026 18:10:37 +0200 Subject: [PATCH 1/2] feat: replace editor right pane with SwiftUI content area (#235) Replace the XIB-bound right pane (HostsTextView + NSPredicateEditor) with SwiftUI views while keeping the Editor.xib window shell intact. New files: - HostsTextViewRepresentable: NSViewRepresentable wrapping HostsTextView with bidirectional content sync and syntax highlighting preference - CombinedHostsPickerView: SwiftUI toggle list replacing NSPredicateEditor for selecting which hosts files to combine - ContentView: Composes picker (for CombinedHosts) + text editor - ContentInstaller: @objc bridge installing SwiftUI into XIB split view Changes: - HostsTextView: Add createForProgrammaticUse factory method, extract setupColors from initWithCoder for reuse - EditorController: Call ContentInstaller after SidebarInstaller - Bridging header: Add HostsTextView.h import - Xcode project: Add 4 new Swift source files The old controllers (HostsTextController, CombinedHostsPredicateController, CombinedHostsPredicateEditorRowTemplate) remain XIB-instantiated but their views are detached from the hierarchy. Deferred to PR 3 for deletion. This is PR 2 of 3 in the Editor.xib modernization plan. --- Gas Mask.xcodeproj/project.pbxproj | 16 ++++ Source/EditorController.m | 1 + Source/HostsTextView.h | 2 + Source/HostsTextView.m | 46 +++++++---- Source/Swift/CombinedHostsPickerView.swift | 78 +++++++++++++++++++ Source/Swift/ContentInstaller.swift | 16 ++++ Source/Swift/ContentView.swift | 15 ++++ Source/Swift/GasMask-Bridging-Header.h | 1 + Source/Swift/HostsTextViewRepresentable.swift | 60 ++++++++++++++ 9 files changed, 222 insertions(+), 13 deletions(-) create mode 100644 Source/Swift/CombinedHostsPickerView.swift create mode 100644 Source/Swift/ContentInstaller.swift create mode 100644 Source/Swift/ContentView.swift create mode 100644 Source/Swift/HostsTextViewRepresentable.swift diff --git a/Gas Mask.xcodeproj/project.pbxproj b/Gas Mask.xcodeproj/project.pbxproj index a6ea577..cc98811 100644 --- a/Gas Mask.xcodeproj/project.pbxproj +++ b/Gas Mask.xcodeproj/project.pbxproj @@ -141,6 +141,10 @@ AA000016000000000000AAAA /* SidebarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000015000000000000AAAA /* SidebarView.swift */; }; AA000018000000000000AAAA /* SidebarInstaller.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000017000000000000AAAA /* SidebarInstaller.swift */; }; AA00001A000000000000AAAA /* HostsDataStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000019000000000000AAAA /* HostsDataStoreTests.swift */; }; + AA00001C000000000000AAAA /* HostsTextViewRepresentable.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA00001B000000000000AAAA /* HostsTextViewRepresentable.swift */; }; + AA00001E000000000000AAAA /* CombinedHostsPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA00001D000000000000AAAA /* CombinedHostsPickerView.swift */; }; + AA000020000000000000AAAA /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA00001F000000000000AAAA /* ContentView.swift */; }; + AA000022000000000000AAAA /* ContentInstaller.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000021000000000000AAAA /* ContentInstaller.swift */; }; BB000002000000000000BBBB /* RemoteIntervalMapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB000001000000000000BBBB /* RemoteIntervalMapper.swift */; }; BB000004000000000000BBBB /* ShortcutRecorderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB000003000000000000BBBB /* ShortcutRecorderView.swift */; }; CC000002000000000000CC01 /* GlobalShortcuts.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC000001000000000000CC01 /* GlobalShortcuts.swift */; }; @@ -339,6 +343,10 @@ AA000015000000000000AAAA /* SidebarView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = SidebarView.swift; path = "Source/Swift/SidebarView.swift"; sourceTree = ""; }; AA000017000000000000AAAA /* SidebarInstaller.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = SidebarInstaller.swift; path = "Source/Swift/SidebarInstaller.swift"; sourceTree = ""; }; AA000019000000000000AAAA /* HostsDataStoreTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HostsDataStoreTests.swift; sourceTree = ""; }; + AA00001B000000000000AAAA /* HostsTextViewRepresentable.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = HostsTextViewRepresentable.swift; path = "Source/Swift/HostsTextViewRepresentable.swift"; sourceTree = ""; }; + AA00001D000000000000AAAA /* CombinedHostsPickerView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = CombinedHostsPickerView.swift; path = "Source/Swift/CombinedHostsPickerView.swift"; sourceTree = ""; }; + AA00001F000000000000AAAA /* ContentView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = ContentView.swift; path = "Source/Swift/ContentView.swift"; sourceTree = ""; }; + AA000021000000000000AAAA /* ContentInstaller.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = ContentInstaller.swift; path = "Source/Swift/ContentInstaller.swift"; sourceTree = ""; }; 35A183A71A0ACF37002D6289 /* menuIcon@2x.tiff */ = {isa = PBXFileReference; lastKnownFileType = image.tiff; name = "menuIcon@2x.tiff"; path = "Resources/Images/menuIcon@2x.tiff"; sourceTree = ""; }; BB000001000000000000BBBB /* RemoteIntervalMapper.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = RemoteIntervalMapper.swift; path = "Source/Swift/RemoteIntervalMapper.swift"; sourceTree = ""; }; BB000003000000000000BBBB /* ShortcutRecorderView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = ShortcutRecorderView.swift; path = "Source/Swift/ShortcutRecorderView.swift"; sourceTree = ""; }; @@ -782,6 +790,10 @@ AA000013000000000000AAAA /* HostsRowView.swift */, AA000015000000000000AAAA /* SidebarView.swift */, AA000017000000000000AAAA /* SidebarInstaller.swift */, + AA00001B000000000000AAAA /* HostsTextViewRepresentable.swift */, + AA00001D000000000000AAAA /* CombinedHostsPickerView.swift */, + AA00001F000000000000AAAA /* ContentView.swift */, + AA000021000000000000AAAA /* ContentInstaller.swift */, BB000020000000000000BBBB /* Preferences */, ); name = Swift; @@ -1110,6 +1122,10 @@ AA000014000000000000AAAA /* HostsRowView.swift in Sources */, AA000016000000000000AAAA /* SidebarView.swift in Sources */, AA000018000000000000AAAA /* SidebarInstaller.swift in Sources */, + AA00001C000000000000AAAA /* HostsTextViewRepresentable.swift in Sources */, + AA00001E000000000000AAAA /* CombinedHostsPickerView.swift in Sources */, + AA000020000000000000AAAA /* ContentView.swift in Sources */, + AA000022000000000000AAAA /* ContentInstaller.swift in Sources */, BB000002000000000000BBBB /* RemoteIntervalMapper.swift in Sources */, BB000004000000000000BBBB /* ShortcutRecorderView.swift in Sources */, CC000002000000000000CC01 /* GlobalShortcuts.swift in Sources */, diff --git a/Source/EditorController.m b/Source/EditorController.m index 6c6895c..5d60de7 100644 --- a/Source/EditorController.m +++ b/Source/EditorController.m @@ -44,6 +44,7 @@ - (void)awakeFromNib } [SidebarInstaller installIn:splitView]; + [ContentInstaller installIn:splitView]; } #pragma mark - Split View Delegate diff --git a/Source/HostsTextView.h b/Source/HostsTextView.h index 8718544..0ba80eb 100644 --- a/Source/HostsTextView.h +++ b/Source/HostsTextView.h @@ -32,4 +32,6 @@ - (void)setSyntaxHighlighting:(BOOL)value; - (BOOL)syntaxHighlighting; ++ (instancetype)createForProgrammaticUse; + @end diff --git a/Source/HostsTextView.m b/Source/HostsTextView.m index 9b9ae3c..1f455f1 100644 --- a/Source/HostsTextView.m +++ b/Source/HostsTextView.m @@ -40,26 +40,46 @@ -(NSRange)selectRangeFromDoubleClick:(NSUInteger)location range:(NSRange)range; @implementation HostsTextView -- (id)initWithCoder:(NSCoder *)decoder +- (void)setupColors { - self = [super initWithCoder:decoder]; - ipv4Color = [NSColor colorWithCalibratedRed:0.27 green:0.36 blue:0.61 alpha:1]; ipv6Color = [NSColor colorWithCalibratedRed:0.27 green:0.36 blue:0.8 alpha:1]; - - if (@available(macOS 10_13, *)) { - textColor = [NSColor colorNamed:@"TextColor"]; - commentColor = [NSColor colorNamed:@"CommentColor"]; - } else { - textColor = [NSColor blackColor]; - commentColor = [NSColor grayColor]; - } - + textColor = [NSColor colorNamed:@"TextColor"]; + commentColor = [NSColor colorNamed:@"CommentColor"]; nameCharacterSet = [NSCharacterSet characterSetWithCharactersInString: @"abcdefghijklmnopqrstuvwxyz0123456789.-"]; - +} + +- (id)initWithCoder:(NSCoder *)decoder +{ + self = [super initWithCoder:decoder]; + [self setupColors]; return self; } ++ (instancetype)createForProgrammaticUse +{ + NSTextContainer *container = [[NSTextContainer alloc] initWithContainerSize:NSMakeSize(FLT_MAX, FLT_MAX)]; + [container setWidthTracksTextView:NO]; + + NSLayoutManager *layoutManager = [[NSLayoutManager alloc] init]; + [layoutManager addTextContainer:container]; + + NSTextStorage *textStorage = [[NSTextStorage alloc] init]; + [textStorage addLayoutManager:layoutManager]; + + HostsTextView *textView = [[HostsTextView alloc] initWithFrame:NSZeroRect textContainer:container]; + [textView setupColors]; + + [textStorage setDelegate:textView]; + [textView setHorizontallyResizable:YES]; + [textView setAutoresizingMask:(NSViewWidthSizable | NSViewHeightSizable)]; + [textView setFont:[NSFont monospacedSystemFontOfSize:12 weight:NSFontWeightMedium]]; + [textView setBackgroundColor:[NSColor colorNamed:@"BackgroundColor"]]; + [textView setTextColor:[NSColor colorNamed:@"TextColor"]]; + + return textView; +} + - (void)awakeFromNib { [[super textStorage] setDelegate:self]; diff --git a/Source/Swift/CombinedHostsPickerView.swift b/Source/Swift/CombinedHostsPickerView.swift new file mode 100644 index 0000000..1648236 --- /dev/null +++ b/Source/Swift/CombinedHostsPickerView.swift @@ -0,0 +1,78 @@ +import SwiftUI + +struct CombinedHostsPickerView: View { + @ObservedObject var store: HostsDataStore + + var body: some View { + if let combined = store.selectedHosts as? CombinedHosts { + pickerContent(for: combined) + } + } + + // MARK: - Picker Content + + private func pickerContent(for combined: CombinedHosts) -> some View { + let allFiles = allNonCombinedHosts() + let includedFiles = (combined.hostsFiles() as? [Hosts]) ?? [] + + ScrollView { + VStack(alignment: .leading, spacing: 2) { + if allFiles.isEmpty { + Text("No hosts files available to combine.") + .foregroundStyle(.secondary) + .font(.system(size: NSFont.smallSystemFontSize)) + .padding(.horizontal, 8) + } else { + ForEach(allFiles, id: \.self) { hosts in + Toggle( + hosts.name() ?? "", + isOn: toggleBinding(for: hosts, in: combined, includedFiles: includedFiles) + ) + .toggleStyle(.checkbox) + .font(.system(size: NSFont.smallSystemFontSize)) + .padding(.horizontal, 8) + .padding(.vertical, 1) + } + } + } + .padding(.vertical, 4) + } + .frame(maxHeight: 150) + } + + // MARK: - Toggle Binding + + private func toggleBinding(for hosts: Hosts, in combined: CombinedHosts, includedFiles: [Hosts]) -> Binding { + Binding( + get: { + let current = (combined.hostsFiles() as? [Hosts]) ?? [] + return current.contains { $0 === hosts } + }, + set: { isOn in + var files = (combined.hostsFiles() as? [Hosts]) ?? [] + if isOn { + if !files.contains(where: { $0 === hosts }) { + files.append(hosts) + } + } else { + files.removeAll { $0 === hosts } + } + combined.setHostsFiles(files) + store.hostsGroups = store.hostsGroups + } + ) + } + + // MARK: - Helpers + + private func allNonCombinedHosts() -> [Hosts] { + var result: [Hosts] = [] + for group in store.hostsGroups { + let children = (group.children as? [Hosts]) ?? [] + for hosts in children where !(hosts is CombinedHosts) { + result.append(hosts) + } + } + return result + } +} diff --git a/Source/Swift/ContentInstaller.swift b/Source/Swift/ContentInstaller.swift new file mode 100644 index 0000000..d4d8531 --- /dev/null +++ b/Source/Swift/ContentInstaller.swift @@ -0,0 +1,16 @@ +import SwiftUI + +@objc final class ContentInstaller: NSObject { + @objc static func install(in splitView: NSSplitView) { + guard splitView.subviews.count >= 2 else { return } + + let store = HostsDataStore.shared + let content = ContentView(store: store) + let hostingView = NSHostingView(rootView: content) + + let rightPane = splitView.subviews[1] + hostingView.frame = rightPane.frame + hostingView.autoresizingMask = [.width, .height] + splitView.replaceSubview(rightPane, with: hostingView) + } +} diff --git a/Source/Swift/ContentView.swift b/Source/Swift/ContentView.swift new file mode 100644 index 0000000..83ecfb2 --- /dev/null +++ b/Source/Swift/ContentView.swift @@ -0,0 +1,15 @@ +import SwiftUI + +struct ContentView: View { + @ObservedObject var store: HostsDataStore + + var body: some View { + VStack(spacing: 0) { + if store.selectedHosts is CombinedHosts { + CombinedHostsPickerView(store: store) + Divider() + } + HostsTextViewRepresentable(store: store) + } + } +} diff --git a/Source/Swift/GasMask-Bridging-Header.h b/Source/Swift/GasMask-Bridging-Header.h index 2912fe5..43d7be8 100644 --- a/Source/Swift/GasMask-Bridging-Header.h +++ b/Source/Swift/GasMask-Bridging-Header.h @@ -12,5 +12,6 @@ #import "LocalHostsController.h" #import "CombinedHostsController.h" #import "ListController.h" +#import "HostsTextView.h" #import "Preferences+Remote.h" #import "LoginItem.h" diff --git a/Source/Swift/HostsTextViewRepresentable.swift b/Source/Swift/HostsTextViewRepresentable.swift new file mode 100644 index 0000000..701e3f8 --- /dev/null +++ b/Source/Swift/HostsTextViewRepresentable.swift @@ -0,0 +1,60 @@ +import SwiftUI + +struct HostsTextViewRepresentable: NSViewRepresentable { + @ObservedObject var store: HostsDataStore + @AppStorage("syntaxHighlighting") private var syntaxHighlighting = true + + func makeNSView(context: Context) -> NSScrollView { + let textView = HostsTextView.createForProgrammaticUse() + textView.delegate = context.coordinator + textView.setSyntaxHighlighting(syntaxHighlighting) + + let scrollView = NSScrollView() + scrollView.documentView = textView + scrollView.hasVerticalScroller = true + scrollView.hasHorizontalScroller = true + scrollView.drawsBackground = false + + context.coordinator.textView = textView + return scrollView + } + + func updateNSView(_ scrollView: NSScrollView, context: Context) { + guard let textView = context.coordinator.textView else { return } + + let contents = store.selectedHosts?.contents() ?? "" + if textView.string != contents { + context.coordinator.isUpdatingFromModel = true + textView.string = contents + context.coordinator.isUpdatingFromModel = false + } + + textView.isEditable = store.selectedHosts?.editable ?? false + + if textView.syntaxHighlighting() != syntaxHighlighting { + textView.setSyntaxHighlighting(syntaxHighlighting) + } + } + + func makeCoordinator() -> Coordinator { + Coordinator(store: store) + } + + // MARK: - Coordinator + + @MainActor final class Coordinator: NSObject, NSTextViewDelegate { + let store: HostsDataStore + weak var textView: HostsTextView? + var isUpdatingFromModel = false + + init(store: HostsDataStore) { + self.store = store + } + + func textDidChange(_ notification: Notification) { + guard !isUpdatingFromModel, + let textView = notification.object as? HostsTextView else { return } + store.selectedHosts?.setContents(textView.string) + } + } +} From eb67b278f34763d050cf76f0d98461888ccc2ad3 Mon Sep 17 00:00:00 2001 From: Siim Raud Date: Sat, 28 Feb 2026 19:30:23 +0200 Subject: [PATCH 2/2] fix: resolve compile errors in HostsTextViewRepresentable and CombinedHostsPickerView - Unwrap optional return from HostsTextView.createForProgrammaticUse (ObjC factory methods return nullable in Swift) - Add @ViewBuilder to pickerContent(for:) to fix opaque return type inference with local let bindings --- Source/Swift/CombinedHostsPickerView.swift | 1 + Source/Swift/HostsTextViewRepresentable.swift | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/Source/Swift/CombinedHostsPickerView.swift b/Source/Swift/CombinedHostsPickerView.swift index 1648236..2395177 100644 --- a/Source/Swift/CombinedHostsPickerView.swift +++ b/Source/Swift/CombinedHostsPickerView.swift @@ -11,6 +11,7 @@ struct CombinedHostsPickerView: View { // MARK: - Picker Content + @ViewBuilder private func pickerContent(for combined: CombinedHosts) -> some View { let allFiles = allNonCombinedHosts() let includedFiles = (combined.hostsFiles() as? [Hosts]) ?? [] diff --git a/Source/Swift/HostsTextViewRepresentable.swift b/Source/Swift/HostsTextViewRepresentable.swift index 701e3f8..06d6282 100644 --- a/Source/Swift/HostsTextViewRepresentable.swift +++ b/Source/Swift/HostsTextViewRepresentable.swift @@ -5,7 +5,9 @@ struct HostsTextViewRepresentable: NSViewRepresentable { @AppStorage("syntaxHighlighting") private var syntaxHighlighting = true func makeNSView(context: Context) -> NSScrollView { - let textView = HostsTextView.createForProgrammaticUse() + guard let textView = HostsTextView.createForProgrammaticUse() else { + return NSScrollView() + } textView.delegate = context.coordinator textView.setSyntaxHighlighting(syntaxHighlighting)