]> git.r.bdr.sh - rbdr/captura/blame - Captura/Presentation/Windows/RecordingWindow.swift
Format the code
[rbdr/captura] / Captura / Presentation / Windows / RecordingWindow.swift
CommitLineData
5802c153
RBR
1/*
2 Copyright (C) 2024 Rubén Beltrán del Río
3
4 This program is free software: you can redistribute it and/or modify
5 it under the terms of the GNU General Public License as published by
6 the Free Software Foundation, either version 3 of the License, or
7 (at your option) any later version.
8
9 This program is distributed in the hope that it will be useful,
10 but WITHOUT ANY WARRANTY; without even the implied warranty of
11 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 GNU General Public License for more details.
13
14 You should have received a copy of the GNU General Public License
15 along with this program. If not, see https://captura.tranquil.systems.
16 */
24220348 17import Cocoa
a4e80427 18import Combine
24220348
RBR
19
20class RecordingWindow: NSWindow {
505c1e62 21
a4e80427
RBR
22 var pixelDensity: CGFloat {
23 self.screen?.backingScaleFactor ?? 1.0
24 }
505c1e62 25
c9b9e1d6
RBR
26 var recordingContentView: RecordingContentView {
27 self.contentView as! RecordingContentView
28 }
505c1e62 29
8e932130 30 init(_ configuration: CaptureSessionConfiguration, _ button: NSRect?) {
505c1e62 31
7ee43fb8 32 let boundingBox = NSScreen.screenWithMouse?.frame ?? NSZeroRect
505c1e62 33
24220348
RBR
34 super.init(
35 contentRect: boundingBox,
36 styleMask: [.borderless],
37 backing: .buffered,
38 defer: false)
39
e834022c 40 self.isReleasedWhenClosed = false
a4e80427 41 self.collectionBehavior = [.canJoinAllSpaces]
24220348
RBR
42 self.isMovableByWindowBackground = false
43 self.isMovable = false
533cd932 44 self.canHide = false
24220348
RBR
45 self.titlebarAppearsTransparent = true
46 self.setFrame(boundingBox, display: true)
47 self.titleVisibility = .hidden
505c1e62
RBR
48 let recordingView = RecordingContentView(
49 configuration, frame: boundingBox, button: button ?? NSZeroRect)
a4e80427 50 recordingView.frame = boundingBox
a4e80427 51 self.contentView = recordingView
082b61f3
RBR
52 self.backgroundColor = NSColor(white: 1.0, alpha: 0.001)
53 // uncomment below to debug window placement visually
54 // self.backgroundColor = NSColor(red: 1.0, green: 0.0, blue: 1.0, alpha: 0.5)
24220348
RBR
55 self.level = .screenSaver
56 self.isOpaque = false
57 self.hasShadow = false
24220348 58 }
505c1e62 59
a4e80427 60 // MARK: - Window Behavior Overrides
505c1e62 61
24220348
RBR
62 override func resetCursorRects() {
63 super.resetCursorRects()
64 let cursor = NSCursor.crosshair
65 self.contentView?.addCursorRect(self.contentView!.bounds, cursor: cursor)
66 }
505c1e62 67
24220348
RBR
68 override var canBecomeKey: Bool {
69 return true
70 }
505c1e62 71
24220348
RBR
72 override var canBecomeMain: Bool {
73 return true
74 }
505c1e62 75
e834022c
RBR
76 override func resignMain() {
77 super.resignMain()
a4e80427
RBR
78 if (self.contentView as? RecordingContentView)?.state != .recording {
79 self.ignoresMouseEvents = false
80 }
e834022c 81 }
505c1e62 82
e834022c
RBR
83 override func becomeMain() {
84 super.becomeMain()
a4e80427
RBR
85 if (self.contentView as? RecordingContentView)?.state != .recording {
86 (self.contentView as? RecordingContentView)?.state = .idle
87 }
e834022c
RBR
88 }
89}
90
91enum RecordingWindowState {
505c1e62 92 case passthrough, idle, drawing, moving, resizing, recording
24220348
RBR
93}
94
95class RecordingContentView: NSView {
505c1e62 96
9431168d 97 init(_ configuration: CaptureSessionConfiguration, frame: NSRect, button: NSRect) {
082b61f3
RBR
98 self.buttonSize = button.size
99 var buttonOffset = NSPoint()
100 for screen in NSScreen.screens {
101 if screen.frame.intersects(button) {
102 let relativeY = screen.frame.height - (button.minY - screen.frame.minY)
103 let relativeX = screen.frame.width - (button.minX - screen.frame.minX)
104 buttonOffset = NSPoint(x: relativeX, y: relativeY)
105 }
106 }
107 self.buttonOffset = buttonOffset
8e932130
RBR
108 super.init(frame: frame)
109 preventResize = configuration.preventResize
110 preventMove = configuration.preventMove
111 autoStart = configuration.autoStart
505c1e62 112
082b61f3 113 self.bounds = frame
505c1e62
RBR
114 self.button = NSRect(
115 x: frame.maxX - buttonOffset.x, y: frame.maxY - buttonOffset.y, width: buttonSize.width,
116 height: buttonSize.height)
117
118 if configuration.x != nil || configuration.y != nil || configuration.width != nil
119 || configuration.height != nil
120 {
8e932130
RBR
121 box = NSRect(
122 x: configuration.x ?? Int(frame.width / 2.0),
123 y: configuration.y ?? Int(frame.height / 2.0),
124 width: configuration.width ?? 400,
125 height: configuration.height ?? 400
126 )
127 }
505c1e62 128
8e932130
RBR
129 if autoStart {
130 DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) {
131 NotificationCenter.default.post(name: .startRecording, object: nil, userInfo: nil)
132 }
133 }
134 }
505c1e62 135
8e932130
RBR
136 required init?(coder: NSCoder) {
137 fatalError("init(coder:) has not been implemented")
138 }
505c1e62 139
082b61f3
RBR
140 private let buttonSize: NSSize
141 private let buttonOffset: NSPoint
a4e80427
RBR
142 public var button: NSRect? = nil
143 @Published public var box: NSRect? = nil
144 public var state: RecordingWindowState = .idle
145 private var mouseLocation: NSPoint = NSPoint()
146 private var origin: NSPoint = NSPoint()
147 private var boxOrigin: NSPoint = NSPoint()
8e932130
RBR
148 private var preventResize = false
149 private var preventMove = false
150 private var autoStart = false
505c1e62 151
a4e80427 152 private var resizeBox: NSRect? {
24220348
RBR
153 if let box {
154 return NSRect(x: box.maxX - 5, y: box.minY - 5, width: 10, height: 10)
155 }
156 return nil
157 }
505c1e62 158
a4e80427
RBR
159 private var shouldPassthrough: Bool {
160 state == .recording || state == .passthrough
161 }
505c1e62 162
a4e80427 163 // MARK: - State changing API
505c1e62 164
a4e80427
RBR
165 public func startRecording() {
166 state = .recording
167 window?.ignoresMouseEvents = true
168 }
505c1e62 169
a4e80427 170 public func stopRecording() {
505c1e62 171
a4e80427 172 }
505c1e62 173
a4e80427
RBR
174 public func reset() {
175 state = .idle
176 window?.ignoresMouseEvents = false
177 }
505c1e62 178
a4e80427
RBR
179 public func startPassthrough() {
180 state = .passthrough
181 window?.ignoresMouseEvents = true
182 }
505c1e62 183
a4e80427
RBR
184 public func stopPassthrough() {
185 state = .idle
186 window?.ignoresMouseEvents = false
187 }
505c1e62 188
a4e80427 189 // MARK: - View Behavior Overrides
505c1e62 190
24220348
RBR
191 override func updateTrackingAreas() {
192 super.updateTrackingAreas()
505c1e62 193
24220348
RBR
194 for trackingArea in self.trackingAreas {
195 self.removeTrackingArea(trackingArea)
196 }
197
505c1e62
RBR
198 let options: NSTrackingArea.Options = [
199 .mouseEnteredAndExited, .activeInKeyWindow, .cursorUpdate, .mouseMoved,
200 ]
201 let trackingArea = NSTrackingArea(
202 rect: self.bounds, options: options, owner: self, userInfo: nil)
24220348
RBR
203 self.addTrackingArea(trackingArea)
204 }
505c1e62 205
7ee43fb8
RBR
206 override func mouseExited(with event: NSEvent) {
207 if state == .idle && box == nil {
208 self.moveWindow()
209 }
210 }
505c1e62 211
24220348 212 override func mouseMoved(with event: NSEvent) {
505c1e62 213
24220348 214 self.mouseLocation = self.convert(event.locationInWindow, from: nil)
505c1e62 215
a4e80427
RBR
216 if shouldPassthrough {
217 NSCursor.arrow.set()
218 } else {
219 if let box {
220 if resizeBox!.contains(mouseLocation) {
221 NSCursor.arrow.set()
24220348 222 } else {
a4e80427
RBR
223 if box.contains(mouseLocation) {
224 NSCursor.openHand.set()
225 } else {
226 NSCursor.crosshair.set()
227 }
24220348 228 }
a4e80427
RBR
229 if let button {
230 if button.contains(mouseLocation) {
231 NSCursor.arrow.set()
232 }
233 }
234 } else {
235 NSCursor.crosshair.set()
24220348 236 }
24220348 237 }
24220348
RBR
238
239 self.setNeedsDisplay(self.bounds)
240 }
505c1e62 241
24220348
RBR
242 override func mouseDragged(with event: NSEvent) {
243 self.mouseLocation = self.convert(event.locationInWindow, from: nil)
e834022c 244 if state == .drawing {
24220348
RBR
245 box = NSRect(
246 x: round(min(origin.x, mouseLocation.x)),
247 y: round(min(origin.y, mouseLocation.y)),
248 width: round(abs(mouseLocation.x - origin.x)),
249 height: round(abs(mouseLocation.y - origin.y))
250 )
251 }
505c1e62 252
e834022c
RBR
253 if box != nil {
254 if state == .moving {
255 NSCursor.closedHand.set()
256 box!.origin = NSPoint(
257 x: self.boxOrigin.x - self.origin.x + self.mouseLocation.x,
258 y: self.boxOrigin.y - self.origin.y + self.mouseLocation.y)
259 }
505c1e62 260
e834022c
RBR
261 if state == .resizing {
262 box = NSRect(
263 x: round(min(origin.x, mouseLocation.x)),
264 y: round(min(origin.y, mouseLocation.y)),
265 width: round(abs(mouseLocation.x - origin.x)),
266 height: round(abs(mouseLocation.y - origin.y))
267 )
268 }
24220348
RBR
269 }
270 self.setNeedsDisplay(self.bounds)
271 }
505c1e62 272
24220348
RBR
273 override func cursorUpdate(with event: NSEvent) {
274 NSCursor.crosshair.set()
275 }
276
277 override func hitTest(_ point: NSPoint) -> NSView? {
a4e80427 278 return shouldPassthrough ? nil : self
24220348 279 }
505c1e62 280
24220348
RBR
281 override var acceptsFirstResponder: Bool {
282 return true
283 }
284
285 override func mouseDown(with event: NSEvent) {
286 self.origin = self.convert(event.locationInWindow, from: nil)
287 if let box {
505c1e62 288
a4e80427
RBR
289 if let button {
290 if button.contains(origin) {
291 NotificationCenter.default.post(name: .startRecording, object: nil, userInfo: nil)
292 return
293 }
294 }
505c1e62 295
8e932130 296 if resizeBox!.contains(origin) && !preventResize {
e834022c
RBR
297 self.origin = NSPoint(x: box.minX, y: box.maxY)
298 state = .resizing
299 return
300 }
8e932130 301 if box.contains(origin) && !preventMove {
e834022c 302 state = .moving
24220348
RBR
303 self.boxOrigin = NSPoint(x: box.origin.x, y: box.origin.y)
304 return
305 }
306 }
505c1e62 307
8e932130
RBR
308 if preventResize || preventMove {
309 return
310 }
505c1e62 311
e834022c 312 state = .drawing
24220348
RBR
313 }
314
315 override func mouseUp(with event: NSEvent) {
a4e80427
RBR
316 if state != .recording {
317 state = .idle
318 }
e834022c 319 }
505c1e62 320
e834022c 321 override func keyDown(with event: NSEvent) {
e834022c 322 switch event.keyCode {
505c1e62
RBR
323 case 53: // Escape key
324 NotificationCenter.default.post(name: .reset, object: nil, userInfo: nil)
325 default:
326 super.keyDown(with: event)
e834022c
RBR
327 }
328 }
505c1e62 329
e834022c 330 override func flagsChanged(with event: NSEvent) {
a4e80427 331 if state == .idle {
e834022c 332 if event.modifierFlags.contains(.shift) {
a4e80427 333 startPassthrough()
e834022c 334 } else {
a4e80427 335 stopPassthrough()
e834022c 336 }
a4e80427 337 }
24220348 338 }
505c1e62 339
24220348 340 override func draw(_ dirtyRect: NSRect) {
a4e80427 341 if shouldPassthrough {
e834022c
RBR
342 NSColor.clear.setFill()
343 } else {
344 NSColor(white: 1.0, alpha: 0.001).setFill()
345 }
24220348 346 dirtyRect.fill()
505c1e62 347
24220348
RBR
348 let dashLength: CGFloat = 5.0
349 let lineWidth = 0.5
082b61f3 350
505c1e62
RBR
351 // Uncomment below to debug button placement visually
352 // if let button {
353 // let buttonPath = NSBezierPath()
354 // buttonPath.move(to: NSPoint(x: button.minX, y: button.minY))
355 // buttonPath.line(to: NSPoint(x: button.maxX, y: button.minY))
356 // buttonPath.line(to: NSPoint(x: button.maxX, y: button.maxY))
357 // buttonPath.line(to: NSPoint(x: button.minX, y: button.maxY))
358 // buttonPath.line(to: NSPoint(x: button.minX, y: button.minY))
359 // NSColor(red: 1, green: 0, blue: 1, alpha: 1).setFill()
360 // buttonPath.fill()
361 // }
24220348 362
e834022c 363 if state == .idle && box == nil {
24220348
RBR
364 let blackLine = NSBezierPath()
365 blackLine.lineWidth = lineWidth
366 blackLine.setLineDash([dashLength, dashLength], count: 2, phase: 0)
505c1e62 367
24220348
RBR
368 // Vertical line (Black)
369 blackLine.move(to: NSPoint(x: self.mouseLocation.x, y: NSMinY(self.bounds)))
370 blackLine.line(to: NSPoint(x: self.mouseLocation.x, y: NSMaxY(self.bounds)))
505c1e62 371
24220348
RBR
372 // Horizontal line (Black)
373 blackLine.move(to: NSPoint(x: NSMinX(self.bounds), y: self.mouseLocation.y))
374 blackLine.line(to: NSPoint(x: NSMaxX(self.bounds), y: self.mouseLocation.y))
505c1e62 375
24220348
RBR
376 NSColor.black.setStroke()
377 blackLine.stroke()
505c1e62 378
24220348
RBR
379 let whiteLine = NSBezierPath()
380 whiteLine.lineWidth = lineWidth
381 whiteLine.setLineDash([dashLength, dashLength], count: 2, phase: dashLength)
505c1e62 382
24220348
RBR
383 // Vertical line (White)
384 whiteLine.move(to: NSPoint(x: self.mouseLocation.x, y: NSMinY(self.bounds)))
385 whiteLine.line(to: NSPoint(x: self.mouseLocation.x, y: NSMaxY(self.bounds)))
505c1e62 386
24220348
RBR
387 // Horizontal line (White)
388 whiteLine.move(to: NSPoint(x: NSMinX(self.bounds), y: self.mouseLocation.y))
389 whiteLine.line(to: NSPoint(x: NSMaxX(self.bounds), y: self.mouseLocation.y))
505c1e62 390
24220348
RBR
391 NSColor.white.setStroke()
392 whiteLine.stroke()
393 }
505c1e62 394
24220348
RBR
395 if let box {
396 let blackBox = NSBezierPath()
397 blackBox.lineWidth = lineWidth
398 blackBox.setLineDash([dashLength, dashLength], count: 2, phase: 0)
399 blackBox.move(to: NSPoint(x: box.minX, y: box.minY))
400 blackBox.line(to: NSPoint(x: box.maxX, y: box.minY))
401 blackBox.line(to: NSPoint(x: box.maxX, y: box.maxY))
402 blackBox.line(to: NSPoint(x: box.minX, y: box.maxY))
403 blackBox.line(to: NSPoint(x: box.minX, y: box.minY))
404 NSColor.black.setStroke()
405 blackBox.stroke()
505c1e62 406
24220348
RBR
407 let whiteBox = NSBezierPath()
408 whiteBox.lineWidth = lineWidth
409 whiteBox.setLineDash([dashLength, dashLength], count: 2, phase: dashLength)
410 whiteBox.move(to: NSPoint(x: box.minX, y: box.minY))
411 whiteBox.line(to: NSPoint(x: box.maxX, y: box.minY))
412 whiteBox.line(to: NSPoint(x: box.maxX, y: box.maxY))
413 whiteBox.line(to: NSPoint(x: box.minX, y: box.maxY))
414 whiteBox.line(to: NSPoint(x: box.minX, y: box.minY))
415 NSColor.white.setStroke()
416 whiteBox.stroke()
505c1e62 417
a4e80427
RBR
418 if state == .recording {
419 return
420 }
505c1e62 421
24220348
RBR
422 if let resizeBox {
423 let clearBox = NSBezierPath()
424 clearBox.move(to: NSPoint(x: resizeBox.minX, y: resizeBox.minY))
425 clearBox.line(to: NSPoint(x: resizeBox.maxX, y: resizeBox.minY))
426 clearBox.line(to: NSPoint(x: resizeBox.maxX, y: resizeBox.maxY))
427 clearBox.line(to: NSPoint(x: resizeBox.minX, y: resizeBox.maxY))
428 clearBox.line(to: NSPoint(x: resizeBox.minX, y: resizeBox.minY))
429 NSColor(white: 0, alpha: 0.2).setFill()
430 clearBox.fill()
431 }
505c1e62 432
e834022c
RBR
433 if state == .moving {
434 let string = "\(Int(box.minX)), \(Int(box.maxY))" as NSString
505c1e62
RBR
435 drawText(
436 string,
437 NSPoint(
438 x: box.minX,
439 y: box.maxY
440 ), true)
e834022c 441 }
505c1e62 442
e834022c
RBR
443 if state == .resizing {
444 let string = "\(Int(mouseLocation.x)), \(Int(mouseLocation.y))" as NSString
445 drawText(string, mouseLocation)
446 }
505c1e62 447
e834022c 448 if box.contains(mouseLocation) && state != .resizing {
505c1e62 449 return
24220348
RBR
450 }
451 }
505c1e62 452
24220348 453 // Draw text
e834022c
RBR
454
455 let string = "\(Int(mouseLocation.x)), \(Int(mouseLocation.y))" as NSString
456 drawText(string, mouseLocation)
457 }
505c1e62 458
a4e80427 459 // MARK: - Utilities
505c1e62 460
e834022c 461 private func drawText(_ text: NSString, _ location: NSPoint, _ isBottomRight: Bool = false) {
505c1e62 462
24220348 463 let textAttributes = [
505c1e62
RBR
464 NSAttributedString.Key.font: NSFont(name: "Hiragino Mincho ProN", size: 12)
465 ?? NSFont.systemFont(ofSize: 12),
24220348
RBR
466 NSAttributedString.Key.foregroundColor: NSColor.white,
467 ]
e834022c
RBR
468 let offset = NSPoint(x: 10, y: 10)
469 let padding = NSPoint(x: 5, y: 2)
470 let size = text.size(withAttributes: textAttributes)
505c1e62
RBR
471 var rect = NSRect(
472 x: location.x + offset.x, y: location.y + offset.y, width: size.width + 2 * padding.x,
473 height: size.height + 2 * padding.y)
474 var textRect = NSRect(
475 x: location.x + offset.x + padding.x, y: location.y + offset.y + padding.y, width: size.width,
476 height: size.height)
477
478 if isBottomRight {
e834022c
RBR
479 rect = rect.offsetBy(dx: -size.width - 2 * offset.x, dy: 0)
480 textRect = textRect.offsetBy(dx: -size.width - 2 * offset.x, dy: 0)
481 }
24220348
RBR
482
483 NSColor.black.set()
484 rect.fill()
485
e834022c 486 text.draw(in: textRect, withAttributes: textAttributes)
24220348 487 }
505c1e62 488
7ee43fb8 489 private func moveWindow() {
7ee43fb8
RBR
490 let screen = NSScreen.screenWithMouse
491 if let currentScreen = self.window?.screen {
492 if currentScreen != screen {
493 let frame = screen?.frame ?? NSZeroRect
9431168d
RBR
494 self.frame = CGRect(origin: NSZeroPoint, size: frame.size)
495 self.bounds = CGRect(origin: NSZeroPoint, size: frame.size)
7ee43fb8 496 self.updateTrackingAreas()
505c1e62 497
7ee43fb8
RBR
498 if let window = self.window {
499 self.bounds = frame
505c1e62
RBR
500 self.button = NSRect(
501 x: frame.maxX - buttonOffset.x, y: frame.maxY - buttonOffset.y, width: buttonSize.width,
502 height: buttonSize.height)
7ee43fb8
RBR
503 window.setFrame(frame, display: true, animate: false)
504 window.makeKeyAndOrderFront(nil)
505 window.orderFrontRegardless()
7ee43fb8
RBR
506 }
507 }
508 }
509 }
24220348 510}