blob: ba9ae838d20c8e725900e0fc4c11631033f0de73 [file] [log] [blame]
// Copyright 2016 The Tulsi Authors. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import Cocoa
import TulsiGenerator
@objc
protocol OptionsEditorOutlineViewDelegate: NSOutlineViewDelegate {
/// Invoked when the delete key is pressed on the given view. Should return true if the event was
/// fully handled, false if it should be passed up the responder chain.
@objc optional func deleteKeyPressedForOptionsEditorOutlineView(_ view: OptionsEditorOutlineView) -> Bool
}
/// Outline view containing the options editor.
final class OptionsEditorOutlineView: NSOutlineView {
override func keyDown(with theEvent: NSEvent) {
guard let eventCharacters = theEvent.charactersIgnoringModifiers else {
super.keyDown(with: theEvent)
return
}
// Eat spacebar presses as the option editor view does not support activation of rows this way.
if eventCharacters == " " {
return
}
if let delegate = self.delegate as? OptionsEditorOutlineViewDelegate {
let scalars = eventCharacters.unicodeScalars
if scalars.count == 1 {
let character = scalars[scalars.startIndex]
if character == UnicodeScalar(NSDeleteCharacter) ||
character == UnicodeScalar(NSBackspaceCharacter) ||
character == UnicodeScalar(NSDeleteFunctionKey) ||
character == UnicodeScalar(NSDeleteCharFunctionKey) {
if let handled = delegate.deleteKeyPressedForOptionsEditorOutlineView?(self), handled {
return
}
}
}
}
super.keyDown(with: theEvent)
}
}
/// Table cell view with the ability to draw a background.
class TextTableCellView: NSTableCellView {
@IBInspectable var drawsBackground: Bool = false {
didSet {
needsDisplay = true
}
}
@IBInspectable var backgroundColor: NSColor = NSColor.controlBackgroundColor {
didSet {
if drawsBackground {
needsDisplay = true
}
}
}
// Whether or not this cell view is currently selected in the table.
var selected: Bool = false {
didSet {
if drawsBackground {
needsDisplay = true
}
}
}
override func draw(_ dirtyRect: NSRect) {
if drawsBackground {
if selected {
NSColor.clear.setFill()
} else {
backgroundColor.setFill()
}
NSRectFill(dirtyRect)
}
super.draw(dirtyRect)
}
}
/// A table cell view containing a pop up button.
final class PopUpButtonTableCellView: TextTableCellView {
@IBOutlet weak var popUpButton: NSPopUpButton!
}
/// A text field within the editor outline view.
final class OptionsEditorTextField: NSTextField {
override func textDidEndEditing(_ notification: Notification) {
var notification = notification
// If the text field completed due to a return keypress convert its movement into "other" so
// that the keypress is not passed up the responder chain causing some other control (e.g., the
// default button in the wizard) to handle it as well.
if let movement = notification.userInfo?["NSTextMovement"] as? Int, movement == NSReturnTextMovement {
let userInfo = ["NSTextMovement": NSOtherTextMovement]
notification = Notification(name: notification.name, object: notification.object, userInfo: userInfo)
}
super.textDidEndEditing(notification)
}
}
/// View controller for the multiline popup editor displayed when a user double clicks an option.
final class OptionsEditorPopoverViewController: NSViewController, NSTextFieldDelegate {
dynamic var value: String? = nil
enum CloseReason {
case cancel, accept
}
var closeReason: CloseReason = .accept
var optionItem: AnyObject? = nil
fileprivate weak var popover: NSPopover? = nil
private var optionNode: OptionsEditorNode! = nil
private var optionLevel: OptionsEditorNode.OptionLevel = .Default
func setRepresentedOptionNode(_ optionNode: OptionsEditorNode, level: OptionsEditorNode.OptionLevel) {
self.optionNode = optionNode
self.optionLevel = level
let (currentValue, inherited) = optionNode.displayItemForOptionLevel(optionLevel)
if inherited {
value = ""
} else {
value = currentValue
}
}
// MARK: - NSTextFieldDelegate
func control(_ control: NSControl, textShouldEndEditing fieldEditor: NSText) -> Bool {
if closeReason == .accept {
optionNode.setDisplayItem(fieldEditor.string, forOptionLevel: optionLevel)
}
return true
}
func control(_ control: NSControl, textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool {
switch commandSelector {
// Operations that cancel.
case #selector(NSControl.cancelOperation(_:)):
closeReason = .cancel
// Operations that commit the current value.
case #selector(NSControl.insertNewline(_:)):
closeReason = .accept
popover?.performClose(control)
return true
// Operations that should be disabled.
case #selector(NSControl.insertBacktab(_:)):
fallthrough
case #selector(NSControl.insertTab(_:)):
return true
default:
break
}
// Allow the system to handle the selector.
return false
}
}
final class OptionsEditorController: NSObject, OptionsEditorOutlineViewDelegate, NSPopoverDelegate {
// Storyboard identifiers.
static let settingColumnIdentifier = "Setting"
static let targetColumnIdentifier = OptionsEditorNode.OptionLevel.Target.rawValue
static let projectColumnIdentifier = OptionsEditorNode.OptionLevel.Project.rawValue
static let defaultColumnIdentifier = OptionsEditorNode.OptionLevel.Default.rawValue
static let tableCellViewIdentifier = "TableCellView"
static let popUpButtonCellViewIdentifier = "PopUpButtonCell"
static let boldPopUpButtonCellViewIdentifier = "BoldPopUpButtonCell"
// The set of column identifiers whose contents are entirely controlled via bindings set in the
// storyboard.
static let bindingsControlledColumns = Set([settingColumnIdentifier, defaultColumnIdentifier])
let storyboard: NSStoryboard
weak var view: NSOutlineView!
// The table column used to display system or tulsiproj defaults.
let defaultValueColumn: NSTableColumn
// The table column used to display project-wide options.
let projectValueColumn: NSTableColumn
// The table column used to display build target-specific build options.
let targetValueColumn: NSTableColumn
dynamic var nodes = [OptionsEditorNode]()
weak var model: OptionsEditorModelProtocol? = nil {
didSet {
guard let model = model else { return }
defaultValueColumn.title = model.defaultValueColumnTitle
projectValueColumn.title = model.projectValueColumnTitle
}
}
// Popover containing a multiline editor for an option the user double clicked on.
var popoverEditor: NSPopover! = nil
var popoverViewController: OptionsEditorPopoverViewController! = nil
init(view: NSOutlineView, storyboard: NSStoryboard) {
self.view = view
self.storyboard = storyboard
defaultValueColumn = view.tableColumn(withIdentifier: OptionsEditorController.defaultColumnIdentifier)!
projectValueColumn = view.tableColumn(withIdentifier: OptionsEditorController.projectColumnIdentifier)!
targetValueColumn = view.tableColumn(withIdentifier: OptionsEditorController.targetColumnIdentifier)!
super.init()
self.view.delegate = self
}
/// Prepares the editor view to edit options with the most specialized column set to the given
/// target rule.
func prepareEditorForTarget(_ target: UIRuleInfo?) {
if target == nil {
targetValueColumn.isHidden = true
} else {
targetValueColumn.title = target!.targetName!
targetValueColumn.isHidden = false
}
var newOptionNodes = [OptionsEditorNode]()
var optionGroupNodes = [TulsiOptionKeyGroup: OptionsEditorGroupNode]()
let optionSet = model?.optionSet
guard let visibleOptions = optionSet?.allVisibleOptions else { return }
for (key, option) in visibleOptions {
let newNode: OptionsEditorNode
switch option.valueType {
case .bool:
newNode = OptionsEditorBooleanNode(key: key, option: option, model: model, target: target)
case .string:
newNode = OptionsEditorStringNode(key: key, option: option, model: model, target: target)
}
if let (group, displayName, description) = optionSet?.groupInfoForOptionKey(key) {
var parent: OptionsEditorGroupNode! = optionGroupNodes[group]
if parent == nil {
parent = OptionsEditorGroupNode(key: group,
displayName: displayName,
description: description)
optionGroupNodes[group] = parent
newOptionNodes.append(parent)
}
parent.addChildNode(newNode)
} else {
newOptionNodes.append(newNode)
}
}
nodes = newOptionNodes.sorted { $0.name < $1.name }
}
func stringBasedControlDidCompleteEditing(_ control: NSControl) {
let (node, modifiedLevel) = optionNodeAndLevelForControl(control)
node.setDisplayItem(control.stringValue, forOptionLevel: modifiedLevel)
reloadDataForEditedControl(control)
}
func popUpFieldDidCompleteEditing(_ button: NSPopUpButton) {
let (node, level) = optionNodeAndLevelForControl(button)
node.setDisplayItem(button.titleOfSelectedItem, forOptionLevel: level)
reloadDataForEditedControl(button)
}
func didDoubleClickInEditorView(_ editor: NSOutlineView) {
if editor.clickedRow < 0 || editor.clickedColumn < 0 {
return
}
let clickedColumn = editor.tableColumns[editor.clickedColumn]
let columnIdentifier = clickedColumn.identifier
guard let optionLevel = OptionsEditorNode.OptionLevel(rawValue: columnIdentifier) else {
assert(columnIdentifier == OptionsEditorController.settingColumnIdentifier,
"Mismatch in storyboard column identifier and OptionLevel enum")
return
}
let optionItem = editor.item(atRow: editor.clickedRow)!
let optionNode = optionNodeForItem(optionItem as AnyObject, outlineView: editor)
// Verify that the column is editable.
if OptionsEditorController.bindingsControlledColumns.contains(columnIdentifier) ||
!optionNode.editableForOptionLevel(optionLevel) {
return
}
if optionNode.supportsMultilineEditor,
let view = editor.view(atColumn: editor.clickedColumn,
row: editor.clickedRow,
makeIfNecessary: false) {
popoverEditor = NSPopover()
if popoverViewController == nil {
popoverViewController = storyboard.instantiateController(withIdentifier: "OptionsEditorPopover") as? OptionsEditorPopoverViewController
}
popoverEditor.contentViewController = popoverViewController
popoverViewController.optionItem = optionItem as AnyObject?
popoverViewController.setRepresentedOptionNode(optionNode, level: optionLevel)
popoverViewController.popover = popoverEditor
popoverEditor.delegate = self
popoverEditor.behavior = .semitransient
popoverEditor.show(relativeTo: NSRect(), of: view, preferredEdge: .minY)
}
}
// MARK: - OptionsEditorOutlineViewDelegate
func deleteKeyPressedForOptionsEditorOutlineView(_ view: OptionsEditorOutlineView) -> Bool {
let selectedRow = view.selectedRow
if selectedRow < 0 || selectedRow >= nodes.count { return false }
let selectedNode = optionNodeForItem(view.item(atRow: selectedRow)! as AnyObject, outlineView: view)
if selectedNode.deleteMostSpecializedValue() {
reloadDataForRow(selectedRow)
return true
}
return false
}
func outlineView(_ outlineView: NSOutlineView, viewFor tableColumn: NSTableColumn?, item: Any) -> NSView? {
if tableColumn == nil { return nil }
let identifier = tableColumn!.identifier
if OptionsEditorController.bindingsControlledColumns.contains(identifier) {
return outlineView.make(withIdentifier: identifier, owner: self)
}
let optionNode = optionNodeForItem(item as AnyObject, outlineView: outlineView)
let optionLevel = OptionsEditorNode.OptionLevel(rawValue: identifier)!
let (displayItem, inherited) = optionNode.displayItemForOptionLevel(optionLevel)
let explicit = !inherited
let highlighted = explicit && optionLevel == optionNode.mostSpecializedOptionLevel
let editable = optionNode.editableForOptionLevel(optionLevel)
let view: NSView?
switch optionNode.valueType {
case .string:
view = outlineView.make(withIdentifier: OptionsEditorController.tableCellViewIdentifier,
owner: self)
if let tableCellView = view as? TextTableCellView {
prepareTableCellView(tableCellView,
withValue: displayItem,
explicit: explicit,
highlighted: highlighted,
editable: editable)
}
case .bool:
// TODO(abaire): Track down why NSPopUpButton ignores mutation to attributedTitle and remove
// the boldPopUpButtonCell here and from the storyboard.
let identifier: String
if explicit {
identifier = OptionsEditorController.boldPopUpButtonCellViewIdentifier
}
else {
identifier = OptionsEditorController.popUpButtonCellViewIdentifier
}
view = outlineView.make(withIdentifier: identifier, owner: self)
if let tableCellView = view as? PopUpButtonTableCellView {
preparePopUpButtonTableCellView(tableCellView,
withMenuItems: optionNode.multiSelectItems,
selectedValue: displayItem,
highlighted: highlighted,
editable: editable)
}
}
return view
}
func outlineView(_ outlineView: NSOutlineView, shouldSelectItem item: Any) -> Bool {
func setColumnsSelectedForRow(_ rowIndex: Int, selected: Bool) {
guard rowIndex >= 0 && rowIndex < view.numberOfRows,
let rowView = view.rowView(atRow: rowIndex, makeIfNecessary: false) else {
return
}
for i in 0 ..< rowView.numberOfColumns {
if let columnView = rowView.view(atColumn: i) as? TextTableCellView {
columnView.selected = selected
}
}
}
setColumnsSelectedForRow(outlineView.selectedRow, selected: false)
setColumnsSelectedForRow(view.row(forItem: item), selected: true)
return true
}
// MARK: - NSPopoverDelegate
func popoverDidClose(_ notification: Notification) {
if notification.userInfo?[NSPopoverCloseReasonKey] as? String != NSPopoverCloseReasonStandard {
return
}
if popoverViewController.closeReason == .accept {
reloadDataForItem(popoverViewController.optionItem)
}
popoverEditor = nil
}
// MARK: - Private methods
private func optionNodeForItem(_ item: AnyObject, outlineView: NSOutlineView) -> OptionsEditorNode {
guard let treeNode = item as? NSTreeNode else {
assertionFailure("Item must be an NSTreeNode")
return nodes[0]
}
return treeNode.representedObject as! OptionsEditorNode
}
private func optionNodeAndLevelForControl(_ control: NSControl) -> (OptionsEditorNode, OptionsEditorNode.OptionLevel) {
let item = view.item(atRow: view.row(for: control))
let node = optionNodeForItem(item! as AnyObject, outlineView: view)
let columnIndex = view.column(for: control)
let columnIdentifier = view.tableColumns[columnIndex].identifier
let level = OptionsEditorNode.OptionLevel(rawValue: columnIdentifier)!
return (node, level)
}
/// Populates the given table cell view with this option's content
private func prepareTableCellView(_ view: TextTableCellView,
withValue value: String,
explicit: Bool,
highlighted: Bool,
editable: Bool) {
guard let textField = view.textField else { return }
textField.isEnabled = editable
view.drawsBackground = highlighted
if highlighted {
textField.textColor = NSColor.controlTextColor
} else {
textField.textColor = NSColor.disabledControlTextColor
}
let attributedValue = NSMutableAttributedString(string: value)
attributedValue.setAttributes([NSFontAttributeName: fontForOption(explicit)],
range: NSRange(location: 0, length: attributedValue.length))
textField.attributedStringValue = attributedValue
}
private func preparePopUpButtonTableCellView(_ view: PopUpButtonTableCellView,
withMenuItems menuItems: [String],
selectedValue: String,
highlighted: Bool,
editable: Bool) {
let button = view.popUpButton
button?.removeAllItems()
button?.addItems(withTitles: menuItems)
button?.selectItem(withTitle: selectedValue)
button?.isEnabled = editable
view.drawsBackground = highlighted
}
private func fontForOption(_ explicit: Bool) -> NSFont {
if explicit {
return NSFont.boldSystemFont(ofSize: 11)
}
return NSFont.systemFont(ofSize: 11)
}
private func reloadDataForEditedControl(_ control: NSControl) {
reloadDataForRow(view.row(for: control))
}
private func reloadDataForRow(_ row: Int) {
guard row >= 0 else { return }
let item = view.item(atRow: row)!
reloadDataForItem(item as AnyObject?)
}
private func reloadDataForItem(_ item: AnyObject?) {
let indexes = NSMutableIndexSet(index: view.row(forItem: item))
if let parent = view.parent(forItem: item) {
indexes.add(view.row(forItem: parent))
} else {
let numChildren = view.numberOfChildren(ofItem: item)
for i in 0 ..< numChildren {
let child = view.child(i, ofItem: item)
let childIndex = view.row(forItem: child)
if childIndex >= 0 {
indexes.add(childIndex)
}
}
}
// Reload everything in the mutable middle columns. The values in the "setting" and "default"
// columns never change.
let columnRange = NSRange(location: 1, length: view.numberOfColumns - 2)
view.reloadData(forRowIndexes: indexes as IndexSet, columnIndexes: IndexSet(integersIn: columnRange.toRange()!))
}
}