JohnCoates/Aerial

View on GitHub
Aerial/Source/Models/Hardware/DisplayDetection.swift

Summary

Maintainability
F
3 days
Test Coverage
//
//  DisplayDetection.swift
//  Aerial
//
//  Created by Guillaume Louel on 09/05/2019.
//  Copyright © 2019 John Coates. All rights reserved.
//

import Foundation
import Cocoa

class Screen: NSObject {
    var id: CGDirectDisplayID
    var width: Int
    var height: Int
    var bottomLeftFrame: CGRect
    var topRightCorner: CGPoint
    var zeroedOrigin: CGPoint
    var isMain: Bool
    var backingScaleFactor: CGFloat

    init(id: CGDirectDisplayID, width: Int, height: Int, bottomLeftFrame: CGRect, isMain: Bool, backingScaleFactor: CGFloat) {
        self.id = id
        self.width = width
        self.height = height
        self.bottomLeftFrame = bottomLeftFrame
        // We precalculate the right corner too, as we will need this !
        self.topRightCorner = CGPoint(x: bottomLeftFrame.origin.x + CGFloat(width),
                                      y: bottomLeftFrame.origin.y + CGFloat(height))
        self.zeroedOrigin = CGPoint(x: 0, y: 0)
        self.isMain = isMain
        self.backingScaleFactor = backingScaleFactor
    }

    override var description: String {
        return "[id=\(self.id), width=\(self.width), height=\(self.height), bottomLeftFrame=\(self.bottomLeftFrame), topRightCorner=\(self.topRightCorner), isMain=\(self.isMain), backingScaleFactor=\(self.backingScaleFactor)]"
    }
}

// swiftlint:disable:next type_body_length
final class DisplayDetection: NSObject {
    static let sharedInstance = DisplayDetection()

    var screens = [Screen]()
    var unusedScreens = [Screen]()

    var cmInPoints: CGFloat = 40
    var maxLeftScreens: CGFloat = 0
    var maxBelowScreens: CGFloat = 0

    var advancedScreenRect: CGRect?
    var advancedZeroedScreenRect: CGRect?

    // MARK: - Lifecycle
    override init() {
        super.init()
        debugLog("📺 Display Detection initialized")
        detectDisplays()
    }

    func resetUnusedScreens() {
        debugLog("📺 reset unused screens")
        unusedScreens = screens
        debugLog("📺☢️ s \(screens.count) us \(unusedScreens.count)")
    }
    
    // MARK: - Detection
    func detectDisplays() {
        // Display detection is done in two passes :
        // - Through CGDisplay, we grab all online screens (connected, but
        //   may or may not be powered on !) and get most information needed
        // - Through NSScreen to get the backingScaleFactor (retinaness of a screen)

        // Cleanup a bit in case of redetection
        screens = [Screen]()
        maxLeftScreens = 0
        maxBelowScreens = 0

        // First pass
        let maxDisplays: UInt32 = 32
        var onlineDisplays = [CGDirectDisplayID](repeating: 0, count: Int(maxDisplays))
        var displayCount: UInt32 = 0

        _ = CGGetOnlineDisplayList(maxDisplays, &onlineDisplays, &displayCount)
        debugLog("\(displayCount) display(s) detected")
        var mainID: CGDirectDisplayID?

        for currentDisplay in onlineDisplays[0..<Int(displayCount)] {
            let isMain = CGDisplayIsMain(currentDisplay)

            if isMain == 1 {
                // We calculate the equivalent of a centimeter in points on the main screen as a reference
                let mmsize = CGDisplayScreenSize(currentDisplay)
                let wide = CGDisplayPixelsWide(currentDisplay)
                cmInPoints = CGFloat(wide) / CGFloat(mmsize.width) * 10
                debugLog("1cm = \(cmInPoints) points")
                mainID = currentDisplay
            }
        }

        // Second pass on NSScreen to grab the retina factor
        for screen in NSScreen.screens {
            let screenID = screen.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as! CGDirectDisplayID

            var thisIsMain = false
            if screenID == mainID {
                thisIsMain = true
            }

            screens.append(Screen(id: screenID,
                                  width: Int(screen.frame.width),
                                  height: Int(screen.frame.height),
                                  bottomLeftFrame: screen.frame,
                                  isMain: thisIsMain,
                                  backingScaleFactor: screen.backingScaleFactor))
        }

        // Before we finish, we calculate the origin of each screen from a 0,0 perspective
        // This calculation is pretty different in advanced mode so it got split up
        if PrefsDisplays.displayMarginsAdvanced && !advancedMargins.displays.isEmpty {
            calculateAdvancedZeroedOrigins()
        } else {
            calculateZeroedOrigins()
        }

        for screen in screens {
            debugLog("📺 found \(screen)")
        }

        // We store the list to pluck it later
        unusedScreens = screens
        
        debugLog("\(getGlobalScreenRect())")
    }

