]> git.r.bdr.sh - rbdr/captura/commitdiff
Add drawing
authorRuben Beltran del Rio <redacted>
Mon, 24 Jul 2023 21:32:41 +0000 (23:32 +0200)
committerRuben Beltran del Rio <redacted>
Mon, 24 Jul 2023 21:32:41 +0000 (23:32 +0200)
Captura.xcodeproj/project.pbxproj
Captura/CapturaApp.swift
Captura/CaptureState.swift [new file with mode: 0644]
Captura/Notification+AppEvents.swift [new file with mode: 0644]
Captura/PreferencesWindow.swift [moved from Captura/ContentView.swift with 90% similarity]
Captura/RecordingWindow.swift [new file with mode: 0644]

index 3d8791afaebd8b9ca328e81db9f6dfd7d9b66a42..95ecff08ce16dc5e93b5980c6a66974efc49d70f 100644 (file)
@@ -7,8 +7,11 @@
        objects = {
 
 /* Begin PBXBuildFile section */
        objects = {
 
 /* Begin PBXBuildFile section */
+               B55DDFCC2A6F0253001A5E76 /* Notification+AppEvents.swift in Sources */ = {isa = PBXBuildFile; fileRef = B55DDFCB2A6F0253001A5E76 /* Notification+AppEvents.swift */; };
+               B55DDFCE2A6F069D001A5E76 /* RecordingWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = B55DDFCD2A6F069D001A5E76 /* RecordingWindow.swift */; };
+               B56C70CD2A6EFDF4009B97EB /* CaptureState.swift in Sources */ = {isa = PBXBuildFile; fileRef = B56C70CC2A6EFDF4009B97EB /* CaptureState.swift */; };
                B5F915522A6EF80D007ECE8E /* CapturaApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5F915512A6EF80D007ECE8E /* CapturaApp.swift */; };
                B5F915522A6EF80D007ECE8E /* CapturaApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5F915512A6EF80D007ECE8E /* CapturaApp.swift */; };
-               B5F915542A6EF80D007ECE8E /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5F915532A6EF80D007ECE8E /* ContentView.swift */; };
+               B5F915542A6EF80D007ECE8E /* PreferencesWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5F915532A6EF80D007ECE8E /* PreferencesWindow.swift */; };
                B5F915562A6EF80E007ECE8E /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = B5F915552A6EF80E007ECE8E /* Assets.xcassets */; };
                B5F915592A6EF80E007ECE8E /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = B5F915582A6EF80E007ECE8E /* Preview Assets.xcassets */; };
                B5F9155B2A6EF80E007ECE8E /* Item.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5F9155A2A6EF80E007ECE8E /* Item.swift */; };
                B5F915562A6EF80E007ECE8E /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = B5F915552A6EF80E007ECE8E /* Assets.xcassets */; };
                B5F915592A6EF80E007ECE8E /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = B5F915582A6EF80E007ECE8E /* Preview Assets.xcassets */; };
                B5F9155B2A6EF80E007ECE8E /* Item.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5F9155A2A6EF80E007ECE8E /* Item.swift */; };
 /* End PBXContainerItemProxy section */
 
 /* Begin PBXFileReference section */
 /* End PBXContainerItemProxy section */
 
 /* Begin PBXFileReference section */
