/** * Tae Won Ha - http://taewon.de - @hataewon * See LICENSE */ import Cocoa import PureLayout import RxSwift struct AppearancePrefData: Equatable, StandardPrefData { fileprivate static let editorFontName = "editor-font-name" fileprivate static let editorFontSize = "editor-font-size" fileprivate static let editorLinespacing = "editor-linespacing" fileprivate static let editorUsesLigatures = "editor-uses-ligatures" static func ==(left: AppearancePrefData, right: AppearancePrefData) -> Bool { return left.editorUsesLigatures == right.editorUsesLigatures && left.editorFont.isEqual(to: right.editorFont) && left.editorLinespacing == right.editorLinespacing } static let `default` = AppearancePrefData(editorFont: NeoVimView.defaultFont, editorLinespacing: NeoVimView.defaultLinespacing, editorUsesLigatures: false) var editorFont: NSFont var editorLinespacing: CGFloat var editorUsesLigatures: Bool init(editorFont: NSFont, editorLinespacing: CGFloat, editorUsesLigatures: Bool) { self.editorFont = editorFont self.editorLinespacing = editorLinespacing self.editorUsesLigatures = editorUsesLigatures } init?(dict: [String: Any]) { guard let editorFontName = dict[AppearancePrefData.editorFontName] as? String, let fEditorFontSize = PrefUtils.float(from: dict, for: AppearancePrefData.editorFontSize), let fEditorLinespacing = PrefUtils.float(from: dict, for: AppearancePrefData.editorLinespacing), let editorUsesLigatures = PrefUtils.bool(from: dict, for: AppearancePrefData.editorUsesLigatures) else { return nil } self.init(editorFont: PrefUtils.saneFont(editorFontName, fontSize: CGFloat(fEditorFontSize)), editorLinespacing: CGFloat(fEditorLinespacing), editorUsesLigatures: editorUsesLigatures) } func dict() -> [String: Any] { return [ AppearancePrefData.editorFontName: self.editorFont.fontName, AppearancePrefData.editorFontSize: Float(self.editorFont.pointSize), AppearancePrefData.editorLinespacing: Float(self.editorLinespacing), AppearancePrefData.editorUsesLigatures: self.editorUsesLigatures, ] } } class AppearancePrefPane: PrefPane, NSComboBoxDelegate, NSControlTextEditingDelegate { override var displayName: String { return "Appearance" } override var pinToContainer: Bool { return true } fileprivate var data: AppearancePrefData { didSet { self.updateViews(newData: self.data) } } fileprivate let fontManager = NSFontManager.shared() fileprivate let sizes = [9, 10, 11, 12, 13, 14, 16, 18, 24, 36, 48, 64] fileprivate let sizeCombo = NSComboBox(forAutoLayout: ()) fileprivate let fontPopup = NSPopUpButton(frame: CGRect.zero, pullsDown: false) fileprivate let linespacingField = NSTextField(forAutoLayout: ()) fileprivate let ligatureCheckbox = NSButton(forAutoLayout: ()) fileprivate let previewArea = NSTextView(frame: CGRect.zero) fileprivate let exampleText = "abcdefghijklmnopqrstuvwxyz\n" + "ABCDEFGHIJKLMNOPQRSTUVWXYZ\n" + "0123456789\n" + "(){}[] +-*/= .,;:!?#&$%@|^\n" + "<- -> => >> << >>= =<< .. \n" + ":: -< >- -<< >>- ++ /= ==" init(source: Observable, initialData: AppearancePrefData) { self.data = initialData super.init(source: source) self.updateViews(newData: initialData) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } fileprivate func set(data: AppearancePrefData) { self.data = data self.publish(event: data) } override func subscription(source: Observable) -> Disposable { return source .filter { $0 is PrefData } .map { ($0 as! PrefData).appearance } .filter { [unowned self] data in data != self.data } .subscribe(onNext: { [unowned self] data in self.data = data }) } override func addViews() { let paneTitle = self.paneTitleTextField(title: "Appearance") let fontTitle = self.titleTextField(title: "Default Font:") let fontPopup = self.fontPopup fontPopup.translatesAutoresizingMaskIntoConstraints = false fontPopup.target = self fontPopup.action = #selector(AppearancePrefPane.fontPopupAction) fontPopup.addItems(withTitles: self.fontManager.availableFontNames(with: .fixedPitchFontMask)!) let sizeCombo = self.sizeCombo sizeCombo.delegate = self sizeCombo.target = self sizeCombo.action = #selector(AppearancePrefPane.sizeComboBoxDidEnter(_:)) self.sizes.forEach { string in sizeCombo.addItem(withObjectValue: string) } let linespacingTitle = self.titleTextField(title: "Line Spacing:") let linespacingField = self.linespacingField let ligatureCheckbox = self.ligatureCheckbox self.configureCheckbox(button: ligatureCheckbox, title: "Use Ligatures", action: #selector(AppearancePrefPane.usesLigaturesAction(_:))) let previewArea = self.previewArea previewArea.isEditable = true previewArea.maxSize = CGSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude) previewArea.isVerticallyResizable = true previewArea.isHorizontallyResizable = true previewArea.textContainer?.heightTracksTextView = false previewArea.textContainer?.widthTracksTextView = false previewArea.autoresizingMask = [ .viewWidthSizable, .viewHeightSizable] previewArea.textContainer?.containerSize = CGSize.init(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude) previewArea.layoutManager?.replaceTextStorage(NSTextStorage(string: self.exampleText)) previewArea.isRichText = false previewArea.turnOffLigatures(self) let previewScrollView = NSScrollView(forAutoLayout: ()) previewScrollView.hasVerticalScroller = true previewScrollView.hasHorizontalScroller = true previewScrollView.autohidesScrollers = true previewScrollView.borderType = .bezelBorder previewScrollView.documentView = previewArea self.addSubview(paneTitle) self.addSubview(fontTitle) self.addSubview(fontPopup) self.addSubview(sizeCombo) self.addSubview(linespacingTitle) self.addSubview(linespacingField) self.addSubview(ligatureCheckbox) self.addSubview(previewScrollView) paneTitle.autoPinEdge(toSuperviewEdge: .top, withInset: 18) paneTitle.autoPinEdge(toSuperviewEdge: .left, withInset: 18) fontTitle.autoPinEdge(toSuperviewEdge: .left, withInset: 18, relation: .greaterThanOrEqual) fontTitle.autoAlignAxis(.baseline, toSameAxisOf: fontPopup) fontPopup.autoPinEdge(.top, to: .bottom, of: paneTitle, withOffset: 18) fontPopup.autoPinEdge(.left, to: .right, of: fontTitle, withOffset: 5) fontPopup.autoSetDimension(.width, toSize: 240) sizeCombo.autoSetDimension(.width, toSize: 60) // If we use .Baseline the combo box is placed one pixel off... sizeCombo.autoAlignAxis(.horizontal, toSameAxisOf: fontPopup) sizeCombo.autoPinEdge(.left, to: .right, of: fontPopup, withOffset: 5) linespacingTitle.autoPinEdge(toSuperviewEdge: .left, withInset: 18, relation: .greaterThanOrEqual) linespacingTitle.autoPinEdge(.right, to: .right, of: fontTitle) linespacingTitle.autoAlignAxis(.baseline, toSameAxisOf: linespacingField) linespacingField.autoPinEdge(.top, to: .bottom, of: sizeCombo, withOffset: 18) linespacingField.autoPinEdge(.left, to: .right, of: linespacingTitle, withOffset: 5) linespacingField.autoSetDimension(.width, toSize: 60) NotificationCenter.default.addObserver(forName: NSNotification.Name.NSControlTextDidEndEditing, object: linespacingField, queue: nil) { [unowned self] _ in self.linespacingAction() } ligatureCheckbox.autoPinEdge(.top, to: .bottom, of: linespacingField, withOffset: 18) ligatureCheckbox.autoPinEdge(.left, to: .right, of: fontTitle, withOffset: 5) previewScrollView.autoSetDimension(.height, toSize: 200, relation: .greaterThanOrEqual) previewScrollView.autoPinEdge(.top, to: .bottom, of: ligatureCheckbox, withOffset: 18) previewScrollView.autoPinEdge(toSuperviewEdge: .right, withInset: 18) previewScrollView.autoPinEdge(toSuperviewEdge: .bottom, withInset: 18) previewScrollView.autoPinEdge(toSuperviewEdge: .left, withInset: 18) self.fontPopup.selectItem(withTitle: self.data.editorFont.fontName) self.sizeCombo.stringValue = String(Int(self.data.editorFont.pointSize)) self.ligatureCheckbox.state = self.data.editorUsesLigatures ? NSOnState : NSOffState self.previewArea.font = self.data.editorFont if self.data.editorUsesLigatures { self.previewArea.useAllLigatures(self) } else { self.previewArea.turnOffLigatures(self) } } fileprivate func updateViews(newData: AppearancePrefData) { let newFont = newData.editorFont self.fontPopup.selectItem(withTitle: newFont.fontName) self.sizeCombo.stringValue = String(Int(newFont.pointSize)) self.linespacingField.stringValue = String(format: "%.2f", newData.editorLinespacing) self.ligatureCheckbox.boolState = newData.editorUsesLigatures self.previewArea.font = newData.editorFont if newData.editorUsesLigatures { self.previewArea.useAllLigatures(self) } else { self.previewArea.turnOffLigatures(self) } } override func windowWillClose() { self.linespacingAction() } } // MARK: - Actions extension AppearancePrefPane { func usesLigaturesAction(_ sender: NSButton) { self.set(data: AppearancePrefData(editorFont: self.data.editorFont, editorLinespacing: self.data.editorLinespacing, editorUsesLigatures: sender.boolState)) } func fontPopupAction(_ sender: NSPopUpButton) { guard let selectedItem = self.fontPopup.selectedItem else { return } guard selectedItem.title != self.data.editorFont.fontName else { return } guard let newFont = NSFont(name: selectedItem.title, size: self.data.editorFont.pointSize) else { return } self.set(data: AppearancePrefData(editorFont: newFont, editorLinespacing: self.data.editorLinespacing, editorUsesLigatures: self.data.editorUsesLigatures)) } func comboBoxSelectionDidChange(_ notification: Notification) { guard (notification.object as! NSComboBox) === self.sizeCombo else { return } let newFontSize = self.cappedFontSize(Int(self.sizes[self.sizeCombo.indexOfSelectedItem])) let newFont = self.fontManager.convert(self.data.editorFont, toSize: newFontSize) self.set(data: AppearancePrefData(editorFont: newFont, editorLinespacing: self.data.editorLinespacing, editorUsesLigatures: self.data.editorUsesLigatures)) } func sizeComboBoxDidEnter(_ sender: AnyObject!) { let newFontSize = self.cappedFontSize(self.sizeCombo.integerValue) let newFont = self.fontManager.convert(self.data.editorFont, toSize: newFontSize) self.set(data: AppearancePrefData(editorFont: newFont, editorLinespacing: self.data.editorLinespacing, editorUsesLigatures: self.data.editorUsesLigatures)) } func linespacingAction() { let newLinespacing = self.cappedLinespacing(self.linespacingField.floatValue) self.set(data: AppearancePrefData(editorFont: self.data.editorFont, editorLinespacing: newLinespacing, editorUsesLigatures: self.data.editorUsesLigatures)) } fileprivate func cappedLinespacing(_ linespacing: Float) -> CGFloat { let cgfLinespacing = CGFloat(linespacing) guard cgfLinespacing >= NeoVimView.minLinespacing else { return NeoVimView.defaultLinespacing } guard cgfLinespacing <= NeoVimView.maxLinespacing else { return NeoVimView.maxLinespacing } return cgfLinespacing } fileprivate func cappedFontSize(_ size: Int) -> CGFloat { let cgfSize = CGFloat(size) guard cgfSize >= NeoVimView.minFontSize else { return NeoVimView.defaultFont.pointSize } guard cgfSize <= NeoVimView.maxFontSize else { return NeoVimView.maxFontSize } return cgfSize } }