    func getScreenCount() -> Int {
        var count = 0
        for screen in screens where screen.height > 200 {
            count += 1
        }

        return count
    }

    // MARK: - Helpers
    // Regular calculation
    func calculateZeroedOrigins() {
        let orect = getGlobalScreenRect()

        for screen in screens {
            debugLog("src orig : \(screen.bottomLeftFrame.origin)")

            let (leftScreens, belowScreens) = detectBorders(forScreen: screen)

            if leftScreens > maxLeftScreens {
                maxLeftScreens = leftScreens
            }
            if belowScreens > maxBelowScreens {
                maxBelowScreens = belowScreens
            }

            screen.zeroedOrigin = CGPoint(x: screen.bottomLeftFrame.origin.x - orect.origin.x + (leftScreens * leftMargin()),
                                          y: screen.bottomLeftFrame.origin.y - orect.origin.y + (belowScreens * belowMargin()))
        }
    }

    // Advanced calculation, this is a bit messy...
    func calculateAdvancedZeroedOrigins() {
        // 2 pass, first we calculate the real position of each screen with offsets applied
        for screen in screens {
            debugLog("Asrc orig : \(screen.bottomLeftFrame.origin)")
            var offsetleft: CGFloat = 0
            var offsettop: CGFloat = 0

            if let display = findDisplayAdvancedMargins(posx: screen.bottomLeftFrame.origin.x, posy: screen.bottomLeftFrame.origin.y) {
                offsetleft = display.offsetleft
                offsettop = display.offsettop
            }

            // These are NOT zeroed at this point !!!
            screen.zeroedOrigin = CGPoint(x: screen.bottomLeftFrame.origin.x + (offsetleft * cmInPoints),
                                          y: screen.bottomLeftFrame.origin.y + (offsettop * cmInPoints))
        }

        // We get an intermediate representation of whole bunch, non zeroed
        let irect = getIntermediateAdvancedScreenRect()
        advancedScreenRect = irect  // We store this for later...
        // And now we zero them !
        for screen in screens {
            screen.zeroedOrigin = CGPoint(x: screen.zeroedOrigin.x - irect.origin.x,
                                          y: screen.zeroedOrigin.y - irect.origin.y)
            debugLog("Zorig : \(screen.zeroedOrigin)")
        }

        // Now that zeroed is really zeroed, we can cheat a bit
        let i0rect = getIntermediateAdvancedScreenRect()
        advancedZeroedScreenRect = i0rect  // We store this for later...

        let orect = getGlobalScreenRect()
        debugLog("Orect : \(orect)")
    }

    // Border detection
    // This will work for most cases, but will fail in some grid/tetris like arrangements
    func detectBorders(forScreen: Screen) -> (CGFloat, CGFloat) {
        var leftScreens: CGFloat = 0
        var belowScreens: CGFloat = 0

        for screen in screens where screen != forScreen {
            if screen.bottomLeftFrame.origin.x < forScreen.bottomLeftFrame.origin.x &&
                screen.bottomLeftFrame.origin.x + CGFloat(screen.width) <=
                forScreen.bottomLeftFrame.origin.x {
                leftScreens += 1
            }
            if screen.bottomLeftFrame.origin.y < forScreen.bottomLeftFrame.origin.y &&
                screen.bottomLeftFrame.origin.y + CGFloat(screen.height) <=
                forScreen.bottomLeftFrame.origin.y {
                belowScreens += 1
            }
        }
        debugLog("left \(leftScreens) below \(belowScreens)")

        return (leftScreens, belowScreens)
    }

    func leftMargin() -> CGFloat {
        return cmInPoints * CGFloat(PrefsDisplays.horizontalMargin)
    }

    func belowMargin() -> CGFloat {
        return cmInPoints * CGFloat(PrefsDisplays.verticalMargin)
    }

    func findScreenWith(frame: CGRect) -> Screen? {
        for screen in screens where frame == screen.bottomLeftFrame {
            return screen
        }

        return nil
    }
    
