├── .editorconfig ├── .gitattributes ├── .gitignore ├── .swiftlint.yml ├── .swiftpm └── xcode │ ├── package.xcworkspace │ └── contents.xcworkspacedata │ └── xcshareddata │ └── xcschemes │ └── Preferences.xcscheme ├── Example ├── AccountsScreen.swift ├── AdvancedPreferenceViewController.swift ├── AdvancedSettingsViewController.xib ├── AppDelegate.swift ├── Assets.xcassets │ ├── AppIcon.appiconset │ │ └── Contents.json │ └── Contents.json ├── Base.lproj │ └── MainMenu.xib ├── GeneralSettingsViewController.swift ├── GeneralSettingsViewController.xib ├── Info.plist ├── Settings.xcodeproj │ ├── Preferences_Info.plist │ ├── project.pbxproj │ └── xcshareddata │ │ └── xcschemes │ │ ├── Example.xcscheme │ │ ├── Preferences-Package.xcscheme │ │ └── xcschememanagement.plist ├── Style+UserDefaults.swift └── Utilities.swift ├── Package.swift ├── Sources └── Settings │ ├── Container.swift │ ├── Pane.swift │ ├── Resources │ └── Localizable.xcstrings │ ├── Section.swift │ ├── SegmentedControlStyleViewController.swift │ ├── Settings.swift │ ├── SettingsPane.swift │ ├── SettingsStyleController.swift │ ├── SettingsTabViewController.swift │ ├── SettingsWindowController.swift │ ├── Style.swift │ ├── ToolbarItemStyleViewController.swift │ └── Utilities.swift ├── license ├── readme.md ├── screenshot.gif ├── segmented-control.png └── toolbar-item.png /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = tab 5 | end_of_line = lf 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.build 2 | /Packages 3 | xcuserdata 4 | project.xcworkspace 5 | -------------------------------------------------------------------------------- /.swiftlint.yml: -------------------------------------------------------------------------------- 1 | only_rules: 2 | - anyobject_protocol 3 | - array_init 4 | - block_based_kvo 5 | - class_delegate_protocol 6 | - closing_brace 7 | - closure_end_indentation 8 | - closure_parameter_position 9 | - closure_spacing 10 | - collection_alignment 11 | - colon 12 | - comma 13 | - compiler_protocol_init 14 | - computed_accessors_order 15 | - conditional_returns_on_newline 16 | - contains_over_filter_count 17 | - contains_over_filter_is_empty 18 | - contains_over_first_not_nil 19 | - contains_over_range_nil_comparison 20 | - control_statement 21 | - custom_rules 22 | - deployment_target 23 | - discarded_notification_center_observer 24 | - discouraged_assert 25 | - discouraged_direct_init 26 | - discouraged_none_name 27 | - discouraged_object_literal 28 | - discouraged_optional_boolean 29 | - discouraged_optional_collection 30 | - duplicate_enum_cases 31 | - duplicate_imports 32 | - duplicated_key_in_dictionary_literal 33 | - dynamic_inline 34 | - empty_collection_literal 35 | - empty_count 36 | - empty_enum_arguments 37 | - empty_parameters 38 | - empty_parentheses_with_trailing_closure 39 | - empty_string 40 | - empty_xctest_method 41 | - enum_case_associated_values_count 42 | - explicit_init 43 | - fallthrough 44 | - fatal_error_message 45 | - first_where 46 | - flatmap_over_map_reduce 47 | - for_where 48 | - generic_type_name 49 | - ibinspectable_in_extension 50 | - identical_operands 51 | - identifier_name 52 | - implicit_getter 53 | - implicit_return 54 | - inclusive_language 55 | - inert_defer 56 | - is_disjoint 57 | - joined_default_parameter 58 | - last_where 59 | - leading_whitespace 60 | - legacy_cggeometry_functions 61 | - legacy_constant 62 | - legacy_constructor 63 | - legacy_hashing 64 | - legacy_multiple 65 | - legacy_nsgeometry_functions 66 | - legacy_random 67 | - literal_expression_end_indentation 68 | - lower_acl_than_parent 69 | - mark 70 | - modifier_order 71 | - multiline_arguments 72 | - multiline_function_chains 73 | - multiline_literal_brackets 74 | - multiline_parameters 75 | - multiline_parameters_brackets 76 | - nimble_operator 77 | - no_extension_access_modifier 78 | - no_fallthrough_only 79 | - no_space_in_method_call 80 | - notification_center_detachment 81 | - nsobject_prefer_isequal 82 | - number_separator 83 | - opening_brace 84 | - operator_usage_whitespace 85 | - operator_whitespace 86 | - orphaned_doc_comment 87 | - overridden_super_call 88 | - prefer_self_type_over_type_of_self 89 | - prefer_zero_over_explicit_init 90 | - private_action 91 | - private_outlet 92 | - private_subject 93 | - private_unit_test 94 | - prohibited_super_call 95 | - protocol_property_accessors_order 96 | - reduce_boolean 97 | - reduce_into 98 | - redundant_discardable_let 99 | - redundant_nil_coalescing 100 | - redundant_objc_attribute 101 | - redundant_optional_initialization 102 | - redundant_set_access_control 103 | - redundant_string_enum_value 104 | - redundant_type_annotation 105 | - redundant_void_return 106 | - required_enum_case 107 | - return_arrow_whitespace 108 | - shorthand_operator 109 | - sorted_first_last 110 | - statement_position 111 | - static_operator 112 | - strong_iboutlet 113 | - superfluous_disable_command 114 | - switch_case_alignment 115 | - switch_case_on_newline 116 | - syntactic_sugar 117 | - test_case_accessibility 118 | - toggle_bool 119 | - trailing_closure 120 | - trailing_comma 121 | - trailing_newline 122 | - trailing_semicolon 123 | - trailing_whitespace 124 | - unavailable_function 125 | - unneeded_break_in_switch 126 | - unneeded_parentheses_in_closure_argument 127 | - unowned_variable_capture 128 | - untyped_error_in_catch 129 | - unused_capture_list 130 | - unused_closure_parameter 131 | - unused_control_flow_label 132 | - unused_enumerated 133 | - unused_optional_binding 134 | - unused_setter_value 135 | - valid_ibinspectable 136 | - vertical_parameter_alignment 137 | - vertical_parameter_alignment_on_call 138 | - vertical_whitespace_closing_braces 139 | - vertical_whitespace_opening_braces 140 | - void_return 141 | - xct_specific_matcher 142 | - xctfail_message 143 | - yoda_condition 144 | analyzer_rules: 145 | - capture_variable 146 | - unused_declaration 147 | - unused_import 148 | number_separator: 149 | minimum_length: 5 150 | identifier_name: 151 | max_length: 152 | warning: 100 153 | error: 100 154 | min_length: 155 | warning: 2 156 | error: 2 157 | validates_start_with_lowercase: false 158 | allowed_symbols: 159 | - '_' 160 | excluded: 161 | - 'x' 162 | - 'y' 163 | - 'z' 164 | - 'a' 165 | - 'b' 166 | - 'x1' 167 | - 'x2' 168 | - 'y1' 169 | - 'y2' 170 | - 'z2' 171 | deployment_target: 172 | macOS_deployment_target: '10.10' 173 | custom_rules: 174 | no_nsrect: 175 | regex: '\bNSRect\b' 176 | match_kinds: typeidentifier 177 | message: 'Use CGRect instead of NSRect' 178 | no_nssize: 179 | regex: '\bNSSize\b' 180 | match_kinds: typeidentifier 181 | message: 'Use CGSize instead of NSSize' 182 | no_nspoint: 183 | regex: '\bNSPoint\b' 184 | match_kinds: typeidentifier 185 | message: 'Use CGPoint instead of NSPoint' 186 | no_cgfloat: 187 | regex: '\bCGFloat\b' 188 | match_kinds: typeidentifier 189 | message: 'Use Double instead of CGFloat' 190 | no_cgfloat2: 191 | regex: '\bCGFloat\(' 192 | message: 'Use Double instead of CGFloat' 193 | swiftui_state_private: 194 | regex: '@(State|StateObject|ObservedObject|EnvironmentObject)\s+var' 195 | message: 'SwiftUI @State/@StateObject/@ObservedObject/@EnvironmentObject properties should be private' 196 | swiftui_environment_private: 197 | regex: '@Environment\(\\\.\w+\)\s+var' 198 | message: 'SwiftUI @Environment properties should be private' 199 | final_class: 200 | regex: '^class [a-zA-Z\d]+[^{]+\{' 201 | message: 'Classes should be marked as final whenever possible. If you actually need it to be subclassable, just add `// swiftlint:disable:next final_class`.' 202 | -------------------------------------------------------------------------------- /.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.swiftpm/xcode/xcshareddata/xcschemes/Preferences.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 32 | 33 | 43 | 44 | 50 | 51 | 57 | 58 | 59 | 60 | 62 | 63 | 66 | 67 | 68 | -------------------------------------------------------------------------------- /Example/AccountsScreen.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import Settings 3 | 4 | /** 5 | Function wrapping SwiftUI into `SettingsPane`, which is mimicking view controller's default construction syntax. 6 | */ 7 | let AccountsSettingsViewController: () -> SettingsPane = { 8 | /** 9 | Wrap your custom view into `Settings.Pane`, while providing necessary toolbar info. 10 | */ 11 | let paneView = Settings.Pane( 12 | identifier: .accounts, 13 | title: "Accounts", 14 | toolbarIcon: NSImage(systemSymbolName: "person.crop.circle", accessibilityDescription: "Accounts settings")! 15 | ) { 16 | AccountsScreen() 17 | } 18 | 19 | return Settings.PaneHostingController(pane: paneView) 20 | } 21 | 22 | /** 23 | The main view of “Accounts” settings pane. 24 | */ 25 | struct AccountsScreen: View { 26 | @State private var isOn1 = true 27 | @State private var isOn2 = false 28 | @State private var isOn3 = true 29 | @State private var selection1 = 1 30 | @State private var selection2 = 0 31 | @State private var selection3 = 0 32 | @State private var isExpanded = false 33 | private let contentWidth: Double = 450.0 34 | 35 | var body: some View { 36 | Settings.Container(contentWidth: contentWidth) { 37 | Settings.Section(title: "Permissions:") { 38 | Toggle("Allow user to administer this computer", isOn: $isOn1) 39 | Text("Administrator has root access to this machine.") 40 | .settingDescription() 41 | Toggle("Allow user to access every file", isOn: $isOn2) 42 | } 43 | Settings.Section(title: "Show scroll bars:") { 44 | Picker("", selection: $selection1) { 45 | Text("When scrolling").tag(0) 46 | Text("Always").tag(1) 47 | } 48 | .labelsHidden() 49 | .pickerStyle(.radioGroup) 50 | } 51 | Settings.Section(label: { 52 | Toggle("Some toggle", isOn: $isOn3) 53 | }) { 54 | Picker("", selection: $selection2) { 55 | Text("Automatic").tag(0) 56 | Text("Manual").tag(1) 57 | } 58 | .labelsHidden() 59 | .frame(width: 120.0) 60 | Text("Automatic mode can slow things down.") 61 | .settingDescription() 62 | } 63 | Settings.Section(title: "Preview mode:") { 64 | Picker("", selection: $selection3) { 65 | Text("Automatic").tag(0) 66 | Text("Manual").tag(1) 67 | } 68 | .labelsHidden() 69 | .frame(width: 120.0) 70 | Text("Automatic mode can slow things down.") 71 | .settingDescription() 72 | } 73 | Settings.Section(title: "Expand this pane:") { 74 | Toggle("Expand", isOn: $isExpanded) 75 | if isExpanded { 76 | ZStack(alignment: .center) { 77 | Rectangle() 78 | .fill(.gray) 79 | .frame(width: 200, height: 200) 80 | .cornerRadius(20) 81 | Text("🦄") 82 | .frame(width: 180, height: 180) 83 | } 84 | } 85 | } 86 | } 87 | } 88 | } 89 | 90 | struct AccountsScreen_Previews: PreviewProvider { 91 | static var previews: some View { 92 | AccountsScreen() 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /Example/AdvancedPreferenceViewController.swift: -------------------------------------------------------------------------------- 1 | import Cocoa 2 | import Settings 3 | 4 | final class AdvancedSettingsViewController: NSViewController, SettingsPane { 5 | let paneIdentifier = Settings.PaneIdentifier.advanced 6 | let paneTitle = "Advanced" 7 | let toolbarItemIcon = NSImage(systemSymbolName: "gearshape.2", accessibilityDescription: "Advanced settings")! 8 | 9 | @IBOutlet private var fontLabel: NSTextField! 10 | private var font = NSFont.systemFont(ofSize: 14) 11 | 12 | override var nibName: NSNib.Name? { "AdvancedSettingsViewController" } 13 | 14 | override func viewDidLoad() { 15 | super.viewDidLoad() 16 | 17 | updateFontLabel() 18 | } 19 | 20 | @IBAction 21 | private func zoomAction(_ sender: Any) {} // swiftlint:disable:this attributes 22 | 23 | @IBAction 24 | private func showFontPanel(_ sender: Any) { 25 | let fontManager = NSFontManager.shared 26 | fontManager.setSelectedFont(font, isMultiple: false) 27 | fontManager.orderFrontFontPanel(self) 28 | } 29 | 30 | @IBAction 31 | private func changeFont(_ sender: NSFontManager) { 32 | font = sender.convert(font) 33 | updateFontLabel() 34 | } 35 | 36 | private func updateFontLabel() { 37 | fontLabel.stringValue = font.displayName ?? font.fontName 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Example/AdvancedSettingsViewController.xib: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 60 | 61 | 62 | 63 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | -------------------------------------------------------------------------------- /Example/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | import Cocoa 2 | import Settings 3 | 4 | extension Settings.PaneIdentifier { 5 | static let general = Self("general") 6 | static let accounts = Self("accounts") 7 | static let advanced = Self("advanced") 8 | } 9 | 10 | @main 11 | final class AppDelegate: NSObject, NSApplicationDelegate { 12 | @IBOutlet private var window: NSWindow! 13 | 14 | private var settingsStyle: Settings.Style { 15 | get { .settingsStyleFromUserDefaults() } 16 | set { 17 | newValue.storeInUserDefaults() 18 | } 19 | } 20 | 21 | private lazy var panes: [SettingsPane] = [ 22 | GeneralSettingsViewController(), 23 | AccountsSettingsViewController(), 24 | AdvancedSettingsViewController() 25 | ] 26 | 27 | private lazy var settingsWindowController = SettingsWindowController( 28 | panes: panes, 29 | style: settingsStyle, 30 | animated: true, 31 | hidesToolbarForSingleItem: true 32 | ) 33 | 34 | func applicationWillFinishLaunching(_ notification: Notification) { 35 | window.orderOut(self) 36 | } 37 | 38 | func applicationDidFinishLaunching(_ notification: Notification) { 39 | settingsWindowController.show(pane: .accounts) 40 | } 41 | 42 | @IBAction private func settingsMenuItemActionHandler(_ sender: NSMenuItem) { 43 | settingsWindowController.show() 44 | } 45 | 46 | @IBAction private func switchStyle(_ sender: Any) { 47 | settingsStyle = settingsStyle == .segmentedControl 48 | ? .toolbarItems 49 | : .segmentedControl 50 | 51 | Task { 52 | try! await NSApp.relaunch() // swiftlint:disable:this force_try 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /Example/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "mac", 5 | "size" : "16x16", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "mac", 10 | "size" : "16x16", 11 | "scale" : "2x" 12 | }, 13 | { 14 | "idiom" : "mac", 15 | "size" : "32x32", 16 | "scale" : "1x" 17 | }, 18 | { 19 | "idiom" : "mac", 20 | "size" : "32x32", 21 | "scale" : "2x" 22 | }, 23 | { 24 | "idiom" : "mac", 25 | "size" : "128x128", 26 | "scale" : "1x" 27 | }, 28 | { 29 | "idiom" : "mac", 30 | "size" : "128x128", 31 | "scale" : "2x" 32 | }, 33 | { 34 | "idiom" : "mac", 35 | "size" : "256x256", 36 | "scale" : "1x" 37 | }, 38 | { 39 | "idiom" : "mac", 40 | "size" : "256x256", 41 | "scale" : "2x" 42 | }, 43 | { 44 | "idiom" : "mac", 45 | "size" : "512x512", 46 | "scale" : "1x" 47 | }, 48 | { 49 | "idiom" : "mac", 50 | "size" : "512x512", 51 | "scale" : "2x" 52 | } 53 | ], 54 | "info" : { 55 | "version" : 1, 56 | "author" : "xcode" 57 | } 58 | } -------------------------------------------------------------------------------- /Example/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "version" : 1, 4 | "author" : "xcode" 5 | } 6 | } -------------------------------------------------------------------------------- /Example/Base.lproj/MainMenu.xib: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | 192 | 193 | 194 | 195 | 196 | 197 | 198 | 199 | 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 209 | 210 | 211 | 212 | 213 | 214 | 215 | 216 | 217 | 218 | 219 | 220 | 221 | 222 | 223 | 224 | 225 | 226 | 227 | 228 | 229 | 230 | 231 | 232 | 233 | 234 | 235 | 236 | 237 | 238 | 239 | 240 | 241 | 242 | 243 | 244 | 245 | 246 | 247 | 248 | 249 | 250 | 251 | 252 | 253 | 254 | 255 | 256 | 257 | 258 | 259 | 260 | 261 | 262 | 263 | 264 | 265 | 266 | 267 | 268 | 269 | 270 | 271 | 272 | 273 | 274 | 275 | 276 | 277 | 278 | 279 | 280 | 281 | 282 | 283 | 284 | 285 | 286 | 287 | 288 | 289 | 290 | 291 | 292 | 293 | 294 | 295 | 296 | 297 | 298 | 299 | 300 | 301 | 302 | 303 | 304 | 305 | 306 | 307 | 308 | 309 | 310 | 311 | 312 | 313 | 314 | 315 | 316 | 317 | 318 | 319 | 320 | 321 | 322 | 323 | 324 | 325 | 326 | 327 | 328 | 329 | 330 | 331 | 332 | 333 | 334 | 335 | 336 | 337 | 338 | 339 | 340 | 341 | 342 | 343 | 344 | 345 | 346 | 347 | 348 | 349 | 350 | 351 | 352 | 353 | 354 | 355 | 356 | 357 | 358 | 359 | 360 | 361 | 362 | 363 | 364 | 365 | 366 | 367 | 368 | 369 | 370 | 371 | 372 | 373 | 374 | 375 | 376 | 377 | 378 | 379 | 380 | 381 | 382 | 383 | 384 | 385 | 386 | 387 | 388 | 389 | 390 | 391 | 392 | 393 | 394 | 395 | 396 | 397 | 398 | 399 | 400 | 401 | 402 | 403 | 404 | 405 | 406 | 407 | 408 | 409 | 410 | 411 | 412 | 413 | 414 | 415 | 416 | 417 | 418 | 419 | 420 | 421 | 422 | 423 | 424 | 425 | 426 | 427 | 428 | 429 | 430 | 431 | 432 | 433 | 434 | 435 | 436 | 437 | 438 | 439 | 440 | 441 | 442 | 443 | 444 | 445 | 446 | 447 | 448 | 449 | 450 | 451 | 452 | 453 | 454 | 455 | 456 | 457 | 458 | 459 | 460 | 461 | 462 | 463 | 464 | 465 | 466 | 467 | 468 | 469 | 470 | 471 | 472 | 473 | 474 | 475 | 476 | 477 | 478 | 479 | 480 | 481 | 482 | 483 | 484 | 485 | 486 | 487 | 488 | 489 | 490 | 491 | 492 | 493 | 494 | 495 | 496 | 497 | 498 | 499 | 500 | 501 | 502 | 503 | 504 | 505 | 506 | 507 | 508 | 509 | 510 | 511 | 512 | 513 | 514 | 515 | 516 | 517 | 518 | 519 | 520 | 521 | 522 | 523 | 524 | 525 | 526 | 527 | 528 | 529 | 530 | 531 | 532 | 533 | 534 | 535 | 536 | 537 | 538 | 539 | 540 | 541 | 542 | 543 | Default 544 | 545 | 546 | 547 | 548 | 549 | 550 | Left to Right 551 | 552 | 553 | 554 | 555 | 556 | 557 | Right to Left 558 | 559 | 560 | 561 | 562 | 563 | 564 | 565 | 566 | 567 | 568 | Default 569 | 570 | 571 | 572 | 573 | 574 | 575 | Left to Right 576 | 577 | 578 | 579 | 580 | 581 | 582 | Right to Left 583 | 584 | 585 | 586 | 587 | 588 | 589 | 590 | 591 | 592 | 593 | 594 | 595 | 596 | 597 | 598 | 599 | 600 | 601 | 602 | 603 | 604 | 605 | 606 | 607 | 608 | 609 | 610 | 611 | 612 | 613 | 614 | 615 | 616 | 617 | 618 | 619 | 620 | 621 | 622 | 623 | 624 | 625 | 626 | 627 | 628 | 629 | 630 | 631 | 632 | 633 | 634 | 635 | 636 | 637 | 638 | 639 | 640 | 641 | 642 | 643 | 644 | 645 | 646 | 647 | 648 | 649 | 650 | 651 | 652 | 653 | 654 | 655 | 656 | 657 | 658 | 659 | 660 | 661 | 662 | 663 | 664 | 665 | 666 | 667 | 668 | 669 | 670 | 671 | 672 | 673 | 674 | 675 | 676 | 677 | 678 | 679 | 680 | 681 | 682 | 683 | 684 | 685 | 686 | 687 | 688 | 689 | -------------------------------------------------------------------------------- /Example/GeneralSettingsViewController.swift: -------------------------------------------------------------------------------- 1 | import Cocoa 2 | import Settings 3 | 4 | final class GeneralSettingsViewController: NSViewController, SettingsPane { 5 | let paneIdentifier = Settings.PaneIdentifier.general 6 | let paneTitle = "General" 7 | let toolbarItemIcon = NSImage(systemSymbolName: "gearshape", accessibilityDescription: "General settings")! 8 | 9 | override var nibName: NSNib.Name? { "GeneralSettingsViewController" } 10 | 11 | override func viewDidLoad() { 12 | super.viewDidLoad() 13 | 14 | // Setup stuff here 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /Example/GeneralSettingsViewController.xib: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | A macOS feature that regularly saves your files and allows you to access previous versions. Auto Save greatly reduce the chance of losing your work in the event of a system error. 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 | 187 | 188 | 189 | 199 | 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 209 | 210 | 211 | 212 | 213 | 214 | 215 | 216 | 217 | 218 | 219 | 220 | 221 | 222 | 223 | 224 | 225 | -------------------------------------------------------------------------------- /Example/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /Example/Settings.xcodeproj/Preferences_Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | en 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundleShortVersionString 16 | $(MARKETING_VERSION) 17 | CFBundleVersion 18 | $(CURRENT_PROJECT_VERSION) 19 | 20 | 21 | -------------------------------------------------------------------------------- /Example/Settings.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | archiveVersion = 1; 4 | classes = { 5 | }; 6 | objectVersion = 55; 7 | objects = { 8 | 9 | /* Begin PBXBuildFile section */ 10 | 502B68E72254947B00789D9F /* Style+UserDefaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = 502B68E52254944600789D9F /* Style+UserDefaults.swift */; }; 11 | E3194E4122573FF3006FE775 /* Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3194E4022573FF3006FE775 /* Utilities.swift */; }; 12 | E34E9EEA20E6149B002F8F86 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = E34E9EE920E6149B002F8F86 /* AppDelegate.swift */; }; 13 | E34E9EEC20E6149D002F8F86 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = E34E9EEB20E6149D002F8F86 /* Assets.xcassets */; }; 14 | E34E9EEF20E6149D002F8F86 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = E34E9EED20E6149D002F8F86 /* MainMenu.xib */; }; 15 | E34E9EFF20E617E7002F8F86 /* GeneralSettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E34E9EFE20E617E7002F8F86 /* GeneralSettingsViewController.swift */; }; 16 | E34E9F0120E61A26002F8F86 /* GeneralSettingsViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = E34E9F0020E61A26002F8F86 /* GeneralSettingsViewController.xib */; }; 17 | E34E9F0320E61BC0002F8F86 /* AdvancedPreferenceViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E34E9F0220E61BC0002F8F86 /* AdvancedPreferenceViewController.swift */; }; 18 | E34E9F0520E61C06002F8F86 /* AdvancedSettingsViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = E34E9F0420E61C06002F8F86 /* AdvancedSettingsViewController.xib */; }; 19 | E3D37D6429C86E6D00AE5ACC /* Settings in Frameworks */ = {isa = PBXBuildFile; productRef = E3D37D6329C86E6D00AE5ACC /* Settings */; }; 20 | E7059F1323C2AC7400F84762 /* AccountsScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7059F1123C2AC3700F84762 /* AccountsScreen.swift */; }; 21 | /* End PBXBuildFile section */ 22 | 23 | /* Begin PBXCopyFilesBuildPhase section */ 24 | E34E9EF920E61508002F8F86 /* Embed Frameworks */ = { 25 | isa = PBXCopyFilesBuildPhase; 26 | buildActionMask = 2147483647; 27 | dstPath = ""; 28 | dstSubfolderSpec = 10; 29 | files = ( 30 | ); 31 | name = "Embed Frameworks"; 32 | runOnlyForDeploymentPostprocessing = 0; 33 | }; 34 | /* End PBXCopyFilesBuildPhase section */ 35 | 36 | /* Begin PBXFileReference section */ 37 | 502B68E52254944600789D9F /* Style+UserDefaults.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; lineEnding = 0; path = "Style+UserDefaults.swift"; sourceTree = ""; usesTabs = 1; }; 38 | E3194E4022573FF3006FE775 /* Utilities.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; lineEnding = 0; path = Utilities.swift; sourceTree = ""; usesTabs = 1; }; 39 | E34E9EE720E6149B002F8F86 /* SettingsExample.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = SettingsExample.app; sourceTree = BUILT_PRODUCTS_DIR; }; 40 | E34E9EE920E6149B002F8F86 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; lineEnding = 0; path = AppDelegate.swift; sourceTree = ""; usesTabs = 1; }; 41 | E34E9EEB20E6149D002F8F86 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 42 | E34E9EEE20E6149D002F8F86 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; }; 43 | E34E9EF020E6149D002F8F86 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 44 | E34E9EFE20E617E7002F8F86 /* GeneralSettingsViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; lineEnding = 0; path = GeneralSettingsViewController.swift; sourceTree = ""; usesTabs = 1; }; 45 | E34E9F0020E61A26002F8F86 /* GeneralSettingsViewController.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = GeneralSettingsViewController.xib; sourceTree = ""; }; 46 | E34E9F0220E61BC0002F8F86 /* AdvancedPreferenceViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; lineEnding = 0; path = AdvancedPreferenceViewController.swift; sourceTree = ""; usesTabs = 1; }; 47 | E34E9F0420E61C06002F8F86 /* AdvancedSettingsViewController.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = AdvancedSettingsViewController.xib; sourceTree = ""; }; 48 | E3D37D6229C86E6100AE5ACC /* Settings */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = Settings; path = ..; sourceTree = ""; }; 49 | E7059F1123C2AC3700F84762 /* AccountsScreen.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; lineEnding = 0; path = AccountsScreen.swift; sourceTree = ""; tabWidth = 4; usesTabs = 1; wrapsLines = 1; }; 50 | /* End PBXFileReference section */ 51 | 52 | /* Begin PBXFrameworksBuildPhase section */ 53 | E34E9EE420E6149B002F8F86 /* Frameworks */ = { 54 | isa = PBXFrameworksBuildPhase; 55 | buildActionMask = 2147483647; 56 | files = ( 57 | E3D37D6429C86E6D00AE5ACC /* Settings in Frameworks */, 58 | ); 59 | runOnlyForDeploymentPostprocessing = 0; 60 | }; 61 | /* End PBXFrameworksBuildPhase section */ 62 | 63 | /* Begin PBXGroup section */ 64 | E340668A28ADAFF3002F8EA9 /* Frameworks */ = { 65 | isa = PBXGroup; 66 | children = ( 67 | E3D37D6229C86E6100AE5ACC /* Settings */, 68 | ); 69 | name = Frameworks; 70 | sourceTree = ""; 71 | }; 72 | E34E9EE820E6149B002F8F86 /* Example */ = { 73 | isa = PBXGroup; 74 | children = ( 75 | E34E9EE920E6149B002F8F86 /* AppDelegate.swift */, 76 | 502B68E52254944600789D9F /* Style+UserDefaults.swift */, 77 | E34E9EED20E6149D002F8F86 /* MainMenu.xib */, 78 | E34E9EFE20E617E7002F8F86 /* GeneralSettingsViewController.swift */, 79 | E34E9F0020E61A26002F8F86 /* GeneralSettingsViewController.xib */, 80 | E34E9F0220E61BC0002F8F86 /* AdvancedPreferenceViewController.swift */, 81 | E34E9F0420E61C06002F8F86 /* AdvancedSettingsViewController.xib */, 82 | E7059F1123C2AC3700F84762 /* AccountsScreen.swift */, 83 | E3194E4022573FF3006FE775 /* Utilities.swift */, 84 | E34E9EEB20E6149D002F8F86 /* Assets.xcassets */, 85 | E34E9EF020E6149D002F8F86 /* Info.plist */, 86 | ); 87 | name = Example; 88 | sourceTree = ""; 89 | }; 90 | OBJ_12 /* Products */ = { 91 | isa = PBXGroup; 92 | children = ( 93 | E34E9EE720E6149B002F8F86 /* SettingsExample.app */, 94 | ); 95 | name = Products; 96 | sourceTree = BUILT_PRODUCTS_DIR; 97 | }; 98 | OBJ_5 = { 99 | isa = PBXGroup; 100 | children = ( 101 | E34E9EE820E6149B002F8F86 /* Example */, 102 | E340668A28ADAFF3002F8EA9 /* Frameworks */, 103 | OBJ_12 /* Products */, 104 | ); 105 | sourceTree = ""; 106 | usesTabs = 1; 107 | }; 108 | /* End PBXGroup section */ 109 | 110 | /* Begin PBXNativeTarget section */ 111 | E34E9EE620E6149B002F8F86 /* SettingsExample */ = { 112 | isa = PBXNativeTarget; 113 | buildConfigurationList = E34E9EF220E6149D002F8F86 /* Build configuration list for PBXNativeTarget "SettingsExample" */; 114 | buildPhases = ( 115 | E34E9EE320E6149B002F8F86 /* Sources */, 116 | E34E9EE420E6149B002F8F86 /* Frameworks */, 117 | E34E9EE520E6149B002F8F86 /* Resources */, 118 | E34E9EF920E61508002F8F86 /* Embed Frameworks */, 119 | ); 120 | buildRules = ( 121 | ); 122 | dependencies = ( 123 | ); 124 | name = SettingsExample; 125 | packageProductDependencies = ( 126 | E3D37D6329C86E6D00AE5ACC /* Settings */, 127 | ); 128 | productName = PreferencesExample; 129 | productReference = E34E9EE720E6149B002F8F86 /* SettingsExample.app */; 130 | productType = "com.apple.product-type.application"; 131 | }; 132 | /* End PBXNativeTarget section */ 133 | 134 | /* Begin PBXProject section */ 135 | OBJ_1 /* Project object */ = { 136 | isa = PBXProject; 137 | attributes = { 138 | LastSwiftUpdateCheck = 0940; 139 | LastUpgradeCheck = 1420; 140 | TargetAttributes = { 141 | E34E9EE620E6149B002F8F86 = { 142 | CreatedOnToolsVersion = 9.4.1; 143 | LastSwiftMigration = 1020; 144 | SystemCapabilities = { 145 | com.apple.Sandbox = { 146 | enabled = 0; 147 | }; 148 | }; 149 | }; 150 | }; 151 | }; 152 | buildConfigurationList = OBJ_2 /* Build configuration list for PBXProject "Settings" */; 153 | compatibilityVersion = "Xcode 13.0"; 154 | developmentRegion = en; 155 | hasScannedForEncodings = 0; 156 | knownRegions = ( 157 | en, 158 | Base, 159 | ); 160 | mainGroup = OBJ_5; 161 | productRefGroup = OBJ_12 /* Products */; 162 | projectDirPath = ""; 163 | projectRoot = ""; 164 | targets = ( 165 | E34E9EE620E6149B002F8F86 /* SettingsExample */, 166 | ); 167 | }; 168 | /* End PBXProject section */ 169 | 170 | /* Begin PBXResourcesBuildPhase section */ 171 | E34E9EE520E6149B002F8F86 /* Resources */ = { 172 | isa = PBXResourcesBuildPhase; 173 | buildActionMask = 2147483647; 174 | files = ( 175 | E34E9EEC20E6149D002F8F86 /* Assets.xcassets in Resources */, 176 | E34E9F0520E61C06002F8F86 /* AdvancedSettingsViewController.xib in Resources */, 177 | E34E9EEF20E6149D002F8F86 /* MainMenu.xib in Resources */, 178 | E34E9F0120E61A26002F8F86 /* GeneralSettingsViewController.xib in Resources */, 179 | ); 180 | runOnlyForDeploymentPostprocessing = 0; 181 | }; 182 | /* End PBXResourcesBuildPhase section */ 183 | 184 | /* Begin PBXSourcesBuildPhase section */ 185 | E34E9EE320E6149B002F8F86 /* Sources */ = { 186 | isa = PBXSourcesBuildPhase; 187 | buildActionMask = 2147483647; 188 | files = ( 189 | E34E9EFF20E617E7002F8F86 /* GeneralSettingsViewController.swift in Sources */, 190 | E34E9EEA20E6149B002F8F86 /* AppDelegate.swift in Sources */, 191 | E7059F1323C2AC7400F84762 /* AccountsScreen.swift in Sources */, 192 | E34E9F0320E61BC0002F8F86 /* AdvancedPreferenceViewController.swift in Sources */, 193 | E3194E4122573FF3006FE775 /* Utilities.swift in Sources */, 194 | 502B68E72254947B00789D9F /* Style+UserDefaults.swift in Sources */, 195 | ); 196 | runOnlyForDeploymentPostprocessing = 0; 197 | }; 198 | /* End PBXSourcesBuildPhase section */ 199 | 200 | /* Begin PBXVariantGroup section */ 201 | E34E9EED20E6149D002F8F86 /* MainMenu.xib */ = { 202 | isa = PBXVariantGroup; 203 | children = ( 204 | E34E9EEE20E6149D002F8F86 /* Base */, 205 | ); 206 | name = MainMenu.xib; 207 | sourceTree = ""; 208 | }; 209 | /* End PBXVariantGroup section */ 210 | 211 | /* Begin XCBuildConfiguration section */ 212 | E34E9EF320E6149D002F8F86 /* Debug */ = { 213 | isa = XCBuildConfiguration; 214 | buildSettings = { 215 | ALWAYS_SEARCH_USER_PATHS = NO; 216 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 217 | CLANG_ANALYZER_NONNULL = YES; 218 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 219 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; 220 | CLANG_CXX_LIBRARY = "libc++"; 221 | CLANG_ENABLE_MODULES = YES; 222 | CLANG_ENABLE_OBJC_WEAK = YES; 223 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 224 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 225 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 226 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 227 | CODE_SIGN_IDENTITY = "-"; 228 | CODE_SIGN_STYLE = Automatic; 229 | COMBINE_HIDPI_IMAGES = YES; 230 | DEAD_CODE_STRIPPING = YES; 231 | DEVELOPMENT_TEAM = ""; 232 | GCC_C_LANGUAGE_STANDARD = gnu11; 233 | GCC_DYNAMIC_NO_PIC = NO; 234 | GCC_PREPROCESSOR_DEFINITIONS = ( 235 | "DEBUG=1", 236 | "$(inherited)", 237 | ); 238 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 239 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 240 | GENERATE_INFOPLIST_FILE = YES; 241 | INFOPLIST_FILE = "$(SRCROOT)/Info.plist"; 242 | INFOPLIST_KEY_NSMainNibFile = MainMenu; 243 | LD_RUNPATH_SEARCH_PATHS = ( 244 | "$(inherited)", 245 | "@executable_path/../Frameworks", 246 | ); 247 | MTL_ENABLE_DEBUG_INFO = YES; 248 | PRODUCT_BUNDLE_IDENTIFIER = com.sindresorhus.PreferencesExample; 249 | PRODUCT_NAME = "$(TARGET_NAME)"; 250 | PROVISIONING_PROFILE_SPECIFIER = ""; 251 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; 252 | SWIFT_VERSION = 5.0; 253 | }; 254 | name = Debug; 255 | }; 256 | E34E9EF420E6149D002F8F86 /* Release */ = { 257 | isa = XCBuildConfiguration; 258 | buildSettings = { 259 | ALWAYS_SEARCH_USER_PATHS = NO; 260 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 261 | CLANG_ANALYZER_NONNULL = YES; 262 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 263 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; 264 | CLANG_CXX_LIBRARY = "libc++"; 265 | CLANG_ENABLE_MODULES = YES; 266 | CLANG_ENABLE_OBJC_WEAK = YES; 267 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 268 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 269 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 270 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 271 | CODE_SIGN_IDENTITY = "-"; 272 | CODE_SIGN_STYLE = Automatic; 273 | COMBINE_HIDPI_IMAGES = YES; 274 | COPY_PHASE_STRIP = NO; 275 | DEAD_CODE_STRIPPING = YES; 276 | DEVELOPMENT_TEAM = ""; 277 | ENABLE_NS_ASSERTIONS = NO; 278 | GCC_C_LANGUAGE_STANDARD = gnu11; 279 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 280 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 281 | GENERATE_INFOPLIST_FILE = YES; 282 | INFOPLIST_FILE = "$(SRCROOT)/Info.plist"; 283 | INFOPLIST_KEY_NSMainNibFile = MainMenu; 284 | LD_RUNPATH_SEARCH_PATHS = ( 285 | "$(inherited)", 286 | "@executable_path/../Frameworks", 287 | ); 288 | MTL_ENABLE_DEBUG_INFO = NO; 289 | PRODUCT_BUNDLE_IDENTIFIER = com.sindresorhus.PreferencesExample; 290 | PRODUCT_NAME = "$(TARGET_NAME)"; 291 | PROVISIONING_PROFILE_SPECIFIER = ""; 292 | SWIFT_VERSION = 5.0; 293 | }; 294 | name = Release; 295 | }; 296 | OBJ_3 /* Debug */ = { 297 | isa = XCBuildConfiguration; 298 | buildSettings = { 299 | CLANG_ENABLE_OBJC_ARC = YES; 300 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 301 | CLANG_WARN_BOOL_CONVERSION = YES; 302 | CLANG_WARN_COMMA = YES; 303 | CLANG_WARN_CONSTANT_CONVERSION = YES; 304 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 305 | CLANG_WARN_EMPTY_BODY = YES; 306 | CLANG_WARN_ENUM_CONVERSION = YES; 307 | CLANG_WARN_INFINITE_RECURSION = YES; 308 | CLANG_WARN_INT_CONVERSION = YES; 309 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 310 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 311 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 312 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 313 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 314 | CLANG_WARN_STRICT_PROTOTYPES = YES; 315 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 316 | CLANG_WARN_UNREACHABLE_CODE = YES; 317 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 318 | COMBINE_HIDPI_IMAGES = YES; 319 | COPY_PHASE_STRIP = NO; 320 | DEAD_CODE_STRIPPING = YES; 321 | DEBUG_INFORMATION_FORMAT = dwarf; 322 | DYLIB_INSTALL_NAME_BASE = "@rpath"; 323 | ENABLE_NS_ASSERTIONS = YES; 324 | ENABLE_STRICT_OBJC_MSGSEND = YES; 325 | ENABLE_TESTABILITY = YES; 326 | GCC_NO_COMMON_BLOCKS = YES; 327 | GCC_OPTIMIZATION_LEVEL = 0; 328 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 329 | GCC_WARN_ABOUT_RETURN_TYPE = YES; 330 | GCC_WARN_UNDECLARED_SELECTOR = YES; 331 | GCC_WARN_UNINITIALIZED_AUTOS = YES; 332 | GCC_WARN_UNUSED_FUNCTION = YES; 333 | GCC_WARN_UNUSED_VARIABLE = YES; 334 | MACOSX_DEPLOYMENT_TARGET = 11.0; 335 | ONLY_ACTIVE_ARCH = YES; 336 | OTHER_SWIFT_FLAGS = "-DXcode"; 337 | PRODUCT_NAME = "$(TARGET_NAME)"; 338 | SDKROOT = macosx; 339 | SUPPORTED_PLATFORMS = macosx; 340 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = SWIFT_PACKAGE; 341 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 342 | USE_HEADERMAP = NO; 343 | }; 344 | name = Debug; 345 | }; 346 | OBJ_4 /* Release */ = { 347 | isa = XCBuildConfiguration; 348 | buildSettings = { 349 | CLANG_ENABLE_OBJC_ARC = YES; 350 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 351 | CLANG_WARN_BOOL_CONVERSION = YES; 352 | CLANG_WARN_COMMA = YES; 353 | CLANG_WARN_CONSTANT_CONVERSION = YES; 354 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 355 | CLANG_WARN_EMPTY_BODY = YES; 356 | CLANG_WARN_ENUM_CONVERSION = YES; 357 | CLANG_WARN_INFINITE_RECURSION = YES; 358 | CLANG_WARN_INT_CONVERSION = YES; 359 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 360 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 361 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 362 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 363 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 364 | CLANG_WARN_STRICT_PROTOTYPES = YES; 365 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 366 | CLANG_WARN_UNREACHABLE_CODE = YES; 367 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 368 | COMBINE_HIDPI_IMAGES = YES; 369 | COPY_PHASE_STRIP = YES; 370 | DEAD_CODE_STRIPPING = YES; 371 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 372 | DYLIB_INSTALL_NAME_BASE = "@rpath"; 373 | ENABLE_STRICT_OBJC_MSGSEND = YES; 374 | GCC_NO_COMMON_BLOCKS = YES; 375 | GCC_OPTIMIZATION_LEVEL = s; 376 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 377 | GCC_WARN_ABOUT_RETURN_TYPE = YES; 378 | GCC_WARN_UNDECLARED_SELECTOR = YES; 379 | GCC_WARN_UNINITIALIZED_AUTOS = YES; 380 | GCC_WARN_UNUSED_FUNCTION = YES; 381 | GCC_WARN_UNUSED_VARIABLE = YES; 382 | MACOSX_DEPLOYMENT_TARGET = 11.0; 383 | OTHER_SWIFT_FLAGS = "-DXcode"; 384 | PRODUCT_NAME = "$(TARGET_NAME)"; 385 | SDKROOT = macosx; 386 | SUPPORTED_PLATFORMS = macosx; 387 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = SWIFT_PACKAGE; 388 | SWIFT_COMPILATION_MODE = wholemodule; 389 | SWIFT_OPTIMIZATION_LEVEL = "-O"; 390 | USE_HEADERMAP = NO; 391 | }; 392 | name = Release; 393 | }; 394 | /* End XCBuildConfiguration section */ 395 | 396 | /* Begin XCConfigurationList section */ 397 | E34E9EF220E6149D002F8F86 /* Build configuration list for PBXNativeTarget "SettingsExample" */ = { 398 | isa = XCConfigurationList; 399 | buildConfigurations = ( 400 | E34E9EF320E6149D002F8F86 /* Debug */, 401 | E34E9EF420E6149D002F8F86 /* Release */, 402 | ); 403 | defaultConfigurationIsVisible = 0; 404 | defaultConfigurationName = Release; 405 | }; 406 | OBJ_2 /* Build configuration list for PBXProject "Settings" */ = { 407 | isa = XCConfigurationList; 408 | buildConfigurations = ( 409 | OBJ_3 /* Debug */, 410 | OBJ_4 /* Release */, 411 | ); 412 | defaultConfigurationIsVisible = 0; 413 | defaultConfigurationName = Release; 414 | }; 415 | /* End XCConfigurationList section */ 416 | 417 | /* Begin XCSwiftPackageProductDependency section */ 418 | E3D37D6329C86E6D00AE5ACC /* Settings */ = { 419 | isa = XCSwiftPackageProductDependency; 420 | productName = Settings; 421 | }; 422 | /* End XCSwiftPackageProductDependency section */ 423 | }; 424 | rootObject = OBJ_1 /* Project object */; 425 | } 426 | -------------------------------------------------------------------------------- /Example/Settings.xcodeproj/xcshareddata/xcschemes/Example.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 37 | 38 | 39 | 40 | 41 | 42 | 52 | 54 | 60 | 61 | 62 | 63 | 69 | 71 | 77 | 78 | 79 | 80 | 82 | 83 | 86 | 87 | 88 | -------------------------------------------------------------------------------- /Example/Settings.xcodeproj/xcshareddata/xcschemes/Preferences-Package.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 32 | 33 | 43 | 44 | 50 | 51 | 52 | 53 | 59 | 60 | 62 | 63 | 66 | 67 | 68 | -------------------------------------------------------------------------------- /Example/Settings.xcodeproj/xcshareddata/xcschemes/xcschememanagement.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | SchemeUserState 5 | 6 | Preferences-Package.xcscheme 7 | 8 | 9 | SuppressBuildableAutocreation 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /Example/Style+UserDefaults.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Settings 3 | 4 | // Helpers to write styles to and read them from UserDefaults. 5 | 6 | extension Settings.Style: RawRepresentable { 7 | public var rawValue: Int { 8 | switch self { 9 | case .toolbarItems: 10 | return 0 11 | case .segmentedControl: 12 | return 1 13 | } 14 | } 15 | 16 | public init?(rawValue: Int) { 17 | switch rawValue { 18 | case 0: 19 | self = .toolbarItems 20 | case 1: 21 | self = .segmentedControl 22 | default: 23 | return nil 24 | } 25 | } 26 | } 27 | 28 | extension Settings.Style { 29 | static let userDefaultsKey = "settingsStyle" 30 | 31 | static func settingsStyleFromUserDefaults(_ userDefaults: UserDefaults = .standard) -> Self { 32 | Self(rawValue: userDefaults.integer(forKey: userDefaultsKey)) 33 | ?? .toolbarItems 34 | } 35 | 36 | func storeInUserDefaults(_ userDefaults: UserDefaults = .standard) { 37 | userDefaults.set(rawValue, forKey: Self.userDefaultsKey) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Example/Utilities.swift: -------------------------------------------------------------------------------- 1 | import Cocoa 2 | 3 | extension NSApplication { 4 | @MainActor 5 | func relaunch() async throws { 6 | let configuration = NSWorkspace.OpenConfiguration() 7 | configuration.createsNewApplicationInstance = true 8 | try await NSWorkspace.shared.openApplication(at: Bundle.main.bundleURL, configuration: configuration) 9 | 10 | NSApp.terminate(nil) 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.8 2 | import PackageDescription 3 | 4 | let package = Package( 5 | name: "Settings", 6 | defaultLocalization: "en", 7 | platforms: [ 8 | .macOS(.v10_13) 9 | ], 10 | products: [ 11 | .library( 12 | name: "Settings", 13 | targets: [ 14 | "Settings" 15 | ] 16 | ) 17 | ], 18 | targets: [ 19 | .target( 20 | name: "Settings", 21 | resources: [ 22 | .process("Resources") 23 | ] 24 | ) 25 | ] 26 | ) 27 | -------------------------------------------------------------------------------- /Sources/Settings/Container.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | @available(macOS 10.15, *) 4 | extension Settings { 5 | /** 6 | Function builder for `Settings` components used in order to restrict types of child views to be of type `Section`. 7 | */ 8 | @resultBuilder 9 | public struct SectionBuilder { 10 | public static func buildBlock(_ sections: Section...) -> [Section] { 11 | sections 12 | } 13 | } 14 | 15 | /** 16 | A view which holds `Settings.Section` views and does all the alignment magic similar to `NSGridView` from AppKit. 17 | */ 18 | public struct Container: View { 19 | private let sectionBuilder: () -> [Section] 20 | private let contentWidth: Double 21 | private let minimumLabelWidth: Double 22 | @State private var maximumLabelWidth = 0.0 23 | 24 | /** 25 | Creates an instance of container component, which handles layout of stacked `Settings.Section` views. 26 | 27 | Custom alignment requires content width to be specified beforehand. 28 | 29 | - Parameters: 30 | - contentWidth: A fixed width of the container's content (excluding paddings). 31 | - minimumLabelWidth: A minimum width for labels within this container. By default, it will fit to the largest label. 32 | - builder: A view builder that creates `Settings.Section`'s of this container. 33 | */ 34 | public init( 35 | contentWidth: Double, 36 | minimumLabelWidth: Double = 0, 37 | @SectionBuilder builder: @escaping () -> [Section] 38 | ) { 39 | self.sectionBuilder = builder 40 | self.contentWidth = contentWidth 41 | self.minimumLabelWidth = minimumLabelWidth 42 | } 43 | 44 | public var body: some View { 45 | let sections = sectionBuilder() 46 | 47 | return VStack(alignment: .settingsSectionLabel) { 48 | ForEach(0.. some View { 60 | sections[index] 61 | if index != sections.count - 1 && sections[index].bottomDivider { 62 | Divider() 63 | // Strangely doesn't work without width being specified. Probably because of custom alignment. 64 | .frame(width: contentWidth, height: 20) 65 | .alignmentGuide(.settingsSectionLabel) { $0[.leading] + max(minimumLabelWidth, maximumLabelWidth) } 66 | } 67 | } 68 | } 69 | } 70 | 71 | /** 72 | Extension with custom alignment guide for section title labels. 73 | */ 74 | @available(macOS 10.15, *) 75 | extension HorizontalAlignment { 76 | private enum SettingsSectionLabelAlignment: AlignmentID { 77 | // swiftlint:disable:next no_cgfloat 78 | static func defaultValue(in context: ViewDimensions) -> CGFloat { 79 | context[HorizontalAlignment.leading] 80 | } 81 | } 82 | 83 | static let settingsSectionLabel = HorizontalAlignment(SettingsSectionLabelAlignment.self) 84 | } 85 | -------------------------------------------------------------------------------- /Sources/Settings/Pane.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | /** 4 | Represents a type that can be converted to `SettingsPane`. 5 | 6 | Acts as type-eraser for `Settings.Pane`. 7 | */ 8 | public protocol SettingsPaneConvertible { 9 | /** 10 | Convert `self` to equivalent `SettingsPane`. 11 | */ 12 | func asSettingsPane() -> SettingsPane 13 | } 14 | 15 | @available(macOS 10.15, *) 16 | extension Settings { 17 | /** 18 | Create a SwiftUI-based settings pane. 19 | 20 | SwiftUI equivalent of the `SettingsPane` protocol. 21 | */ 22 | public struct Pane: View, SettingsPaneConvertible { 23 | let identifier: PaneIdentifier 24 | let title: String 25 | let toolbarIcon: NSImage 26 | let content: Content 27 | 28 | public init( 29 | identifier: PaneIdentifier, 30 | title: String, 31 | toolbarIcon: NSImage, 32 | contentView: () -> Content 33 | ) { 34 | self.identifier = identifier 35 | self.title = title 36 | self.toolbarIcon = toolbarIcon 37 | self.content = contentView() 38 | } 39 | 40 | public var body: some View { content } 41 | 42 | public func asSettingsPane() -> SettingsPane { 43 | PaneHostingController(pane: self) 44 | } 45 | } 46 | 47 | /** 48 | Hosting controller enabling `Settings.Pane` to be used alongside AppKit `NSViewController`'s. 49 | */ 50 | public final class PaneHostingController: NSHostingController, SettingsPane { 51 | public let paneIdentifier: PaneIdentifier 52 | public let paneTitle: String 53 | public let toolbarItemIcon: NSImage 54 | 55 | init( 56 | identifier: PaneIdentifier, 57 | title: String, 58 | toolbarIcon: NSImage, 59 | content: Content 60 | ) { 61 | self.paneIdentifier = identifier 62 | self.paneTitle = title 63 | self.toolbarItemIcon = toolbarIcon 64 | super.init(rootView: content) 65 | } 66 | 67 | public convenience init(pane: Pane) { 68 | self.init( 69 | identifier: pane.identifier, 70 | title: pane.title, 71 | toolbarIcon: pane.toolbarIcon, 72 | content: pane.content 73 | ) 74 | } 75 | 76 | @available(*, unavailable) 77 | @objc 78 | dynamic required init?(coder: NSCoder) { 79 | fatalError("init(coder:) has not been implemented") 80 | } 81 | } 82 | } 83 | 84 | @available(macOS 10.15, *) 85 | extension View { 86 | /** 87 | Applies font and color for a label used for describing a setting. 88 | */ 89 | public func settingDescription() -> some View { 90 | font(.system(size: 11.0)) 91 | // TODO: Use `.foregroundStyle` when targeting macOS 12. 92 | .foregroundColor(.secondary) 93 | } 94 | 95 | /** 96 | Applies font and color for a label used for describing a setting. 97 | */ 98 | @available(*, deprecated, renamed: "settingDescription") 99 | public func preferenceDescription() -> some View { 100 | settingDescription() 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /Sources/Settings/Resources/Localizable.xcstrings: -------------------------------------------------------------------------------- 1 | { 2 | "version": "1.0", 3 | "strings": { 4 | "settings": { 5 | "localizations": { 6 | "ko": { 7 | "stringUnit": { 8 | "state": "translated", 9 | "value": "설정" 10 | } 11 | }, 12 | "uk": { 13 | "stringUnit": { 14 | "state": "translated", 15 | "value": "Параметри" 16 | } 17 | }, 18 | "es-419": { 19 | "stringUnit": { 20 | "state": "translated", 21 | "value": "Ajustes" 22 | } 23 | }, 24 | "hi": { 25 | "stringUnit": { 26 | "value": "समायोजन", 27 | "state": "translated" 28 | } 29 | }, 30 | "hu": { 31 | "stringUnit": { 32 | "state": "translated", 33 | "value": "Beállítások" 34 | } 35 | }, 36 | "th": { 37 | "stringUnit": { 38 | "value": "ค่าติดตั้ง", 39 | "state": "translated" 40 | } 41 | }, 42 | "ja": { 43 | "stringUnit": { 44 | "value": "設定", 45 | "state": "translated" 46 | } 47 | }, 48 | "en": { 49 | "stringUnit": { 50 | "state": "translated", 51 | "value": "Settings" 52 | } 53 | }, 54 | "it": { 55 | "stringUnit": { 56 | "state": "translated", 57 | "value": "Impostazioni" 58 | } 59 | }, 60 | "tr": { 61 | "stringUnit": { 62 | "value": "Ayarlar", 63 | "state": "translated" 64 | } 65 | }, 66 | "id": { 67 | "stringUnit": { 68 | "state": "translated", 69 | "value": "Pengaturan" 70 | } 71 | }, 72 | "ru": { 73 | "stringUnit": { 74 | "value": "Настройки", 75 | "state": "translated" 76 | } 77 | }, 78 | "sk": { 79 | "stringUnit": { 80 | "state": "translated", 81 | "value": "Nastavenia" 82 | } 83 | }, 84 | "ro": { 85 | "stringUnit": { 86 | "state": "translated", 87 | "value": "Configurări" 88 | } 89 | }, 90 | "ar": { 91 | "stringUnit": { 92 | "value": "الإعدادات", 93 | "state": "translated" 94 | } 95 | }, 96 | "pl": { 97 | "stringUnit": { 98 | "value": "Ustawienia", 99 | "state": "translated" 100 | } 101 | }, 102 | "hr": { 103 | "stringUnit": { 104 | "state": "translated", 105 | "value": "Postavke" 106 | } 107 | }, 108 | "nl": { 109 | "stringUnit": { 110 | "state": "translated", 111 | "value": "Instellingen" 112 | } 113 | }, 114 | "fi": { 115 | "stringUnit": { 116 | "value": "Asetukset", 117 | "state": "translated" 118 | } 119 | }, 120 | "zh-hans": { 121 | "stringUnit": { 122 | "value": "设置", 123 | "state": "translated" 124 | } 125 | }, 126 | "da": { 127 | "stringUnit": { 128 | "value": "Indstillinger", 129 | "state": "translated" 130 | } 131 | }, 132 | "fr-CA": { 133 | "stringUnit": { 134 | "state": "translated", 135 | "value": "Réglages" 136 | } 137 | }, 138 | "ms": { 139 | "stringUnit": { 140 | "value": "Tetapan", 141 | "state": "translated" 142 | } 143 | }, 144 | "cs": { 145 | "stringUnit": { 146 | "state": "translated", 147 | "value": "Nastavení" 148 | } 149 | }, 150 | "sv": { 151 | "stringUnit": { 152 | "value": "Inställningar", 153 | "state": "translated" 154 | } 155 | }, 156 | "zh-hant": { 157 | "stringUnit": { 158 | "state": "translated", 159 | "value": "設定" 160 | } 161 | }, 162 | "el": { 163 | "stringUnit": { 164 | "state": "translated", 165 | "value": "Ρυθμίσεις" 166 | } 167 | }, 168 | "pt-PT": { 169 | "stringUnit": { 170 | "value": "Definições", 171 | "state": "translated" 172 | } 173 | }, 174 | "es": { 175 | "stringUnit": { 176 | "value": "Ajustes", 177 | "state": "translated" 178 | } 179 | }, 180 | "vi": { 181 | "stringUnit": { 182 | "state": "translated", 183 | "value": "Cài đặt" 184 | } 185 | }, 186 | "fr": { 187 | "stringUnit": { 188 | "value": "Réglages", 189 | "state": "translated" 190 | } 191 | }, 192 | "en-GB": { 193 | "stringUnit": { 194 | "value": "Settings", 195 | "state": "translated" 196 | } 197 | }, 198 | "pt": { 199 | "stringUnit": { 200 | "state": "translated", 201 | "value": "Ajustes" 202 | } 203 | }, 204 | "ca": { 205 | "stringUnit": { 206 | "value": "Configuració", 207 | "state": "translated" 208 | } 209 | }, 210 | "no": { 211 | "stringUnit": { 212 | "state": "translated", 213 | "value": "Innstillinger" 214 | } 215 | }, 216 | "de": { 217 | "stringUnit": { 218 | "value": "Einstellungen", 219 | "state": "translated" 220 | } 221 | }, 222 | "he": { 223 | "stringUnit": { 224 | "value": "הגדרות", 225 | "state": "translated" 226 | } 227 | }, 228 | "en-AU": { 229 | "stringUnit": { 230 | "state": "translated", 231 | "value": "Settings" 232 | } 233 | }, 234 | "zh-HK": { 235 | "stringUnit": { 236 | "value": "設定", 237 | "state": "translated" 238 | } 239 | } 240 | }, 241 | "extractionState": "manual" 242 | }, 243 | "preferences": { 244 | "localizations": { 245 | "hr": { 246 | "stringUnit": { 247 | "state": "translated", 248 | "value": "Postavke" 249 | } 250 | }, 251 | "de": { 252 | "stringUnit": { 253 | "state": "translated", 254 | "value": "Einstellungen" 255 | } 256 | }, 257 | "zh-hant": { 258 | "stringUnit": { 259 | "value": "偏好設定", 260 | "state": "translated" 261 | } 262 | }, 263 | "cs": { 264 | "stringUnit": { 265 | "state": "translated", 266 | "value": "Předvolby" 267 | } 268 | }, 269 | "ms": { 270 | "stringUnit": { 271 | "value": "Keutamaan", 272 | "state": "translated" 273 | } 274 | }, 275 | "ko": { 276 | "stringUnit": { 277 | "state": "translated", 278 | "value": "환경설정" 279 | } 280 | }, 281 | "tr": { 282 | "stringUnit": { 283 | "state": "translated", 284 | "value": "Tercihler" 285 | } 286 | }, 287 | "th": { 288 | "stringUnit": { 289 | "state": "translated", 290 | "value": "การตั้งค่า" 291 | } 292 | }, 293 | "el": { 294 | "stringUnit": { 295 | "state": "translated", 296 | "value": "Προτιμήσεις" 297 | } 298 | }, 299 | "ar": { 300 | "stringUnit": { 301 | "state": "translated", 302 | "value": "تفضيلات" 303 | } 304 | }, 305 | "pt-PT": { 306 | "stringUnit": { 307 | "state": "translated", 308 | "value": "Preferências" 309 | } 310 | }, 311 | "it": { 312 | "stringUnit": { 313 | "state": "translated", 314 | "value": "Preferenze" 315 | } 316 | }, 317 | "vi": { 318 | "stringUnit": { 319 | "value": "Tùy chọn", 320 | "state": "translated" 321 | } 322 | }, 323 | "fr-CA": { 324 | "stringUnit": { 325 | "value": "Préférences", 326 | "state": "translated" 327 | } 328 | }, 329 | "ca": { 330 | "stringUnit": { 331 | "value": "Preferències", 332 | "state": "translated" 333 | } 334 | }, 335 | "zh-hans": { 336 | "stringUnit": { 337 | "state": "translated", 338 | "value": "偏好设置" 339 | } 340 | }, 341 | "da": { 342 | "stringUnit": { 343 | "state": "translated", 344 | "value": "Indstillinger" 345 | } 346 | }, 347 | "fr": { 348 | "stringUnit": { 349 | "value": "Préférences", 350 | "state": "translated" 351 | } 352 | }, 353 | "he": { 354 | "stringUnit": { 355 | "state": "translated", 356 | "value": "העדפות" 357 | } 358 | }, 359 | "fi": { 360 | "stringUnit": { 361 | "value": "Asetukset", 362 | "state": "translated" 363 | } 364 | }, 365 | "sk": { 366 | "stringUnit": { 367 | "value": "Nastavenia", 368 | "state": "translated" 369 | } 370 | }, 371 | "es-419": { 372 | "stringUnit": { 373 | "value": "Preferencias", 374 | "state": "translated" 375 | } 376 | }, 377 | "pt": { 378 | "stringUnit": { 379 | "state": "translated", 380 | "value": "Preferências" 381 | } 382 | }, 383 | "zh-HK": { 384 | "stringUnit": { 385 | "state": "translated", 386 | "value": "偏好設定" 387 | } 388 | }, 389 | "sv": { 390 | "stringUnit": { 391 | "value": "Inställningar", 392 | "state": "translated" 393 | } 394 | }, 395 | "id": { 396 | "stringUnit": { 397 | "state": "translated", 398 | "value": "Preferensi" 399 | } 400 | }, 401 | "ru": { 402 | "stringUnit": { 403 | "value": "Настройки", 404 | "state": "translated" 405 | } 406 | }, 407 | "uk": { 408 | "stringUnit": { 409 | "state": "translated", 410 | "value": "Параметри" 411 | } 412 | }, 413 | "nl": { 414 | "stringUnit": { 415 | "state": "translated", 416 | "value": "Voorkeuren" 417 | } 418 | }, 419 | "ja": { 420 | "stringUnit": { 421 | "value": "環境設定", 422 | "state": "translated" 423 | } 424 | }, 425 | "pl": { 426 | "stringUnit": { 427 | "state": "translated", 428 | "value": "Preferencje" 429 | } 430 | }, 431 | "en-GB": { 432 | "stringUnit": { 433 | "state": "translated", 434 | "value": "Preferences" 435 | } 436 | }, 437 | "en-AU": { 438 | "stringUnit": { 439 | "state": "translated", 440 | "value": "Preferences" 441 | } 442 | }, 443 | "hu": { 444 | "stringUnit": { 445 | "value": "Beállítások", 446 | "state": "translated" 447 | } 448 | }, 449 | "ro": { 450 | "stringUnit": { 451 | "value": "Preferințe", 452 | "state": "translated" 453 | } 454 | }, 455 | "hi": { 456 | "stringUnit": { 457 | "state": "translated", 458 | "value": "प्राथमिकता" 459 | } 460 | }, 461 | "en": { 462 | "stringUnit": { 463 | "value": "Preferences", 464 | "state": "translated" 465 | } 466 | }, 467 | "es": { 468 | "stringUnit": { 469 | "state": "translated", 470 | "value": "Preferencias" 471 | } 472 | }, 473 | "no": { 474 | "stringUnit": { 475 | "state": "translated", 476 | "value": "Valg" 477 | } 478 | } 479 | }, 480 | "extractionState": "manual" 481 | } 482 | }, 483 | "sourceLanguage": "en" 484 | } 485 | -------------------------------------------------------------------------------- /Sources/Settings/Section.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | @available(macOS 10.15, *) 4 | extension Settings { 5 | /** 6 | Represents a section with right-aligned title and optional bottom divider. 7 | */ 8 | @available(macOS 10.15, *) 9 | public struct Section: View { 10 | /** 11 | Preference key holding max width of section labels. 12 | */ 13 | private struct LabelWidthPreferenceKey: PreferenceKey { 14 | typealias Value = Double 15 | 16 | static var defaultValue = 0.0 17 | 18 | static func reduce(value: inout Double, nextValue: () -> Double) { 19 | let next = nextValue() 20 | value = next > value ? next : value 21 | } 22 | } 23 | 24 | /** 25 | Convenience overlay for finding a label's dimensions using `GeometryReader`. 26 | */ 27 | private struct LabelOverlay: View { 28 | var body: some View { 29 | GeometryReader { geometry in 30 | Color.clear 31 | .preference( 32 | key: LabelWidthPreferenceKey.self, 33 | value: geometry.size.width 34 | ) 35 | } 36 | } 37 | } 38 | 39 | /** 40 | Convenience modifier for applying `LabelWidthPreferenceKey`. 41 | */ 42 | struct LabelWidthModifier: ViewModifier { 43 | @Binding var maximumWidth: Double 44 | 45 | func body(content: Content) -> some View { 46 | content 47 | .onPreferenceChange(LabelWidthPreferenceKey.self) { newMaximumWidth in 48 | maximumWidth = newMaximumWidth 49 | } 50 | } 51 | } 52 | 53 | public let label: AnyView 54 | public let content: AnyView 55 | public let bottomDivider: Bool 56 | public let verticalAlignment: VerticalAlignment 57 | 58 | /** 59 | A section is responsible for controlling a single setting. 60 | 61 | - Parameters: 62 | - bottomDivider: Whether to place a `Divider` after the section content. Default is `false`. 63 | - verticalAlignement: The vertical alignment of the section content. 64 | - label: A view describing the setting handled by this section. 65 | - content: A content view. 66 | */ 67 | public init( 68 | bottomDivider: Bool = false, 69 | verticalAlignment: VerticalAlignment = .firstTextBaseline, 70 | label: @escaping () -> some View, 71 | @ViewBuilder content: @escaping () -> some View 72 | ) { 73 | self.label = label() 74 | .overlay(LabelOverlay()) 75 | .eraseToAnyView() // TODO: Remove use of `AnyView`. 76 | self.bottomDivider = bottomDivider 77 | self.verticalAlignment = verticalAlignment 78 | let stack = VStack(alignment: .leading) { content() } 79 | self.content = stack.eraseToAnyView() 80 | } 81 | 82 | /** 83 | Creates instance of section, responsible for controling a single setting with `Text` as a `Label`. 84 | 85 | - Parameters: 86 | - title: A string describing the setting handled by this section. 87 | - bottomDivider: Whether to place a `Divider` after the section content. Default is `false`. 88 | - verticalAlignement: The vertical alignment of the section content. 89 | - content: A content view. 90 | */ 91 | public init( 92 | title: String, 93 | bottomDivider: Bool = false, 94 | verticalAlignment: VerticalAlignment = .firstTextBaseline, 95 | @ViewBuilder content: @escaping () -> some View 96 | ) { 97 | let textLabel = { 98 | Text(title) 99 | .font(.system(size: 13.0)) 100 | .overlay(LabelOverlay()) 101 | .eraseToAnyView() 102 | } 103 | 104 | self.init( 105 | bottomDivider: bottomDivider, 106 | verticalAlignment: verticalAlignment, 107 | label: textLabel, 108 | content: content 109 | ) 110 | } 111 | 112 | public var body: some View { 113 | HStack(alignment: verticalAlignment) { 114 | label 115 | .alignmentGuide(.settingsSectionLabel) { $0[.trailing] } 116 | content 117 | Spacer() 118 | } 119 | } 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /Sources/Settings/SegmentedControlStyleViewController.swift: -------------------------------------------------------------------------------- 1 | import Cocoa 2 | 3 | extension NSToolbarItem.Identifier { 4 | static let toolbarSegmentedControlItem = Self("toolbarSegmentedControlItem") 5 | } 6 | 7 | extension NSUserInterfaceItemIdentifier { 8 | static let toolbarSegmentedControl = Self("toolbarSegmentedControl") 9 | } 10 | 11 | final class SegmentedControlStyleViewController: NSViewController, SettingsStyleController { 12 | var segmentedControl: NSSegmentedControl! { 13 | get { view as? NSSegmentedControl } 14 | set { 15 | view = newValue 16 | } 17 | } 18 | 19 | var isKeepingWindowCentered: Bool { true } 20 | 21 | weak var delegate: SettingsStyleControllerDelegate? 22 | 23 | private var panes: [SettingsPane]! 24 | 25 | required init(panes: [SettingsPane]) { 26 | super.init(nibName: nil, bundle: nil) 27 | self.panes = panes 28 | } 29 | 30 | @available(*, unavailable) 31 | required init?(coder: NSCoder) { 32 | fatalError("init(coder:) has not been implemented") 33 | } 34 | 35 | override func loadView() { 36 | view = createSegmentedControl(panes: panes) 37 | } 38 | 39 | fileprivate func createSegmentedControl(panes: [SettingsPane]) -> NSSegmentedControl { 40 | let segmentedControl = NSSegmentedControl() 41 | segmentedControl.segmentCount = panes.count 42 | segmentedControl.segmentStyle = .texturedSquare 43 | segmentedControl.target = self 44 | segmentedControl.action = #selector(segmentedControlAction) 45 | segmentedControl.identifier = .toolbarSegmentedControl 46 | 47 | if let cell = segmentedControl.cell as? NSSegmentedCell { 48 | cell.controlSize = .regular 49 | cell.trackingMode = .selectOne 50 | } 51 | 52 | let segmentSize: CGSize = { 53 | let insets = CGSize(width: 36, height: 12) 54 | var maxSize = CGSize.zero 55 | 56 | for pane in panes { 57 | let title = pane.paneTitle 58 | let titleSize = title.size( 59 | withAttributes: [ 60 | .font: NSFont.systemFont(ofSize: NSFont.systemFontSize(for: .regular)) 61 | ] 62 | ) 63 | 64 | maxSize = CGSize( 65 | width: max(titleSize.width, maxSize.width), 66 | height: max(titleSize.height, maxSize.height) 67 | ) 68 | } 69 | 70 | return CGSize( 71 | width: maxSize.width + insets.width, 72 | height: maxSize.height + insets.height 73 | ) 74 | }() 75 | 76 | let segmentBorderWidth = Double(panes.count) + 1 77 | let segmentWidth = segmentSize.width * Double(panes.count) + segmentBorderWidth 78 | let segmentHeight = segmentSize.height 79 | segmentedControl.frame = CGRect(x: 0, y: 0, width: segmentWidth, height: segmentHeight) 80 | 81 | for (index, pane) in panes.enumerated() { 82 | segmentedControl.setLabel(pane.paneTitle, forSegment: index) 83 | segmentedControl.setWidth(segmentSize.width, forSegment: index) 84 | if let cell = segmentedControl.cell as? NSSegmentedCell { 85 | cell.setTag(index, forSegment: index) 86 | } 87 | } 88 | 89 | return segmentedControl 90 | } 91 | 92 | @IBAction private func segmentedControlAction(_ control: NSSegmentedControl) { 93 | delegate?.activateTab(index: control.selectedSegment, animated: true) 94 | } 95 | 96 | func selectTab(index: Int) { 97 | segmentedControl.selectedSegment = index 98 | } 99 | 100 | func toolbarItemIdentifiers() -> [NSToolbarItem.Identifier] { 101 | [ 102 | .flexibleSpace, 103 | .toolbarSegmentedControlItem, 104 | .flexibleSpace 105 | ] 106 | } 107 | 108 | func toolbarItem(paneIdentifier: Settings.PaneIdentifier) -> NSToolbarItem? { 109 | let toolbarItemIdentifier = paneIdentifier.toolbarItemIdentifier 110 | precondition(toolbarItemIdentifier == .toolbarSegmentedControlItem) 111 | 112 | // When the segments outgrow the window, we need to provide a group of 113 | // NSToolbarItems with custom menu item labels and action handling for the 114 | // context menu that pops up at the right edge of the window. 115 | let toolbarItemGroup = NSToolbarItemGroup(itemIdentifier: toolbarItemIdentifier) 116 | toolbarItemGroup.view = segmentedControl 117 | toolbarItemGroup.subitems = panes.enumerated().map { index, settingsPane -> NSToolbarItem in 118 | let item = NSToolbarItem(itemIdentifier: .init("segment-\(settingsPane.paneTitle)")) 119 | item.label = settingsPane.paneTitle 120 | 121 | let menuItem = NSMenuItem( 122 | title: settingsPane.paneTitle, 123 | action: #selector(segmentedControlMenuAction), 124 | keyEquivalent: "" 125 | ) 126 | menuItem.tag = index 127 | menuItem.target = self 128 | item.menuFormRepresentation = menuItem 129 | 130 | return item 131 | } 132 | 133 | return toolbarItemGroup 134 | } 135 | 136 | @IBAction private func segmentedControlMenuAction(_ menuItem: NSMenuItem) { 137 | delegate?.activateTab(index: menuItem.tag, animated: true) 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /Sources/Settings/Settings.swift: -------------------------------------------------------------------------------- 1 | /** 2 | The namespace for this package. 3 | */ 4 | public enum Settings {} 5 | 6 | /** 7 | A typealias for this package's namespace to solve the conflict with [SwiftUI.Settings](https://developer.apple.com/documentation/swiftui/settings). 8 | 9 | You can also use the following code snippet to solve the conflict: 10 | 11 | ```swift 12 | import enum Settings.Settings 13 | ``` 14 | */ 15 | public typealias AppSettings = Settings 16 | 17 | // TODO: Remove in the next major version. 18 | // Preserve backwards compatibility. 19 | @available(*, deprecated, renamed: "Settings") 20 | public typealias Preferences = Settings 21 | @available(*, deprecated, renamed: "SettingsPane") 22 | public typealias PreferencePane = SettingsPane 23 | @available(*, deprecated, renamed: "SettingsPaneConvertible") 24 | public typealias PreferencePaneConvertible = SettingsPaneConvertible 25 | @available(*, deprecated, renamed: "SettingsWindowController") 26 | public typealias PreferencesWindowController = SettingsWindowController 27 | 28 | @available(macOS 10.15, *) 29 | extension Settings.Pane { 30 | @available(*, deprecated, renamed: "asSettingsPane()") 31 | public func asPreferencePane() -> PreferencePane { 32 | asSettingsPane() 33 | } 34 | } 35 | 36 | extension SettingsWindowController { 37 | @available(*, deprecated, renamed: "init(panes:style:animated:hidesToolbarForSingleItem:)") 38 | public convenience init( 39 | preferencePanes: [PreferencePane], 40 | style: Settings.Style = .toolbarItems, 41 | animated: Bool = true, 42 | hidesToolbarForSingleItem: Bool = true 43 | ) { 44 | self.init( 45 | panes: preferencePanes, 46 | style: style, 47 | animated: animated, 48 | hidesToolbarForSingleItem: hidesToolbarForSingleItem) 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /Sources/Settings/SettingsPane.swift: -------------------------------------------------------------------------------- 1 | import AppKit 2 | 3 | extension Settings { 4 | public struct PaneIdentifier: Hashable, RawRepresentable, Codable { 5 | public let rawValue: String 6 | 7 | public init(rawValue: String) { 8 | self.rawValue = rawValue 9 | } 10 | } 11 | } 12 | 13 | public protocol SettingsPane: NSViewController { 14 | var paneIdentifier: Settings.PaneIdentifier { get } 15 | var paneTitle: String { get } 16 | var toolbarItemIcon: NSImage { get } 17 | } 18 | 19 | extension SettingsPane { 20 | public var toolbarItemIdentifier: NSToolbarItem.Identifier { 21 | paneIdentifier.toolbarItemIdentifier 22 | } 23 | 24 | public var toolbarItemIcon: NSImage { .empty } 25 | } 26 | 27 | extension Settings.PaneIdentifier { 28 | public init(_ rawValue: String) { 29 | self.init(rawValue: rawValue) 30 | } 31 | 32 | public init(fromToolbarItemIdentifier itemIdentifier: NSToolbarItem.Identifier) { 33 | self.init(rawValue: itemIdentifier.rawValue) 34 | } 35 | 36 | public var toolbarItemIdentifier: NSToolbarItem.Identifier { 37 | NSToolbarItem.Identifier(rawValue) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Sources/Settings/SettingsStyleController.swift: -------------------------------------------------------------------------------- 1 | import Cocoa 2 | 3 | protocol SettingsStyleController: AnyObject { 4 | var delegate: SettingsStyleControllerDelegate? { get set } 5 | var isKeepingWindowCentered: Bool { get } 6 | 7 | func toolbarItemIdentifiers() -> [NSToolbarItem.Identifier] 8 | func toolbarItem(paneIdentifier: Settings.PaneIdentifier) -> NSToolbarItem? 9 | func selectTab(index: Int) 10 | } 11 | 12 | protocol SettingsStyleControllerDelegate: AnyObject { 13 | func activateTab(paneIdentifier: Settings.PaneIdentifier, animated: Bool) 14 | func activateTab(index: Int, animated: Bool) 15 | } 16 | -------------------------------------------------------------------------------- /Sources/Settings/SettingsTabViewController.swift: -------------------------------------------------------------------------------- 1 | import Cocoa 2 | 3 | final class SettingsTabViewController: NSViewController, SettingsStyleControllerDelegate { 4 | private var activeTab: Int? 5 | private var panes = [SettingsPane]() 6 | private var style: Settings.Style? 7 | internal var settingsPanesCount: Int { panes.count } 8 | private var settingsStyleController: SettingsStyleController! 9 | private var isKeepingWindowCentered: Bool { settingsStyleController.isKeepingWindowCentered } 10 | 11 | private var toolbarItemIdentifiers: [NSToolbarItem.Identifier] { 12 | settingsStyleController?.toolbarItemIdentifiers() ?? [] 13 | } 14 | 15 | var window: NSWindow! { view.window } 16 | 17 | var isAnimated = true 18 | 19 | var activeViewController: NSViewController? { 20 | guard let activeTab else { 21 | return nil 22 | } 23 | 24 | return panes[activeTab] 25 | } 26 | 27 | override func loadView() { 28 | view = NSView() 29 | view.translatesAutoresizingMaskIntoConstraints = false 30 | } 31 | 32 | func configure(panes: [SettingsPane], style: Settings.Style) { 33 | self.panes = panes 34 | self.style = style 35 | children = panes 36 | 37 | let toolbar = NSToolbar(identifier: "SettingsToolbar") 38 | toolbar.allowsUserCustomization = false 39 | toolbar.displayMode = .iconAndLabel 40 | toolbar.showsBaselineSeparator = true 41 | toolbar.delegate = self 42 | 43 | switch style { 44 | case .segmentedControl: 45 | settingsStyleController = SegmentedControlStyleViewController(panes: panes) 46 | case .toolbarItems: 47 | settingsStyleController = ToolbarItemStyleViewController( 48 | panes: panes, 49 | toolbar: toolbar, 50 | centerToolbarItems: false 51 | ) 52 | } 53 | settingsStyleController.delegate = self 54 | 55 | // Called last so that `settingsStyleController` can be asked for items. 56 | window.toolbar = toolbar 57 | } 58 | 59 | func activateTab(paneIdentifier: Settings.PaneIdentifier, animated: Bool) { 60 | guard let index = (panes.firstIndex { $0.paneIdentifier == paneIdentifier }) else { 61 | return activateTab(index: 0, animated: animated) 62 | } 63 | 64 | activateTab(index: index, animated: animated) 65 | } 66 | 67 | func activateTab(index: Int, animated: Bool) { 68 | defer { 69 | activeTab = index 70 | settingsStyleController.selectTab(index: index) 71 | updateWindowTitle(tabIndex: index) 72 | } 73 | 74 | if activeTab == nil { 75 | immediatelyDisplayTab(index: index) 76 | } else { 77 | guard index != activeTab else { 78 | return 79 | } 80 | 81 | animateTabTransition(index: index, animated: animated) 82 | } 83 | } 84 | 85 | func restoreInitialTab() { 86 | if activeTab == nil { 87 | activateTab(index: 0, animated: false) 88 | } 89 | } 90 | 91 | private func updateWindowTitle(tabIndex: Int) { 92 | window.title = { 93 | if panes.count > 1 { 94 | return panes[tabIndex].paneTitle 95 | } else { 96 | let settings: String 97 | if #available(macOS 13, *) { 98 | settings = NSLocalizedString("settings", bundle: .module, comment: "Settings") 99 | } else { 100 | settings = NSLocalizedString("preferences", bundle: .module, comment: "Preferences") 101 | } 102 | 103 | let appName = Bundle.main.appName 104 | return "\(appName) \(settings)" 105 | } 106 | }() 107 | } 108 | 109 | /** 110 | Cached constraints that pin `childViewController` views to the content view. 111 | */ 112 | private var activeChildViewConstraints = [NSLayoutConstraint]() 113 | 114 | private func immediatelyDisplayTab(index: Int) { 115 | let toViewController = panes[index] 116 | view.addSubview(toViewController.view) 117 | activeChildViewConstraints = toViewController.view.constrainToSuperviewBounds() 118 | setWindowFrame(for: toViewController, animated: false) 119 | } 120 | 121 | private func animateTabTransition(index: Int, animated: Bool) { 122 | guard let activeTab else { 123 | assertionFailure("animateTabTransition called before a tab was displayed; transition only works from one tab to another") 124 | immediatelyDisplayTab(index: index) 125 | return 126 | } 127 | 128 | let fromViewController = panes[activeTab] 129 | let toViewController = panes[index] 130 | 131 | // View controller animations only work on macOS 10.14 and newer. 132 | let options: NSViewController.TransitionOptions 133 | if #available(macOS 10.14, *) { 134 | options = animated && isAnimated ? [.crossfade] : [] 135 | } else { 136 | options = [] 137 | } 138 | 139 | view.removeConstraints(activeChildViewConstraints) 140 | toViewController.view.translatesAutoresizingMaskIntoConstraints = false 141 | 142 | transition( 143 | from: fromViewController, 144 | to: toViewController, 145 | options: options 146 | ) { [self] in 147 | if 148 | isAnimated, 149 | let toolbarItemStyleViewController = settingsStyleController as? ToolbarItemStyleViewController 150 | { 151 | toolbarItemStyleViewController.refreshPreviousSelectedItem() 152 | } 153 | 154 | activeChildViewConstraints = toViewController.view.constrainToSuperviewBounds() 155 | } 156 | } 157 | 158 | override func transition( 159 | from fromViewController: NSViewController, 160 | to toViewController: NSViewController, 161 | options: NSViewController.TransitionOptions = [], 162 | completionHandler completion: (() -> Void)? = nil 163 | ) { 164 | let isAnimated = !options.isEmpty && options.isSubset(of: [ 165 | .crossfade, 166 | .slideUp, 167 | .slideDown, 168 | .slideForward, 169 | .slideBackward, 170 | .slideLeft, 171 | .slideRight 172 | ]) 173 | 174 | if isAnimated { 175 | NSAnimationContext.runAnimationGroup({ context in 176 | context.allowsImplicitAnimation = true 177 | context.duration = 0.25 178 | context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) 179 | setWindowFrame(for: toViewController, animated: true) 180 | 181 | super.transition( 182 | from: fromViewController, 183 | to: toViewController, 184 | options: options, 185 | completionHandler: completion 186 | ) 187 | }, completionHandler: nil) 188 | } else { 189 | super.transition( 190 | from: fromViewController, 191 | to: toViewController, 192 | options: options, 193 | completionHandler: completion 194 | ) 195 | } 196 | } 197 | 198 | private func setWindowFrame(for viewController: NSViewController, animated: Bool = false) { 199 | guard let window else { 200 | preconditionFailure() 201 | } 202 | 203 | let contentSize = viewController.view.fittingSize 204 | 205 | let newWindowSize = window.frameRect(forContentRect: CGRect(origin: .zero, size: contentSize)).size 206 | var frame = window.frame 207 | frame.origin.y += frame.height - newWindowSize.height 208 | frame.size = newWindowSize 209 | 210 | if isKeepingWindowCentered { 211 | let horizontalDiff = (window.frame.width - newWindowSize.width) / 2 212 | frame.origin.x += horizontalDiff 213 | } 214 | 215 | let animatableWindow = animated ? window.animator() : window 216 | animatableWindow.setFrame(frame, display: false) 217 | } 218 | } 219 | 220 | extension SettingsTabViewController: NSToolbarDelegate { 221 | func toolbarDefaultItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] { 222 | toolbarItemIdentifiers 223 | } 224 | 225 | func toolbarAllowedItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] { 226 | toolbarItemIdentifiers 227 | } 228 | 229 | func toolbarSelectableItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] { 230 | style == .segmentedControl ? [] : toolbarItemIdentifiers 231 | } 232 | 233 | public func toolbar( 234 | _ toolbar: NSToolbar, 235 | itemForItemIdentifier itemIdentifier: NSToolbarItem.Identifier, 236 | willBeInsertedIntoToolbar flag: Bool 237 | ) -> NSToolbarItem? { 238 | if itemIdentifier == .flexibleSpace { 239 | return nil 240 | } 241 | 242 | return settingsStyleController.toolbarItem(paneIdentifier: .init(fromToolbarItemIdentifier: itemIdentifier)) 243 | } 244 | } 245 | -------------------------------------------------------------------------------- /Sources/Settings/SettingsWindowController.swift: -------------------------------------------------------------------------------- 1 | import AppKit 2 | 3 | extension NSWindow.FrameAutosaveName { 4 | static let settings: NSWindow.FrameAutosaveName = "com.sindresorhus.Settings.FrameAutosaveName" 5 | } 6 | 7 | public final class SettingsWindowController: NSWindowController { 8 | private let tabViewController = SettingsTabViewController() 9 | 10 | public var isAnimated: Bool { 11 | get { tabViewController.isAnimated } 12 | set { 13 | tabViewController.isAnimated = newValue 14 | } 15 | } 16 | 17 | public var hidesToolbarForSingleItem: Bool { 18 | didSet { 19 | updateToolbarVisibility() 20 | } 21 | } 22 | 23 | private func updateToolbarVisibility() { 24 | window?.toolbar?.isVisible = (hidesToolbarForSingleItem == false) 25 | || (tabViewController.settingsPanesCount > 1) 26 | } 27 | 28 | public init( 29 | panes: [SettingsPane], 30 | style: Settings.Style = .toolbarItems, 31 | animated: Bool = true, 32 | hidesToolbarForSingleItem: Bool = true 33 | ) { 34 | precondition(!panes.isEmpty, "You need to set at least one pane") 35 | 36 | let window = UserInteractionPausableWindow( 37 | contentRect: panes[0].view.bounds, 38 | styleMask: [ 39 | .titled, 40 | .closable 41 | ], 42 | backing: .buffered, 43 | defer: true 44 | ) 45 | self.hidesToolbarForSingleItem = hidesToolbarForSingleItem 46 | super.init(window: window) 47 | 48 | window.contentViewController = tabViewController 49 | 50 | window.titleVisibility = { 51 | switch style { 52 | case .toolbarItems: 53 | return .visible 54 | case .segmentedControl: 55 | return panes.count <= 1 ? .visible : .hidden 56 | } 57 | }() 58 | 59 | if #available(macOS 11.0, *), style == .toolbarItems { 60 | window.toolbarStyle = .preference 61 | } 62 | 63 | tabViewController.isAnimated = animated 64 | tabViewController.configure(panes: panes, style: style) 65 | updateToolbarVisibility() 66 | } 67 | 68 | @available(*, unavailable) 69 | override public init(window: NSWindow?) { 70 | fatalError("init(window:) is not supported, use init(panes:style:animated:hidesToolbarForSingleItem:)") 71 | } 72 | 73 | @available(*, unavailable) 74 | public required init?(coder: NSCoder) { 75 | fatalError("init(coder:) is not supported, use init(panes:style:animated:hidesToolbarForSingleItem:hidesToolbarForSingleItem:)") 76 | } 77 | 78 | 79 | /** 80 | Show the settings window and brings it to front. 81 | 82 | If you pass a `Settings.PaneIdentifier`, the window will activate the corresponding tab. 83 | 84 | - Parameter paneIdentifier: Identifier of the settings pane to display, or `nil` to show the tab that was open when the user last closed the window. 85 | 86 | - Note: Unless you need to open a specific pane, prefer not to pass a parameter at all or `nil`. 87 | 88 | - See `close()` to close the window again. 89 | - See `showWindow(_:)` to show the window without the convenience of activating the app. 90 | */ 91 | public func show(pane paneIdentifier: Settings.PaneIdentifier? = nil) { 92 | if let paneIdentifier { 93 | tabViewController.activateTab(paneIdentifier: paneIdentifier, animated: false) 94 | } else { 95 | tabViewController.restoreInitialTab() 96 | } 97 | 98 | #if compiler(>=5.9) && canImport(AppKit) 99 | if #available(macOS 14, *) { 100 | NSApp.activate() 101 | } else { 102 | NSApp.activate(ignoringOtherApps: true) 103 | } 104 | #else 105 | NSApp.activate(ignoringOtherApps: true) 106 | #endif 107 | 108 | showWindow(self) 109 | restoreWindowPosition() 110 | } 111 | 112 | private func restoreWindowPosition() { 113 | guard let window else { 114 | return 115 | } 116 | 117 | window.center() 118 | window.setFrameUsingName(.settings) 119 | window.setFrameAutosaveName(.settings) 120 | } 121 | } 122 | 123 | extension SettingsWindowController { 124 | /** 125 | Returns the active pane if it responds to the given action. 126 | */ 127 | override public func supplementalTarget(forAction action: Selector, sender: Any?) -> Any? { 128 | if let target = super.supplementalTarget(forAction: action, sender: sender) { 129 | return target 130 | } 131 | 132 | guard let activeViewController = tabViewController.activeViewController else { 133 | return nil 134 | } 135 | 136 | if let target = NSApp.target(forAction: action, to: activeViewController, from: sender) as? NSResponder, target.responds(to: action) { 137 | return target 138 | } 139 | 140 | if let target = activeViewController.supplementalTarget(forAction: action, sender: sender) as? NSResponder, target.responds(to: action) { 141 | return target 142 | } 143 | 144 | return nil 145 | } 146 | } 147 | 148 | @available(macOS 10.15, *) 149 | extension SettingsWindowController { 150 | /** 151 | Create a settings window from only SwiftUI-based settings panes. 152 | */ 153 | public convenience init( 154 | panes: [SettingsPaneConvertible], 155 | style: Settings.Style = .toolbarItems, 156 | animated: Bool = true, 157 | hidesToolbarForSingleItem: Bool = true 158 | ) { 159 | self.init( 160 | panes: panes.map { $0.asSettingsPane() }, 161 | style: style, 162 | animated: animated, 163 | hidesToolbarForSingleItem: hidesToolbarForSingleItem 164 | ) 165 | } 166 | } 167 | -------------------------------------------------------------------------------- /Sources/Settings/Style.swift: -------------------------------------------------------------------------------- 1 | import Cocoa 2 | 3 | extension Settings { 4 | public enum Style { 5 | case toolbarItems 6 | case segmentedControl 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /Sources/Settings/ToolbarItemStyleViewController.swift: -------------------------------------------------------------------------------- 1 | import Cocoa 2 | 3 | final class ToolbarItemStyleViewController: NSObject, SettingsStyleController { 4 | let toolbar: NSToolbar 5 | let centerToolbarItems: Bool 6 | let panes: [SettingsPane] 7 | var isKeepingWindowCentered: Bool { centerToolbarItems } 8 | weak var delegate: SettingsStyleControllerDelegate? 9 | private var previousSelectedItemIdentifier: NSToolbarItem.Identifier? 10 | 11 | init(panes: [SettingsPane], toolbar: NSToolbar, centerToolbarItems: Bool) { 12 | self.panes = panes 13 | self.toolbar = toolbar 14 | self.centerToolbarItems = centerToolbarItems 15 | } 16 | 17 | func toolbarItemIdentifiers() -> [NSToolbarItem.Identifier] { 18 | var toolbarItemIdentifiers = [NSToolbarItem.Identifier]() 19 | 20 | if centerToolbarItems { 21 | toolbarItemIdentifiers.append(.flexibleSpace) 22 | } 23 | 24 | for pane in panes { 25 | toolbarItemIdentifiers.append(pane.toolbarItemIdentifier) 26 | } 27 | 28 | if centerToolbarItems { 29 | toolbarItemIdentifiers.append(.flexibleSpace) 30 | } 31 | 32 | return toolbarItemIdentifiers 33 | } 34 | 35 | func toolbarItem(paneIdentifier: Settings.PaneIdentifier) -> NSToolbarItem? { 36 | guard let pane = (panes.first { $0.paneIdentifier == paneIdentifier }) else { 37 | preconditionFailure() 38 | } 39 | 40 | let toolbarItem = NSToolbarItem(itemIdentifier: paneIdentifier.toolbarItemIdentifier) 41 | toolbarItem.label = pane.paneTitle 42 | toolbarItem.image = pane.toolbarItemIcon 43 | toolbarItem.target = self 44 | toolbarItem.action = #selector(toolbarItemSelected) 45 | return toolbarItem 46 | } 47 | 48 | @IBAction private func toolbarItemSelected(_ toolbarItem: NSToolbarItem) { 49 | delegate?.activateTab( 50 | paneIdentifier: .init(fromToolbarItemIdentifier: toolbarItem.itemIdentifier), 51 | animated: true 52 | ) 53 | } 54 | 55 | func selectTab(index: Int) { 56 | toolbar.selectedItemIdentifier = panes[index].toolbarItemIdentifier 57 | } 58 | 59 | public func refreshPreviousSelectedItem() { 60 | // On macOS Sonoma, sometimes NSToolbar would preserve the 61 | // visual selected state of previous selected toolbar item during 62 | // view animation. 63 | // AppKit doesn’t seem to offer a way to refresh toolbar items. 64 | // So we manually “refresh” it. 65 | if 66 | #available(macOS 14, *), 67 | let previousSelectedItemIdentifier, 68 | let index = toolbar.items.firstIndex(where: { $0.itemIdentifier == previousSelectedItemIdentifier }) 69 | { 70 | toolbar.removeItem(at: index) 71 | toolbar.insertItem(withItemIdentifier: previousSelectedItemIdentifier, at: index) 72 | } 73 | 74 | previousSelectedItemIdentifier = toolbar.selectedItemIdentifier 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /Sources/Settings/Utilities.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | extension NSImage { 4 | static var empty: NSImage { NSImage(size: .zero) } 5 | } 6 | 7 | extension NSView { 8 | @discardableResult 9 | func constrainToSuperviewBounds() -> [NSLayoutConstraint] { 10 | guard let superview else { 11 | preconditionFailure("superview has to be set first") 12 | } 13 | 14 | var result = [NSLayoutConstraint]() 15 | result.append(contentsOf: NSLayoutConstraint.constraints(withVisualFormat: "H:|-0-[subview]-0-|", options: .directionLeadingToTrailing, metrics: nil, views: ["subview": self])) 16 | result.append(contentsOf: NSLayoutConstraint.constraints(withVisualFormat: "V:|-0-[subview]-0-|", options: .directionLeadingToTrailing, metrics: nil, views: ["subview": self])) 17 | translatesAutoresizingMaskIntoConstraints = false 18 | superview.addConstraints(result) 19 | 20 | return result 21 | } 22 | } 23 | 24 | extension NSEvent { 25 | /** 26 | Events triggered by user interaction. 27 | */ 28 | static let userInteractionEvents: [EventType] = [ 29 | .leftMouseDown, 30 | .leftMouseUp, 31 | .rightMouseDown, 32 | .rightMouseUp, 33 | .leftMouseDragged, 34 | .rightMouseDragged, 35 | .keyDown, 36 | .keyUp, 37 | .scrollWheel, 38 | .tabletPoint, 39 | .otherMouseDown, 40 | .otherMouseUp, 41 | .otherMouseDragged, 42 | .gesture, 43 | .magnify, 44 | .swipe, 45 | .rotate, 46 | .beginGesture, 47 | .endGesture, 48 | .smartMagnify, 49 | .pressure, 50 | .quickLook, 51 | .directTouch 52 | ] 53 | 54 | /** 55 | Whether the event was triggered by user interaction. 56 | */ 57 | var isUserInteraction: Bool { Self.userInteractionEvents.contains(type) } 58 | } 59 | 60 | extension Bundle { 61 | var appName: String { 62 | string(forInfoDictionaryKey: "CFBundleDisplayName") 63 | ?? string(forInfoDictionaryKey: "CFBundleName") 64 | ?? string(forInfoDictionaryKey: "CFBundleExecutable") 65 | ?? "" 66 | } 67 | 68 | private func string(forInfoDictionaryKey key: String) -> String? { 69 | // `object(forInfoDictionaryKey:)` prefers localized info dictionary over the regular one automatically 70 | object(forInfoDictionaryKey: key) as? String 71 | } 72 | } 73 | 74 | 75 | /** 76 | A window that allows you to disable all user interactions via `isUserInteractionEnabled`. 77 | 78 | Used to avoid breaking animations when the user clicks too fast. Disable user interactions during animations and you're set. 79 | */ 80 | class UserInteractionPausableWindow: NSWindow { // swiftlint:disable:this final_class 81 | var isUserInteractionEnabled = true 82 | 83 | override func sendEvent(_ event: NSEvent) { 84 | guard isUserInteractionEnabled || !event.isUserInteraction else { 85 | return 86 | } 87 | 88 | super.sendEvent(event) 89 | } 90 | 91 | override func responds(to selector: Selector!) -> Bool { 92 | // Deactivate toolbar interactions from the Main Menu. 93 | if selector == #selector(NSWindow.toggleToolbarShown(_:)) { 94 | return false 95 | } 96 | 97 | return super.responds(to: selector) 98 | } 99 | } 100 | 101 | 102 | @available(macOS 10.15, *) 103 | extension View { 104 | /** 105 | Equivalent to `.eraseToAnyPublisher()` from the Combine framework. 106 | */ 107 | func eraseToAnyView() -> AnyView { 108 | AnyView(self) 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /license: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Sindre Sorhus (https://sindresorhus.com) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # Settings 2 | 3 | > Add a settings window to your macOS app in minutes 4 | 5 | 6 | 7 | Just pass in some view controllers and this package will take care of the rest. Built-in SwiftUI support. 8 | 9 | *This package is compatible with macOS 13 and automatically uses `Settings` instead of `Preferences` in the window title on macOS 13 and later.* 10 | 11 | *This project was previously known as `Preferences`.* 12 | 13 | ## Requirements 14 | 15 | macOS 10.13 and later. 16 | 17 | ## Install 18 | 19 | Add `https://github.com/sindresorhus/Settings` in the [“Swift Package Manager” tab in Xcode](https://developer.apple.com/documentation/xcode/adding_package_dependencies_to_your_app). 20 | 21 | ## Usage 22 | 23 | *Run the `Example` Xcode project to try a live example (requires macOS 11 or later).* 24 | 25 | First, create some settings pane identifiers: 26 | 27 | ```swift 28 | import Settings 29 | 30 | extension Settings.PaneIdentifier { 31 | static let general = Self("general") 32 | static let advanced = Self("advanced") 33 | } 34 | ``` 35 | 36 | Second, create a couple of view controllers for the settings panes you want. The only difference from implementing a normal view controller is that you have to add the `SettingsPane` protocol and implement the `paneIdentifier`, `toolbarItemTitle`, and `toolbarItemIcon` properties, as shown below. You can leave out `toolbarItemIcon` if you're using the `.segmentedControl` style. 37 | 38 | `GeneralSettingsViewController.swift` 39 | 40 | ```swift 41 | import Cocoa 42 | import Settings 43 | 44 | final class GeneralSettingsViewController: NSViewController, SettingsPane { 45 | let paneIdentifier = Settings.PaneIdentifier.general 46 | let paneTitle = "General" 47 | let toolbarItemIcon = NSImage(systemSymbolName: "gearshape", accessibilityDescription: "General settings")! 48 | 49 | override var nibName: NSNib.Name? { "GeneralSettingsViewController" } 50 | 51 | override func viewDidLoad() { 52 | super.viewDidLoad() 53 | 54 | // Setup stuff here 55 | } 56 | } 57 | ``` 58 | 59 | Note: If you need to support macOS versions older than macOS 11, you have to add a [fallback for the `toolbarItemIcon`](#backwards-compatibility). 60 | 61 | `AdvancedSettingsViewController.swift` 62 | 63 | ```swift 64 | import Cocoa 65 | import Settings 66 | 67 | final class AdvancedSettingsViewController: NSViewController, SettingsPane { 68 | let paneIdentifier = Settings.PaneIdentifier.advanced 69 | let paneTitle = "Advanced" 70 | let toolbarItemIcon = NSImage(systemSymbolName: "gearshape.2", accessibilityDescription: "Advanced settings")! 71 | 72 | override var nibName: NSNib.Name? { "AdvancedSettingsViewController" } 73 | 74 | override func viewDidLoad() { 75 | super.viewDidLoad() 76 | 77 | // Setup stuff here 78 | } 79 | } 80 | ``` 81 | 82 | If you need to respond actions indirectly, the settings window controller will forward responder chain actions to the active pane if it responds to that selector. 83 | 84 | ```swift 85 | final class AdvancedSettingsViewController: NSViewController, SettingsPane { 86 | @IBOutlet private var fontLabel: NSTextField! 87 | private var selectedFont = NSFont.systemFont(ofSize: 14) 88 | 89 | @IBAction private func changeFont(_ sender: NSFontManager) { 90 | font = sender.convert(font) 91 | } 92 | } 93 | ``` 94 | 95 | In the `AppDelegate`, initialize a new `SettingsWindowController` and pass it the view controllers. Then add an action outlet for the `Settings…` menu item to show the settings window. 96 | 97 | `AppDelegate.swift` 98 | 99 | ```swift 100 | import Cocoa 101 | import Settings 102 | 103 | @main 104 | final class AppDelegate: NSObject, NSApplicationDelegate { 105 | @IBOutlet private var window: NSWindow! 106 | 107 | private lazy var settingsWindowController = SettingsWindowController( 108 | panes: [ 109 | GeneralSettingsViewController(), 110 | AdvancedSettingsViewController() 111 | ] 112 | ) 113 | 114 | func applicationDidFinishLaunching(_ notification: Notification) {} 115 | 116 | @IBAction 117 | func settingsMenuItemActionHandler(_ sender: NSMenuItem) { 118 | settingsWindowController.show() 119 | } 120 | } 121 | ``` 122 | 123 | ### Settings Tab Styles 124 | 125 | When you create the `SettingsWindowController`, you can choose between the `NSToolbarItem`-based style (default) and the `NSSegmentedControl`: 126 | 127 | ```swift 128 | // … 129 | private lazy var settingsWindowController = SettingsWindowController( 130 | panes: [ 131 | GeneralSettingsViewController(), 132 | AdvancedSettingsViewController() 133 | ], 134 | style: .segmentedControl 135 | ) 136 | // … 137 | ``` 138 | 139 | `.toolbarItem` style: 140 | 141 | ![NSToolbarItem based (default)](toolbar-item.png) 142 | 143 | `.segmentedControl` style: 144 | 145 | ![NSSegmentedControl based](segmented-control.png) 146 | 147 | ## API 148 | 149 | ```swift 150 | public enum Settings {} 151 | 152 | extension Settings { 153 | public enum Style { 154 | case toolbarItems 155 | case segmentedControl 156 | } 157 | } 158 | 159 | public protocol SettingsPane: NSViewController { 160 | var paneIdentifier: Settings.PaneIdentifier { get } 161 | var paneTitle: String { get } 162 | var toolbarItemIcon: NSImage { get } // Not required when using the .`segmentedControl` style 163 | } 164 | 165 | public final class SettingsWindowController: NSWindowController { 166 | init( 167 | panes: [SettingsPane], 168 | style: Settings.Style = .toolbarItems, 169 | animated: Bool = true, 170 | hidesToolbarForSingleItem: Bool = true 171 | ) 172 | 173 | init( 174 | panes: [SettingsPaneConvertible], 175 | style: Settings.Style = .toolbarItems, 176 | animated: Bool = true, 177 | hidesToolbarForSingleItem: Bool = true 178 | ) 179 | 180 | func show(pane: Settings.PaneIdentifier? = nil) 181 | } 182 | ``` 183 | 184 | As with any `NSWindowController`, call `NSWindowController#close()` to close the settings window. 185 | 186 | ## Recommendation 187 | 188 | The easiest way to create the user interface within each pane is to use a [`NSGridView`](https://developer.apple.com/documentation/appkit/nsgridview) in Interface Builder. See the example project in this repo for a demo. 189 | 190 | ## SwiftUI support 191 | 192 | If your deployment target is macOS 10.15 or later, you can use the bundled SwiftUI components to create panes. Create a `Settings.Pane` (instead of `SettingsPane` when using AppKit) using your custom view and necessary toolbar information. 193 | 194 | Run the `Example` target in the Xcode project in this repo to see a real-world example. The `Accounts` tab is in SwiftUI. 195 | 196 | There are also some bundled convenience SwiftUI components, like [`Settings.Container`](Sources/Settings/Container.swift) and [`Settings.Section`](Sources/Settings/Section.swift) to automatically achieve similar alignment to AppKit's [`NSGridView`](https://developer.apple.com/documentation/appkit/nsgridview). And also a `.settiingDescription()` view modifier to style text as a setting description. 197 | 198 | Tip: The [`Defaults`](https://github.com/sindresorhus/Defaults#swiftui-support) package makes it very easy to persist the settings. 199 | 200 | ```swift 201 | struct CustomPane: View { 202 | var body: some View { 203 | Settings.Container(contentWidth: 450.0) { 204 | Settings.Section(title: "Section Title") { 205 | // Some view. 206 | } 207 | Settings.Section(label: { 208 | // Custom label aligned on the right side. 209 | }) { 210 | // Some view. 211 | } 212 | … 213 | } 214 | } 215 | } 216 | ``` 217 | 218 | Then in the `AppDelegate`, initialize a new `SettingsWindowController` and pass it the pane views. 219 | 220 | ```swift 221 | // … 222 | 223 | private lazy var settingsWindowController = SettingsWindowController( 224 | panes: [ 225 | Pane( 226 | identifier: …, 227 | title: …, 228 | toolbarIcon: NSImage(…) 229 | ) { 230 | CustomPane() 231 | }, 232 | Pane( 233 | identifier: …, 234 | title: …, 235 | toolbarIcon: NSImage(…) 236 | ) { 237 | AnotherCustomPane() 238 | } 239 | ] 240 | ) 241 | 242 | // … 243 | ``` 244 | 245 | If you want to use SwiftUI panes alongside standard AppKit `NSViewController`'s, instead wrap the pane views into `Settings.PaneHostingController` and pass them to `SettingsWindowController` as you would with standard panes. 246 | 247 | ```swift 248 | let CustomViewSettingsPaneViewController: () -> SettingsPane = { 249 | let paneView = Settings.Pane( 250 | identifier: …, 251 | title: …, 252 | toolbarIcon: NSImage(…) 253 | ) { 254 | // Your custom view (and modifiers if needed). 255 | CustomPane() 256 | // .environmentObject(someSettingsManager) 257 | } 258 | 259 | return Settings.PaneHostingController(paneView: paneView) 260 | } 261 | 262 | // … 263 | 264 | private lazy var settingsWindowController = SettingsWindowController( 265 | panes: [ 266 | GeneralSettingsViewController(), 267 | AdvancedSettingsViewController(), 268 | CustomViewSettingsPaneViewController() 269 | ], 270 | style: .segmentedControl 271 | ) 272 | 273 | // … 274 | ``` 275 | 276 | [Full example here.](Example/AccountsScreen.swift). 277 | 278 | ## Backwards compatibility 279 | 280 | macOS 11 and later supports SF Symbols which can be conveniently used for the toolbar icons. If you need to support older macOS versions, you have to add a fallback. Apple recommends using the same icons even for older systems. The best way to achieve this is to [export the relevant SF Symbols icons](https://github.com/davedelong/sfsymbols) to images and add them to your Asset Catalog. 281 | 282 | ## Known issues 283 | 284 | ### The settings window doesn't show 285 | 286 | This can happen when you are not using auto-layout or have not set a size for the view controller. You can fix this by either using auto-layout or setting an explicit size, for example, `preferredContentSize` in `viewDidLoad()`. [We intend to fix this.](https://github.com/sindresorhus/Settings/pull/28) 287 | 288 | ### There are no animations on macOS 10.13 and earlier 289 | 290 | The `animated` parameter of `SettingsWindowController.init` has no effect on macOS 10.13 or earlier as those versions don't support `NSViewController.TransitionOptions.crossfade`. 291 | 292 | ## FAQ 293 | 294 | ### How can I localize the window title? 295 | 296 | The `SettingsWindowController` adheres to the [macOS Human Interface Guidelines](https://developer.apple.com/design/human-interface-guidelines/patterns/settings/) and uses this set of rules to determine the window title: 297 | 298 | - **Multiple settings panes:** Uses the currently selected `paneTitle` as the window title. Localize your `paneTitle`s to get localized window titles. 299 | - **Single settings pane:** Sets the window title to `APPNAME Settings`. The app name is obtained from your app's bundle. You can localize its `Info.plist` to customize the title. The `Settings` part is taken from the "Settings…" menu item, see #12. The order of lookup for the app name from your bundle: 300 | 1. `CFBundleDisplayName` 301 | 2. `CFBundleName` 302 | 3. `CFBundleExecutable` 303 | 4. Fall back to `""` to show you're missing some settings. 304 | 305 | ### Why should I use this instead of just manually implementing it myself? 306 | 307 | It can't be that hard right? Well, turns out it is: 308 | 309 | - The recommended way is to implement it using storyboards. [But storyboards...](https://gist.github.com/iraycd/01b45c5e1be7ef6957b7) And if you want the segmented control style, you have to implement it programmatically, [which is quite complex](https://github.com/sindresorhus/Settings/blob/85f8d793050004fc0154c7f6a061412e00d13fa3/Sources/Preferences/SegmentedControlStyleViewController.swift). 310 | - [Even Apple gets it wrong, a lot.](https://twitter.com/sindresorhus/status/1113382212584464384) 311 | - You have to correctly handle [window](https://github.com/sindresorhus/Settings/commit/cc25d58a9ec379812fc8f2fd7ba48f3d35b4cbff) and [tab restoration](https://github.com/sindresorhus/Settings/commit/2bb3fc7418f3dc49b534fab986807c4e70ba78c3). 312 | - [The window title format depends on whether you have a single or multiple panes.](https://developer.apple.com/design/human-interface-guidelines/patterns/settings/) 313 | - It's difficult to get the transition animation right. A lot of apps have flaky animation between panes. 314 | - You end up having to deal with a lot of gnarly auto-layout complexities. 315 | 316 | ### How is it better than [`MASPreferences`](https://github.com/shpakovski/MASPreferences)? 317 | 318 | - Written in Swift. *(No bridging header!)* 319 | - Swifty API using a protocol. 320 | - Supports segmented control style tabs. 321 | - SwiftUI support. 322 | - Fully documented. 323 | - Adheres to the [macOS Human Interface Guidelines](https://developer.apple.com/design/human-interface-guidelines/patterns/settings/). 324 | - The window title is automatically localized by using the system string. 325 | 326 | ## Related 327 | 328 | - [Defaults](https://github.com/sindresorhus/Defaults) - Swifty and modern UserDefaults 329 | - [LaunchAtLogin](https://github.com/sindresorhus/LaunchAtLogin) - Add "Launch at Login" functionality to your macOS app 330 | - [KeyboardShortcuts](https://github.com/sindresorhus/KeyboardShortcuts) - Add user-customizable global keyboard shortcuts to your macOS app 331 | - [DockProgress](https://github.com/sindresorhus/DockProgress) - Show progress in your app's Dock icon 332 | - [More…](https://github.com/search?q=user%3Asindresorhus+language%3Aswift+archived%3Afalse&type=repositories) 333 | 334 | You might also like Sindre's [apps](https://sindresorhus.com/apps). 335 | 336 | ## Used in these apps 337 | 338 | - [TableFlip](https://tableflipapp.com) - Visual Markdown table editor by [Christian Tietze](https://github.com/DivineDominion) 339 | - [The Archive](https://zettelkasten.de/the-archive/) - Note-taking app by [Christian Tietze](https://github.com/DivineDominion) 340 | - [Word Counter](https://wordcounterapp.com) - Measuring writer's productivity by [Christian Tietze](https://github.com/DivineDominion) 341 | - [Medis](https://getmedis.com) - A Redis GUI by [Zihua Li](https://github.com/luin) 342 | - [OK JSON](https://okjson.app) - A scriptable JSON formatter by [Francis Feng](https://github.com/francisfeng) 343 | - [Menu Helper](https://github.com/Kyle-Ye/MenuHelper) - A Finder Extension App by [Kyle-Ye](https://github.com/Kyle-Ye) 344 | 345 | Want to tell the world about your app that is using this package? Open a PR! 346 | 347 | ## Maintainers 348 | 349 | - [Sindre Sorhus](https://github.com/sindresorhus) 350 | - [Christian Tietze](https://github.com/DivineDominion) 351 | -------------------------------------------------------------------------------- /screenshot.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sindresorhus/Settings/879ea83a7bbc6dbebf62bed8c547f090146372a6/screenshot.gif -------------------------------------------------------------------------------- /segmented-control.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sindresorhus/Settings/879ea83a7bbc6dbebf62bed8c547f090146372a6/segmented-control.png -------------------------------------------------------------------------------- /toolbar-item.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sindresorhus/Settings/879ea83a7bbc6dbebf62bed8c547f090146372a6/toolbar-item.png --------------------------------------------------------------------------------