+               B55DDFCB2A6F0253001A5E76 /* Notification+AppEvents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Notification+AppEvents.swift"; sourceTree = "<group>"; };
+               B55DDFCD2A6F069D001A5E76 /* RecordingWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordingWindow.swift; sourceTree = "<group>"; };
+               B56C70CC2A6EFDF4009B97EB /* CaptureState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CaptureState.swift; sourceTree = "<group>"; };
                B5F9154E2A6EF80D007ECE8E /* Captura.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Captura.app; sourceTree = BUILT_PRODUCTS_DIR; };
                B5F915512A6EF80D007ECE8E /* CapturaApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CapturaApp.swift; sourceTree = "<group>"; };
                B5F9154E2A6EF80D007ECE8E /* Captura.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Captura.app; sourceTree = BUILT_PRODUCTS_DIR; };
                B5F915512A6EF80D007ECE8E /* CapturaApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CapturaApp.swift; sourceTree = "<group>"; };
-               B5F915532A6EF80D007ECE8E /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
+               B5F915532A6EF80D007ECE8E /* PreferencesWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferencesWindow.swift; sourceTree = "<group>"; };
                B5F915552A6EF80E007ECE8E /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
                B5F915582A6EF80E007ECE8E /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = "<group>"; };
                B5F9155A2A6EF80E007ECE8E /* Item.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Item.swift; sourceTree = "<group>"; };
                B5F915552A6EF80E007ECE8E /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
                B5F915582A6EF80E007ECE8E /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = "<group>"; };
                B5F9155A2A6EF80E007ECE8E /* Item.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Item.swift; sourceTree = "<group>"; };
                        isa = PBXGroup;
                        children = (
                                B5F915512A6EF80D007ECE8E /* CapturaApp.swift */,
                        isa = PBXGroup;
                        children = (
                                B5F915512A6EF80D007ECE8E /* CapturaApp.swift */,
-                               B5F915532A6EF80D007ECE8E /* ContentView.swift */,
+                               B5F915532A6EF80D007ECE8E /* PreferencesWindow.swift */,
                                B5F915552A6EF80E007ECE8E /* Assets.xcassets */,
                                B5F9155A2A6EF80E007ECE8E /* Item.swift */,
                                B5F9155C2A6EF80E007ECE8E /* Captura.entitlements */,
                                B5F915572A6EF80E007ECE8E /* Preview Content */,
                                B5F915552A6EF80E007ECE8E /* Assets.xcassets */,
                                B5F9155A2A6EF80E007ECE8E /* Item.swift */,
                                B5F9155C2A6EF80E007ECE8E /* Captura.entitlements */,
                                B5F915572A6EF80E007ECE8E /* Preview Content */,
+                               B56C70CC2A6EFDF4009B97EB /* CaptureState.swift */,
+                               B55DDFCB2A6F0253001A5E76 /* Notification+AppEvents.swift */,
+                               B55DDFCD2A6F069D001A5E76 /* RecordingWindow.swift */,
                        );
                        path = Captura;
                        sourceTree = "<group>";
                        );
                        path = Captura;
                        sourceTree = "<group>";
                        isa = PBXSourcesBuildPhase;
                        buildActionMask = 2147483647;
                        files = (
                        isa = PBXSourcesBuildPhase;
                        buildActionMask = 2147483647;
                        files = (
-                               B5F915542A6EF80D007ECE8E /* ContentView.swift in Sources */,
+                               B5F915542A6EF80D007ECE8E /* PreferencesWindow.swift in Sources */,
+                               B55DDFCE2A6F069D001A5E76 /* RecordingWindow.swift in Sources */,
+                               B55DDFCC2A6F0253001A5E76 /* Notification+AppEvents.swift in Sources */,
                                B5F9155B2A6EF80E007ECE8E /* Item.swift in Sources */,
                                B5F9155B2A6EF80E007ECE8E /* Item.swift in Sources */,
+                               B56C70CD2A6EFDF4009B97EB /* CaptureState.swift in Sources */,
                                B5F915522A6EF80D007ECE8E /* CapturaApp.swift in Sources */,
                        );
                        runOnlyForDeploymentPostprocessing = 0;
                                B5F915522A6EF80D007ECE8E /* CapturaApp.swift in Sources */,
                        );
                        runOnlyForDeploymentPostprocessing = 0;
index b0ccf32d0acadb9073122dcad825049b0eb90eb4..4dc65a478d25a7c8bea47f22a12ed51dbe09fca2 100644 (file)
-//
-//  CapturaApp.swift
-//  Captura
-//
-//  Created by Ruben Beltran del Rio on 7/24/23.
-//
-
 import SwiftUI
 import SwiftData
 import SwiftUI
 import SwiftData
