選択範囲のスクリーンショットを取得するmacOSアプリの作成

# 概要
- [「Mac でスクリーンショットを撮る」](https://support.apple.com/ja-jp/HT201361)のような、画面上を選択しその範囲のスクリーンショットを取るような実装を行う

![Oct-16-2021 22-32-29](https://i.imgur.com/GOxJfLJ.gif)

# GitHub
- [Qiita\_ScreenshotDemo](https://github.com/pommdau/Qiita_ScreenshotDemo/tree/master)

# 参考
- [Kyome22/ScanTextFromImage](https://github.com/Kyome22/ScanTextFromImage)
    - 主に参考にしています
- [\[iOS\] frameとboundsの違いを理解する \- Qiita](https://qiita.com/tanaka-tt/items/0b2df30e1c79638580f7)
    - frameが今回は対象
- [How to set a custom cursor in an SKScene? \(XCode, Swift4, Spritekit\)](https://stackoverflow.com/questions/50090601/how-to-set-a-custom-cursor-in-an-skscene-xcode-swift4-spritekit)
    - マウスカーソルの座標のNSTextFieldを今回はNSPanel側に表示しているが、カーソルの画像として表示させる方法も考えられる
- [CGWindowListCreateImage のオプション](http://xcatsan.blogspot.com/2008/03/cgwindowlistcreateimage.html)
    - スクリーンショットの影をつけるオプション
- [Programmatically Screenshot \| Swift 3, macOS
Ask Question](https://stackoverflow.com/questions/39691106/programmatically-screenshot-swift-3-macos)
- [Swift：あるNSWindowより下層領域のスクリーンショットを取得 \- Qiita](https://qiita.com/Kyome/items/2f21b11b7db611b2f26b)

# 実装
## 方針
- 前提として、複数のスクリーンがある状態（マルチディスプレイ）でも動作できるように考える
- まず各スクリーン上に全体を覆うような`ScreenshotPanel(NSPanel)`を最前面に配置する
    - 下図では分かりやすい用に青く着色している
- `ScreenshotPanel(NSPanel)`上でマウスで領域を選択し取得できるようにする
- 選択した範囲に沿うように`ScreenshotView(NSView)`を配置・変形させる

![](https://i.imgur.com/UcfzNGa.jpg)

## ViewController.swift
### ScreenshotPanelの表示
- 下記の通り`ScreenshotPanel`を各スクリーン上に表示する

```swift
@IBAction func selectRect(_ sender: Any?) {
    guard panels.isEmpty else {
        return
    }
    
    NSCursor.crosshair.set()
    NSApp.activate(ignoringOtherApps: true)
    for (i, screen) in NSScreen.screens.enumerated() {
        let panel = ScreenshotPanel(screen.frame)
        panel.screenshotPanelDelegate = self
        panel.name = "Screen_\(i)"
        panels.append(panel)
        panel.orderFrontRegardless()
    }
    
    setupMonitors()
}
```

### esc押下時にScreenshotPanelを閉じる

- `esc`を押下した際に、上記の`ScreenshotPanel`を全て閉じるため、下記プロパティを持たせておく
    - [Swift3 ViewControllerでキーイベント\(keyDown\)を捕捉する](https://blog.ikappio.com/detect-keydown-in-viewcontroller/)

```swift
private var monitors = [Any?]()  // keyDownの監視用
private var panels = [ScreenshotPanel]()  // escが押された際にこれらを閉じる
```

### カーソルの表示変更

- またスクリーンショット開始時のカーソルには`NSCursor.crosshair.set()`を使用する
    - 参考: [NSCursor](https://developer.apple.com/documentation/appkit/nscursor)
- カーソルが切り替わるトリガは正確に把握していないけれど、`ScreenshotPanel`側でもマウスのアクションを受け取る際に随時呼び出すようにするといい感じに。
- 残念ながら、下記のMac標準のスクリーンショット時のカーソルは用意されてない

![](https://i.imgur.com/cwkipuU.jpg)

### 選択領域から画像の作成
- 下記で取得できる`selectedRect`は、各スクリーンの左下を原点としたときの`NSRect`である
    - つまり`origin=NSPoint.zero`で固定としている

```swift
extension ViewController: ScreenshotPanelDelegate {

    func screenshotPanel(_ screenshotPanel: ScreenshotPanel, didSelectRect selectedRect: NSRect) {
```

- 各画面の全体のスクリーンショット画像作成関しては、Kyomeさんの記事を参照
- 座標の変換がややこしいので下記を参考にしつつ補正する
- [Swift: macOSでの座標系のややこしい話](https://zenn.dev/kyome/articles/6d55d686069b0c)
    - macOSでは左下が原点
    - スクリーンショットの画像作成時にy軸が反転させる必要がある
- [\(旧\) Cocoaの日々: NSScreen座標系 から CGWindow座標系へ](http://xcatsan.blogspot.com/2009/09/nsscreen-cgwindow.html)

```swift
// NSScreen座標系からCGWindow座標系に変換
let selectedRectInCGImage = NSRect(x: selectedRect.origin.x,
                                   y: screenshotPanel.frame.height - selectedRect.origin.y - selectedRect.height,
                                   width: selectedRect.width,
                                   height: selectedRect.height)
```

## ScreenshotPanel.swift
### 座標の扱い
- 下記２つの座標を情報として扱う(参考: [Swift: macOSでの座標系のややこしい話](https://zenn.dev/kyome/articles/6d55d686069b0c#%E6%9C%AC%E9%A1%8C%EF%BC%9Amacos%E3%81%AE%E3%82%84%E3%82%84%E3%81%93%E3%81%97%E3%81%84%E5%BA%A7%E6%A8%99%E7%B3%BB))
    - メイン画面の左下を原点としたときの座標
    - パネルの画面の左下を原点としたときの座標

```swift
// メイン画面の左下を原点としたときの座標
private var pointInScreen: NSPoint {
    return NSEvent.mouseLocation
}

// パネルの画面の左下を原点としたときの座標
private var pointInPanel: NSPoint {
    return NSEvent.mouseLocation - self.frame.origin
}
```

- 今回`pointInScreen`はカーソルが`ScreenshotPanel`上にあるかを判断するためだけに使用する

```swift
private var containsCursorInScreen: Bool {
    return self.frame.contains(pointInScreen)
}
```

- 基本的に座標は`ScreenshotPanel`上で考えるため`pointInPanel`を扱うことになる
- 絶対的な座標は`ScreenshotPanel`から見ると関係ないことだし、またそうしたほうが扱いやすいため


### マウスアクションの取得
- 下記を使用してクリック・ドラッグ・移動時のマウスアクションを受け取る
    - [addLocalMonitorForEvents\(matching:handler:\)](https://developer.apple.com/documentation/appkit/nsevent/1534971-addlocalmonitorforevents)
    - [NSEvent\.EventTypeMask](https://developer.apple.com/documentation/appkit/nsevent/eventtypemask)
- マウスアクションに対して、選択領域の表示や座標のラベルの更新を行う
- また領域が選択された場合は、`delegate`を使って選択領域を`ViewController`へ通知を返している

```swift
// MARK: - Monitors

private func setupMonitors() {
    
    monitors.append(NSEvent.addLocalMonitorForEvents(matching: .leftMouseDown, handler: { (event) -> NSEvent? in
        self.startPoint = self.pointInPanel
        self.endPoint = nil
        self.configurePointLabel()
        NSCursor.crosshair.set()
        return event
    }))
    
    monitors.append(NSEvent.addLocalMonitorForEvents(matching: .leftMouseDragged, handler: { (event) -> NSEvent? in
        self.endPoint = self.pointInPanel
        self.configurePointLabel()
        NSCursor.crosshair.set()
        return event
    }))
    
    monitors.append(NSEvent.addLocalMonitorForEvents(matching: .leftMouseUp, handler: { (event) -> NSEvent? in
        self.endPoint = self.pointInPanel
        if self.selectedRect == .zero {
            self.screenshotPanelDelegate?.screenshotPanelDidCancel(self)
        } else {
            self.screenshotPanelDelegate?.screenshotPanel(self, didSelectRect: self.selectedRect)
        }
        
        self.startPoint = nil
        self.endPoint = nil
        self.configurePointLabel()
        NSCursor.arrow.set()
        return event
    }))
    
    monitors.append(NSEvent.addLocalMonitorForEvents(matching: .mouseMoved, handler: { (event) -> NSEvent? in
        self.configurePointLabel()
        NSCursor.crosshair.set()
        return event
    }))
}

private func teardownMonitors() {
    for monitor in monitors {
        NSEvent.removeMonitor(monitor!)
    }
    monitors.removeAll()
}
```

## CursorPointLabel.swift
### 概要
- カーソルの右下に座標を表示するための`NSTextField`
- 座標に関して、Mac標準のスクリーンショット時のカーソルは各ディスプレイの左上を原点としているが、今回は左下を原点として実装している

### 座標の文字属性
- 標準のスクリーンショット時のカーソルを参考に、座標の文字の属性を設定する
    - 黒い画面でも視認するよう、右下に向かって白い影をつけているのが分かる
- 上: macOS標準の範囲指定時のカーソル
- 下: 今回実装したもの

<img width="200" alt="image" src="https://i.imgur.com/YEGgrfB.jpg">

<img width="200" alt="image" src="https://i.imgur.com/ktXl6yK.jpg">

```swift
private var textAttributes: [NSAttributedString.Key : Any] = {
    var textAttributes: [NSAttributedString.Key : Any] = [
        .foregroundColor : NSColor.black,
    ]
    
    let myShadow = NSShadow()
    myShadow.shadowColor = NSColor.white
    myShadow.shadowBlurRadius = 1
    myShadow.shadowOffset = NSSize(width: 1, height: 1)
    textAttributes[.shadow] = myShadow
    
    return textAttributes
}()
```

### 座標のフォーマット

- マウスカーソルの座標のカンマ区切りは下記の通り実装
    - [【Swift5】数値を３桁毎にカンマ区切りにした文字列にする \- xyk blog](https://xyk.hatenablog.com/entry/2020/02/03/161054)

```swift
private var cunsomFormatter: NumberFormatter = {
    let formatter = NumberFormatter()
    formatter.numberStyle = .decimal
    formatter.groupingSeparator = "," // 区切り文字を指定
    formatter.groupingSize = 3 // 何桁ごとに区切り文字を入れるか指定
    
    return formatter
}()
```