HuggingChat-Mac/Views/FloatingPanel.swift (298 lines of code) (raw):
//
// FloatingPanel.swift
// HuggingChat-Mac
//
// Created by Cyril Zakka on 8/16/24.
//
import AppKit
import Foundation
class FloatingPanel: NSPanel, NSWindowDelegate {
var isFileImporterVisible: Bool = false
var isFocused: Bool = false {
didSet {
if isFocused {
animateToPosition(nearestSnapPosition(to: self.frame.origin))
}
}
}
// Snapping window
enum SnapPosition: Int {
case bottomLeft = 1
case bottomRight = 4
case topLeft = 2
case topRight = 3
}
/// Padding from edge of screen
let padding: CGFloat = 10
private var initialMouseOffset: NSPoint = .zero
private var currentVelocity: NSPoint = .zero
private var lastMousePosition: NSPoint = .zero
private var lastUpdateTime: TimeInterval = 0
var snapPosition: SnapPosition = .topRight
init(contentRect: NSRect, backing: NSWindow.BackingStoreType, defer flag: Bool) {
super.init(contentRect: contentRect, styleMask: [.nonactivatingPanel, .fullSizeContentView, .resizable], backing: backing, defer: flag)
self.delegate = self
// Spotlight behavior
self.setFrameAutosaveName("hfChatBar")
self.isFloatingPanel = true
self.level = .floating
self.collectionBehavior.insert(.fullScreenAuxiliary)
self.collectionBehavior.insert(.canJoinAllSpaces)
self.titleVisibility = .hidden
self.titlebarAppearsTransparent = true
self.standardWindowButton(.closeButton)?.isHidden = true
self.standardWindowButton(.miniaturizeButton)?.isHidden = true
self.standardWindowButton(.zoomButton)?.isHidden = true
self.isMovableByWindowBackground = true
self.isReleasedWhenClosed = false
self.backgroundColor = NSColor.clear
self.isOpaque = false
self.hasShadow = true
// Attachment shadows not updated when scrolling leading to artifact.
// Should invalidate shadow on scroll. Set to false for now.
// Shadow is set manually.
// Animates but slightly slower
// self.animationBehavior = .utilityWindow
}
override var canBecomeKey: Bool {
return true
}
override var canBecomeMain: Bool {
return true
}
@objc func cancel(_ sender: Any?) {
close()
}
override func resignMain() {
super.resignMain()
if !isFocused {
close()
}
}
func windowDidResignKey(_ notification: Notification) {
if !isFileImporterVisible && !isFocused {
close()
}
}
func updateFileImporterVisibility(_ isVisible: Bool) {
isFileImporterVisible = isVisible
}
func updateFocusMode(_ isInFocus: Bool) {
isFocused = isInFocus
}
}
extension FloatingPanel {
override func mouseDragged(with event: NSEvent) {
guard isFocused else { return }
let currentTime = ProcessInfo.processInfo.systemUptime
let deltaTime = currentTime - lastUpdateTime
if lastUpdateTime == 0 {
// First drag event
let mouseLocation = NSEvent.mouseLocation
initialMouseOffset = NSPoint(
x: mouseLocation.x - frame.minX,
y: mouseLocation.y - frame.minY
)
} else {
// Calculate velocity
let mouseLocation = NSEvent.mouseLocation
currentVelocity = NSPoint(
x: (mouseLocation.x - lastMousePosition.x) / CGFloat(deltaTime),
y: (mouseLocation.y - lastMousePosition.y) / CGFloat(deltaTime)
)
lastMousePosition = mouseLocation
}
lastUpdateTime = currentTime
updateWindowPosition(with: NSEvent.mouseLocation)
}
override func mouseUp(with event: NSEvent) {
guard isFocused else { return }
lastUpdateTime = 0
guard let screen = NSScreen.main else { return }
// Check if velocity is high enough to move
let velocityMagnitude = hypot(currentVelocity.x, currentVelocity.y)
let minVelocityThreshold: CGFloat = 300 // Increased threshold for movement
if velocityMagnitude < minVelocityThreshold {
// If velocity is too low, snap back to original position
animateToPosition(snapPosition)
currentVelocity = .zero
return
}
// Project where the window would end up based on velocity and deceleration
let projectedPoint = calculateProjectedPosition()
// Calculate window center
let currentCenter = NSPoint(x: frame.midX, y: frame.midY)
// Calculate movement vector
let movementVector = NSPoint(
x: projectedPoint.x - currentCenter.x,
y: projectedPoint.y - currentCenter.y
)
// Find target edge based on projected position
let targetPosition = determineTargetPosition(
from: currentCenter,
projectedPoint: projectedPoint,
movementVector: movementVector,
screenFrame: screen.frame
)
// Clamp velocity for animation
let maxVelocity: CGFloat = 2000
currentVelocity = NSPoint(
x: max(min(currentVelocity.x, maxVelocity), -maxVelocity),
y: max(min(currentVelocity.y, maxVelocity), -maxVelocity)
)
animateToPosition(targetPosition)
currentVelocity = .zero
}
private func determineTargetPosition(
from currentCenter: NSPoint,
projectedPoint: NSPoint,
movementVector: NSPoint,
screenFrame: NSRect
) -> SnapPosition {
// Determine quadrant of projected position
let projectedOnLeft = projectedPoint.x < screenFrame.width / 2
let projectedOnTop = projectedPoint.y > screenFrame.height / 2
// Calculate primary movement direction
let isHorizontalMovement = abs(movementVector.x) > abs(movementVector.y)
// If movement is strongly diagonal (within 30% of 45 degrees), use pure projection
let movementRatio = abs(abs(movementVector.x) / abs(movementVector.y) - 1)
let isDiagonalMovement = movementRatio < 0.3
if isDiagonalMovement {
// For diagonal movement, just use the projected quadrant
return projectedOnTop ?
(projectedOnLeft ? .topLeft : .topRight) :
(projectedOnLeft ? .bottomLeft : .bottomRight)
}
// For primarily horizontal or vertical movement
if isHorizontalMovement {
let targetLeft = movementVector.x < 0
// Use the projected vertical position instead of current
return projectedOnTop ?
(targetLeft ? .topLeft : .topRight) :
(targetLeft ? .bottomLeft : .bottomRight)
} else {
let targetTop = movementVector.y > 0
// Use the projected horizontal position instead of current
return projectedOnLeft ?
(targetTop ? .topLeft : .bottomLeft) :
(targetTop ? .topRight : .bottomRight)
}
}
private func calculateProjectedPosition() -> NSPoint {
let decelerationRate: CGFloat = 0.998
let projectedX = frame.midX + projectFinalDistance(velocity: currentVelocity.x, decelerationRate: decelerationRate)
let projectedY = frame.midY + projectFinalDistance(velocity: currentVelocity.y, decelerationRate: decelerationRate)
guard let screen = NSScreen.main else { return NSPoint(x: projectedX, y: projectedY) }
let margin: CGFloat = frame.width / 2 + padding
return NSPoint(
x: min(max(projectedX, margin), screen.frame.width - margin),
y: min(max(projectedY, margin), screen.frame.height - margin)
)
}
private func projectFinalDistance(velocity: CGFloat, decelerationRate: CGFloat) -> CGFloat {
return velocity * decelerationRate / (1 - decelerationRate)
}
private func updateWindowPosition(with mouseLocation: NSPoint) {
let newOrigin = NSPoint(
x: mouseLocation.x - initialMouseOffset.x,
y: mouseLocation.y - initialMouseOffset.y
)
setFrame(NSRect(origin: newOrigin, size: frame.size), display: true)
}
private func nearestSnapPosition(to point: NSPoint) -> SnapPosition {
guard let screen = NSScreen.main else { return .topRight }
let positions: [(SnapPosition, NSPoint)] = [
(.topLeft, NSPoint(x: padding, y: screen.frame.height - frame.height - padding)),
(.topRight, NSPoint(x: screen.frame.width - frame.width - padding, y: screen.frame.height - frame.height - padding)),
(.bottomLeft, NSPoint(x: padding, y: padding*4)),
(.bottomRight, NSPoint(x: screen.frame.width - frame.width - padding, y: padding*4))
]
// Weight the decision based on both distance and velocity direction
let nearest = positions.min(by: { (a, b) in
let distanceA = hypot(point.x - a.1.x, point.y - a.1.y)
let distanceB = hypot(point.x - b.1.x, point.y - b.1.y)
// Add velocity bias - prefer positions in the direction of movement
let velocityBiasA = velocityBias(towards: a.1)
let velocityBiasB = velocityBias(towards: b.1)
return (distanceA - velocityBiasA) < (distanceB - velocityBiasB)
})
return nearest?.0 ?? .topRight
}
private func velocityBias(towards point: NSPoint) -> CGFloat {
let deltaX = point.x - frame.minX
let deltaY = point.y - frame.minY
let distance = hypot(deltaX, deltaY)
guard distance > 0 else { return 0 }
let directionX = deltaX / distance
let directionY = deltaY / distance
let velocityMagnitude = hypot(currentVelocity.x, currentVelocity.y)
if velocityMagnitude > 0 {
let normalizedVelocityX = currentVelocity.x / velocityMagnitude
let normalizedVelocityY = currentVelocity.y / velocityMagnitude
// Reduce the velocity bias influence
let velocityBiasFactor: CGFloat = 50 // Reduced from 100
let alignment = (normalizedVelocityX * directionX + normalizedVelocityY * directionY)
return alignment * velocityBiasFactor
}
return 0
}
private func animateToPosition(_ position: SnapPosition) {
guard let screen = NSScreen.main else { return }
let targetFrame: NSRect
switch position {
case .topLeft:
targetFrame = NSRect(
x: padding,
y: screen.frame.height - frame.height - padding,
width: frame.width,
height: frame.height
)
case .topRight:
targetFrame = NSRect(
x: screen.frame.width - frame.width - padding,
y: screen.frame.height - frame.height - padding,
width: frame.width,
height: frame.height
)
case .bottomLeft:
targetFrame = NSRect(
x: padding,
y: padding*4,
width: frame.width,
height: frame.height
)
case .bottomRight:
targetFrame = NSRect(
x: screen.frame.width - frame.width - padding,
y: padding*4,
width: frame.width,
height: frame.height
)
}
let velocityMagnitude = min(hypot(currentVelocity.x, currentVelocity.y), 1000)
let baseDuration: TimeInterval = 0.4
let velocityDampingFactor: CGFloat = 0.3
let velocityFactor = min((velocityMagnitude * velocityDampingFactor) / 1000, 0.8) // Cap the minimum duration
let duration = baseDuration * (1.0 - velocityFactor)
NSAnimationContext.runAnimationGroup { [weak self] context in
context.duration = max(duration, 0.15) // Ensure minimum animation duration
context.timingFunction = CAMediaTimingFunction(controlPoints: 0.4, 0.0, 0.2, 1.0)
self?.animator().setFrame(targetFrame, display: true)
}
snapPosition = position
}
private func snapToFocusedPosition() {
guard let screen = NSScreen.main else { return }
// Determine which side of the screen we're currently closer to
let isOnLeftSide = frame.midX < screen.frame.width / 2
let isOnTopHalf = frame.midY > screen.frame.height / 2
// Get target position based on current location
let targetPosition: SnapPosition = isOnTopHalf ?
(isOnLeftSide ? .topLeft : .topRight) :
(isOnLeftSide ? .bottomLeft : .bottomRight)
// Animate to position with a quick, smooth animation
NSAnimationContext.runAnimationGroup { [weak self] context in
context.duration = 0.3
context.timingFunction = CAMediaTimingFunction(controlPoints: 0.2, 0.8, 0.2, 1.0)
self?.animator().setFrame(self?.frameFor(position: targetPosition) ?? .zero, display: true)
}
snapPosition = targetPosition
}
// Helper method to get frame for position (to avoid code duplication)
private func frameFor(position: SnapPosition) -> NSRect {
guard let screen = NSScreen.main else { return .zero }
switch position {
case .topLeft:
return NSRect(
x: padding,
y: screen.frame.height - frame.height - padding,
width: frame.width,
height: frame.height
)
case .topRight:
return NSRect(
x: screen.frame.width - frame.width - padding,
y: screen.frame.height - frame.height - padding,
width: frame.width,
height: frame.height
)
case .bottomLeft:
return NSRect(
x: padding,
y: padding,
width: frame.width,
height: frame.height
)
case .bottomRight:
return NSRect(
x: screen.frame.width - frame.width - padding,
y: padding,
width: frame.width,
height: frame.height
)
}
}
}