Files
2024-03-05 19:00:57 +04:00

642 lines
21 KiB
Swift

//
// File.swift
//
//
// Created by Mikhail Filimonov on 06.12.2021.
//
import Foundation
import AppKit
import SwiftSignalKit
public protocol AppMenuItemImageDrawable : NSView {
func updateState(_ controlState: ControlState)
func setColor(_ color: NSColor)
func isEqual(to other: ContextMenuItem) -> Bool
}
open class AppMenuBasicItem : TableRowItem {
public struct Interaction {
public let action:(ContextMenuItem)->Void
public let presentSubmenu:(ContextMenuItem)->Void
public let cancelSubmenu:(ContextMenuItem)->Void
public let hover:(ContextMenuItem)->Void
public let close:()->Void
}
fileprivate(set) public var menuItem: ContextMenuItem?
fileprivate(set) public var interaction: Interaction?
public let presentation: AppMenu.Presentation
public init(_ initialSize: NSSize, presentation: AppMenu.Presentation, menuItem: ContextMenuItem? = nil, interaction: Interaction? = nil) {
self.menuItem = menuItem
self.interaction = interaction
self.presentation = presentation
super.init(initialSize)
}
public var searchable: String {
return ""
}
open override var height: CGFloat {
return 4
}
open var effectiveSize: NSSize {
return NSMakeSize(0, height)
}
open override var stableId: AnyHashable {
return arc4random64()
}
open override func viewClass() -> AnyClass {
return AppMenuBasicItemView.self
}
}
open class AppMenuBasicItemView: TableRowView {
public let containerView = Control()
public required init(frame frameRect: NSRect) {
super.init(frame: frameRect)
super.addSubview(containerView)
containerView.layer?.cornerRadius = .cornerRadius
}
required public init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
open override func addSubview(_ view: NSView) {
containerView.addSubview(view)
}
open override func layout() {
super.layout()
containerView.frame = bounds.insetBy(dx: 4, dy: 0)
}
public var contentSize: NSSize {
return containerView.frame.size
}
deinit {
containerView.removeAllSubviews()
}
open override var backdorColor: NSColor {
return .clear
}
}
public class AppMenuSeparatorItem : AppMenuBasicItem {
public init(_ initialSize: NSSize, presentation: AppMenu.Presentation) {
super.init(initialSize, presentation: presentation)
}
public override var height: CGFloat {
return 5
}
public override var effectiveSize: NSSize {
return NSMakeSize(0, height)
}
public override var stableId: AnyHashable {
return arc4random64()
}
public override func viewClass() -> AnyClass {
return AppMenuSeparatorItemView.self
}
}
private final class AppMenuSeparatorItemView: AppMenuBasicItemView {
private let view: View = View()
required init(frame frameRect: NSRect) {
super.init(frame: frameRect)
addSubview(view)
}
required public init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override var backdorColor: NSColor {
return .clear
}
override func updateColors() {
super.updateColors()
guard let item = item as? AppMenuSeparatorItem else {
return
}
view.backgroundColor = item.presentation.borderColor
}
override func layout() {
super.layout()
view.frame = NSMakeRect(11, 2, containerView.frame.width - 22, 1)
}
override func set(item: TableRowItem, animated: Bool = false) {
super.set(item: item, animated: animated)
}
}
open class AppMenuRowItem : AppMenuBasicItem {
public let item: ContextMenuItem
public private(set) var text: TextViewLayout
public let leftInset: CGFloat = 11
public let innerInset: CGFloat = 4
public let moreSize: NSSize = NSMakeSize(6, 8)
public let selectedSize: NSSize = NSMakeSize(9, 8)
fileprivate let keyEquivalentText: TextViewLayout?
private var observation_i: NSKeyValueObservation?
private var observation_t: NSKeyValueObservation?
public init(_ initialSize: NSSize, item: ContextMenuItem, interaction: Interaction, presentation: AppMenu.Presentation) {
self.item = item
let textColor = presentation.primaryColor(item)
let attr = parseMarkdownIntoAttributedString(item.title, attributes: MarkdownAttributes(body: MarkdownAttributeSet(font: .medium(.text), textColor: textColor), bold: MarkdownAttributeSet(font: .bold(.text), textColor: textColor), link: MarkdownAttributeSet(font: .bold(.text), textColor: presentation.colors.accentIcon), linkAttribute: { contents in
return (NSAttributedString.Key.link.rawValue, contents)
}))
self.text = TextViewLayout(attr, maximumNumberOfLines: item.removeTail ? 1 : .max)
if item.keyEquivalent.isEmpty {
keyEquivalentText = nil
} else {
keyEquivalentText = TextViewLayout(.initialize(string: item.keyEquivalent, color: presentation.secondaryColor(item), font: .medium(.text)))
}
super.init(initialSize, presentation: presentation, menuItem: item, interaction: interaction)
self.menuItem = item
self.observation_i = item.observe(\.image, options: .new, changeHandler: { [weak self] object, change in
self?.redraw(animated: true)
})
self.observation_t = item.observe(\.title, options: .new, changeHandler: { [weak self] object, change in
self?.redraw(animated: true)
})
item.redraw = { [weak self] in
self?.redraw(animated: true)
}
}
open var imageSize: CGFloat {
return 18
}
public override var searchable: String {
return item.title
}
public override func redraw(animated: Bool = false, options: NSTableView.AnimationOptions = .effectFade, presentAsNew: Bool = false) {
let textColor = presentation.primaryColor(item)
let attr = parseMarkdownIntoAttributedString(item.title, attributes: MarkdownAttributes(body: MarkdownAttributeSet(font: .medium(.text), textColor: textColor), bold: MarkdownAttributeSet(font: .bold(.text), textColor: textColor), link: MarkdownAttributeSet(font: .bold(.text), textColor: presentation.colors.accentIcon), linkAttribute: { contents in
return (NSAttributedString.Key.link.rawValue, contents)
}))
self.text = TextViewLayout(attr, maximumNumberOfLines: item.removeTail ? 1 : .max)
_ = makeSize(self.width)
super.redraw(animated: animated, options: options, presentAsNew: presentAsNew)
}
public var drawable: AppMenuItemImageDrawable? {
return item.itemImage?(presentation.primaryColor(item), item)
}
deinit {
self.observation_i?.invalidate()
self.observation_t?.invalidate()
}
var hasDrawable: Bool {
if let menu = item.menu {
var range: NSRange = NSMakeRange(NSNotFound, 0)
for (i, item) in menu.items.enumerated() {
if item === self.item {
range.location = i
range.length = 1
} else if range.location != NSNotFound {
if item is ContextSeparatorItem {
break
} else {
range.length += 1
}
}
}
if range.location != NSNotFound, range.location > 0 {
for i in stride(from: range.location - 1, to: -1, by: -1) {
let item = menu.items[i]
if item is ContextSeparatorItem {
break
} else {
range.location -= 1
range.length += 1
}
}
}
let blockItems = menu.items[range.min ..< range.max]
return blockItems.compactMap { $0 as? ContextMenuItem }.contains(where: { $0.itemImage != nil })
}
return false
}
open var textSize: CGFloat {
return text.layoutSize.width + leftInset * 2 + innerInset * 2
}
open override var effectiveSize: NSSize {
var defaultSize = NSMakeSize(textSize, height)
if let _ = self.item.image {
defaultSize.width += imageSize + leftInset - 2
} else if imageSize != 18 {
defaultSize.width += imageSize + leftInset - 2
}
if hasDrawable {
defaultSize.width += imageSize + leftInset - 2
}
if let keyEquivalentText = keyEquivalentText {
defaultSize.width += keyEquivalentText.layoutSize.width + 6
}
if item.submenu != nil {
defaultSize.width += moreSize.width + leftInset
}
if item.state == .on {
defaultSize.width += selectedSize.width + leftInset
}
return defaultSize
}
open override var height: CGFloat {
if self.text.lines.count <= 1 {
return 28
} else {
return self.text.layoutSize.height + 8
}
}
open var textMaxWidth: CGFloat {
let width = item.overrideWidth ?? width
return width - leftInset * 2 - innerInset * 2
}
open override func makeSize(_ width: CGFloat = CGFloat.greatestFiniteMagnitude, oldWidth: CGFloat = 0) -> Bool {
_ = super.makeSize(width, oldWidth: oldWidth)
let width = item.overrideWidth ?? width
self.text.measure(width: textMaxWidth)
self.keyEquivalentText?.measure(width: .greatestFiniteMagnitude)
return true
}
open override var stableId: AnyHashable {
return item.id
}
open override func viewClass() -> AnyClass {
return AppMenuRowView.self
}
}
open class AppMenuRowView: AppMenuBasicItemView {
private let textView = TextView()
private var imageView: ImageView? = nil
private var keyEquivalent: TextView? = nil
private var drawable: AppMenuItemImageDrawable? = nil
private var more: ImageView? = nil
public let contentView = View()
private let hoverDisposable = MetaDisposable()
public required init(frame frameRect: NSRect) {
super.init(frame: frameRect)
super.addSubview(contentView)
self.addSubview(textView)
textView.userInteractionEnabled = false
textView.isSelectable = false
textView.disableBackgroundDrawing = true
containerView.scaleOnClick = true
containerView.layer?.cornerRadius = .cornerRadius
containerView.scaleOnClick = true
let CheckWindow:() -> Bool = { [weak self] in
let wNumber = NSWindow.windowNumber(at: NSEvent.mouseLocation, belowWindowWithWindowNumber: 0)
guard wNumber == self?.window?.windowNumber else {
return false
}
return true
}
containerView.set(handler: { [weak self] _ in
guard let item = self?.item as? AppMenuRowItem, CheckWindow() else {
return
}
item.interaction?.presentSubmenu(item.item)
}, for: .Hover)
containerView.set(handler: { [weak self] _ in
guard let item = self?.item as? AppMenuRowItem else {
return
}
if !CheckWindow() {
return
}
if item.item.isEnabled {
self?.drawable?.updateState(.Hover)
self?.updateState(.Hover)
}
self?.hoverDisposable.set(delaySignal(0.5).start(completed: {
guard let item = self?.item as? AppMenuRowItem else {
return
}
item.interaction?.hover(item.item)
}))
}, for: .Hover)
containerView.set(handler: { [weak self] _ in
guard let item = self?.item as? AppMenuRowItem else {
return
}
if !CheckWindow() {
return
}
if item.item.isEnabled {
self?.drawable?.updateState(.Highlight)
self?.updateState(.Highlight)
}
self?.hoverDisposable.set(nil)
}, for: .Highlight)
containerView.set(handler: { [weak self] _ in
if !CheckWindow() {
return
}
self?.drawable?.updateState(.Normal)
self?.updateState(.Normal)
self?.hoverDisposable.set(nil)
}, for: .Normal)
containerView.set(handler: { [weak self] _ in
if !CheckWindow() {
return
}
self?.drawable?.updateState(.Other)
self?.updateState(.Other)
self?.hoverDisposable.set(nil)
}, for: .Other)
containerView.set(handler: { [weak self] _ in
if !CheckWindow() {
return
}
self?.invokeClick()
}, for: .SingleClick)
containerView.set(handler: { [weak self] _ in
if !CheckWindow() {
return
}
self?.invokeClick()
}, for: .RightDown)
}
open func invokeClick() {
guard let item = self.item as? AppMenuRowItem else {
return
}
item.interaction?.action(item.item)
}
open override func addSubview(_ view: NSView) {
contentView.addSubview(view)
}
private var previous: ControlState = .Normal
open func updateState(_ state: ControlState) {
}
open override func mouseDown(with event: NSEvent) {
}
open override func mouseUp(with event: NSEvent) {
var bp = 0
bp += 1
}
open override func cursorUpdate(with event: NSEvent) {
super.cursorUpdate(with: event)
NSCursor.arrow.set()
}
open override func updateMouse(animated: Bool) {
super.updateMouse(animated: animated)
updateColors()
containerView.updateState()
}
open override func updateColors() {
super.updateColors()
guard let item = item as? AppMenuRowItem, let table = item.table else {
return
}
containerView.isSelected = item.isSelected
let canHighlight = table.selectedItem() == nil || item.isSelected
containerView.isEnabled = item.item.isEnabled && canHighlight
containerView.set(background: canHighlight ? item.presentation.highlightColor : .clear, for: .Hover)
containerView.set(background: .clear, for: .Normal)
containerView.set(background: canHighlight ? item.presentation.highlightColor : .clear, for: .Highlight)
}
public required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
public var textX: CGFloat {
guard let item = item as? AppMenuRowItem else {
return 0
}
if let _ = drawable {
return item.leftInset + item.imageSize + item.leftInset - 2
} else if let _ = imageView {
return item.leftInset + item.imageSize + item.leftInset - 2
} else if item.hasDrawable {
return item.leftInset + item.imageSize + item.leftInset - 2
} else {
return item.leftInset
}
}
open var textY: CGFloat {
guard let item = item as? AppMenuRowItem else {
return 0
}
return focus(item.text.layoutSize).minY
}
public var rightX: CGFloat {
guard let item = item as? AppMenuRowItem else {
return 0
}
var right: CGFloat = containerView.frame.width - item.leftInset
if item.item.submenu != nil {
right -= (item.moreSize.width + 2)
}
return right
}
public var imageFrame: NSRect {
return imageView?.frame ?? .zero
}
open override func layout() {
super.layout()
contentView.setFrameSize(containerView.frame.size)
guard let item = item as? AppMenuRowItem else {
return
}
if let drawable = drawable {
if item.text.numberOfLines == 1 {
drawable.centerY(x: item.leftInset)
} else {
drawable.setFrameOrigin(NSMakePoint(item.leftInset, 4))
}
textView.setFrameOrigin(NSMakePoint(drawable.frame.maxX + item.leftInset - 2, textY))
} else if let imageView = imageView {
imageView.centerY(x: item.leftInset)
textView.setFrameOrigin(NSMakePoint(imageView.frame.maxX + item.leftInset - 2, textY))
} else if item.hasDrawable {
textView.setFrameOrigin(NSMakePoint(item.leftInset + item.imageSize + item.leftInset - 2, textY))
} else {
textView.setFrameOrigin(NSMakePoint(item.leftInset, textY))
}
if let more = more {
more.centerY(x: containerView.frame.width - item.leftInset - more.frame.width)
}
if let keyEquivalent = keyEquivalent {
keyEquivalent.centerY(x: self.rightX - keyEquivalent.frame.width)
}
}
open override func viewDidMoveToWindow() {
super.viewDidMoveToWindow()
// self.drawable?.updateState(.Hover)
}
open override func set(item: TableRowItem, animated: Bool = false) {
super.set(item: item, animated: animated)
guard let item = item as? AppMenuRowItem else {
return
}
textView.update(item.text)
textView.change(opacity: item.item.title.isEmpty ? 0 : 1, animated: animated)
let drawable: AppMenuItemImageDrawable?
if let current = self.drawable, current.isEqual(to: item.item) {
drawable = current
} else if let current = item.drawable {
drawable = current
self.drawable = drawable
} else {
drawable = nil
}
if let drawable = drawable {
if drawable.superview != containerView {
contentView.addSubview(drawable)
}
} else if let current = self.drawable {
self.drawable = nil
performSubviewRemoval(current, animated: animated)
}
if let image = item.item.image {
let current: ImageView
if let view = self.imageView {
current = view
} else {
current = ImageView()
current.setFrameSize(item.imageSize, item.imageSize)
current.isEventLess = true
containerView.addSubview(current)
self.imageView = current
}
current.layer?.contents = image
current.layer?.opacity = item.item.isEnabled ? 1 : 0.4
} else if let view = self.imageView {
self.imageView = nil
performSubviewRemoval(view, animated: animated)
}
if item.item.submenu != nil || item.item.state == .on {
let current: ImageView
if let view = self.more {
current = view
} else {
current = ImageView()
if item.item.state == .on {
current.setFrameSize(item.selectedSize)
} else {
current.setFrameSize(item.moreSize)
}
current.layer?.masksToBounds = false
current.contentGravity = .center
contentView.addSubview(current)
self.more = current
}
let stateOnImage = item.item.stateOnImage?.precomposed(item.presentation.primaryColor(item.item))
current.image = item.item.state == .on ? stateOnImage ?? item.presentation.selected : item.presentation.more
current.layer?.opacity = item.item.isEnabled ? 1 : 0.4
} else if let view = self.more {
self.more = nil
performSubviewRemoval(view, animated: animated)
}
if let keyEquivalent = item.keyEquivalentText {
let current: TextView
if let view = self.keyEquivalent {
current = view
} else {
current = TextView()
current.userInteractionEnabled = false
current.isSelectable = false
current.isEventLess = true
self.addSubview(current)
self.keyEquivalent = current
}
current.update(keyEquivalent)
current.alphaValue = item.item.isEnabled ? 1 : 0.8
} else if let view = self.keyEquivalent {
self.keyEquivalent = nil
performSubviewRemoval(view, animated: animated)
}
needsLayout = true
}
open override var backdorColor: NSColor {
return .clear
}
}