+import Cocoa
 
 @main
 struct CapturaApp: App {
 
 @main
 struct CapturaApp: App {
+  
+    @NSApplicationDelegateAdaptor(CapturaAppDelegate.self) var appDelegate
 
     var body: some Scene {
         WindowGroup {
 
     var body: some Scene {
         WindowGroup {
-            ContentView()
+            PreferencesWindow()
+              .handlesExternalEvents(preferring: Set(arrayLiteral: "PreferencesWindow"), allowing: Set(arrayLiteral: "*"))
+              .frame(width: 650, height: 450)
         }
         }
+        .handlesExternalEvents(matching: Set(arrayLiteral: "PreferencesWindow"))
         .modelContainer(for: Item.self)
         .modelContainer(for: Item.self)
+      }
+}
+
+class CapturaAppDelegate: NSObject, NSApplicationDelegate {
+    
+    @Environment(\.openURL) var openURL
+    var statusItem: NSStatusItem!
+    var captureState: CaptureState = .idle
+    var recordingWindow: RecordingWindow? = nil
+
+    func applicationDidFinishLaunching(_ notification: Notification) {
+      setupMenu()
+      NotificationCenter.default.addObserver(
+        self,
+        selector: #selector(self.didReceiveNotification(_:)),
+        name: nil,
+        object: nil)
+      closeWindow()
+    }
+  
+    // MARK: - Setup Functions
+  
+  
+    private func setupMenu() {
+      statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
+
+      statusItem.button!.image = NSImage(systemSymbolName: "rectangle.dashed.badge.record", accessibilityDescription: "Captura")
+      statusItem.isVisible = true
+      statusItem.menu = NSMenu()
+      
+      let recordItem = NSMenuItem(title: "record", action: #selector(CapturaAppDelegate.onClickStartRecording), keyEquivalent: "6")
+      recordItem.keyEquivalentModifierMask = [.command, .shift]
+      
+      statusItem.menu?.addItem(recordItem)
+    }
+  
+    private func closeWindow() {
+      if let window = NSApplication.shared.windows.first {
+          window.close()
+      }
+    }
+  
+    // MARK: - UI Event Handlers
+
+    @objc private func onClickStartRecording() {
+      NotificationCenter.default.post(name: .startAreaSelection, object: nil, userInfo: nil)
+    }
+  
+  
+    // MARK: - App State Event Listeners
+  
+    @objc func didReceiveNotification(_ notification: Notification) {
+      switch(notification.name) {
+      case .startAreaSelection:
+        startAreaSelection()
+      case .startRecording:
+        startRecording()
+      case .stopRecording:
+        stopRecording()
+      case .finalizeRecording:
+        finalizeRecording()
+      case .reset:
+        reset()
+      default:
+        return
+      }
+/*
+      if let data = notification.userInfo?["data"] as? String {
+          print("Data received: \(data)")
+      }
+ */
+    }
+
+  
+    @objc func startAreaSelection() {
+      if captureState != .selectingArea {
+        captureState = .selectingArea
+        recordingWindow = RecordingWindow()
+        print("Recording")
+      }
+    }
+  
+    func startRecording() {
+      captureState = .recording
+    }
+  
+    func stopRecording() {
+      captureState = .uploading
+    }
+  
+    func finalizeRecording() {
+      captureState = .uploaded
+    }
+  
+    func reset() {
+      captureState = .idle
     }
 }
     }
 }
diff --git a/Captura/CaptureState.swift b/Captura/CaptureState.swift
new file mode 100644 (file)
index 0000000..be354f6
--- /dev/null
@@ -0,0 +1,7 @@
+enum CaptureState {
+  case idle
+  case selectingArea
+  case recording
+  case uploading
+  case uploaded
+}
diff --git a/Captura/Notification+AppEvents.swift b/Captura/Notification+AppEvents.swift
new file mode 100644 (file)
index 0000000..809c00e
--- /dev/null
@@ -0,0 +1,9 @@
+import Foundation
+
+extension Notification.Name {
+  static let startAreaSelection = Notification.Name("startSelectingArea")
+  static let startRecording = Notification.Name("startRecording")
+  static let stopRecording = Notification.Name("stopRecording")
+  static let finalizeRecording = Notification.Name("finalizeRecording")
+  static let reset = Notification.Name("reset")
+}
similarity index 90%
rename from Captura/ContentView.swift
rename to Captura/PreferencesWindow.swift
index 63fa7b823a3f2133fd930ed4e8d3c96d0829ed4a..fdb3a6c28efa901a1440608bd835e95fab2a0cd7 100644 (file)
@@ -1,14 +1,7 @@
-//
-//  ContentView.swift
-//  Captura
-//
-//  Created by Ruben Beltran del Rio on 7/24/23.
-//
-
 import SwiftUI
 import SwiftData
 
 import SwiftUI
 import SwiftData
 
-struct ContentView: View {
+struct PreferencesWindow: View {
     @Environment(\.modelContext) private var modelContext
     @Query private var items: [Item]
     
     @Environment(\.modelContext) private var modelContext
     @Query private var items: [Item]
     
@@ -52,6 +45,6 @@ struct ContentView: View {
 }
 
 #Preview {
 }
 
 #Preview {
-    ContentView()
+  PreferencesWindow()
         .modelContainer(for: Item.self, inMemory: true)
 }
         .modelContainer(for: Item.self, inMemory: true)
 }
