]> git.r.bdr.sh - rbdr/captura/blob - Captura/Presentation/Windows/RecordingWindow.swift
71e3a5577605ffd75735886b562b9f1518315ac9
[rbdr/captura] / Captura / Presentation / Windows / RecordingWindow.swift
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 */
17 import Cocoa
18 import Combine
19
20 class RecordingWindow: NSWindow {
21
22 var pixelDensity: CGFloat {
23 self.screen?.backingScaleFactor ?? 1.0
24 }
25
26 var recordingContentView: RecordingContentView {
27 self.contentView as! RecordingContentView
28 }
29
30 init(_ configuration: CaptureSessionConfiguration, _ button: NSRect?) {
31
32 let boundingBox = NSScreen.screenWithMouse?.frame ?? NSZeroRect
33
34 super.init(
35 contentRect: boundingBox,
36 styleMask: [.borderless],
37 backing: .buffered,
38 defer: false)
39
40 self.isReleasedWhenClosed = false
41 self.collectionBehavior = [.canJoinAllSpaces]
42 self.isMovableByWindowBackground = false
43 self.isMovable = false
44 self.canHide = false
45 self.titlebarAppearsTransparent = true
46 self.setFrame(boundingBox, display: true)
47 self.titleVisibility = .hidden
48 let recordingView = RecordingContentView(
49 configuration, frame: boundingBox, button: button ?? NSZeroRect)
50 recordingView.frame = boundingBox
51 self.contentView = recordingView
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)
55 self.level = .screenSaver
56 self.isOpaque = false
57 self.hasShadow = false
58 }
59
60 // MARK: - Window Behavior Overrides
61
62 override func resetCursorRects() {
63 super.resetCursorRects()
64 let cursor = NSCursor.crosshair
65 self.contentView?.addCursorRect(self.contentView!.bounds, cursor: cursor)
66 }
67
68 override var canBecomeKey: Bool {
69 return true
70 }
71
72 override var canBecomeMain: Bool {
73 return true
74 }
75
76 override func resignMain() {
77 super.resignMain()
78 if (self.contentView as? RecordingContentView)?.state != .recording {
79 self.ignoresMouseEvents = false
80 }
81 }
82
83 override func becomeMain() {
84 super.becomeMain()
85 if (self.contentView as? RecordingContentView)?.state != .recording {
86 (self.contentView as? RecordingContentView)?.state = .idle
87 }
88 }
89 }
90
91 enum RecordingWindowState {
92 case passthrough, idle, drawing, moving, resizing, recording
93 }
94
95 class RecordingContentView: NSView {
96
97 init(_ configuration: CaptureSessionConfiguration, frame: NSRect, button: NSRect) {
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
108 super.init(frame: frame)
109 preventResize = configuration.preventResize
110 preventMove = configuration.preventMove
111 autoStart = configuration.autoStart
112
113 self.bounds = frame
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 {
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 }
128
129 if autoStart {
130 DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) {
131 NotificationCenter.default.post(name: .startRecording, object: nil, userInfo: nil)
132 }
133 }
134 }
135
136 required init?(coder: NSCoder) {
137 fatalError("init(coder:) has not been implemented")
138 }
139
140 private let buttonSize: NSSize
141 private let buttonOffset: NSPoint
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()
148 private var preventResize = false
149 private var preventMove = false
150 private var autoStart = false
151
152 private var resizeBox: NSRect? {
153 if let box {
154 return NSRect(x: box.maxX - 5, y: box.minY - 5, width: 10, height: 10)
155 }
156 return nil
157 }
158
159 private var shouldPassthrough: Bool {
160 state == .recording || state == .passthrough
161 }
162
163 // MARK: - State changing API
164
165 public func startRecording() {
166 state = .recording
167 window?.ignoresMouseEvents = true
168 }
169
170 public func stopRecording() {
171
172 }
173
174 public func reset() {
175 state = .idle
176 window?.ignoresMouseEvents = false
177 }
178
179 public func startPassthrough() {
180 state = .passthrough
181 window?.ignoresMouseEvents = true
182 }
183
184 public func stopPassthrough() {
185 state = .idle
186 window?.ignoresMouseEvents = false
187 }
188
189 // MARK: - View Behavior Overrides
190
191 override func updateTrackingAreas() {
192 super.updateTrackingAreas()
193
194 for trackingArea in self.trackingAreas {
195 self.removeTrackingArea(trackingArea)
196 }
197
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)
203 self.addTrackingArea(trackingArea)
204 }
205
206 override func mouseExited(with event: NSEvent) {
207 if state == .idle && box == nil {
208 self.moveWindow()
209 }
210 }
211
212 override func mouseMoved(with event: NSEvent) {
213
214 self.mouseLocation = self.convert(event.locationInWindow, from: nil)
215
216 if shouldPassthrough {
217 NSCursor.arrow.set()
218 } else {
219 if let box {
220 if resizeBox!.contains(mouseLocation) {
221 NSCursor.arrow.set()
222 } else {
223 if box.contains(mouseLocation) {
224 NSCursor.openHand.set()
225 } else {
226 NSCursor.crosshair.set()
227 }
228 }
229 if let button {
230 if button.contains(mouseLocation) {
231 NSCursor.arrow.set()
232 }
233 }
234 } else {
235 NSCursor.crosshair.set()
236 }
237 }
238
239 self.setNeedsDisplay(self.bounds)
240 }
241
242 override func mouseDragged(with event: NSEvent) {
243 self.mouseLocation = self.convert(event.locationInWindow, from: nil)
244 if state == .drawing {
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 }
252
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 }
260
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 }
269 }
270 self.setNeedsDisplay(self.bounds)
271 }
272
273 override func cursorUpdate(with event: NSEvent) {
274 NSCursor.crosshair.set()
275 }
276
277 override func hitTest(_ point: NSPoint) -> NSView? {
278 return shouldPassthrough ? nil : self
279 }
280
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 {
288
289 if let button {
290 if button.contains(origin) {
291 NotificationCenter.default.post(name: .startRecording, object: nil, userInfo: nil)
292 return
293 }
294 }
295
296 if resizeBox!.contains(origin) && !preventResize {
297 self.origin = NSPoint(x: box.minX, y: box.maxY)
298 state = .resizing
299 return
300 }
301 if box.contains(origin) && !preventMove {
302 state = .moving
303 self.boxOrigin = NSPoint(x: box.origin.x, y: box.origin.y)
304 return
305 }
306 }
307
308 if preventResize || preventMove {
309 return
310 }
311
312 state = .drawing
313 }
314
315 override func mouseUp(with event: NSEvent) {
316 if state != .recording {
317 state = .idle
318 }
319 }
320
321 override func keyDown(with event: NSEvent) {
322 switch event.keyCode {
323 case 53: // Escape key
324 NotificationCenter.default.post(name: .reset, object: nil, userInfo: nil)
325 default:
326 super.keyDown(with: event)
327 }
328 }
329
330 override func flagsChanged(with event: NSEvent) {
331 if state == .idle {
332 if event.modifierFlags.contains(.shift) {
333 startPassthrough()
334 } else {
335 stopPassthrough()
336 }
337 }
338 }
339
340 override func draw(_ dirtyRect: NSRect) {
341 if shouldPassthrough {
342 NSColor.clear.setFill()
343 } else {
344 NSColor(white: 1.0, alpha: 0.001).setFill()
345 }
346 dirtyRect.fill()
347
348 let dashLength: CGFloat = 5.0
349 let lineWidth = 0.5
350
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 // }
362
363 if state == .idle && box == nil {
364 let blackLine = NSBezierPath()
365 blackLine.lineWidth = lineWidth
366 blackLine.setLineDash([dashLength, dashLength], count: 2, phase: 0)
367
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)))
371
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))
375
376 NSColor.black.setStroke()
377 blackLine.stroke()
378
379 let whiteLine = NSBezierPath()
380 whiteLine.lineWidth = lineWidth
381 whiteLine.setLineDash([dashLength, dashLength], count: 2, phase: dashLength)
382
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)))
386
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))
390
391 NSColor.white.setStroke()
392 whiteLine.stroke()
393 }
394
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()
406
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()
417
418 if state == .recording {
419 return
420 }
421
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 }
432
433 if state == .moving {
434 let string = "\(Int(box.minX)), \(Int(box.maxY))" as NSString
435 drawText(
436 string,
437 NSPoint(
438 x: box.minX,
439 y: box.maxY
440 ), true)
441 }
442
443 if state == .resizing {
444 let string = "\(Int(mouseLocation.x)), \(Int(mouseLocation.y))" as NSString
445 drawText(string, mouseLocation)
446 }
447
448 if box.contains(mouseLocation) && state != .resizing {
449 return
450 }
451 }
452
453 // Draw text
454
455 let string = "\(Int(mouseLocation.x)), \(Int(mouseLocation.y))" as NSString
456 drawText(string, mouseLocation)
457 }
458
459 // MARK: - Utilities
460
461 private func drawText(_ text: NSString, _ location: NSPoint, _ isBottomRight: Bool = false) {
462
463 let textAttributes = [
464 NSAttributedString.Key.font: NSFont(name: "Hiragino Mincho ProN", size: 12)
465 ?? NSFont.systemFont(ofSize: 12),
466 NSAttributedString.Key.foregroundColor: NSColor.white,
467 ]
468 let offset = NSPoint(x: 10, y: 10)
469 let padding = NSPoint(x: 5, y: 2)
470 let size = text.size(withAttributes: textAttributes)
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 {
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 }
482
483 NSColor.black.set()
484 rect.fill()
485
486 text.draw(in: textRect, withAttributes: textAttributes)
487 }
488
489 private func moveWindow() {
490 let screen = NSScreen.screenWithMouse
491 if let currentScreen = self.window?.screen {
492 if currentScreen != screen {
493 let frame = screen?.frame ?? NSZeroRect
494 self.frame = CGRect(origin: NSZeroPoint, size: frame.size)
495 self.bounds = CGRect(origin: NSZeroPoint, size: frame.size)
496 self.updateTrackingAreas()
497
498 if let window = self.window {
499 self.bounds = frame
500 self.button = NSRect(
501 x: frame.maxX - buttonOffset.x, y: frame.maxY - buttonOffset.y, width: buttonSize.width,
502 height: buttonSize.height)
503 window.setFrame(frame, display: true, animate: false)
504 window.makeKeyAndOrderFront(nil)
505 window.orderFrontRegardless()
506 }
507 }
508 }
509 }
510 }