    func alternateFindScreenWith(frame: CGRect) -> Screen? {
        debugLog("📺☢️ fs : \(frame.size.debugDescription)")
        // This is a really simple workaround, we look at the size only, and with the screen list in reverse which seems to kindaaaa match ?
        // We temporarily ignore bsf as we may not be able to access view.window this early it seems

        debugLog("📺☢️ s \(screens.count) us \(unusedScreens.count)")
        
        for i in (0 ..< unusedScreens.count).reversed() {
            if unusedScreens[i].bottomLeftFrame.size == frame.size {
                let foundScreen = unusedScreens[i]
                unusedScreens.remove(at: i)
                debugLog("foundScreen : \(foundScreen.bottomLeftFrame.debugDescription)")
                return foundScreen
            }
        }
        
        return nil
    }


    func findScreenWith(id: CGDirectDisplayID) -> Screen? {
        for screen in screens where screen.id == id {
            return screen
        }

        return nil
    }
    
    func markScreenAsUsed(id: CGDirectDisplayID) {
        // remove the screen from the unused list
        debugLog("pre filter \(unusedScreens.count)")
        let filteredScreens = unusedScreens.filter { $0.id != id }
        unusedScreens = filteredScreens
        debugLog("post filter \(unusedScreens.count)")
    }

    // Calculate the size of the global screen (the composite of all the displays attached)
    func getGlobalScreenRect() -> CGRect {
        if PrefsDisplays.displayMarginsAdvanced && !advancedMargins.displays.isEmpty, let adv = advancedScreenRect {
            // Now this is awkward... we precalculated this at detectdisplays->advancedZeroedOrigins
            return adv
        } else {
            var minX: CGFloat = 0.0, minY: CGFloat = 0.0, maxX: CGFloat = 0.0, maxY: CGFloat = 0.0
            for screen in screens {
                if screen.bottomLeftFrame.origin.x < minX {
                    minX = screen.bottomLeftFrame.origin.x
                }
                if screen.bottomLeftFrame.origin.y < minY {
                    minY = screen.bottomLeftFrame.origin.y
                }
                if screen.topRightCorner.x > maxX {
                    maxX = screen.topRightCorner.x
                }
                if screen.topRightCorner.y > maxY {
                    maxY = screen.topRightCorner.y
                }
            }

            return CGRect(x: minX, y: minY, width: maxX-minX+(maxLeftScreens*leftMargin()), height: maxY-minY+(maxBelowScreens*belowMargin()))
        }
    }

    func getIntermediateAdvancedScreenRect() -> CGRect {
        // At this point, this is non zeroed
        var minX: CGFloat = 0.0, minY: CGFloat = 0.0, maxX: CGFloat = 0.0, maxY: CGFloat = 0.0
        for screen in screens {
            if screen.zeroedOrigin.x < minX {
                minX = screen.zeroedOrigin.x
            }
            if screen.zeroedOrigin.y < minY {
                minY = screen.zeroedOrigin.y
            }
            if (screen.zeroedOrigin.x + CGFloat(screen.width)) > maxX {
                maxX = screen.zeroedOrigin.x + CGFloat(screen.width)
            }
            if (screen.zeroedOrigin.y + CGFloat(screen.height)) > maxY {
                maxY = screen.zeroedOrigin.y + CGFloat(screen.height)
            }
        }

        return CGRect(x: minX, y: minY, width: maxX-minX, height: maxY-minY)
    }

    func getZeroedActiveSpannedRect() -> CGRect {
        if PrefsDisplays.displayMarginsAdvanced && !advancedMargins.displays.isEmpty, let advz = advancedZeroedScreenRect {
            // Now this is awkward... we precalculated this at detectdisplays->advancedZeroedOrigins
            return advz
        } else {
            var minX: CGFloat = 0.0, minY: CGFloat = 0.0, maxX: CGFloat = 0.0, maxY: CGFloat = 0.0
            for screen in screens where isScreenActive(id: screen.id) {
                if screen.bottomLeftFrame.origin.x < minX {
                    minX = screen.bottomLeftFrame.origin.x
                }
                if screen.bottomLeftFrame.origin.y < minY {
                    minY = screen.bottomLeftFrame.origin.y
                }
                if screen.topRightCorner.x > maxX {
                    maxX = screen.topRightCorner.x
                }
                if screen.topRightCorner.y > maxY {
                    maxY = screen.topRightCorner.y
                }
            }

            let width = maxX - minX
            let height = maxY - minY
            // Zero the origin to the global rect
            let orect = getGlobalScreenRect()
            minX -= orect.origin.x
            minY -= orect.origin.y
            return CGRect(x: minX, y: minY, width: width+(maxLeftScreens*leftMargin()), height: height+(maxBelowScreens*belowMargin()))
        }
    }