diff --git a/Captura/RecordingWindow.swift b/Captura/RecordingWindow.swift
new file mode 100644 (file)
index 0000000..cf572e1
--- /dev/null
@@ -0,0 +1,248 @@
+import Cocoa
+
+class RecordingWindow: NSWindow {
+  
+  init() {
+    
+    let screens = NSScreen.screens
+    var boundingBox = NSZeroRect
+    for screen in screens {
+        boundingBox = NSUnionRect(boundingBox, screen.frame)
+    }
+    
+    super.init(
+      contentRect: boundingBox,
+      styleMask: [.borderless],
+      backing: .buffered,
+      defer: false)
+
+    self.center()
+    self.isMovableByWindowBackground = false
+    self.isMovable = false
+    self.titlebarAppearsTransparent = true
+    self.setFrame(boundingBox, display: true)
+    self.titleVisibility = .hidden
+    self.contentView = RecordingContentView()
+    self.backgroundColor = NSColor(white: 1.0, alpha: 0.001)
+    self.level = .screenSaver
+    self.isOpaque = false
+    self.hasShadow = false
+    self.makeKeyAndOrderFront(nil)
+    self.makeFirstResponder(nil)
+  }
+  
+  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
+  }
+}
+
+class RecordingContentView: NSView {
+  
+  var isDrawing = false
+  var isMoving = false
+  var isResizing = false
+  var box: NSRect? = nil
+  var mouseLocation: NSPoint = NSPoint()
+  var origin: NSPoint = NSPoint()
+  var boxOrigin: NSPoint = NSPoint()
+  
+  var resizeBox: NSRect? {
+    if let box {
+      return NSRect(x: box.maxX - 5, y: box.minY - 5, width: 10, height: 10)
+    }
+    return nil
+  }
+  
+  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)
+    self.addTrackingArea(trackingArea)
+  }
+  
+  override func mouseMoved(with event: NSEvent) {
+    
+    self.mouseLocation = self.convert(event.locationInWindow, from: nil)
+    
+    if let box {
+      if resizeBox!.contains(mouseLocation) {
+        NSCursor.arrow.set()
+      } else {
+        if box.contains(mouseLocation) {
+          NSCursor.openHand.set()
+        } else {
+          NSCursor.crosshair.set()
+        }
+      }
+    } else {
+      NSCursor.crosshair.set()
+    }
+    
+
+    self.setNeedsDisplay(self.bounds)
+  }
+  
+  override func mouseDragged(with event: NSEvent) {
+    self.mouseLocation = self.convert(event.locationInWindow, from: nil)
+    if isDrawing {
+      box = NSRect(
+        x: round(min(origin.x, mouseLocation.x)),
+        y: round(min(origin.y, mouseLocation.y)),
+        width: round(abs(mouseLocation.x - origin.x)),
+        height: round(abs(mouseLocation.y - origin.y))
+      )
+    }
+    
+    if isMoving && box != nil {
+      NSCursor.closedHand.set()
+      box!.origin = NSPoint(
+        x: self.boxOrigin.x - self.origin.x + self.mouseLocation.x,
+        y: self.boxOrigin.y - self.origin.y + self.mouseLocation.y)
+    }
+    self.setNeedsDisplay(self.bounds)
+  }
+  
+  override func cursorUpdate(with event: NSEvent) {
+    NSCursor.crosshair.set()
+  }
+
+  override func hitTest(_ point: NSPoint) -> NSView? {
+    return 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 box.contains(origin) {
+        isMoving = true
+        self.boxOrigin = NSPoint(x: box.origin.x, y: box.origin.y)
+        return
+      }
+    }
+    
+    isDrawing = true
+  }
+
+  override func mouseUp(with event: NSEvent) {
+    isDrawing = false
+    isMoving = false
+  }
+  
+  override func draw(_ dirtyRect: NSRect) {
+    NSColor(white: 1.0, alpha: 0.001).setFill()
+    dirtyRect.fill()
+    
+    let dashLength: CGFloat = 5.0
+    let lineWidth = 0.5
+
+    if !isDrawing && 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.setLineDash([dashLength, dashLength], count: 2, phase: 0)
+      blackBox.move(to: NSPoint(x: box.minX, y: box.minY))
+      blackBox.line(to: NSPoint(x: box.maxX, y: box.minY))
+      blackBox.line(to: NSPoint(x: box.maxX, y: box.maxY))
+      blackBox.line(to: NSPoint(x: box.minX, y: box.maxY))
+      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.move(to: NSPoint(x: box.minX, y: box.minY))
+      whiteBox.line(to: NSPoint(x: box.maxX, y: box.minY))
+      whiteBox.line(to: NSPoint(x: box.maxX, y: box.maxY))
+      whiteBox.line(to: NSPoint(x: box.minX, y: box.maxY))
+      whiteBox.line(to: NSPoint(x: box.minX, y: box.minY))
+      NSColor.white.setStroke()
+      whiteBox.stroke()
+      
+      if let resizeBox {
+        let clearBox = NSBezierPath()
+        clearBox.move(to: NSPoint(x: resizeBox.minX, y: resizeBox.minY))
+        clearBox.line(to: NSPoint(x: resizeBox.maxX, y: resizeBox.minY))
+        clearBox.line(to: NSPoint(x: resizeBox.maxX, y: resizeBox.maxY))
+        clearBox.line(to: NSPoint(x: resizeBox.minX, y: resizeBox.maxY))
+        clearBox.line(to: NSPoint(x: resizeBox.minX, y: resizeBox.minY))
+        NSColor(white: 0, alpha: 0.2).setFill()
+        clearBox.fill()
+      }
+      
+      if box.contains(mouseLocation) && !isResizing {
+        return;
+      }
+    }
+    
+    // Draw text
+    
+    let offset = NSPoint(x: 10, y: 10)
+    let padding = NSPoint(x: 5, y: 2)
+    
+    let textAttributes = [
+      NSAttributedString.Key.font: NSFont(name: "Hiragino Mincho ProN", size: 12) ?? NSFont.systemFont(ofSize: 12),
+      NSAttributedString.Key.foregroundColor: NSColor.white,
+    ]
+
+    let string = "\(Int(mouseLocation.x)), \(Int(mouseLocation.y))" as NSString
+    let size = string.size(withAttributes: textAttributes)
+    let rect = NSRect(x: mouseLocation.x + offset.x, y: mouseLocation.y + offset.y, width: size.width + 2 * padding.x, height: size.height + 2 * padding.y)
+    let textRect = NSRect(x: mouseLocation.x + offset.x + padding.x, y: mouseLocation.y + offset.y + padding.y, width: size.width, height: size.height)
+
+    NSColor.black.set()
+    rect.fill()
+
+    string.draw(in: textRect, withAttributes: textAttributes)
+  }
+}