+/*
+ Copyright (C) 2024 Rubén Beltrán del Río
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program. If not, see https://captura.tranquil.systems.
+ */
import Cocoa
import Combine
class RecordingWindow: NSWindow {
-
+
var pixelDensity: CGFloat {
self.screen?.backingScaleFactor ?? 1.0
}
-
+
var recordingContentView: RecordingContentView {
self.contentView as! RecordingContentView
}
-
- init(_ button: NSRect?) {
-
- let screens = NSScreen.screens
- var boundingBox = NSZeroRect
- for screen in screens {
- boundingBox = NSUnionRect(boundingBox, screen.frame)
- }
-
+
+ init(_ configuration: CaptureSessionConfiguration, _ button: NSRect?) {
+
+ let boundingBox = NSScreen.screenWithMouse?.frame ?? NSZeroRect
+
super.init(
contentRect: boundingBox,
styleMask: [.borderless],
self.titlebarAppearsTransparent = true
self.setFrame(boundingBox, display: true)
self.titleVisibility = .hidden
- let recordingView = RecordingContentView()
+ let recordingView = RecordingContentView(
+ configuration, frame: boundingBox, button: button ?? NSZeroRect)
recordingView.frame = boundingBox
- recordingView.button = button
self.contentView = recordingView
self.backgroundColor = NSColor(white: 1.0, alpha: 0.001)
+ // uncomment below to debug window placement visually
+ // self.backgroundColor = NSColor(red: 1.0, green: 0.0, blue: 1.0, alpha: 0.5)
self.level = .screenSaver
self.isOpaque = false
self.hasShadow = false
}
-
+
// MARK: - Window Behavior Overrides
-
+
override func resetCursorRects() {
super.resetCursorRects()
let cursor = NSCursor.crosshair
self.contentView?.addCursorRect(self.contentView!.bounds, cursor: cursor)
}
-
+
override var canBecomeKey: Bool {
return true
}
-
+
override var canBecomeMain: Bool {
return true
}
-
+
override func resignMain() {
super.resignMain()
if (self.contentView as? RecordingContentView)?.state != .recording {
self.ignoresMouseEvents = false
}
}
-
+
override func becomeMain() {
super.becomeMain()
if (self.contentView as? RecordingContentView)?.state != .recording {
}
enum RecordingWindowState {
- case passthrough, idle, drawing, moving, resizing, recording;
+ case passthrough, idle, drawing, moving, resizing, recording
}
class RecordingContentView: NSView {
-
+
+ init(_ configuration: CaptureSessionConfiguration, frame: NSRect, button: NSRect) {
+ self.buttonSize = button.size
+ var buttonOffset = NSPoint()
+ for screen in NSScreen.screens {
+ if screen.frame.intersects(button) {
+ let relativeY = screen.frame.height - (button.minY - screen.frame.minY)
+ let relativeX = screen.frame.width - (button.minX - screen.frame.minX)
+ buttonOffset = NSPoint(x: relativeX, y: relativeY)
+ }
+ }
+ self.buttonOffset = buttonOffset
+ super.init(frame: frame)
+ preventResize = configuration.preventResize
+ preventMove = configuration.preventMove
+ autoStart = configuration.autoStart
+
+ self.bounds = frame
+ self.button = NSRect(
+ x: frame.maxX - buttonOffset.x, y: frame.maxY - buttonOffset.y, width: buttonSize.width,
+ height: buttonSize.height)
+
+ if configuration.x != nil || configuration.y != nil || configuration.width != nil
+ || configuration.height != nil
+ {
+ box = NSRect(
+ x: configuration.x ?? Int(frame.width / 2.0),
+ y: configuration.y ?? Int(frame.height / 2.0),
+ width: configuration.width ?? 400,
+ height: configuration.height ?? 400
+ )
+ }
+
+ if autoStart {
+ DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) {
+ NotificationCenter.default.post(name: .startRecording, object: nil, userInfo: nil)
+ }
+ }
+ }
+
+ required init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+
+ private let buttonSize: NSSize
+ private let buttonOffset: NSPoint
public var button: NSRect? = nil
@Published public var box: NSRect? = nil
public var state: RecordingWindowState = .idle
private var mouseLocation: NSPoint = NSPoint()
private var origin: NSPoint = NSPoint()
private var boxOrigin: NSPoint = NSPoint()
-
+ private var preventResize = false
+ private var preventMove = false
+ private var autoStart = false
+
private var resizeBox: NSRect? {
if let box {
return NSRect(x: box.maxX - 5, y: box.minY - 5, width: 10, height: 10)
}
return nil
}
-
+
private var shouldPassthrough: Bool {
state == .recording || state == .passthrough
}
-
+
// MARK: - State changing API
-
+
public func startRecording() {
state = .recording
window?.ignoresMouseEvents = true
}
-
+
public func stopRecording() {
-
+
}
-
+
public func reset() {
state = .idle
window?.ignoresMouseEvents = false
}
-
+
public func startPassthrough() {
state = .passthrough
window?.ignoresMouseEvents = true
}
-
+
public func stopPassthrough() {
state = .idle
window?.ignoresMouseEvents = false
}
-
+
// MARK: - View Behavior Overrides
-
+
override func updateTrackingAreas() {
super.updateTrackingAreas()
-
+
for trackingArea in self.trackingAreas {
self.removeTrackingArea(trackingArea)
}
- let options: NSTrackingArea.Options = [.mouseEnteredAndExited, .activeInKeyWindow, .cursorUpdate, .mouseMoved]
- let trackingArea = NSTrackingArea(rect: self.bounds, options: options, owner: self, userInfo: nil)
+ let options: NSTrackingArea.Options = [
+ .mouseEnteredAndExited, .activeInKeyWindow, .cursorUpdate, .mouseMoved,
+ ]
+ let trackingArea = NSTrackingArea(
+ rect: self.bounds, options: options, owner: self, userInfo: nil)
self.addTrackingArea(trackingArea)
}
-
+
+ override func mouseExited(with event: NSEvent) {
+ if state == .idle && box == nil {
+ self.moveWindow()
+ }
+ }
+
override func mouseMoved(with event: NSEvent) {
-
+
self.mouseLocation = self.convert(event.locationInWindow, from: nil)
-
+
if shouldPassthrough {
NSCursor.arrow.set()
} else {
self.setNeedsDisplay(self.bounds)
}
-
+
override func mouseDragged(with event: NSEvent) {
self.mouseLocation = self.convert(event.locationInWindow, from: nil)
if state == .drawing {
height: round(abs(mouseLocation.y - origin.y))
)
}
-
+
if box != nil {
if state == .moving {
NSCursor.closedHand.set()
x: self.boxOrigin.x - self.origin.x + self.mouseLocation.x,
y: self.boxOrigin.y - self.origin.y + self.mouseLocation.y)
}
-
+
if state == .resizing {
box = NSRect(
x: round(min(origin.x, mouseLocation.x)),
}
self.setNeedsDisplay(self.bounds)
}
-
+
override func cursorUpdate(with event: NSEvent) {
NSCursor.crosshair.set()
}
override func hitTest(_ point: NSPoint) -> NSView? {
return shouldPassthrough ? nil : self
}
-
+
override var acceptsFirstResponder: Bool {
return true
}
override func mouseDown(with event: NSEvent) {
self.origin = self.convert(event.locationInWindow, from: nil)
if let box {
-
+
if let button {
if button.contains(origin) {
NotificationCenter.default.post(name: .startRecording, object: nil, userInfo: nil)
return
}
}
-
- if resizeBox!.contains(origin) {
+
+ if resizeBox!.contains(origin) && !preventResize {
self.origin = NSPoint(x: box.minX, y: box.maxY)
state = .resizing
return
}
- if box.contains(origin) {
+ if box.contains(origin) && !preventMove {
state = .moving
self.boxOrigin = NSPoint(x: box.origin.x, y: box.origin.y)
return
}
}
-
+
+ if preventResize || preventMove {
+ return
+ }
+
state = .drawing
}
state = .idle
}
}
-
+
override func keyDown(with event: NSEvent) {
switch event.keyCode {
- case 53: // Escape key
- NotificationCenter.default.post(name: .reset, object: nil, userInfo: nil)
- default:
- super.keyDown(with: event)
+ case 53: // Escape key
+ NotificationCenter.default.post(name: .reset, object: nil, userInfo: nil)
+ default:
+ super.keyDown(with: event)
}
}
-
+
override func flagsChanged(with event: NSEvent) {
if state == .idle {
if event.modifierFlags.contains(.shift) {
}
}
}
-
+
override func draw(_ dirtyRect: NSRect) {
if shouldPassthrough {
NSColor.clear.setFill()
NSColor(white: 1.0, alpha: 0.001).setFill()
}
dirtyRect.fill()
-
+
let dashLength: CGFloat = 5.0
let lineWidth = 0.5
+ // Uncomment below to debug button placement visually
+ // if let button {
+ // let buttonPath = NSBezierPath()
+ // buttonPath.move(to: NSPoint(x: button.minX, y: button.minY))
+ // buttonPath.line(to: NSPoint(x: button.maxX, y: button.minY))
+ // buttonPath.line(to: NSPoint(x: button.maxX, y: button.maxY))
+ // buttonPath.line(to: NSPoint(x: button.minX, y: button.maxY))
+ // buttonPath.line(to: NSPoint(x: button.minX, y: button.minY))
+ // NSColor(red: 1, green: 0, blue: 1, alpha: 1).setFill()
+ // buttonPath.fill()
+ // }
+
if state == .idle && box == nil {
let blackLine = NSBezierPath()
blackLine.lineWidth = lineWidth
blackLine.setLineDash([dashLength, dashLength], count: 2, phase: 0)
-
+
// Vertical line (Black)
blackLine.move(to: NSPoint(x: self.mouseLocation.x, y: NSMinY(self.bounds)))
blackLine.line(to: NSPoint(x: self.mouseLocation.x, y: NSMaxY(self.bounds)))
-
+
// Horizontal line (Black)
blackLine.move(to: NSPoint(x: NSMinX(self.bounds), y: self.mouseLocation.y))
blackLine.line(to: NSPoint(x: NSMaxX(self.bounds), y: self.mouseLocation.y))
-
+
NSColor.black.setStroke()
blackLine.stroke()
-
+
let whiteLine = NSBezierPath()
whiteLine.lineWidth = lineWidth
whiteLine.setLineDash([dashLength, dashLength], count: 2, phase: dashLength)
-
+
// Vertical line (White)
whiteLine.move(to: NSPoint(x: self.mouseLocation.x, y: NSMinY(self.bounds)))
whiteLine.line(to: NSPoint(x: self.mouseLocation.x, y: NSMaxY(self.bounds)))
-
+
// Horizontal line (White)
whiteLine.move(to: NSPoint(x: NSMinX(self.bounds), y: self.mouseLocation.y))
whiteLine.line(to: NSPoint(x: NSMaxX(self.bounds), y: self.mouseLocation.y))
-
+
NSColor.white.setStroke()
whiteLine.stroke()
}
-
+
if let box {
let blackBox = NSBezierPath()
blackBox.lineWidth = lineWidth
blackBox.line(to: NSPoint(x: box.minX, y: box.minY))
NSColor.black.setStroke()
blackBox.stroke()
-
+
let whiteBox = NSBezierPath()
whiteBox.lineWidth = lineWidth
whiteBox.setLineDash([dashLength, dashLength], count: 2, phase: dashLength)
whiteBox.line(to: NSPoint(x: box.minX, y: box.minY))
NSColor.white.setStroke()
whiteBox.stroke()
-
+
if state == .recording {
return
}
-
+
if let resizeBox {
let clearBox = NSBezierPath()
clearBox.move(to: NSPoint(x: resizeBox.minX, y: resizeBox.minY))
NSColor(white: 0, alpha: 0.2).setFill()
clearBox.fill()
}
-
+
if state == .moving {
let string = "\(Int(box.minX)), \(Int(box.maxY))" as NSString
- drawText(string, NSPoint(
- x: box.minX,
- y: box.maxY
- ), true)
+ drawText(
+ string,
+ NSPoint(
+ x: box.minX,
+ y: box.maxY
+ ), true)
}
-
+
if state == .resizing {
let string = "\(Int(mouseLocation.x)), \(Int(mouseLocation.y))" as NSString
drawText(string, mouseLocation)
}
-
+
if box.contains(mouseLocation) && state != .resizing {
- return;
+ return
}
}
-
+
// Draw text
let string = "\(Int(mouseLocation.x)), \(Int(mouseLocation.y))" as NSString
drawText(string, mouseLocation)
}
-
+
// MARK: - Utilities
-
+
private func drawText(_ text: NSString, _ location: NSPoint, _ isBottomRight: Bool = false) {
-
+
let textAttributes = [
- NSAttributedString.Key.font: NSFont(name: "Hiragino Mincho ProN", size: 12) ?? NSFont.systemFont(ofSize: 12),
+ NSAttributedString.Key.font: NSFont(name: "Hiragino Mincho ProN", size: 12)
+ ?? NSFont.systemFont(ofSize: 12),
NSAttributedString.Key.foregroundColor: NSColor.white,
]
let offset = NSPoint(x: 10, y: 10)
let padding = NSPoint(x: 5, y: 2)
let size = text.size(withAttributes: textAttributes)
- var rect = NSRect(x: location.x + offset.x, y: location.y + offset.y, width: size.width + 2 * padding.x, height: size.height + 2 * padding.y)
- var textRect = NSRect(x: location.x + offset.x + padding.x, y: location.y + offset.y + padding.y, width: size.width, height: size.height)
-
- if (isBottomRight) {
+ var rect = NSRect(
+ x: location.x + offset.x, y: location.y + offset.y, width: size.width + 2 * padding.x,
+ height: size.height + 2 * padding.y)
+ var textRect = NSRect(
+ x: location.x + offset.x + padding.x, y: location.y + offset.y + padding.y, width: size.width,
+ height: size.height)
+
+ if isBottomRight {
rect = rect.offsetBy(dx: -size.width - 2 * offset.x, dy: 0)
textRect = textRect.offsetBy(dx: -size.width - 2 * offset.x, dy: 0)
}
text.draw(in: textRect, withAttributes: textAttributes)
}
+
+ private func moveWindow() {
+ let screen = NSScreen.screenWithMouse
+ if let currentScreen = self.window?.screen {
+ if currentScreen != screen {
+ let frame = screen?.frame ?? NSZeroRect
+ self.frame = CGRect(origin: NSZeroPoint, size: frame.size)
+ self.bounds = CGRect(origin: NSZeroPoint, size: frame.size)
+ self.updateTrackingAreas()
+
+ if let window = self.window {
+ self.bounds = frame
+ self.button = NSRect(
+ x: frame.maxX - buttonOffset.x, y: frame.maxY - buttonOffset.y, width: buttonSize.width,
+ height: buttonSize.height)
+ window.setFrame(frame, display: true, animate: false)
+ window.makeKeyAndOrderFront(nil)
+ window.orderFrontRegardless()
+ }
+ }
+ }
+ }
}