/** * Tae Won Ha - http://taewon.de - @hataewon * See LICENSE */ import Cocoa import PureLayout protocol WorkspaceBarDelegate: class { func resizeWillStart(workspaceBar: WorkspaceBar) func resizeDidEnd(workspaceBar: WorkspaceBar) } class WorkspaceBar: NSView, WorkspaceToolDelegate { static fileprivate let separatorColor = NSColor.controlShadowColor static fileprivate let separatorThickness = CGFloat(1) fileprivate var tools = [WorkspaceTool]() fileprivate weak var selectedTool: WorkspaceTool? fileprivate var isMouseDownOngoing = false fileprivate var dragIncrement = CGFloat(1) fileprivate var layoutConstraints = [NSLayoutConstraint]() required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } // MARK: - API static let minimumDimension = CGFloat(50) let location: WorkspaceBarLocation var isButtonVisible = true { didSet { self.relayout() } } var isOpen: Bool { return self.selectedTool != nil } var dimensionConstraint = NSLayoutConstraint() weak var delegate: WorkspaceBarDelegate? init(location: WorkspaceBarLocation) { self.location = location super.init(frame: CGRect.zero) super.translatesAutoresizingMaskIntoConstraints = false self.wantsLayer = true self.layer!.backgroundColor = NSColor.windowBackgroundColor.cgColor } func relayout() { self.removeConstraints(self.layoutConstraints) self.removeAllSubviews() if self.isEmpty() { self.set(dimension: 0) return } if self.isButtonVisible { self.layoutButtons() if self.isOpen { let curTool = self.selectedTool! self.layout(curTool) let newDimension = self.barDimension(withToolDimension: curTool.dimension) self.set(dimension: newDimension) } else { self.set(dimension: self.barDimensionWithButtonsWithoutTool()) } } else { if self.isOpen { let curTool = self.selectedTool! self.layoutWithoutButtons(curTool) let newDimension = self.barDimensionWithoutButtons(withToolDimension: curTool.dimension) self.set(dimension: newDimension) } else { self.set(dimension: 0) } } } func append(tool: WorkspaceTool) { tool.delegate = self tool.location = self.location tools.append(tool) if self.isOpen { self.selectedTool?.isSelected = false self.selectedTool = tool } self.relayout() } } // MARK: - NSView extension WorkspaceBar { override func draw(_ dirtyRect: NSRect) { super.draw(dirtyRect) if self.isButtonVisible { self.drawInnerSeparator(dirtyRect) } if self.isOpen { self.drawOuterSeparator(dirtyRect) } } override func hitTest(_ point: NSPoint) -> NSView? { if self.resizeRect().contains(point) { return self } return super.hitTest(point) } override func mouseDown(with event: NSEvent) { guard self.isOpen else { return } if self.isMouseDownOngoing { return } let initialMouseLoc = self.convert(event.locationInWindow, from: nil) let mouseInResizeRect = NSMouseInRect(initialMouseLoc, self.resizeRect(), self.isFlipped) guard mouseInResizeRect && event.type == .leftMouseDown else { super.mouseDown(with: event) return } self.isMouseDownOngoing = true self.delegate?.resizeWillStart(workspaceBar: self) self.dimensionConstraint.priority = NSLayoutPriorityDragThatCannotResizeWindow - 1 var dragged = false var curEvent = event let nextEventMask: NSEventMask = [ NSEventMask.leftMouseDragged, NSEventMask.leftMouseDown, NSEventMask.leftMouseUp ] while curEvent.type != .leftMouseUp { let nextEvent = NSApp.nextEvent(matching: nextEventMask, until: Date.distantFuture, inMode: RunLoopMode.eventTrackingRunLoopMode, dequeue: true) guard nextEvent != nil else { break } curEvent = nextEvent! guard curEvent.type == .leftMouseDragged else { break } let curMouseLoc = self.convert(curEvent.locationInWindow, from: nil) let distance = sq(initialMouseLoc.x - curMouseLoc.x) + sq(initialMouseLoc.y - curMouseLoc.y) guard dragged || distance >= 1 else { continue } let locInSuperview = self.superview!.convert(curEvent.locationInWindow, from: nil) let newDimension = self.newDimension(forLocationInSuperview: locInSuperview) self.set(dimension: newDimension) dragged = true } self.dimensionConstraint.priority = NSLayoutPriorityDragThatCannotResizeWindow self.isMouseDownOngoing = false self.delegate?.resizeDidEnd(workspaceBar: self) } override func resetCursorRects() { guard self.isOpen else { return } switch self.location { case .top, .bottom: self.addCursorRect(self.resizeRect(), cursor: NSCursor.resizeUpDown()) case .right, .left: self.addCursorRect(self.resizeRect(), cursor: NSCursor.resizeLeftRight()) } } fileprivate func drawInnerSeparator(_ dirtyRect: NSRect) { WorkspaceBar.separatorColor.set() let innerLineRect = self.innerSeparatorRect() if dirtyRect.intersects(innerLineRect) { NSRectFill(innerLineRect) } } fileprivate func drawOuterSeparator(_ dirtyRect: NSRect) { WorkspaceBar.separatorColor.set() let outerLineRect = self.outerSeparatorRect() if dirtyRect.intersects(outerLineRect) { NSRectFill(outerLineRect) } } fileprivate func buttonSize() -> CGSize { if self.isEmpty() { return CGSize.zero } return self.tools.first!.button.intrinsicContentSize } fileprivate func innerSeparatorRect() -> CGRect { let bounds = self.bounds let thickness = WorkspaceBar.separatorThickness let bar = self.buttonSize() switch self.location { case .top: return CGRect(x: 0, y: bounds.height - bar.height - thickness, width: bounds.width, height: thickness) case .right: return CGRect(x: bounds.width - bar.width - thickness, y: 0, width: thickness, height: bounds.height) case .bottom: return CGRect(x: 0, y: bar.height, width: bounds.width, height: thickness) case .left: return CGRect(x: bar.width, y: 0, width: thickness, height: bounds.height) } } fileprivate func newDimension(forLocationInSuperview locInSuperview: CGPoint) -> CGFloat { let dimension = self.dimension(forLocationInSuperview: locInSuperview) return self.dragIncrement * floor(dimension / self.dragIncrement) } fileprivate func dimension(forLocationInSuperview locInSuperview: CGPoint) -> CGFloat { let superviewBounds = self.superview!.bounds switch self.location { case .top: return superviewBounds.height - locInSuperview.y case .right: return superviewBounds.width - locInSuperview.x case .bottom: return locInSuperview.y case .left: return locInSuperview.x } } fileprivate func sq(_ number: CGFloat) -> CGFloat { return number * number } fileprivate func outerSeparatorRect() -> CGRect { let thickness = WorkspaceBar.separatorThickness switch self.location { case .top: return CGRect(x: 0, y: 0, width: self.bounds.width, height: thickness) case .right: return CGRect(x: 0, y: 0, width: thickness, height: self.bounds.height) case .bottom: return CGRect(x: 0, y: self.bounds.height - thickness, width: self.bounds.width, height: thickness) case .left: return CGRect(x: self.bounds.width - thickness, y: 0, width: thickness, height: self.bounds.height) } } fileprivate func resizeRect() -> CGRect { let separatorRect = self.outerSeparatorRect() let clickDimension = CGFloat(4) switch self.location { case .top: return separatorRect.offsetBy(dx: 0, dy: clickDimension).union(separatorRect) case .right: return separatorRect.offsetBy(dx: clickDimension, dy: 0).union(separatorRect) case .bottom: return separatorRect.offsetBy(dx: 0, dy: -clickDimension).union(separatorRect) case .left: return separatorRect.offsetBy(dx: -clickDimension, dy: 0).union(separatorRect) } } fileprivate func set(dimension: CGFloat) { let saneDimension = self.saneDimension(from: dimension) self.dimensionConstraint.constant = saneDimension let toolDimension = self.toolDimension(fromBarDimension: saneDimension) if self.isOpen { self.selectedTool?.dimension = toolDimension } // In 10.12 we need the following, otherwise resizing the tools does not work correctly. self.layoutSubtreeIfNeeded() self.window?.invalidateCursorRects(for: self) self.needsDisplay = true } fileprivate func saneDimension(from dimension: CGFloat) -> CGFloat { if dimension == 0 { return 0 } if self.isOpen { return max(dimension, self.selectedTool!.minimumDimension, WorkspaceBar.minimumDimension) } return max(dimension, self.barDimensionWithButtonsWithoutTool()) } } // MARK: - Layout extension WorkspaceBar { fileprivate func isEmpty() -> Bool { return self.tools.isEmpty } fileprivate func hasTools() -> Bool { return !self.isEmpty() } fileprivate func layoutWithoutButtons(_ tool: WorkspaceTool) { let view = tool.view let thickness = WorkspaceBar.separatorThickness self.addSubview(view) switch self.location { case .top: self.layoutConstraints.append(contentsOf: [ view.autoPinEdge(toSuperviewEdge: .top), view.autoPinEdge(toSuperviewEdge: .right), view.autoPinEdge(toSuperviewEdge: .bottom, withInset: thickness), view.autoPinEdge(toSuperviewEdge: .left), view.autoSetDimension(.height, toSize: tool.minimumDimension, relation: .greaterThanOrEqual) ]) case .right: self.layoutConstraints.append(contentsOf: [ view.autoPinEdge(toSuperviewEdge: .top), view.autoPinEdge(toSuperviewEdge: .right), view.autoPinEdge(toSuperviewEdge: .bottom), view.autoPinEdge(toSuperviewEdge: .left, withInset: thickness), view.autoSetDimension(.width, toSize: tool.minimumDimension, relation: .greaterThanOrEqual) ]) case .bottom: self.layoutConstraints.append(contentsOf: [ view.autoPinEdge(toSuperviewEdge: .top, withInset: thickness), view.autoPinEdge(toSuperviewEdge: .right), view.autoPinEdge(toSuperviewEdge: .bottom), view.autoPinEdge(toSuperviewEdge: .left), view.autoSetDimension(.height, toSize: tool.minimumDimension, relation: .greaterThanOrEqual) ]) case .left: self.layoutConstraints.append(contentsOf: [ view.autoPinEdge(toSuperviewEdge: .top), view.autoPinEdge(toSuperviewEdge: .right, withInset: thickness), view.autoPinEdge(toSuperviewEdge: .bottom), view.autoPinEdge(toSuperviewEdge: .left), view.autoSetDimension(.width, toSize: tool.minimumDimension, relation: .greaterThanOrEqual) ]) } } fileprivate func layout(_ tool: WorkspaceTool) { let view = tool.view let button = tool.button let thickness = WorkspaceBar.separatorThickness self.addSubview(view) switch self.location { case .top: self.layoutConstraints.append(contentsOf: [ view.autoPinEdge(.top, to: .bottom, of: button, withOffset: thickness), view.autoPinEdge(toSuperviewEdge: .right), view.autoPinEdge(toSuperviewEdge: .bottom, withInset: thickness), view.autoPinEdge(toSuperviewEdge: .left), view.autoSetDimension(.height, toSize: tool.minimumDimension, relation: .greaterThanOrEqual) ]) case .right: self.layoutConstraints.append(contentsOf: [ view.autoPinEdge(toSuperviewEdge: .top), view.autoPinEdge(.right, to: .left, of: button, withOffset: -thickness), // Offset is count l -> r, view.autoPinEdge(toSuperviewEdge: .bottom), view.autoPinEdge(toSuperviewEdge: .left, withInset: thickness), view.autoSetDimension(.width, toSize: tool.minimumDimension, relation: .greaterThanOrEqual) ]) case .bottom: self.layoutConstraints.append(contentsOf: [ view.autoPinEdge(toSuperviewEdge: .top, withInset: thickness), view.autoPinEdge(toSuperviewEdge: .right), view.autoPinEdge(.bottom, to: .top, of: button, withOffset: -thickness), // Offset is count t -> b, view.autoPinEdge(toSuperviewEdge: .left), view.autoSetDimension(.height, toSize: tool.minimumDimension, relation: .greaterThanOrEqual) ]) case .left: self.layoutConstraints.append(contentsOf: [ view.autoPinEdge(toSuperviewEdge: .top), view.autoPinEdge(toSuperviewEdge: .right, withInset: thickness), view.autoPinEdge(toSuperviewEdge: .bottom), view.autoPinEdge(.left, to: .right, of: button, withOffset: thickness), view.autoSetDimension(.width, toSize: tool.minimumDimension, relation: .greaterThanOrEqual) ]) } } fileprivate func layoutButtons() { guard let firstTool = self.tools.first else { return } self.tools .map { $0.button } .forEach(self.addSubview) let firstButton = firstTool.button switch self.location { case .top: self.layoutConstraints.append(contentsOf: [ firstButton.autoPinEdge(toSuperviewEdge: .top), firstButton.autoPinEdge(toSuperviewEdge: .left), ]) case .right: self.layoutConstraints.append(contentsOf: [ firstButton.autoPinEdge(toSuperviewEdge: .top), firstButton.autoPinEdge(toSuperviewEdge: .right), ]) case .bottom: self.layoutConstraints.append(contentsOf: [ firstButton.autoPinEdge(toSuperviewEdge: .left), firstButton.autoPinEdge(toSuperviewEdge: .bottom), ]) case .left: self.layoutConstraints.append(contentsOf: [ firstButton.autoPinEdge(toSuperviewEdge: .top), firstButton.autoPinEdge(toSuperviewEdge: .left), ]) } var lastButton = firstButton for button in self.tools[1.. CGFloat { switch self.location { case .top, .bottom: return self.buttonSize().height + WorkspaceBar.separatorThickness case .right, .left: return self.buttonSize().width + WorkspaceBar.separatorThickness } } fileprivate func barDimensionWithoutButtons(withToolDimension toolDimension: CGFloat) -> CGFloat { return toolDimension + WorkspaceBar.separatorThickness } fileprivate func barDimension(withToolDimension toolDimension: CGFloat) -> CGFloat { return self.barDimensionWithButtonsWithoutTool() + toolDimension + WorkspaceBar.separatorThickness } fileprivate func toolDimension(fromBarDimension barDimension: CGFloat) -> CGFloat { if self.isButtonVisible { return barDimension - WorkspaceBar.separatorThickness - barDimensionWithButtonsWithoutTool() } return barDimension - WorkspaceBar.separatorThickness } } // MARK: - WorkspaceToolDelegate extension WorkspaceBar { func toggle(_ tool: WorkspaceTool) { self.delegate?.resizeWillStart(workspaceBar: self) if self.isOpen { let curTool = self.selectedTool! if curTool === tool { // In this case, curTool.isSelected is already set to false in WorkspaceTool.toggle() self.selectedTool = nil } else { curTool.isSelected = false self.selectedTool = tool } } else { self.selectedTool = tool } self.relayout() self.delegate?.resizeDidEnd(workspaceBar: self) } }