    // NSScreen coordinates are with a bottom left origin, whereas CGDisplay
    // coordinates are top left origin, this function converts the origin.y value
    func convertTopLeftToBottomLeft(rect: CGRect) -> CGRect {
        let screenFrame = (NSScreen.main?.frame)!
        let newY = 0 - (rect.origin.y - screenFrame.size.height + rect.height)
        return CGRect(x: rect.origin.x, y: newY, width: rect.width, height: rect.height)
    }

    // MARK: - Public utility fuctions

    func isScreenActive(id: CGDirectDisplayID) -> Bool {
        let screen = findScreenWith(id: id)
        debugLog("ISA : \(screen)")
        
        switch PrefsDisplays.displayMode {
        case .allDisplays:
            // This one is easy
            return true
        case .mainOnly:
            if let scr = screen {
                if scr.isMain {
                    return true
                }
            }
            return false
        case .secondaryOnly:
            if getScreenCount() > 1 {
                if let scr = screen {
                    if scr.isMain {
                        return false
                    }
                }
            }
            return true
        case .selection:
            if isScreenSelected(id: id) {
                return true
            }
            return false
        }
    }

    func isScreenSelected(id: CGDirectDisplayID) -> Bool {
        // If we have it in the dictionnary, then return that
        if PrefsAdvanced.newDisplayDict.keys.contains(String(id)) {
            return PrefsAdvanced.newDisplayDict[String(id)]!
        }
        return false    // Unknown screens will not be considered selected
    }

    func selectScreen(id: CGDirectDisplayID) {
        PrefsAdvanced.newDisplayDict[String(id)] = true
    }

    func unselectScreen(id: CGDirectDisplayID) {
        PrefsAdvanced.newDisplayDict[String(id)] = false
    }

    func getMarginsJSON() -> String {
        var adv: AdvancedMargin

        if !advancedMargins.displays.isEmpty {
            // If we have something already in preferences, return that
            adv = advancedMargins
        } else {
            // Generate a JSON from current config
            var marginArray = [DisplayAdvancedMargin]()

            for screen in screens {
                let zleft = screen.bottomLeftFrame.origin.x
                let ztop = screen.bottomLeftFrame.origin.y

                let (leftScreens, belowScreens) = detectBorders(forScreen: screen)

                let offsetleft = leftScreens * CGFloat(PrefsDisplays.horizontalMargin)
                let offsettop = belowScreens * CGFloat(PrefsDisplays.verticalMargin)

                marginArray.append(DisplayAdvancedMargin(zleft: zleft, ztop: ztop, offsetleft: offsetleft, offsettop: offsettop))
            }

            adv = AdvancedMargin(displays: marginArray)
        }

        let encoder = JSONEncoder()
        encoder.outputFormatting = .prettyPrinted

        do {
            let jsonData = try encoder.encode(adv)
            if let jsonString = String(data: jsonData, encoding: .utf8) {
                return jsonString
            }
        } catch {
            errorLog(error.localizedDescription)
        }

        return ""
    }

    func findDisplayAdvancedMargins(posx: CGFloat, posy: CGFloat) -> DisplayAdvancedMargin? {
        for display in advancedMargins.displays {
            if posx == display.zleft && posy == display.ztop {
                return display
            }
        }

        return nil
    }

    var advancedMargins: AdvancedMargin {
        get {
            let jsonString = PrefsDisplays.advancedMargins

            if let jsonData = jsonString.data(using: .utf8) {
                let decoder = JSONDecoder()

                do {
                    let adv = try decoder.decode(AdvancedMargin.self, from: jsonData)
                    return adv
                } catch {
                    errorLog(error.localizedDescription)
                }
            }
            return AdvancedMargin(displays: [DisplayAdvancedMargin]())
        }
        set {
            let encoder = JSONEncoder()
            encoder.outputFormatting = .prettyPrinted

            do {
                let jsonData = try encoder.encode(newValue)
                if let jsonString = String(data: jsonData, encoding: .utf8) {
                    PrefsDisplays.advancedMargins = jsonString
                }
            } catch {
                errorLog(error.localizedDescription)
            }
        }
    }
}

struct AdvancedMargin: Codable {
    let displays: [DisplayAdvancedMargin]
}

struct DisplayAdvancedMargin: Codable {
    var zleft: CGFloat
    var ztop: CGFloat
    var offsetleft: CGFloat
    var offsettop: CGFloat
}