Path: blob/a-new-beginning/Folium-iOS/Controllers/SettingsControllers/MandarineSettingsController.swift
2 views
//
// MandarineSettingsController.swift
// Folium-iOS
//
// Created by Jarrod Norwell on 27/2/2026.
//
import Foundation
import SettingsKit
import Mandarine
import UIKit
enum MandarineSettingsHeaders : String, CaseIterable {
case core = "Core"
case graphics = "Graphics"
case resolution = "Resolution"
var header: SettingHeader {
switch self {
case .core,
.graphics,
.resolution:
SettingHeader(text: rawValue)
}
}
static var allHeaders: [SettingHeader] { allCases.map { $0.header } }
}
enum MandarineSettingsItems : String, CaseIterable {
// Core
case forceNTSC = "mandarine.v1.38.forceNTSC"
// Graphics
case widescreen = "mandarine.v1.38.widescreen"
// Resolution
case height = "mandarine.v1.38.height"
case width = "mandarine.v1.38.width"
var title: String {
switch self {
case .forceNTSC:
"Force NTSC"
case .height:
"Height"
case .widescreen:
"Widescreen"
case .width:
"Width"
}
}
var secondaryTitle: String? {
switch self {
case .height,
.width:
"Unavailable with software rendering"
default:
nil
}
}
var details: String? {
switch self {
case .widescreen:
"Enables widescreen patches so that 3D games render in a 16:9 aspect ratio"
default:
nil
}
}
var isEnabled: Bool {
switch self {
case .height,
.width:
false
default:
true
}
}
func setting(_ delegate: SettingDelegate? = nil) -> BaseSetting {
switch self {
case .height,
.width:
InputNumberSetting(key: rawValue,
title: title,
details: details,
secondaryTitle: secondaryTitle,
isEnabled: isEnabled,
min: self == .height ? 480 : 640,
max: self == .height ? 1920 : 2560,
value: UserDefaults.standard.double(forKey: rawValue),
delegate: delegate)
case .forceNTSC,
.widescreen:
BoolSetting(key: rawValue,
title: title,
details: details,
isEnabled: isEnabled,
value: UserDefaults.standard.bool(forKey: rawValue),
delegate: delegate)
}
}
static func settings(_ header: MandarineSettingsHeaders) -> [MandarineSettingsItems] {
switch header {
case .core:
[.forceNTSC]
case .graphics:
[.widescreen]
case .resolution:
[.height, .width]
}
}
}
class MandarineSettingsController : UIViewController, UICollectionViewDelegate {
var dataSource: UICollectionViewDiffableDataSource<MandarineSettingsHeaders, AnyHashableSendable>? = nil
var snapshot: NSDiffableDataSourceSnapshot<MandarineSettingsHeaders, AnyHashableSendable>? = nil
var mandarine: Mandarine
init(_ mandarine: Mandarine) {
self.mandarine = mandarine
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
if let navigationController {
navigationController.navigationBar.prefersLargeTitles = true
}
navigationItem.leftBarButtonItem = UIBarButtonItem(image: UIImage(systemName: "xmark"), primaryAction: UIAction { _ in
self.dismiss(animated: true)
})
if #available(iOS 26, *) {
navigationItem.title = "Mandarine"
navigationItem.largeTitle = navigationItem.title
navigationItem.subtitle = "PlayStation 1 (Avocado)"
navigationItem.largeSubtitle = navigationItem.subtitle
} else {
title = "Mandarine"
}
navigationItem.style = .browser
var configuration: UICollectionLayoutListConfiguration = UICollectionLayoutListConfiguration(appearance: .insetGrouped)
configuration.backgroundColor = .clear
configuration.headerMode = .supplementary
configuration.trailingSwipeActionsConfigurationProvider = { indexPath in
guard let dataSource = self.dataSource, let item: BaseSetting = dataSource.itemIdentifier(for: indexPath) as? BaseSetting else {
return UISwipeActionsConfiguration()
}
let informationContextualAction: UIContextualAction = UIContextualAction(style: .normal, title: nil, handler: { action, view, performed in
let alertController: UIAlertController = UIAlertController(title: item.title, message: item.details, preferredStyle: .alert)
alertController.addAction(UIAlertAction(title: "Dismiss", style: .default) { _ in
performed(true)
})
// alertController.preferredAction = alertController.actions.first
self.present(alertController, animated: true)
})
informationContextualAction.image = UIImage(systemName: "info")
return UISwipeActionsConfiguration(actions: [
informationContextualAction
])
}
let collectionView: UICollectionView = UICollectionView(frame: .zero,
collectionViewLayout: UICollectionViewCompositionalLayout.list(using: configuration))
collectionView.translatesAutoresizingMaskIntoConstraints = false
collectionView.delegate = self
view.addSubview(collectionView)
collectionView.topAnchor.constraint(equalTo: view.topAnchor).isActive = true
collectionView.leadingAnchor.constraint(equalTo: view.leadingAnchor).isActive = true
collectionView.bottomAnchor.constraint(equalTo: view.bottomAnchor).isActive = true
collectionView.trailingAnchor.constraint(equalTo: view.trailingAnchor).isActive = true
if #available(iOS 26, *) {
let backgroundView: UIVisualEffectView = UIVisualEffectView(effect: UIGlassEffect(style: .regular))
backgroundView.cornerConfiguration = .corners(radius: .containerConcentric())
collectionView.backgroundColor = .clear
collectionView.backgroundView = backgroundView
view.backgroundColor = .clear
} else {
view.backgroundColor = .systemBackground
}
let headerCellRegistration = UICollectionView.SupplementaryRegistration<UICollectionViewListCell>(elementKind: UICollectionView.elementKindSectionHeader) { supplementaryView, elementKind, indexPath in
var contentConfiguration = UIListContentConfiguration.extraProminentInsetGroupedHeader()
if let dataSource = self.dataSource, let section: MandarineSettingsHeaders = dataSource.sectionIdentifier(for: indexPath.section) {
contentConfiguration.text = section.header.text
contentConfiguration.secondaryText = section.header.secondaryText
contentConfiguration.secondaryTextProperties.color = .secondaryLabel
}
supplementaryView.contentConfiguration = contentConfiguration
}
let boolCell: UICollectionView.CellRegistration<UICollectionViewListCell, BoolSetting> = CellManager.Settings.boolCell
let inputNumberCell: UICollectionView.CellRegistration<UICollectionViewListCell, InputNumberSetting> = CellManager.Settings.inputNumberCell
dataSource = UICollectionViewDiffableDataSource(collectionView: collectionView) { collectionView, indexPath, itemIdentifier in
switch itemIdentifier {
case let boolSetting as BoolSetting:
collectionView.dequeueConfiguredReusableCell(using: boolCell, for: indexPath, item: boolSetting)
case let inputNumberSetting as InputNumberSetting:
collectionView.dequeueConfiguredReusableCell(using: inputNumberCell, for: indexPath, item: inputNumberSetting)
default:
nil
}
}
guard let dataSource else {
return
}
dataSource.supplementaryViewProvider = { collectionView, elementKind, indexPath in
collectionView.dequeueConfiguredReusableSupplementary(using: headerCellRegistration, for: indexPath)
}
snapshot = NSDiffableDataSourceSnapshot<MandarineSettingsHeaders, AnyHashableSendable>()
guard var snapshot else {
return
}
snapshot.appendSections(MandarineSettingsHeaders.allCases)
snapshot.sectionIdentifiers.forEach { header in
snapshot.appendItems(MandarineSettingsItems.settings(header).map { setting in
setting.setting(self)
}, toSection: header)
}
Task {
await dataSource.apply(snapshot)
}
}
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
collectionView.deselectItem(at: indexPath, animated: true)
guard let dataSource else {
return
}
switch dataSource.itemIdentifier(for: indexPath) {
case let inputSetting as InputNumberSetting:
guard inputSetting.isEnabled else {
return
}
let alertController = UIAlertController(title: inputSetting.title,
message: "Min: \(Int(inputSetting.min)), Max: \(Int(inputSetting.max))",
preferredStyle: .alert)
alertController.addTextField { textField in
textField.keyboardType = .numberPad
}
alertController.addAction(UIAlertAction(title: "Cancel", style: .cancel))
alertController.addAction(UIAlertAction(title: "Save", style: .default) { action in
guard let textFields = alertController.textFields, let textField = textFields.first,
let value: NSString = textField.text as? NSString else {
return
}
UserDefaults.standard.set(value.doubleValue, forKey: inputSetting.key)
if let delegate = inputSetting.delegate {
delegate.didChangeSetting(at: indexPath)
}
})
present(alertController, animated: true)
default:
break
}
}
}
extension MandarineSettingsController : SettingDelegate {
func didChangeSetting(at indexPath: IndexPath) {
guard let dataSource, let sectionIdentifier = dataSource.sectionIdentifier(for: indexPath.section) else {
return
}
var snapshot = dataSource.snapshot()
let item = snapshot.itemIdentifiers(inSection: sectionIdentifier)[indexPath.item]
switch item {
case let boolSetting as BoolSetting:
boolSetting.value = UserDefaults.standard.bool(forKey: boolSetting.key)
case let inputNumberSetting as InputNumberSetting:
inputNumberSetting.value = UserDefaults.standard.double(forKey: inputNumberSetting.key)
default:
break
}
snapshot.reloadItems([item])
Task {
await dataSource.apply(snapshot, animatingDifferences: false)
}
}
}